Skip to content

Commit ba1a169

Browse files
authored
Add bulk live-comment apply for agent reviews (#162)
1 parent 0d92723 commit ba1a169

19 files changed

+1017
-27
lines changed

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ Use it to:
162162
- inspect the current review context
163163
- jump to a file, hunk, or line
164164
- reload the current window with a different `diff` or `show` command
165-
- add, list, and remove inline comments (by hunk or by line)
165+
- add, batch-apply, list, and remove inline comments (by hunk or by line)
166166

167167
Most users only need `hunk session ...`. Use `hunk mcp serve` only for manual startup or debugging of the local daemon.
168168

@@ -177,12 +177,16 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
177177
hunk session reload --repo . -- show HEAD~1 -- README.md
178178
hunk session comment add --repo . --file README.md --hunk 2 --summary "Explain this hunk"
179179
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording"
180+
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" --focus
181+
printf '%s\n' '{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}' | hunk session comment apply --repo . --stdin
182+
printf '%s\n' '{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain this hunk"}]}' | hunk session comment apply --repo . --stdin --focus
180183
hunk session comment list --repo .
181184
hunk session comment rm --repo . <comment-id>
182185
hunk session comment clear --repo . --file README.md --yes
183186
```
184187

185188
`hunk session reload ... -- <hunk command>` swaps what a live session is showing without opening a new TUI window.
189+
Pass `--focus` to jump the live session to a new note or the first note in a batch apply.
186190

187191
- `--repo <path>` selects the live session by its current loaded repo root.
188192
- `--source <path>` is reload-only: it changes where the nested `diff` / `show` command runs, but does not select the session.

skills/hunk-review/SKILL.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ If no session exists, ask the user to launch Hunk in their terminal first.
1717
3. hunk session context --repo . # check current focus
1818
4. hunk session navigate ... # move to the right place
1919
5. hunk session reload -- <command> # swap contents if needed
20-
6. hunk session comment add ... # leave review notes
20+
6. hunk session comment add ... # leave one review note
21+
7. hunk session comment apply ... # apply many agent notes in one stdin batch
2122
```
2223

2324
## Session selection
@@ -91,16 +92,20 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
9192
### Comments
9293

9394
```bash
94-
hunk session comment add --repo . --file README.md --hunk 2 --summary "Explain the hunk" [--rationale "..."] [--author "agent"] [--no-reveal]
95-
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--no-reveal]
95+
hunk session comment add --repo . --file README.md --hunk 2 --summary "Explain the hunk" [--rationale "..."] [--author "agent"] [--focus]
96+
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus]
97+
printf '%s\n' '{"comments":[{"filePath":"README.md","hunk":2,"summary":"Explain the hunk"}]}' | hunk session comment apply --repo . --stdin [--focus]
9698
hunk session comment list --repo . [--file README.md]
9799
hunk session comment rm --repo . <comment-id>
98100
hunk session comment clear --repo . --yes [--file README.md]
99101
```
100102

103+
- `comment add` is best for one note; `comment apply` is best when an agent already has several notes ready
101104
- `comment add` requires `--file`, `--summary`, and exactly one of `--hunk`, `--old-line`, or `--new-line`
105+
- `comment apply` payload items require `filePath`, `summary`, and one target such as `hunk`, `oldLine`, or `newLine`
102106
- Prefer `--hunk <n>` when you want to annotate the whole diff hunk instead of picking a single line manually
103-
- `comment add` reveals the note by default; pass `--no-reveal` to keep the current focus
107+
- `comment add` and `comment apply` both keep the current focus by default; pass `--focus` when you want to jump to the new note or the first note in a batch
108+
- `comment apply` reads a JSON batch from stdin and validates the full batch before mutating the live session
104109
- `comment list` and `comment clear` accept optional `--file`
105110
- Quote `--summary` and `--rationale` defensively in the shell
106111

@@ -121,13 +126,14 @@ Typical flow:
121126
1. Load the right content (`reload` if needed)
122127
2. Navigate to the first interesting file / hunk
123128
3. Add a comment explaining what's happening and why
124-
4. Move to the next point of interest -- repeat
129+
4. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations
125130
5. Summarize when done
126131

127132
Guidelines:
128133

129134
- Work in the order that tells the clearest story, not necessarily file order
130135
- Navigate before commenting so the user sees the code you're discussing
136+
- Use `comment apply` for agent-generated batches and `comment add` for one-off notes
131137
- Keep comments focused: intent, structure, risks, or follow-ups
132138
- Don't comment on every hunk -- highlight what the user wouldn't spot themselves
133139

@@ -140,3 +146,4 @@ Guidelines:
140146
- **"Pass the replacement Hunk command after `--`"** -- include `--` before the nested `diff` / `show` command.
141147
- **"Specify exactly one navigation target"** -- pick one of `--hunk`, `--old-line`, or `--new-line`.
142148
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.
149+
- **"Pass --stdin to read batch comments from stdin JSON."** -- `comment apply` only reads its batch payload from stdin.

src/core/cli.ts

Lines changed: 172 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
LayoutMode,
99
PagerCommandInput,
1010
ParsedCliInput,
11+
SessionCommentApplyItemInput,
1112
} from "./types";
1213
import { resolveCliVersion } from "./version";
1314

