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
5 changes: 5 additions & 0 deletions .changeset/calm-coins-navigate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Navigate a live review directly to a comment returned by `hunk session comment list`.
7 changes: 7 additions & 0 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,13 @@ hunk session navigate --repo . --file src/App.tsx --hunk 2
hunk session navigate --repo . --next-comment
```

To jump to an exact comment returned by the JSON list, copy its `commentId` into `--comment`:

```bash
hunk session comment list --repo . --json
hunk session navigate --repo . --comment <comment-id> --json
```

Use `reload` when you want the already-open Hunk window to show a different diff or commit:

```bash
Expand Down
1 change: 1 addition & 0 deletions scripts/generate-docs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ describe("generated website references", () => {
expect(reference).toContain(option.flag);
}
}
expect(reference).toContain("for `--file` navigation, exactly one of");
});

test("renders every runtime-parsed config key with defaults and compatibility metadata", () => {
Expand Down
13 changes: 8 additions & 5 deletions scripts/generate-docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,14 @@ export function renderCliReference() {
"",
"**Constraints:** " +
command.constraints
.map((constraint) =>
constraint.kind === "exactly-one"
? `exactly one of ${constraint.flags.map((flag) => `\`${flag}\``).join(", ")}`
: `at most one of ${constraint.flags.map((flag) => `\`${flag}\``).join(", ")}`,
)
.map((constraint) => {
const scope = constraint.documentationScope
? `${constraint.documentationScope}, `
: "";
return constraint.kind === "exactly-one"
? `${scope}exactly one of ${constraint.flags.map((flag) => `\`${flag}\``).join(", ")}`
: `${scope}at most one of ${constraint.flags.map((flag) => `\`${flag}\``).join(", ")}`;
})
.join("; ") +
".",
);
Expand Down
7 changes: 7 additions & 0 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ hunk session review (<session-id> | --repo <path>) [--include-patch] [--include-

```bash
hunk session navigate (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>) [--json]
hunk session navigate (<session-id> | --repo <path>) --comment <id> [--json]
hunk session navigate (<session-id> | --repo <path>) (--next-comment | --prev-comment) [--json]
```

Expand All @@ -70,6 +71,12 @@ hunk session navigate --repo . --file src/App.tsx --new-line 372
hunk session navigate --repo . --file src/App.tsx --old-line 355
```

Exact comment navigation uses the `commentId` returned by `hunk session comment list --json` and does not require `--file`:

```bash
hunk session navigate --repo . --comment comment-1
```

Relative comment navigation jumps between annotated hunks and does not require `--file`:

```bash
Expand Down
48 changes: 48 additions & 0 deletions src/app/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1251,6 +1251,54 @@ describe("parseCli", () => {
});
});

test("parses session navigate with a comment id", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"navigate",
"--repo",
"/tmp/repo",
"--comment",
"comment-1",
"--json",
]);

expect(parsed).toEqual({
kind: "session",
action: "navigate",
selector: { repoRoot: resolve("/tmp/repo") },
commentId: "comment-1",
output: "json",
});
});

test("rejects session navigate when --comment is combined with another selector", async () => {
const conflictingOptions = [
["--file", "README.md"],
["--hunk", "1"],
["--old-line", "10"],
["--new-line", "10"],
["--next-comment"],
["--prev-comment"],
];

for (const conflictingOption of conflictingOptions) {
await expect(
parseCli([
"bun",
"hunk",
"session",
"navigate",
"session-1",
"--comment",
"comment-1",
...conflictingOption,
]),
).rejects.toThrow("Specify exactly one navigation selector");
}
});

test("rejects session navigate with both --next-comment and --prev-comment", async () => {
await expect(
parseCli([
Expand Down
25 changes: 25 additions & 0 deletions src/app/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,31 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {

await parseStandaloneCommand(command, rest);

// A comment id is resolved by the daemon and must not silently discard another selector.
const commentHasConflictingSelector =
parsedOptions.comment !== undefined &&
(parsedOptions.file !== undefined ||
parsedOptions.hunk !== undefined ||
parsedOptions.oldLine !== undefined ||
parsedOptions.newLine !== undefined ||
parsedOptions.nextComment === true ||
parsedOptions.prevComment === true);
if (commentHasConflictingSelector) {
throw new Error(
"Specify exactly one navigation selector: --comment, --next-comment / --prev-comment, or --file with a navigation target.",
);
}

if (parsedOptions.comment !== undefined) {
return {
kind: "session",
action: "navigate",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
commentId: parsedOptions.comment,
} as const;
}

/** Relative comment navigation mode. */
if (parsedOptions.nextComment || parsedOptions.prevComment) {
enforceConstraint(COMMENT_DIRECTION_CONSTRAINT, parsedOptions);
Expand Down
26 changes: 26 additions & 0 deletions src/app/session/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,32 @@ describe("createHunkSessionBridge", () => {
expect(handlers.openAgentNotes).toHaveBeenCalledTimes(1);
});

test("prefers exact line coordinates over a hunk hint", async () => {
const handlers = createHandlers();
const bridge = createHunkSessionBridge(handlers);

await bridge.dispatchCommand({
type: "command",
requestId: "nav-line-1",
command: "navigate_to_hunk",
input: {
sessionId: "session-1",
filePath: "src/example.ts",
hunkIndex: 2,
side: "new",
line: 17,
},
});

expect(handlers.navigateToLocation).toHaveBeenCalledWith({
sessionId: "session-1",
filePath: "src/example.ts",
hunkIndex: undefined,
side: "new",
line: 17,
});
});

test("routes navigate, reload, remove, and clear commands through their dedicated handlers", async () => {
const handlers = createHandlers();
const bridge = createHunkSessionBridge(handlers);
Expand Down
11 changes: 9 additions & 2 deletions src/app/session/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,15 @@ export function createHunkSessionBridge(handlers: HunkSessionBridgeHandlers) {

return result;
}
case "navigate_to_hunk":
return handlers.navigateToLocation(message.input);
case "navigate_to_hunk": {
// Exact line coordinates are more specific than a hunk index. Comment-id navigation
// resolves to both, so drop the hunk hint and let the terminal reveal the annotated row.
const input =
message.input.side && message.input.line !== undefined
? { ...message.input, hunkIndex: undefined }
: message.input;
return handlers.navigateToLocation(input);
}
case "highlight":
return handlers.addAgentLineHighlight(message.input);
case "clear_highlights":
Expand Down
1 change: 1 addition & 0 deletions src/core/run/commandInputs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ export interface SessionNavigateCommandInput {
side?: "old" | "new";
line?: number;
commentDirection?: "next" | "prev";
commentId?: string;
}

export interface SessionReloadCommandInput {
Expand Down
18 changes: 15 additions & 3 deletions src/hunk-review/skillDocument.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,18 @@ function synopsisLines(...specs: AgentCommandSpec[]) {

const commands = SESSION_AGENT_COMMANDS;

/** Navigate examples that anchor on a file are absolute; the rest jump between comments. */
function navigateExamples(kind: "absolute" | "relative") {
/** Group navigation examples by their selector mode. */
function navigateExamples(kind: "absolute" | "comment" | "relative") {
const examples = commands.navigate.examples ?? [];
return examples.filter((example) => example.includes("--file") === (kind === "absolute"));
return examples.filter((example) => {
if (kind === "absolute") {
return example.includes("--file");
}
if (kind === "comment") {
return example.includes("--comment ");
}
return example.includes("--next-comment") || example.includes("--prev-comment");
});
}

const FRONTMATTER = [
Expand Down Expand Up @@ -98,6 +106,10 @@ const NAVIGATE_SECTION = [
"",
...bashFence(navigateExamples("absolute")),
"",
"Exact comment navigation uses the `commentId` returned by `hunk session comment list --json` and does not require `--file`:",
"",
...bashFence(navigateExamples("comment")),
"",
"Relative comment navigation jumps between annotated hunks and does not require `--file`:",
"",
...bashFence(navigateExamples("relative")),
Expand Down
14 changes: 14 additions & 0 deletions src/session/agent/cliClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,15 @@ describe("HTTP Hunk session CLI client", () => {
output: "json",
}),
).toEqual({ fileId: "file-1", filePath: "src/app.ts", hunkIndex: 1 });
expect(
await client.navigateToHunk({
kind: "session",
action: "navigate",
selector,
commentId: "comment-1",
output: "json",
}),
).toEqual({ fileId: "file-1", filePath: "src/app.ts", hunkIndex: 1 });
expect(
await client.reloadSession({
kind: "session",
Expand Down Expand Up @@ -264,6 +273,11 @@ describe("HTTP Hunk session CLI client", () => {
line: 12,
commentDirection: "next",
},
{
action: "navigate",
selector,
commentId: "comment-1",
},
{
action: "reload",
selector,
Expand Down
1 change: 1 addition & 0 deletions src/session/agent/cliClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient {
side: input.side,
line: input.line,
commentDirection: input.commentDirection,
commentId: input.commentId,
})
).result;
}
Expand Down
8 changes: 8 additions & 0 deletions src/session/agent/surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,14 @@ export type AgentCommandConstraint =
readonly kind: "exactly-one";
/** Names the choice in the error message, e.g. "navigation target". */
readonly label: string;
/** Optional context shown in generated docs when this rule applies to one command mode. */
readonly documentationScope?: string;
readonly flags: readonly string[];
}
| {
readonly kind: "at-most-one";
/** Optional context shown in generated docs when this rule applies to one command mode. */
readonly documentationScope?: string;
readonly flags: readonly string[];
};

Expand Down Expand Up @@ -147,6 +151,7 @@ export const RELOAD_SELECTOR_SYNOPSIS = "(<session-id> | --repo <path> | --sessi
export const NAVIGATE_TARGET_CONSTRAINT = {
kind: "exactly-one",
label: "navigation target",
documentationScope: "for `--file` navigation",
flags: ["--hunk <n>", "--old-line <n>", "--new-line <n>"],
} as const satisfies AgentCommandConstraint;

Expand Down Expand Up @@ -268,19 +273,22 @@ export const SESSION_AGENT_COMMANDS = {
},
oldLineOption,
newLineOption,
{ flag: "--comment <id>", description: "jump to the live comment with this id" },
{ flag: "--next-comment", description: "jump to the next annotated hunk" },
{ flag: "--prev-comment", description: "jump to the previous annotated hunk" },
jsonOption,
],
constraints: [NAVIGATE_TARGET_CONSTRAINT, COMMENT_DIRECTION_CONSTRAINT],
synopsis: [
`hunk session navigate ${SESSION_SELECTOR_SYNOPSIS} --file <path> ${constraintSynopsis(NAVIGATE_TARGET_CONSTRAINT)} [--json]`,
`hunk session navigate ${SESSION_SELECTOR_SYNOPSIS} --comment <id> [--json]`,
`hunk session navigate ${SESSION_SELECTOR_SYNOPSIS} ${constraintSynopsis(COMMENT_DIRECTION_CONSTRAINT)} [--json]`,
],
examples: [
"hunk session navigate --repo . --file src/App.tsx --hunk 2",
"hunk session navigate --repo . --file src/App.tsx --new-line 372",
"hunk session navigate --repo . --file src/App.tsx --old-line 355",
"hunk session navigate --repo . --comment comment-1",
"hunk session navigate --repo . --next-comment",
"hunk session navigate --repo . --prev-comment",
],
Expand Down
Loading