Skip to content
Open
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
7 changes: 6 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
- 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 @@ -175,13 +175,18 @@ hunk session reload --repo . -- diff
hunk session reload --repo /path/to/worktree -- diff
hunk session reload --session-path /path/to/live-window --source /path/to/other-checkout -- diff
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
19 changes: 14 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,14 +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 --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` requires `--file`, `--summary`, and exactly one of `--old-line` or `--new-line`
- `comment add` reveals the note by default; pass `--no-reveal` to keep the current focus
- `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` 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 @@ -119,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 @@ -138,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.
198 changes: 187 additions & 11 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> (--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> (--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 @@ -743,24 +834,25 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.requiredOption("--file <path>", "diff file path as shown by Hunk")
.requiredOption("--summary <text>", "short review note")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--hunk <n>", "1-based hunk number within the file", parsePositiveInt)
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.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;
let parsedOptions: {
repo?: string;
file: string;
summary: string;
hunk?: number;
oldLine?: number;
newLine?: number;
rationale?: string;
author?: string;
reveal?: boolean;
focus?: boolean;
json?: boolean;
} = {
file: "",
Expand All @@ -774,11 +866,12 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
repo?: string;
file: string;
summary: string;
hunk?: number;
oldLine?: number;
newLine?: number;
rationale?: string;
author?: string;
reveal?: boolean;
focus?: boolean;
json?: boolean;
},
) => {
Expand All @@ -794,11 +887,14 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
await parseStandaloneCommand(command, commentRest);

const selectors = [
parsedOptions.hunk !== undefined,
parsedOptions.oldLine !== undefined,
parsedOptions.newLine !== undefined,
].filter(Boolean);
if (selectors.length !== 1) {
throw new Error("Specify exactly one comment target: --old-line <n> or --new-line <n>.");
throw new Error(
"Specify exactly one comment target: --hunk <n>, --old-line <n>, or --new-line <n>.",
);
}

return {
Expand All @@ -807,12 +903,92 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
filePath: parsedOptions.file,
side: parsedOptions.oldLine !== undefined ? "old" : "new",
line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0,
hunkNumber: parsedOptions.hunk,
side:
parsedOptions.oldLine !== undefined
? "old"
: parsedOptions.newLine !== undefined
? "new"
: undefined,
line: parsedOptions.oldLine ?? parsedOptions.newLine,
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 @@ -942,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
Loading