@@ -192,6 +193,94 @@ function resolveJsonOutput(options: { json?: boolean }) {
192193
return options.json ? "json" : "text";
193194
}
194195

196+
function parsePositiveJsonInt(
197+
value: unknown,
198+
{ field, itemNumber }: { field: string; itemNumber: number },
199+
) {
200+
if (value === undefined) {
201+
return undefined;
202+
}
203+
204+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
205+
throw new Error(`Comment ${itemNumber} field \`${field}\` must be a positive integer.`);
206+
}
207+
208+
return value;
209+
}
210+
211+
function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemInput[] {
212+
if (raw.trim().length === 0) {
213+
throw new Error("Session comment apply expected one JSON object on stdin.");
214+
}
215+
216+
let parsed: unknown;
217+
try {
218+
parsed = JSON.parse(raw);
219+
} catch {
220+
throw new Error("Session comment apply expected valid JSON on stdin.");
221+
}
222+
223+
if (!parsed || typeof parsed !== "object") {
224+
throw new Error("Session comment apply expected one JSON object with a comments array.");
225+
}
226+
227+
const value = parsed as Record<string, unknown>;
228+
if (!Array.isArray(value.comments)) {
229+
throw new Error("Session comment apply expected a top-level `comments` array.");
230+
}
231+
232+
return value.comments.map((comment, index) => {
233+
const itemNumber = index + 1;
234+
if (!comment || typeof comment !== "object") {
235+
throw new Error(`Comment ${itemNumber} must be a JSON object.`);
236+
}
237+
238+
const item = comment as Record<string, unknown>;
239+
const filePath = item.filePath;
240+
if (typeof filePath !== "string" || filePath.length === 0) {
241+
throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`);
242+
}
243+
244+
const summary = item.summary;
245+
if (typeof summary !== "string" || summary.length === 0) {
246+
throw new Error(`Comment ${itemNumber} requires a non-empty \`summary\`.`);
247+
}
248+
249+
const hunk = parsePositiveJsonInt(item.hunk, { field: "hunk", itemNumber });
250+
const hunkNumber = parsePositiveJsonInt(item.hunkNumber, { field: "hunkNumber", itemNumber });
251+
if (hunk !== undefined && hunkNumber !== undefined && hunk !== hunkNumber) {
252+
throw new Error(
253+
`Comment ${itemNumber} must not disagree between \`hunk\` and \`hunkNumber\`.`,
254+
);
255+
}
256+
257+
const oldLine = parsePositiveJsonInt(item.oldLine, { field: "oldLine", itemNumber });
258+
const newLine = parsePositiveJsonInt(item.newLine, { field: "newLine", itemNumber });
259+
const resolvedHunkNumber = hunk ?? hunkNumber;
260+
261+
const selectors = [
262+
resolvedHunkNumber !== undefined,
263+
oldLine !== undefined,
264+
newLine !== undefined,
265+
].filter(Boolean);
266+
if (selectors.length !== 1) {
267+
throw new Error(
268+
`Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`,
269+
);
270+
}
271+
272+
return {
273+
filePath,
274+
hunkNumber: resolvedHunkNumber,
275+
side: oldLine !== undefined ? "old" : newLine !== undefined ? "new" : undefined,
276+
line: oldLine ?? newLine,
277+
summary,
278+
rationale: typeof item.rationale === "string" ? item.rationale : undefined,
279+
author: typeof item.author === "string" ? item.author : undefined,
280+
};
281+
});
282+
}
283+
195284
/** Normalize one explicit session selector from either session id or repo root. */
196285
function resolveExplicitSessionSelector(
197286
sessionId: string | undefined,
@@ -484,7 +573,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
484573
" hunk session navigate (<session-id> | --repo <path>) (--next-comment | --prev-comment)",
485574
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- diff [ref] [-- <pathspec...>]",
486575
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- show [ref] [-- <pathspec...>]",
487-
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text>",
576+
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text> [--focus]",
577+
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
488578
" hunk session comment list (<session-id> | --repo <path>)",
489579
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
490580
" hunk session comment clear (<session-id> | --repo <path>) --yes",
@@ -728,7 +818,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
728818
text:
729819
[
730820
"Usage:",
731-
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text>",
821+
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text> [--focus]",
822+
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
732823
" hunk session comment list (<session-id> | --repo <path>) [--file <path>]",
733824
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
734825
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --yes",
@@ -748,8 +839,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
748839
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
749840
.option("--rationale <text>", "optional longer explanation")
750841
.option("--author <name>", "optional author label")
751-
.option("--reveal", "jump to and reveal the note")
752-
.option("--no-reveal", "add the note without moving focus")
842+
.option("--focus", "add the note and focus the viewport on it")
753843
.option("--json", "emit structured JSON");
754844

755845
let parsedSessionId: string | undefined;
@@ -762,7 +852,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
762852
newLine?: number;
763853
rationale?: string;
764854
author?: string;
765-
reveal?: boolean;
855+
focus?: boolean;
766856
json?: boolean;
767857
} = {
768858
file: "",
@@ -781,7 +871,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
781871
newLine?: number;
782872
rationale?: string;
783873
author?: string;
784-
reveal?: boolean;
874+
focus?: boolean;
785875
json?: boolean;
786876
},
787877
) => {
@@ -824,7 +914,81 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
824914
summary: parsedOptions.summary,
825915
rationale: parsedOptions.rationale,
826916
author: parsedOptions.author,
827-
reveal: parsedOptions.reveal ?? true,
917+
reveal: parsedOptions.focus ?? false,
918+
};
919+
}
920+
921+
if (commentSubcommand === "apply") {
922+
const command = new Command("session comment apply")
923+
.description("apply many live inline review notes from stdin JSON")
924+
.argument("[sessionId]")
925+
.option("--repo <path>", "target the live session whose repo root matches this path")
926+
.option("--stdin", "read the comment batch from stdin as JSON")
927+
.option("--focus", "apply the batch and focus the first note")
928+
.option("--json", "emit structured JSON");
929+
930+
let parsedSessionId: string | undefined;
931+
let parsedOptions: {
932+
repo?: string;
933+
stdin?: boolean;
934+
focus?: boolean;
935+
json?: boolean;
936+
} = {};
937+
938+
command.action(
939+
(
940+
sessionId: string | undefined,
941+
options: {
942+
repo?: string;
943+
stdin?: boolean;
944+
focus?: boolean;
945+
json?: boolean;
946+
},
947+
) => {
948+
parsedSessionId = sessionId;
949+
parsedOptions = options;
950+
},
951+
);
952+
953+
if (commentRest.includes("--help") || commentRest.includes("-h")) {
954+
return {
955+
kind: "help",
956+
text:
957+
`${command.helpInformation().trimEnd()}\n\n` +
958+
[
959+
"Stdin JSON shape:",
960+
" {",
961+
' "comments": [',
962+
" {",
963+
' "filePath": "README.md",',
964+
' "hunk": 2,',
965+
' "summary": "Explain this hunk",',
966+
' "rationale": "Optional detail",',
967+
' "author": "Pi"',
968+
" }",
969+
" ]",
970+
" }",
971+
].join("\n") +
972+
"\n",
973+
};
974+
}
975+
976+
await parseStandaloneCommand(command, commentRest);
977+
if (!parsedOptions.stdin) {
978+
throw new Error("Pass --stdin to read batch comments from stdin JSON.");
979+
}
980+
981+
const comments = parseSessionCommentApplyPayload(
982+
await new Response(Bun.stdin.stream()).text(),
983+
);
984+
985+
return {
986+
kind: "session",
987+
action: "comment-apply",
988+
output: resolveJsonOutput(parsedOptions),
989+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
990+
comments,
991+
revealMode: parsedOptions.focus ? "first" : "none",
828992
};
829993
}
830994

