Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ Use it to:
- inspect the current review context
- jump to a file, hunk, or line
- reload the current window with a different `diff` or `show` command
- add, list, and remove inline comments (by hunk or by line)
- add, batch-apply, list, and remove inline comments (by hunk or by line)

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

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

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

- `--repo <path>` selects the live session by its current loaded repo root.
- `--source <path>` is reload-only: it changes where the nested `diff` / `show` command runs, but does not select the session.
Expand Down
17 changes: 12 additions & 5 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ If no session exists, ask the user to launch Hunk in their terminal first.
3. hunk session context --repo . # check current focus
4. hunk session navigate ... # move to the right place
5. hunk session reload -- <command> # swap contents if needed
6. hunk session comment add ... # leave review notes
6. hunk session comment add ... # leave one review note
7. hunk session comment apply ... # apply many agent notes in one stdin batch
```

## Session selection
Expand Down Expand Up @@ -91,16 +92,20 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
### Comments

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

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

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

Guidelines:

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

Expand All @@ -140,3 +146,4 @@ Guidelines:
- **"Pass the replacement Hunk command after `--`"** -- include `--` before the nested `diff` / `show` command.
- **"Specify exactly one navigation target"** -- pick one of `--hunk`, `--old-line`, or `--new-line`.
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.
- **"Pass --stdin to read batch comments from stdin JSON."** -- `comment apply` only reads its batch payload from stdin.
180 changes: 172 additions & 8 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
LayoutMode,
PagerCommandInput,
ParsedCliInput,
SessionCommentApplyItemInput,
} from "./types";
import { resolveCliVersion } from "./version";

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

function parsePositiveJsonInt(
value: unknown,
{ field, itemNumber }: { field: string; itemNumber: number },
) {
if (value === undefined) {
return undefined;
}

if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
throw new Error(`Comment ${itemNumber} field \`${field}\` must be a positive integer.`);
}

return value;
}

function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemInput[] {
if (raw.trim().length === 0) {
throw new Error("Session comment apply expected one JSON object on stdin.");
}

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error("Session comment apply expected valid JSON on stdin.");
}

if (!parsed || typeof parsed !== "object") {
throw new Error("Session comment apply expected one JSON object with a comments array.");
}

const value = parsed as Record<string, unknown>;
if (!Array.isArray(value.comments)) {
throw new Error("Session comment apply expected a top-level `comments` array.");
}

return value.comments.map((comment, index) => {
const itemNumber = index + 1;
if (!comment || typeof comment !== "object") {
throw new Error(`Comment ${itemNumber} must be a JSON object.`);
}

const item = comment as Record<string, unknown>;
const filePath = item.filePath;
if (typeof filePath !== "string" || filePath.length === 0) {
throw new Error(`Comment ${itemNumber} requires a non-empty \`filePath\`.`);
}

const summary = item.summary;
if (typeof summary !== "string" || summary.length === 0) {
throw new Error(`Comment ${itemNumber} requires a non-empty \`summary\`.`);
}

const hunk = parsePositiveJsonInt(item.hunk, { field: "hunk", itemNumber });
const hunkNumber = parsePositiveJsonInt(item.hunkNumber, { field: "hunkNumber", itemNumber });
if (hunk !== undefined && hunkNumber !== undefined && hunk !== hunkNumber) {
throw new Error(
`Comment ${itemNumber} must not disagree between \`hunk\` and \`hunkNumber\`.`,
);
}

const oldLine = parsePositiveJsonInt(item.oldLine, { field: "oldLine", itemNumber });
const newLine = parsePositiveJsonInt(item.newLine, { field: "newLine", itemNumber });
const resolvedHunkNumber = hunk ?? hunkNumber;

const selectors = [
resolvedHunkNumber !== undefined,
oldLine !== undefined,
newLine !== undefined,
].filter(Boolean);
if (selectors.length !== 1) {
throw new Error(
`Comment ${itemNumber} must specify exactly one of \`hunk\`, \`hunkNumber\`, \`oldLine\`, or \`newLine\`.`,
);
}

return {
filePath,
hunkNumber: resolvedHunkNumber,
side: oldLine !== undefined ? "old" : newLine !== undefined ? "new" : undefined,
line: oldLine ?? newLine,
summary,
rationale: typeof item.rationale === "string" ? item.rationale : undefined,
author: typeof item.author === "string" ? item.author : undefined,
};
});
}

/** Normalize one explicit session selector from either session id or repo root. */
function resolveExplicitSessionSelector(
sessionId: string | undefined,
Expand Down Expand Up @@ -484,7 +573,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session navigate (<session-id> | --repo <path>) (--next-comment | --prev-comment)",
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- diff [ref] [-- <pathspec...>]",
" hunk session reload (<session-id> | --repo <path> | --session-path <path>) [--source <path>] -- show [ref] [-- <pathspec...>]",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text> [--focus]",
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
" hunk session comment list (<session-id> | --repo <path>)",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) --yes",
Expand Down Expand Up @@ -728,7 +818,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
text:
[
"Usage:",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) --summary <text> [--focus]",
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
" hunk session comment list (<session-id> | --repo <path>) [--file <path>]",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --yes",
Expand All @@ -748,8 +839,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--author <name>", "optional author label")
.option("--reveal", "jump to and reveal the note")
.option("--no-reveal", "add the note without moving focus")
.option("--focus", "add the note and focus the viewport on it")
.option("--json", "emit structured JSON");

