SLOPSHOPPER

comment-diff

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

newbandspinnerrowsguardtoast
v0.4.3MITupdated 2026-10-25formstep/claude-mod-comment-diff
A shopper browsing a rack in a slop shop
README

https://github.com/user-attachments/assets/599c0337-7678-4df6-b63a-dd3ee40f9566

comment-diff

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.

Features

  • Line comments on every diff. Each 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.
  • Reviews or single comments. Collect several comments into a review and send them together, or send one comment straight away.
  • Overview above the prompt. A "Diff comments" list above the prompt box shows every comment with its status, file and line, plus a Submit review button.
  • Threads. Reply to a comment to add to it, edit your last message, or reopen a resolved comment by replying to it.
  • Claude resolves what it fixed. The mod gives Claude a 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.
  • Works mid-turn. A comment sent while Claude is busy is delivered with its next tool result, so it does not have to wait for the turn to end.

Requirements

  • Claude Code 2.1.302 or later, in a terminal session. The diff view is drawn in the terminal only; other surfaces keep the normal diff.
  • Mouse clicks enabled in your terminal (the +, edit, reply and the buttons are clickable).

Install

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.

Update or remove

claude plugin update comment-diff
claude plugin uninstall comment-diff

Usage

1. Comment on a line

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 buttonAction
EnterAdd the comment to the review (pending)
Send nowSend this comment to Claude now
Cancel, or the same + againDrop 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.

2. Submit the review

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.

3. Follow up

Comment status icons:

IconStatus
○Pending: written, not yet sent
◐Sent: Claude has it
✓Resolved, by you or by Claude (with its note)
  • Open a comment: click it in the list. The box opens with its diff context and the comment's last message in the prompt, ready to rewrite, plus Resolve and Delete. A resolved comment opens as a reply, which reopens it.
  • Reply: click reply under a comment in the diff.
  • Edit: click edit on an unresolved comment. If Claude already read it, the edited text is sent again.
  • Resolve / Delete: use the buttons in the list or the box. Resolved comments cannot be deleted; they stay under their line in the diff as a record.
  • Cleanup: a resolved comment leaves the list a minute after it is resolved, so the list holds only what is still open.
  • Reopen: reply to a resolved comment and it becomes pending again.

Comments last for the session. They are not saved between sessions.

Limits

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.

Development

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:

PathWhat it holds
hooks/register.tsxHook wiring and drawing: the diff, the comment list, the comment box above the prompt, the resolve tool
hooks/diff-rows.tsxThe diff's rows: each line with its +, and the comments under them
hooks/threads.tsThe comments' state and every change to it: add, reply, edit, send, resolve, delete
hooks/delivery.tsSending to Claude: as a prompt when idle, or on the next tool result mid-turn
hooks/comment-text.tsHow comments read: status icons, locations, the review prompt
hooks/diff.tsDiff parsing and one-line hunks
types/index.d.tsState contract and shared types
tests/claude plugin test suite

License

MIT

Source 7 files
hooks/register.tsx 483 lines
1import { 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};
483
hooks/comment-text.ts 79 lines
1import 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}
79
hooks/delivery.ts 115 lines
1import 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};
115
hooks/diff.ts 173 lines
1import 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);
173
hooks/diff-rows.tsx 102 lines
1import 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}
102
hooks/threads.ts 238 lines
1import { 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}
238
types/index.d.ts 69 lines
1export 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