Comment on lines of Claude's diffs, send them as a review and track pending/resolved comments above the prompt

https://github.com/user-attachments/assets/599c0337-7678-4df6-b63a-dd3ee40f9566
A Claude Code mod that lets you review Claude's edits the way you review a pull request on GitHub: comment on individual lines of a diff, batch the comments into a review, send it, and watch Claude work through and resolve each one.
Edit and Write result, and each shell command that changes files, is redrawn in the transcript as a commentable diff with a + next to every line. Click it to write a comment on that line.resolve tool. After it handles a comment it marks it resolved with a one-line note on what it did, which shows next to the comment.+, edit, reply and the buttons are clickable).At the prompt of a Claude Code terminal session:
/plugin install comment-diff --marketplace formstep/claude-mod-comment-diff
Answer y to add the formstep marketplace, then press Enter to pick the scope (user scope, the default, enables it in every session). The mod is active immediately, with no restart.
From a shell:
claude plugin install comment-diff --marketplace formstep/claude-mod-comment-diff
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with formstep/claude-mod-comment-diff, then Add plugin.
claude plugin update comment-diff
claude plugin uninstall comment-diff
When Claude edits or writes a file, or runs a command that changes one, the diff in the transcript shows a dim + beside each line. Click it and a box opens above the prompt with the line and the lines around it. Type the comment in the prompt box itself:
| Key or button | Action |
|---|---|
Enter | Add the comment to the review (pending) |
| Send now | Send this comment to Claude now |
Cancel, or the same + again | Drop the comment |
Whatever you had typed in the prompt is set aside while you write the comment, and put back after. Slash commands still run as usual.
Pending comments collect in the Diff comments list above the prompt. Press Submit review (n) to send them all to Claude in one message. Each comment includes the file, the line and a few lines of surrounding context, so Claude knows exactly what you mean. The transcript shows only a one-line prompt naming the comments (Handle review comments c1, c2.), not the whole review. A review submitted while Claude works is sent when its turn ends.
Comment status icons:
| Icon | Status |
|---|---|
○ | Pending: written, not yet sent |
◐ | Sent: Claude has it |
✓ | Resolved, by you or by Claude (with its note) |
reply under a comment in the diff.edit on an unresolved comment. If Claude already read it, the edited text is sent again.Comments last for the session. They are not saved between sessions.
Very large diffs (past about 90,000 characters of text, roughly 800 lines or more) and errored tool calls keep Claude Code's normal diff view, without comment buttons. Long diffs below that are drawn in several parts, each with its own + buttons. Edits a subagent makes are not drawn as diffs in the transcript, so they have no comment buttons either.
Run the mod from a local clone instead of the installed copy:
git clone https://github.com/formstep/claude-mod-comment-diff
claude --plugin-dir ./claude-mod-comment-diff
Check and test it:
claude plugin validate .
claude plugin test .
tsc -p . # after the mod has loaded once, so .claude-plugin/types/ exists
Layout:
| Path | What it holds |
|---|---|
hooks/register.tsx | Hook wiring and drawing: the diff, the comment list, the comment box above the prompt, the resolve tool |
hooks/diff-rows.tsx | The diff's rows: each line with its +, and the comments under them |
hooks/threads.ts | The comments' state and every change to it: add, reply, edit, send, resolve, delete |
hooks/delivery.ts | Sending to Claude: as a prompt when idle, or on the next tool result mid-turn |
hooks/comment-text.ts | How comments read: status icons, locations, the review prompt |
hooks/diff.ts | Diff parsing and one-line hunks |
types/index.d.ts | State contract and shared types |
tests/ | claude plugin test suite |
hooks/register.tsx 483 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface, Register } from "claude-code";
3
4import type { CommentDraft, CommentTarget, DiffComment, LineContext, LineRef } from "../types";
5import {
6 countSummary,
7 location,
8 resolvedNote,
9 resolver,
10 STATUS,
11} from "./comment-text";
12import { attachToToolResult, flushAtTurnEnd, trackTurnStart } from "./delivery";
13import {
14 basename,
15 changeCounts,
16 changedFile,
17 changeSummary,
18 drawnText,
19 oneLineHunk,
20 shellChanges,
21 withoutShellDiff,
22} from "./diff";
23import { diffRows } from "./diff-rows";
24import {
25 deleteComment,
26 readThreads,
27 resolve,
28 saveMessage,
29 sendNow,
30 submitReview,
31 timeResolved,
32} from "./threads";
33
34const DIFF_TOOLS = new Set(["Edit", "Write"]);
35// A tree draws the first 100,000 characters of its texts and holds 20,000
36// nodes, about four a row; past either a diff is left to the engine.
37const MAX_TEXT = 90_000;
38const MAX_ROWS = 4_000;
39// A list row's columns besides the comment's id, text, location and its right
40// side: the icon, the spaces and gaps between them, and a margin so the row
41// never wraps.
42const ROW_CHROME_COLUMNS = 9;
43// What an open comment's buttons draw on the terminal, the gap between them included.
44const ROW_BUTTONS = "[ Resolve ] [ Delete ]";
45
46const drafting = atom({ plugin: "comment-diff", key: "draft" } as const, null);
47
48const oneLine = (text: string) => text.replace(/\s*\n\s*/g, " ");
49
50const truncate = (text: string, max: number) =>
51 text.length > max ? `${text.slice(0, Math.max(1, max - 1))}…` : text;
52
53async function remove($: EngineInterface, id: string) {
54 await deleteComment($, id);
55
56 const draft = await read($, drafting);
57
58 if (draft !== null && draft.target.kind !== "new" && draft.target.id === id) {
59 await endDraft($);
60 }
61}
62
63const heading = (target: CommentTarget) =>
64 target.kind === "new"
65 ? "New comment"
66 : `${target.kind === "reply" ? "Reply to" : "Edit"} ${target.id}`;
67
68type DraftStart = Pick<CommentDraft, "toolUseId" | "target">;
69
70// The line a draft is on, and the comment it adds to or edits.
71function draftLine(
72 draft: DraftStart,
73 all: readonly DiffComment[],
74): { ref: LineRef; context: LineContext; comment?: DiffComment } | undefined {
75 const { target } = draft;
76
77 if (target.kind === "new") {
78 const { filePath, index, line, context } = target;
79
80 return { ref: { toolUseId: draft.toolUseId, filePath, index, line }, context };
81 }
82
83 const { id } = target;
84 const comment = all.find((c) => c.id === id);
85
86 return comment === undefined ? undefined : { ref: comment, context: comment.context, comment };
87}
88
89// Whether two presses are on the same line's comment, or the same comment.
90function isSameTarget(a: DraftStart, b: DraftStart) {
91 const x = a.target;
92 const y = b.target;
93
94 if (x.kind === "new" || y.kind === "new") {
95 return x.kind === "new" && y.kind === "new" && a.toolUseId === b.toolUseId && x.index === y.index;
96 }
97
98 return x.kind === y.kind && x.id === y.id;
99}
100
101// Starts a comment, written in the prompt box under the box that shows its
102// line. The person's own draft is set aside until the comment is saved or
103// cancelled; an edit starts from the comment's last message. A second press
104// on the comment being written cancels it, as Esc would if the prompt let it.
105async function compose($: EngineInterface, started: DraftStart) {
106 const current = await read($, drafting);
107
108 if (current !== null && isSameTarget(current, started)) {
109 await endDraft($);
110 return;
111 }
112
113 const found = draftLine(started, await readThreads($));
114
115 if (found === undefined) {
116 $.ui.toast("That comment was deleted meanwhile");
117 return;
118 }
119
120 const aside = current?.aside ?? (await $.prompt.read()).text;
121 await update($, drafting, () => ({ ...started, aside }));
122
123 if (started.target.kind === "edit") {
124 await $.prompt.fill({ text: found.comment?.messages.at(-1) ?? "" });
125 } else if (current === null || current.target.kind === "edit") {
126 await $.prompt.fill({ text: "" });
127 }
128}
129
130// Ends the comment being written and gives the prompt back the person's draft.
131async function endDraft($: EngineInterface) {
132 const draft = await read($, drafting);
133
134 if (draft === null) {
135 return;
136 }
137
138 await update($, drafting, () => null);
139 await $.prompt.fill({ text: draft.aside });
140}
141
142// Saves `text` as the comment being written; resolves its id, or undefined
143// when nothing was saved.
144async function saveDraft($: EngineInterface, text: string, now: boolean) {
145 const draft = await read($, drafting);
146 const trimmed = text.trim();
147
148 if (draft === null || trimmed === "") {
149 return undefined;
150 }
151
152 const id = await saveMessage($, draft.toolUseId, draft.target, trimmed);
153 await endDraft($);
154
155 if (id !== undefined && now) {
156 await sendNow($, id);
157 }
158
159 return id;
160}
161
162export const register: Register = (on) => {
163 on("session.start", async ($, e, next) => {
164 await $.tool.register({
165 name: "resolve",
166 description:
167 'Marks review comments the person left on lines of your diffs as resolved. Call it once you have handled a comment, with the comment ids from the review message (like "c3") and a one-line note on what you did.',
168 inputSchema: {
169 type: "object",
170 properties: {
171 ids: { type: "array", items: { type: "string" }, description: 'Comment ids, like "c3"' },
172 note: { type: "string", description: "What you did, in one line" },
173 },
174 required: ["ids"],
175 },
176 isDeferred: false,
177 });
178
179 // 0.3.0 wrote comments in a pane, its draft holding no set-aside prompt.
180 const draft = await read($, drafting);
181
182 if (draft !== null && typeof draft.aside !== "string") {
183 await update($, drafting, () => null);
184 }
185
186 await $.ui.close({ id: "diff-comment" });
187 await timeResolved($);
188
189 return next(e);
190 });
191
192 // The matcher stays a literal: the engine's scan reads no imported names there.
193 on("tool.call", { tool: "mcp__comment-diff__resolve" }, async ($, e) => {
194 const asked = Array.isArray(e.ids)
195 ? e.ids.filter((id): id is string => typeof id === "string")
196 : [];
197 const note = typeof e.note === "string" ? e.note.trim() : undefined;
198 const wasResolved = new Set(
199 (await readThreads($)).filter((c) => c.status === "resolved").map((c) => c.id),
200 );
201 // Judged by what the write found, so a comment deleted meanwhile counts as unknown.
202 const found = new Set((await resolve($, new Set(asked), "claude", note)).map((c) => c.id));
203
204 const unknown = asked.filter((id) => !found.has(id));
205 const already = asked.filter((id) => found.has(id) && wasResolved.has(id));
206 const open = asked.filter((id) => found.has(id) && !wasResolved.has(id));
207
208 const parts = [
209 open.length > 0 ? `Resolved ${open.join(", ")}.` : "Resolved nothing.",
210 already.length > 0 ? `Already resolved: ${already.join(", ")}.` : "",
211 unknown.length > 0
212 ? `No such comment (the person may have deleted it): ${unknown.join(", ")}.`
213 : "",
214 ];
215
216 return { result: parts.filter(Boolean).join(" ") };
217 });
218
219 on("turn.start", trackTurnStart);
220 on("tool.call", attachToToolResult);
221 on("turn.complete", flushAtTurnEnd);
222
223 // While a comment is being written, Enter in the prompt saves it.
224 on("prompt.submit", async ($, e, next) => {
225 const draft = await read($, drafting);
226
227 if (draft === null || e.origin.kind !== "composer" || e.command !== undefined) {
228 return next(e);
229 }
230
231 if (e.text.trim() === "") {
232 return { drop: "Type the comment first" };
233 }
234
235 const id = await saveDraft($, e.text, false);
236 // Again once the engine has cleared the box of the dropped prompt.
237 $.clock.after(0, () => void $.prompt.fill({ text: id === undefined ? e.text : draft.aside }));
238
239 return { drop: id === undefined ? "That comment was deleted meanwhile" : `Comment ${id} saved` };
240 });
241
242 on("ui.render", { component: "ToolResult" }, async ($, e, next) => {
243 if (e.surface !== "terminal" || e.props.isErrored) {
244 return next(e);
245 }
246
247 const { tool, output } = e.props;
248 const edited = DIFF_TOOLS.has(tool) ? changedFile(output) : undefined;
249 const shell = tool === "Bash" ? shellChanges(output) : undefined;
250 const files = edited === undefined ? (shell?.files ?? []) : [edited];
251 const lines = files.flatMap((file) => file.hunks.flat());
252 const comments = (await readThreads($)).filter((c) => c.toolUseId === e.requestId);
253 const text = drawnText(lines) + comments.flatMap((c) => c.messages).join("").length;
254
255 if (lines.length === 0 || text > MAX_TEXT || lines.length > MAX_ROWS) {
256 return next(e);
257 }
258
259 const elements = $.ui.resolve(e);
260 const { Box, Text } = elements;
261 const open = (target: CommentTarget) => compose($, { toolUseId: e.requestId, target });
262
263 if (shell === undefined) {
264 return (
265 <Box flexDirection="column">
266 <Text>
267 {changeSummary(lines)}
268 <Text dimColor> · + to comment</Text>
269 </Text>
270 {files.flatMap((file) => diffRows(elements, file, 0, comments, open))}
271 </Box>
272 );
273 }
274
275 // The command's own output as the engine draws it, and under it each file
276 // it changed, headed as the engine heads it.
277 const ran = await next({ ...e, props: { ...e.props, output: withoutShellDiff(output) } });
278 const cwd = `${await $.session.cwd()}/`;
279 let first = 0;
280
281 return (
282 <Box flexDirection="column">
283 {ran}
284 {shell.files.map((file) => {
285 const fileLines = file.hunks.flat();
286 const rows = diffRows(elements, file, first, comments, open);
287 first += fileLines.length;
288
289 return (
290 <Box flexDirection="column">
291 <Text>
292 {file.change}{" "}
293 <Text weight="bold">
294 {file.filePath.startsWith(cwd) ? file.filePath.slice(cwd.length) : file.filePath}
295 </Text>{" "}
296 {changeCounts(fileLines)}
297 <Text dimColor> · + to comment</Text>
298 </Text>
299 {rows}
300 </Box>
301 );
302 })}
303 {shell.moreFiles > 0 && (
304 <Text dimColor>
305 … {shell.moreFiles} more {shell.moreFiles === 1 ? "file" : "files"} changed
306 </Text>
307 )}
308 </Box>
309 );
310 });
311
312 // The comment being written: its line, the lines around it and the keys,
313 // above the prompt it is typed in. A pane would dock beside a wide
314 // transcript and narrow it, and every diff in it would be drawn again.
315 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
316 const draft = await read($, drafting);
317 const found = draft === null ? undefined : draftLine(draft, await readThreads($));
318
319 if (draft === null || found === undefined || e.props.hasSurvey) {
320 return next(e);
321 }
322
323 const { Box, Button, Code, Text } = $.ui.resolve(e);
324 const isEdit = draft.target.kind === "edit";
325 const messages = found.comment?.messages ?? [];
326 const earlier = isEdit ? messages.slice(0, -1) : messages;
327 const open = found.comment?.status === "resolved" ? undefined : found.comment;
328 const { lines, at } = found.context;
329
330 return (
331 <Box flexDirection="column" borderStyle="round" borderDimColor paddingX={1}>
332 <Box flexDirection="row" gap={1}>
333 <Box flexDirection="row" flexGrow={1} flexShrink={1} gap={1}>
334 <Text weight="bold">{heading(draft.target)}</Text>
335 <Text dimColor>{location(found.ref, basename(found.ref.filePath))}</Text>
336 </Box>
337 <Box flexDirection="row" flexShrink={0} gap={1}>
338 {open !== undefined && (
339 <>
340 <Button
341 key="draft-resolve"
342 onPress={async () => {
343 await resolve($, new Set([open.id]), "person");
344 await endDraft($);
345 }}
346 >
347 <Text color="success">Resolve</Text>
348 </Button>
349 <Button key="draft-delete" onPress={() => remove($, open.id)}>
350 <Text color="error">Delete</Text>
351 </Button>
352 </>
353 )}
354 {/* Ctrl+Enter cannot stand for it: the prompt submits on that chord. */}
355 <Button
356 key="draft-send-now"
357 onPress={async () => {
358 if ((await saveDraft($, (await $.prompt.read()).text, true)) === undefined) {
359 $.ui.toast("Type the comment in the prompt first");
360 }
361 }}
362 >
363 Send now
364 </Button>
365 <Button key="draft-cancel" onPress={() => endDraft($)}>
366 Cancel
367 </Button>
368 </Box>
369 </Box>
370 <Box flexDirection="column" marginTop={1}>
371 {lines.map((line, i) => (
372 <Box flexDirection="row">
373 <Text color="warning">{i === at ? "›" : " "}</Text>
374 <Text> </Text>
375 <Box flexGrow={1} flexShrink={1}>
376 <Code format="diff" path={found.ref.filePath} source={oneLineHunk(line)} />
377 </Box>
378 </Box>
379 ))}
380 </Box>
381 {earlier.length > 0 && (
382 <Box flexDirection="column" marginTop={1}>
383 {earlier.map((message) => (
384 <Text dimColor>{message}</Text>
385 ))}
386 </Box>
387 )}
388 <Box key="draft-keys" marginTop={1}>
389 <Text>
390 <Text dimColor>Type it in the prompt · </Text>
391 <Text weight="bold">Enter</Text>
392 <Text dimColor> {isEdit ? "save" : "add to review queue"}</Text>
393 </Text>
394 </Box>
395 </Box>
396 );
397 });
398
399 on("ui.render", { component: "PromptHint" }, async ($, e, next) => {
400 const list = (await readThreads($)).filter((c) => !c.unlisted);
401 const hint = await next(e);
402
403 if (list.length === 0) {
404 return hint;
405 }
406
407 const { Box, Button, Text } = $.ui.resolve(e);
408 const columns = e.viewport?.columns ?? 80;
409 const pending = list.filter((c) => c.status === "pending").length;
410 const ordered = [
411 ...list.filter((c) => c.status !== "resolved"),
412 ...list.filter((c) => c.status === "resolved"),
413 ];
414
415 return (
416 <Box flexDirection="column">
417 {hint}
418 <Box flexDirection="row" gap={1} marginTop={1}>
419 <Box flexDirection="row" flexGrow={1} gap={1}>
420 <Text weight="bold">Diff comments</Text>
421 <Text dimColor>{countSummary(list)}</Text>
422 </Box>
423 {pending > 0 && (
424 <Button
425 key="submit-review"
426 variant="primary"
427 label={`Submit review (${pending})`}
428 onPress={() => submitReview($)}
429 />
430 )}
431 </Box>
432 {ordered.map((comment) => {
433 const isOpen = comment.status !== "resolved";
434 const where = location(comment, basename(comment.filePath), "removed");
435 const side = isOpen ? ROW_BUTTONS : resolver(comment);
436 const room =
437 columns - comment.id.length - where.length - side.length - ROW_CHROME_COLUMNS;
438 const said = comment.messages.map(oneLine).join(" · ");
439
440 return (
441 <Box key={`comment-${comment.id}`} flexDirection="row" gap={1}>
442 <Box flexDirection="row" flexGrow={1} flexShrink={1}>
443 <Button
444 key={`go-${comment.id}`}
445 plain
446 onPress={() =>
447 compose($, {
448 toolUseId: comment.toolUseId,
449 // A resolved comment cannot be edited; a reply reopens it.
450 target: { kind: isOpen ? "edit" : "reply", id: comment.id },
451 })
452 }
453 >
454 <Text color={STATUS[comment.status].color}>{STATUS[comment.status].icon}</Text>{" "}
455 <Text dimColor>{comment.id}</Text>{" "}
456 <Text dimColor={!isOpen}>{truncate(said, Math.max(12, room))}</Text>
457 {" "}
458 <Text dimColor>{where}</Text>
459 </Button>
460 </Box>
461 {isOpen ? (
462 <Box flexDirection="row" flexShrink={0} gap={1}>
463 <Button
464 key={`resolve-${comment.id}`}
465 onPress={() => resolve($, new Set([comment.id]), "person")}
466 >
467 <Text color="success">Resolve</Text>
468 </Button>
469 <Button key={`delete-${comment.id}`} onPress={() => remove($, comment.id)}>
470 <Text color="error">Delete</Text>
471 </Button>
472 </Box>
473 ) : (
474 <Text dimColor>{side}</Text>
475 )}
476 </Box>
477 );
478 })}
479 </Box>
480 );
481 });
482};
483hooks/comment-text.ts 79 lines1import type { CommentStatus, DiffComment, LineRef } from "../types";
2import { diffRow, lineNumber } from "./diff";
3
4export const RESOLVE_TOOL = "mcp__comment-diff__resolve";
5
6export const STATUS: Record<CommentStatus, { icon: string; color: string }> = {
7 pending: { icon: "○", color: "warning" },
8 sent: { icon: "◐", color: "suggestion" },
9 resolved: { icon: "✓", color: "success" },
10};
11
12export function location(ref: LineRef, path = ref.filePath, removedTag = "removed line"): string {
13 const removed = ref.line.kind === "removed" ? ` (${removedTag})` : "";
14
15 return `${path}:${lineNumber(ref.line)}${removed}`;
16}
17
18export const resolver = (comment: DiffComment) =>
19 `resolved by ${comment.resolvedBy === "claude" ? "Claude" : "you"}`;
20
21export function resolvedNote(comment: DiffComment) {
22 if (comment.status !== "resolved") {
23 return "";
24 }
25
26 return comment.resolution
27 ? ` — ${resolver(comment)}: ${comment.resolution}`
28 : ` — ${resolver(comment)}`;
29}
30
31export function countSummary(list: readonly DiffComment[]) {
32 const count = (status: CommentStatus) => list.filter((c) => c.status === status).length;
33
34 return `${count("pending")} pending · ${count("sent")} sent · ${count("resolved")} resolved`;
35}
36
37const quote = (text: string) =>
38 text
39 .split("\n")
40 .map((line) => `> ${line}`)
41 .join("\n");
42
43function messagesFor(comment: DiffComment): string {
44 const unread = comment.messages.slice(comment.sent);
45
46 if (comment.sent === 0) {
47 return unread.join("\n\n");
48 }
49
50 const read = comment.messages.slice(0, comment.sent).map(quote).join("\n");
51
52 return `Already sent:\n${read}\n\nNew:\n${unread.join("\n\n")}`;
53}
54
55export function reviewPrompt(comments: readonly DiffComment[]): string {
56 const intro = [
57 `Review comments on your diffs (${comments.length}).`,
58 `Handle each one. When a comment is handled, call the ${RESOLVE_TOOL} tool with its id and a one-line note on what you did. If you disagree or need more input, answer instead of resolving it.`,
59 ].join("\n");
60
61 const blocks = comments.map((comment) =>
62 [
63 `[${comment.id}] ${location(comment)}${comment.edited ? " (edited since you read it)" : ""}`,
64 "```diff",
65 diffRow(comment.line),
66 "```",
67 messagesFor(comment),
68 ].join("\n"),
69 );
70
71 return [intro, ...blocks].join("\n\n");
72}
73
74// The prompt that starts Claude's turn on a review: the review itself goes in
75// as a row the person does not see.
76export function reviewLine(ids: readonly string[]): string {
77 return `Handle review ${ids.length === 1 ? "comment" : "comments"} ${ids.join(", ")}.`;
78}
79hooks/delivery.ts 115 lines1import type { EngineInterface, Hook } from "claude-code";
2
3import { reviewLine } from "./comment-text";
4
5// What goes to Claude, the comments it carries, and what to do when it does
6// not arrive.
7export type Parcel = {
8 ids: readonly string[];
9 text: string;
10 onFailure: (reason: string) => Promise<void>;
11};
12
13// A plugin's prompt reaches Claude only once it is idle. Mid-turn, comments
14// sent now ride on the next tool result of the main loop instead, and a
15// review waits for the turn to end.
16let isMainTurnRunning = false;
17let agentTurnsRunning = 0;
18let outbox: Parcel[] = [];
19let held: Parcel[] = [];
20
21// Submits `text` for Claude; resolves why it was not sent, if it was not.
22//
23// The transcript draws a plugin's prompt whole and runs none of the plugin's
24// own hooks on that row, so the text goes in as a row the person does not
25// see, and the prompt that starts the turn only names the comments. Called
26// while Claude is idle, so that row is read with the prompt, not mid-turn.
27async function trySubmit($: EngineInterface, text: string, ids: readonly string[]) {
28 try {
29 const appended = await $.session.append({
30 message: { type: "user", content: [{ type: "text", text }] },
31 });
32 const prompt = appended.deny === undefined ? reviewLine(ids) : text;
33 const sent = await $.prompt.submit({ text: prompt, asUser: true });
34
35 return sent.drop === undefined ? undefined : `The comments were not sent: ${sent.drop}`;
36 } catch (error) {
37 return `Could not send the comments: ${error instanceof Error ? error.message : String(error)}`;
38 }
39}
40
41// Submits the parcels as one message, and hands each the reason it was not sent.
42async function submit($: EngineInterface, parcels: readonly Parcel[]) {
43 const failure = await trySubmit(
44 $,
45 parcels.map((p) => p.text).join("\n\n"),
46 parcels.flatMap((p) => p.ids),
47 );
48
49 if (failure !== undefined) {
50 await Promise.all(parcels.map((p) => p.onFailure(failure)));
51 }
52}
53
54// Mid-turn, a parcel sent `now` is queued for the next tool result, and a
55// review for the turn's end.
56export async function deliver($: EngineInterface, parcel: Parcel, mode: "review" | "now") {
57 if (isMainTurnRunning) {
58 if (mode === "now") {
59 outbox = [...outbox, parcel];
60 } else {
61 held = [...held, parcel];
62 }
63 return;
64 }
65
66 await submit($, [parcel]);
67}
68
69export const trackTurnStart: Hook<"turn.start"> = async (_$, e, next) => {
70 if (e.agentId === undefined) {
71 isMainTurnRunning = true;
72 } else {
73 agentTurnsRunning += 1;
74 }
75
76 return next(e);
77};
78
79export const attachToToolResult: Hook<"tool.call"> = async (_$, e, next) => {
80 const done = await next(e);
81
82 // While a subagent runs, a tool result may be its, not Claude's.
83 if (done.deny !== undefined || outbox.length === 0 || agentTurnsRunning > 0) {
84 return done;
85 }
86
87 const delivered = outbox;
88 outbox = [];
89
90 return { ...done, context: [...(done.context ?? []), ...delivered.map((p) => p.text)] };
91};
92
93// What no tool result carried, and every review held, goes out as a prompt
94// once the main turn ends.
95// Not awaited: the prompt waits for Claude to be idle, after this hook.
96export const flushAtTurnEnd: Hook<"turn.complete"> = async ($, e, next) => {
97 const done = await next(e);
98
99 if (e.agentId !== undefined) {
100 agentTurnsRunning = Math.max(0, agentTurnsRunning - 1);
101 return done;
102 }
103
104 isMainTurnRunning = false;
105
106 if (outbox.length > 0 || held.length > 0) {
107 const left = [...outbox, ...held];
108 outbox = [];
109 held = [];
110 void submit($, left);
111 }
112
113 return done;
114};
115hooks/diff.ts 173 lines1import type { DiffLine } from "../types";
2
3export type ChangedFile = {
4 filePath: string;
5 hunks: DiffLine[][];
6};
7
8// A file a shell command changed, and the word the engine heads its diff with.
9export type ShellChange = ChangedFile & { change: "Created" | "Deleted" | "Updated" };
10
11// The files a Bash call changed, as the engine diffed them, and how many more
12// it changed than it diffed.
13export type ShellChanges = { files: ShellChange[]; moreFiles: number };
14
15// Lines of the hunk kept on each side of a commented line.
16export const CONTEXT_LINES = 3;
17
18type Hunk = {
19 oldStart: number;
20 newStart: number;
21 lines: readonly string[];
22};
23
24const MARKER = { added: "+", removed: "-", context: " " } as const;
25
26const isRecord = (value: unknown): value is Record<string, unknown> =>
27 typeof value === "object" && value !== null;
28
29const isHunk = (value: unknown): value is Hunk =>
30 isRecord(value) &&
31 typeof value.oldStart === "number" &&
32 typeof value.newStart === "number" &&
33 Array.isArray(value.lines);
34
35// Render trees refuse control characters other than tab and newline, and CRLF
36// files leave a trailing \r on every patch line.
37const printable = (text: string) => text.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "");
38
39function hunkLines(hunk: Hunk): DiffLine[] {
40 let oldLine = hunk.oldStart;
41 let newLine = hunk.newStart;
42 const lines: DiffLine[] = [];
43
44 for (const raw of hunk.lines) {
45 const text = printable(raw.slice(1));
46
47 if (raw.startsWith("+")) {
48 lines.push({ kind: "added", oldLine, newLine, text });
49 newLine += 1;
50 } else if (raw.startsWith("-")) {
51 lines.push({ kind: "removed", oldLine, newLine, text });
52 oldLine += 1;
53 } else if (raw.startsWith(" ")) {
54 lines.push({ kind: "context", oldLine, newLine, text });
55 oldLine += 1;
56 newLine += 1;
57 }
58 }
59
60 return lines;
61}
62
63function createdLines(content: string): DiffLine[] {
64 const rows = content.split("\n");
65
66 if (rows.at(-1) === "") {
67 rows.pop();
68 }
69
70 return rows.map((row, i) => ({
71 kind: "added",
72 oldLine: 1,
73 newLine: i + 1,
74 text: printable(row),
75 }));
76}
77
78export function changedFile(output: unknown): ChangedFile | undefined {
79 if (!isRecord(output) || typeof output.filePath !== "string") {
80 return undefined;
81 }
82
83 const patch = Array.isArray(output.structuredPatch) ? output.structuredPatch.filter(isHunk) : [];
84
85 if (patch.length > 0) {
86 return { filePath: output.filePath, hunks: patch.map(hunkLines) };
87 }
88
89 if (output.type === "create" && typeof output.content === "string") {
90 return { filePath: output.filePath, hunks: [createdLines(output.content)] };
91 }
92
93 return undefined;
94}
95
96// A Bash result's `bashEditDiff`: the working-tree changes the command made.
97export function shellChanges(output: unknown): ShellChanges | undefined {
98 const diff = isRecord(output) ? output.bashEditDiff : undefined;
99
100 if (!isRecord(diff) || !Array.isArray(diff.files)) {
101 return undefined;
102 }
103
104 const files = diff.files.filter(isRecord).flatMap((file): ShellChange[] => {
105 if (typeof file.filePath !== "string" || !Array.isArray(file.hunks)) {
106 return [];
107 }
108
109 const change = file.created === true ? "Created" : file.deleted === true ? "Deleted" : "Updated";
110
111 return [{ filePath: file.filePath, hunks: file.hunks.filter(isHunk).map(hunkLines), change }];
112 });
113
114 return { files, moreFiles: typeof diff.moreFiles === "number" ? diff.moreFiles : 0 };
115}
116
117// A Bash result less its diff, which the engine then leaves out of its drawing.
118export function withoutShellDiff(output: unknown): unknown {
119 if (!isRecord(output)) {
120 return output;
121 }
122
123 const rest = { ...output };
124 delete rest.bashEditDiff;
125
126 return rest;
127}
128
129export const basename = (path: string) => path.slice(path.lastIndexOf("/") + 1);
130
131const plural = (n: number) => `${n} ${n === 1 ? "line" : "lines"}`;
132
133export function changeSummary(lines: readonly DiffLine[]): string {
134 const added = lines.filter((l) => l.kind === "added").length;
135 const removed = lines.filter((l) => l.kind === "removed").length;
136
137 if (added > 0 && removed > 0) {
138 return `Added ${plural(added)}, removed ${plural(removed)}`;
139 }
140
141 return added > 0 ? `Added ${plural(added)}` : `Removed ${plural(removed)}`;
142}
143
144// The counts after a changed file's name, as the engine writes them: `(+3 -1)`.
145export function changeCounts(lines: readonly DiffLine[]): string {
146 const added = lines.filter((l) => l.kind === "added").length;
147 const removed = lines.filter((l) => l.kind === "removed").length;
148
149 return `(+${added} -${removed})`;
150}
151
152export const lineNumber = (line: DiffLine) =>
153 line.kind === "removed" ? line.oldLine : line.newLine;
154
155// The line as a unified diff writes it: marker, then text.
156export const diffRow = (line: DiffLine) => `${MARKER[line.kind]}${line.text}`;
157
158// A one-line unified hunk, so the engine's own diff renderer draws each row
159// with its gutter, marker and colours while we put a button beside it.
160export function oneLineHunk(line: DiffLine): string {
161 const oldRange =
162 line.kind === "added" ? `${Math.max(0, line.oldLine - 1)},0` : `${line.oldLine},1`;
163 const newRange =
164 line.kind === "removed" ? `${Math.max(0, line.newLine - 1)},0` : `${line.newLine},1`;
165
166 return `@@ -${oldRange} +${newRange} @@\n${diffRow(line)}`;
167}
168
169// The characters of text a diff's rows draw: each line's one-line hunk and
170// its + label.
171export const drawnText = (lines: readonly DiffLine[]) =>
172 lines.reduce((sum, line) => sum + oneLineHunk(line).length + 1, 0);
173hooks/diff-rows.tsx 102 lines1import type { ElementTable, RenderElement } from "claude-code";
2
3import type { CommentTarget, DiffComment, DiffLine, LineContext } from "../types";
4import { resolvedNote, STATUS } from "./comment-text";
5import { basename, CONTEXT_LINES, oneLineHunk } from "./diff";
6import type { ChangedFile } from "./diff";
7
8type RowElements = Pick<ElementTable<"terminal">, "Box" | "Button" | "Code" | "Text">;
9
10// The line at `at` with the lines of its hunk around it.
11function contextAt(hunk: readonly DiffLine[], at: number): LineContext {
12 const from = Math.max(0, at - CONTEXT_LINES);
13
14 return { lines: hunk.slice(from, at + CONTEXT_LINES + 1), at: at - from };
15}
16
17// The file's rows, each line with its + and any comment on it under it; the
18// lines are numbered across the tool call's files from `first`.
19//
20// Drawn in the hooks module's tree, not a Client's: the engine seats a pane
21// opened for a press on a Button a hook drew at any width, and one opened for
22// a Client's message only from 144 columns.
23export function diffRows(
24 { Box, Button, Code, Text }: RowElements,
25 file: ChangedFile,
26 first: number,
27 comments: readonly DiffComment[],
28 open: (target: CommentTarget) => void,
29): RenderElement[] {
30 // The language is read off the name; a short path keeps every row small.
31 const path = basename(file.filePath);
32 const byIndex = new Map(comments.map((c) => [c.index, c]));
33
34 const note = (comment: DiffComment) => (
35 <Box flexDirection="row" gap={1} paddingLeft={4}>
36 <Text color={STATUS[comment.status].color}>{STATUS[comment.status].icon}</Text>
37 <Text dimColor>{comment.id}</Text>
38 <Box flexDirection="column" flexShrink={1}>
39 {comment.messages.map((message, i) => (
40 <Text dimColor={comment.status === "resolved"}>
41 {message}
42 <Text dimColor>{i === comment.messages.length - 1 ? resolvedNote(comment) : ""}</Text>
43 </Text>
44 ))}
45 </Box>
46 {comment.status !== "resolved" && (
47 <Button
48 key={`edit-${comment.id}`}
49 plain
50 dimColor
51 label="edit"
52 onPress={() => open({ kind: "edit", id: comment.id })}
53 />
54 )}
55 <Button
56 key={`reply-${comment.index}`}
57 plain
58 dimColor
59 label="reply"
60 onPress={() => open({ kind: "reply", id: comment.id })}
61 />
62 </Box>
63 );
64
65 const rows: RenderElement[] = [];
66 let index = first;
67
68 for (const [hunkIndex, hunk] of file.hunks.entries()) {
69 if (hunkIndex > 0) {
70 rows.push(<Text dimColor> ...</Text>);
71 }
72
73 for (const [at, line] of hunk.entries()) {
74 const here = index;
75 index += 1;
76
77 const comment = byIndex.get(here);
78 const target: CommentTarget =
79 comment === undefined
80 ? { kind: "new", filePath: file.filePath, index: here, line, context: contextAt(hunk, at) }
81 : { kind: "reply", id: comment.id };
82
83 // The + takes the cells either side of it: a terminal that reports the
84 // pointer within a cell lets a click a hair off a one-cell label miss.
85 rows.push(
86 <Box flexDirection="row">
87 <Button key={`add-${here}`} plain dimColor label=" + " onPress={() => open(target)} />
88 <Box flexGrow={1} flexShrink={1}>
89 <Code format="diff" path={path} source={oneLineHunk(line)} />
90 </Box>
91 </Box>,
92 );
93
94 if (comment !== undefined) {
95 rows.push(note(comment));
96 }
97 }
98 }
99
100 return rows;
101}
102hooks/threads.ts 238 lines1import { atom, read, update } from "claude-code";
2import type { EngineInterface } from "claude-code";
3
4import type { DiffComment, CommentTarget, Resolver } from "../types";
5import { reviewPrompt } from "./comment-text";
6import { deliver } from "./delivery";
7
8// How long a resolved comment stays in the list above the prompt.
9const RESOLVED_LISTED_MS = 60_000;
10
11const threads = atom({ plugin: "comment-diff", key: "threads" } as const, []);
12const seq = atom({ plugin: "comment-diff", key: "seq" } as const, 0);
13
14// The engine's scan follows $ into a function declaration only, and reads an
15// atom only in the file that declares it, so this file owns the comments.
16export function readThreads($: EngineInterface) {
17 return read($, threads);
18}
19
20// Rewrites the comments `ids` names in one write; resolves the list as written.
21function patch(
22 $: EngineInterface,
23 ids: ReadonlySet<string>,
24 change: (comment: DiffComment) => DiffComment,
25) {
26 return update($, threads, (all) => all.map((c) => (ids.has(c.id) ? change(c) : c)));
27}
28
29async function addComment(
30 $: EngineInterface,
31 toolUseId: string,
32 { filePath, index, line, context }: Extract<CommentTarget, { kind: "new" }>,
33 text: string,
34) {
35 const n = await update($, seq, (last) => last + 1);
36 const comment: DiffComment = {
37 toolUseId,
38 filePath,
39 index,
40 line,
41 context,
42 id: `c${n}`,
43 messages: [text],
44 sent: 0,
45 status: "pending",
46 };
47 await update($, threads, (all) => [...all, comment]);
48
49 return comment.id;
50}
51
52// A message added to a resolved comment reopens it, so Claude reads it.
53async function extendComment($: EngineInterface, id: string, text: string) {
54 const all = await patch(
55 $,
56 new Set([id]),
57 ({ resolvedBy, resolution, resolvedAt, unlisted, ...rest }) => ({
58 ...rest,
59 messages: [...rest.messages, text],
60 status: "pending",
61 }),
62 );
63
64 if (!all.some((c) => c.id === id)) {
65 $.ui.toast(`${id} was deleted meanwhile, so the message was not added`);
66 return undefined;
67 }
68
69 return id;
70}
71
72async function editComment($: EngineInterface, id: string, text: string) {
73 const all = await patch($, new Set([id]), (c) => {
74 if (c.status === "resolved") {
75 return c;
76 }
77
78 const last = c.messages.length - 1;
79 const messages = [...c.messages.slice(0, last), text];
80
81 // Claude has read the message being edited: it goes out again.
82 return last < c.sent
83 ? { ...c, messages, sent: last, status: "pending", edited: true }
84 : { ...c, messages };
85 });
86 const after = all.find((c) => c.id === id);
87
88 if (after === undefined || after.status === "resolved") {
89 $.ui.toast(`${id} was ${after ? "resolved" : "deleted"} meanwhile, so the edit was not saved`);
90 return undefined;
91 }
92
93 return id;
94}
95
96// Saves what the diff view posted; resolves the comment's id, or undefined
97// when it was not saved.
98export function saveMessage(
99 $: EngineInterface,
100 toolUseId: string,
101 target: CommentTarget,
102 text: string,
103) {
104 switch (target.kind) {
105 case "new":
106 return addComment($, toolUseId, target, text);
107 case "reply":
108 return extendComment($, target.id, text);
109 case "edit":
110 return editComment($, target.id, text);
111 }
112}
113
114async function submitComments(
115 $: EngineInterface,
116 toSend: readonly DiffComment[],
117 mode: "review" | "now",
118) {
119 if (toSend.length === 0) {
120 return;
121 }
122
123 const before = new Map(toSend.map((c) => [c.id, c]));
124 const text = reviewPrompt(toSend);
125
126 // Marked before submitting: the submit waits until Claude is idle, and a
127 // second press meanwhile must not send the same comments twice.
128 await patch($, new Set(before.keys()), (c) => {
129 if (c.status === "resolved") {
130 return c;
131 }
132
133 const { edited, ...rest } = c;
134
135 return { ...rest, sent: c.messages.length, status: "sent" };
136 });
137
138 await deliver(
139 $,
140 { ids: [...before.keys()], text, onFailure: (reason) => putBack($, before, reason) },
141 mode,
142 );
143}
144
145// A send that did not arrive leaves its comments pending, as Claude last read
146// them, even where a message was added or edited while it was on its way.
147async function putBack(
148 $: EngineInterface,
149 before: ReadonlyMap<string, DiffComment>,
150 reason: string,
151) {
152 $.ui.toast(reason);
153 await patch($, new Set(before.keys()), (c) => {
154 const old = before.get(c.id);
155
156 if (old === undefined || c.status === "resolved") {
157 return c;
158 }
159
160 const { edited, ...rest } = c;
161
162 return {
163 ...rest,
164 sent: Math.min(c.sent, old.sent),
165 status: "pending",
166 ...(old.edited ? { edited: true } : {}),
167 };
168 });
169}
170
171export async function sendNow($: EngineInterface, id: string) {
172 const comment = (await readThreads($)).find((c) => c.id === id);
173
174 if (comment?.status === "pending") {
175 await submitComments($, [comment], "now");
176 }
177}
178
179export async function submitReview($: EngineInterface) {
180 await submitComments(
181 $,
182 (await readThreads($)).filter((c) => c.status === "pending"),
183 "review",
184 );
185}
186
187// Resolves the list as written.
188export async function resolve(
189 $: EngineInterface,
190 ids: ReadonlySet<string>,
191 by: Resolver,
192 note?: string,
193) {
194 const at = await $.clock.now();
195 const all = await patch($, ids, (c) =>
196 c.status === "resolved"
197 ? c
198 : {
199 ...c,
200 status: "resolved",
201 resolvedBy: by,
202 resolvedAt: at,
203 ...(note ? { resolution: note } : {}),
204 },
205 );
206 unlistLater($, at, at);
207
208 return all;
209}
210
211// Takes the comments resolved at `at` out of the list; one reopened since, or
212// resolved again, has another time and stays.
213function unlistLater($: EngineInterface, at: number, now: number) {
214 $.clock.after(Math.max(0, at + RESOLVED_LISTED_MS - now), () => {
215 void update($, threads, (all) =>
216 all.map((c): DiffComment =>
217 c.status === "resolved" && (c.resolvedAt ?? 0) === at ? { ...c, unlisted: true } : c,
218 ),
219 );
220 });
221}
222
223// Timers end with the module, so after a reload each resolved comment still
224// listed is timed again, from when it was resolved.
225export async function timeResolved($: EngineInterface) {
226 const now = await $.clock.now();
227 const listed = (await readThreads($)).filter((c) => c.status === "resolved" && !c.unlisted);
228
229 for (const at of new Set(listed.map((c) => c.resolvedAt ?? 0))) {
230 unlistLater($, at, now);
231 }
232}
233
234// Resolved comments cannot be deleted: they stay as a record.
235export async function deleteComment($: EngineInterface, id: string) {
236 await update($, threads, (all) => all.filter((c) => c.id !== id || c.status === "resolved"));
237}
238types/index.d.ts 69 lines1export type DiffLineKind = "added" | "removed" | "context";
2
3export type DiffLine = {
4 kind: DiffLineKind;
5 oldLine: number;
6 newLine: number;
7 text: string;
8};
9
10export type LineRef = {
11 toolUseId: string;
12 filePath: string;
13 index: number;
14 line: DiffLine;
15};
16
17export type LineContext = {
18 lines: DiffLine[];
19 at: number;
20};
21
22export type CommentStatus = "pending" | "sent" | "resolved";
23
24export type Resolver = "person" | "claude";
25
26export type DiffComment = LineRef & {
27 id: string;
28 messages: string[];
29 sent: number;
30 status: CommentStatus;
31 context: LineContext;
32 edited?: true;
33 resolvedBy?: Resolver;
34 resolution?: string;
35 // When it was resolved, in milliseconds since the epoch.
36 resolvedAt?: number;
37 // Out of the list above the prompt, a while after it was resolved; the
38 // diff keeps it under its line.
39 unlisted?: true;
40};
41
42// What a press in the diff opens the comment pane on: a new comment on a
43// line, a reply to a comment, or an edit of its last message.
44export type CommentTarget =
45 | { kind: "new"; filePath: string; index: number; line: DiffLine; context: LineContext }
46 | { kind: "reply" | "edit"; id: string };
47
48// The comment being written in the prompt box, the tool call whose diff it is
49// on, and the person's own draft, set aside until the comment is done.
50export type CommentDraft = {
51 toolUseId: string;
52 target: CommentTarget;
53 aside: string;
54};
55
56declare module "claude-code" {
57 interface PluginState {
58 "comment-diff": {
59 threads: DiffComment[];
60 draft: CommentDraft | null;
61 seq: number;
62 };
63 }
64
65 interface McpToolInputs {
66 "mcp__comment-diff__resolve": { ids: string[]; note?: string };
67 }
68}
69