let parsedSessionId: string | undefined;
Expand All @@ -762,7 +852,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
newLine?: number;
rationale?: string;
author?: string;
reveal?: boolean;
focus?: boolean;
json?: boolean;
} = {
file: "",
Expand All @@ -781,7 +871,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
newLine?: number;
rationale?: string;
author?: string;
reveal?: boolean;
focus?: boolean;
json?: boolean;
},
) => {
Expand Down Expand Up @@ -824,7 +914,81 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
summary: parsedOptions.summary,
rationale: parsedOptions.rationale,
author: parsedOptions.author,
reveal: parsedOptions.reveal ?? true,
reveal: parsedOptions.focus ?? false,
};
}

if (commentSubcommand === "apply") {
const command = new Command("session comment apply")
.description("apply many live inline review notes from stdin JSON")
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--stdin", "read the comment batch from stdin as JSON")
.option("--focus", "apply the batch and focus the first note")
.option("--json", "emit structured JSON");

let parsedSessionId: string | undefined;
let parsedOptions: {
repo?: string;
stdin?: boolean;
focus?: boolean;
json?: boolean;
} = {};

command.action(
(
sessionId: string | undefined,
options: {
repo?: string;
stdin?: boolean;
focus?: boolean;
json?: boolean;
},
) => {
parsedSessionId = sessionId;
parsedOptions = options;
},
);

if (commentRest.includes("--help") || commentRest.includes("-h")) {
return {
kind: "help",
text:
`${command.helpInformation().trimEnd()}\n\n` +
[
"Stdin JSON shape:",
" {",
' "comments": [',
" {",
' "filePath": "README.md",',
' "hunk": 2,',
' "summary": "Explain this hunk",',
' "rationale": "Optional detail",',
' "author": "Pi"',
" }",
" ]",
" }",
].join("\n") +
"\n",
};
}

await parseStandaloneCommand(command, commentRest);
if (!parsedOptions.stdin) {
throw new Error("Pass --stdin to read batch comments from stdin JSON.");
}

const comments = parseSessionCommentApplyPayload(
await new Response(Bun.stdin.stream()).text(),
);

return {
kind: "session",
action: "comment-apply",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
comments,
revealMode: parsedOptions.focus ? "first" : "none",
};
}

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

throw new Error("Supported comment subcommands are add, list, rm, and clear.");
throw new Error("Supported comment subcommands are add, apply, list, rm, and clear.");
}

throw new Error(`Unknown session command: ${subcommand}`);
Expand Down
24 changes: 16 additions & 8 deletions src/core/liveComments.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Hunk } from "@pierre/diffs";
import type { CommentTargetInput, DiffSide, LiveComment } from "../mcp/types";
import type { DiffFile } from "./types";
import type { CommentToolInput, DiffSide, LiveComment } from "../mcp/types";

export interface ResolvedCommentTarget {
hunkIndex: number;
Expand Down Expand Up @@ -43,6 +43,7 @@ export function findHunkIndexForLine(file: DiffFile, side: DiffSide, line: numbe
export function firstCommentTargetForHunk(hunk: Hunk): Omit<ResolvedCommentTarget, "hunkIndex"> {
let deletionLineNumber = hunk.deletionStart;
let additionLineNumber = hunk.additionStart;
let firstDeletionLine: number | undefined;

for (const content of hunk.hunkContent) {
if (content.type === "context") {
Expand All @@ -58,12 +59,19 @@ export function firstCommentTargetForHunk(hunk: Hunk): Omit<ResolvedCommentTarge
};
}

if (content.deletions > 0) {
return {
side: "old",
line: deletionLineNumber,
};
if (content.deletions > 0 && firstDeletionLine === undefined) {
firstDeletionLine = deletionLineNumber;
}

deletionLineNumber += content.deletions;
additionLineNumber += content.additions;
}

if (firstDeletionLine !== undefined) {
return {
side: "old",
line: firstDeletionLine,
};
}

const fallbackRange = hunkLineRange(hunk);
Expand All @@ -75,7 +83,7 @@ export function firstCommentTargetForHunk(hunk: Hunk): Omit<ResolvedCommentTarge
/** Resolve a line-based or hunk-based live-comment target against one visible diff file. */
export function resolveCommentTarget(
file: DiffFile,
input: CommentToolInput,
input: CommentTargetInput,
): ResolvedCommentTarget {
if (input.hunkIndex !== undefined) {
const hunk = file.metadata.hunks[input.hunkIndex];
Expand Down Expand Up @@ -107,7 +115,7 @@ export function resolveCommentTarget(

/** Convert one incoming MCP comment command into a live annotation. */
export function buildLiveComment(
input: CommentToolInput & { side: DiffSide; line: number },
input: CommentTargetInput & { side: DiffSide; line: number },
commentId: string,
createdAt: string,
hunkIndex: number,
Expand Down
Loading
Loading