@@ -954,7 +1118,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
9541118
};
9551119
}
9561120

957-
throw new Error("Supported comment subcommands are add, list, rm, and clear.");
1121+
throw new Error("Supported comment subcommands are add, apply, list, rm, and clear.");
9581122
}
9591123

9601124
throw new Error(`Unknown session command: ${subcommand}`);

src/core/liveComments.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Hunk } from "@pierre/diffs";
2+
import type { CommentTargetInput, DiffSide, LiveComment } from "../mcp/types";
23
import type { DiffFile } from "./types";
3-
import type { CommentToolInput, DiffSide, LiveComment } from "../mcp/types";
44

55
export interface ResolvedCommentTarget {
66
hunkIndex: number;
@@ -83,7 +83,7 @@ export function firstCommentTargetForHunk(hunk: Hunk): Omit<ResolvedCommentTarge
8383
/** Resolve a line-based or hunk-based live-comment target against one visible diff file. */
8484
export function resolveCommentTarget(
8585
file: DiffFile,
86-
input: CommentToolInput,
86+
input: CommentTargetInput,
8787
): ResolvedCommentTarget {
8888
if (input.hunkIndex !== undefined) {
8989
const hunk = file.metadata.hunks[input.hunkIndex];
@@ -115,7 +115,7 @@ export function resolveCommentTarget(
115115

116116
/** Convert one incoming MCP comment command into a live annotation. */
117117
export function buildLiveComment(
118-
input: CommentToolInput & { side: DiffSide; line: number },
118+
input: CommentTargetInput & { side: DiffSide; line: number },
119119
commentId: string,
120120
createdAt: string,
121121
hunkIndex: number,

src/core/types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,25 @@ export interface SessionCommentAddCommandInput {
144144
reveal: boolean;
145145
}
146146

147+
export interface SessionCommentApplyItemInput {
148+
filePath: string;
149+
hunkNumber?: number;
150+
side?: "old" | "new";
151+
line?: number;
152+
summary: string;
153+
rationale?: string;
154+
author?: string;
155+
}
156+
157+
export interface SessionCommentApplyCommandInput {
158+
kind: "session";
159+
action: "comment-apply";
160+
output: SessionCommandOutput;
161+
selector: SessionSelectorInput;
162+
comments: SessionCommentApplyItemInput[];
163+
revealMode: "none" | "first";
164+
}
165+
147166
export interface SessionCommentListCommandInput {
148167
kind: "session";
149168
action: "comment-list";
@@ -175,6 +194,7 @@ export type SessionCommandInput =
175194
| SessionNavigateCommandInput
176195
| SessionReloadCommandInput
177196
| SessionCommentAddCommandInput
197+
| SessionCommentApplyCommandInput
178198
| SessionCommentListCommandInput
179199
| SessionCommentRemoveCommandInput
180200
| SessionCommentClearCommandInput;

0 commit comments

Comments
 (0)