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

Agents can now light up exact character ranges in a live review with `hunk session highlight add` / `clear` (five contrast-guaranteed tones, painted through the same pipeline as extension line highlights), and `hunk session navigate` line targets now land the viewport on the exact line instead of just its hunk.
12 changes: 12 additions & 0 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,18 @@ keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every
geometry measurement: a highlight change is a repaint, never a re-plan. The
static pager never runs extension code, so highlights are interactive-only.

Agent attention marks (`hunk session highlight add` / `clear`) join this same
pipeline rather than growing a second one: `useReviewController.ts` validates
each daemon-pushed mark with the same `validate.ts` contract and caps, holds
them per file, and `src/ui/highlights/merge.ts` appends them after extension
marks in the one map `DiffPane` paints from — so agent marks share paint,
contrast, and geometry guarantees, and win where ranges overlap. Unlike
extension marks, nothing re-derives agent marks after a reload, so
`src/ui/highlights/reconcile.ts` carries them across a document replacement only
for files whose `contentIdentity` is unchanged — those still show the same
characters — and drops the rest. Line-target `session navigate` reuses the same
`revealLine` landing policy `ctx.navigation.revealLine` gets.

`src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback
containment. The presentation controller stores the active mode and funnels all
exit paths through one teardown, including re-entrant handoffs.
Expand Down
31 changes: 30 additions & 1 deletion skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: hunk-review
description: Interacts with live Hunk diff review sessions via CLI. Inspects review focus, navigates files and hunks, reloads session contents, and adds inline review comments. Use when the user has a Hunk session running or wants to review diffs interactively.
description: Interacts with live Hunk diff review sessions via CLI. Inspects review focus, navigates files, hunks, and exact lines, reloads session contents, adds inline review comments, and paints attention marks on character ranges. Use when the user has a Hunk session running or wants to review diffs interactively.
---

# Hunk Review
Expand All @@ -21,6 +21,7 @@ If no session exists, ask the user to launch Hunk in their terminal first.
7. hunk session reload -- <command> # swap contents if needed
8. hunk session comment add ... # leave one review note
9. hunk session comment apply ... # apply many agent notes in one stdin batch
10. hunk session highlight add ... # light up the exact range you are explaining
```

## Session selection
Expand Down Expand Up @@ -78,6 +79,7 @@ hunk session navigate --repo . --prev-comment

- `--hunk <n>` is 1-based
- `--new-line` / `--old-line` are 1-based line numbers on that diff side
- A line target lands the user's viewport on that exact line (falling back to its hunk when the line is inside a collapsed region); `--hunk` lands on the hunk
- Use either `--next-comment` or `--prev-comment`, not both

### Reload
Expand Down Expand Up @@ -132,6 +134,30 @@ printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tig
- `comment list` and `comment clear` accept optional `--file`
- Quote `--summary` and `--rationale` defensively in the shell

### Attention marks

Highlights paint character ranges inside the diff lines the user is looking at — use them to light up the exact expression you are explaining while you narrate.

```bash
hunk session highlight add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --start <n> --end <n> [--tone <tone>] [--focus] [--json]
hunk session highlight clear (<session-id> | --repo <path>) [--file <path>] [--json]
```

Examples:

```bash
hunk session highlight add --repo . --file src/App.tsx --new-line 42 --start 6 --end 19
hunk session highlight add --repo . --file src/App.tsx --new-line 42 --start 6 --end 19 --tone warning --focus
hunk session highlight clear --repo .
```

- `highlight add` requires `--file`, exactly one of `--old-line` or `--new-line`, and the `--start` / `--end` offsets
- `--start` is a 0-based inclusive offset into the line's text and `--end` is exclusive, counted in UTF-16 code units — the same `[start, end)` range extensions use
- Tones: `match` (default), `info`, `warning`, `error`; `current` renders as reverse video and is best reserved for the one range under discussion
- Pass `--focus` to also land the viewport on the marked line
- Marks survive scrolling, navigation, and reloads that leave the marked file's content unchanged; a reload that changes that file drops its marks, and `highlight clear` removes them explicitly (optionally per `--file`)
- Marks are visual only — pair them with a `comment add` when the explanation should persist as a note

### Experimental rich markup notes (STML)

Only use STML when `hunk session context --json` lists `stml` in `experimentalFeatures`. The user opts into that experience by launching the review with `--experimental`; do not ask a normal session to render markup.
Expand Down Expand Up @@ -166,6 +192,7 @@ 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 `highlight add --focus` to steer the user's eyes to the exact expression while you explain it, and `highlight clear` before moving to the next topic
- Use `comment apply` for agent-generated batches and `comment add` for one-off notes
- Use `--focus` sparingly when the note itself should actively steer the review
- Keep comments focused: intent, structure, risks, or follow-ups
Expand All @@ -181,5 +208,7 @@ Guidelines:
- **"Pass --stdin to read batch comments from stdin JSON."** -- `comment apply` only reads its batch payload from stdin.
- **"Specify exactly one navigation target"** -- pick one of `--hunk`, `--old-line`, or `--new-line`.
- **"Specify exactly one comment target"** -- pass `comment add` one of `--old-line` or `--new-line`.
- **"Specify exactly one highlight target"** -- pass `highlight add` one of `--old-line` or `--new-line`.
- **"Highlight --end must be greater than --start"** -- offsets are `[start, end)` UTF-16 code units into the line text; end is exclusive.
- **"Specify either --next-comment or --prev-comment, not both."** -- choose one comment-navigation direction.
- **"Could not read the raw diff for ..."** -- the session reloaded or closed while `--include-patch` was reading it. Re-run `review`; drop `--include-patch` if you only need file and hunk structure.
132 changes: 132 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,138 @@ describe("parseCli", () => {
});
});

test("parses session highlight add with defaults", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"highlight",
"add",
"session-1",
"--file",
"src/App.tsx",
"--new-line",
"42",
"--start",
"0",
"--end",
"13",
]);

expect(parsed).toEqual({
kind: "session",
action: "highlight-add",
selector: { sessionId: "session-1" },
filePath: "src/App.tsx",
side: "new",
line: 42,
start: 0,
end: 13,
reveal: false,
output: "text",
});
});

test("parses session highlight add with tone, old side, and focus", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"highlight",
"add",
"--repo",
"/tmp/repo",
"--file",
"src/App.tsx",
"--old-line",
"7",
"--start",
"6",
"--end",
"19",
"--tone",
"warning",
"--focus",
"--json",
]);

expect(parsed).toEqual({
kind: "session",
action: "highlight-add",
selector: { repoRoot: resolve("/tmp/repo") },
filePath: "src/App.tsx",
side: "old",
line: 7,
start: 6,
end: 19,
tone: "warning",
reveal: true,
output: "json",
});
});

test("rejects session highlight add with an empty range, bad tone, or missing target", async () => {
const base = [
"bun",
"hunk",
"session",
"highlight",
"add",
"session-1",
"--file",
"src/App.tsx",
];

await expect(
parseCli([...base, "--new-line", "42", "--start", "5", "--end", "5"]),
).rejects.toThrow("Highlight --end must be greater than --start");
await expect(
parseCli([...base, "--new-line", "42", "--start", "0", "--end", "4", "--tone", "loud"]),
).rejects.toThrow("Highlight tone must be one of match, current, info, warning, error.");
await expect(parseCli([...base, "--start", "0", "--end", "4"])).rejects.toThrow(
"Specify exactly one highlight target: --old-line <n> or --new-line <n>.",
);
await expect(
parseCli([...base, "--new-line", "42", "--start", "-1", "--end", "4"]),
).rejects.toThrow();
});

test("parses session highlight clear globally and per file", async () => {
expect(await parseCli(["bun", "hunk", "session", "highlight", "clear", "session-1"])).toEqual({
kind: "session",
action: "highlight-clear",
selector: { sessionId: "session-1" },
output: "text",
});

expect(
await parseCli([
"bun",
"hunk",
"session",
"highlight",
"clear",
"--repo",
"/tmp/repo",
"--file",
"src/App.tsx",
"--json",
]),
).toEqual({
kind: "session",
action: "highlight-clear",
selector: { repoRoot: resolve("/tmp/repo") },
filePath: "src/App.tsx",
output: "json",
});
});

test("rejects unknown session highlight subcommands", async () => {
await expect(parseCli(["bun", "hunk", "session", "highlight", "paint"])).rejects.toThrow(
"Supported highlight subcommands are add and clear.",
);
});

test("rejects session commands without an explicit target", async () => {
await expect(parseCli(["bun", "hunk", "session", "get"])).rejects.toThrow(
"Specify one live Hunk session with <session-id> or --repo <path>.",
Expand Down
108 changes: 108 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,20 @@ import {
type SessionCommandOptions,
COMMENT_DIRECTION_CONSTRAINT,
COMMENT_TARGET_CONSTRAINT,
HIGHLIGHT_TARGET_CONSTRAINT,
HIGHLIGHT_TONES,
isHighlightTone,
NAVIGATE_TARGET_CONSTRAINT,
optionKeyFromFlag,
SESSION_AGENT_COMMANDS,
SESSION_AGENT_COMMAND_LIST,
SESSION_COMMENT_COMMAND_LIST,
SESSION_HIGHLIGHT_COMMAND_LIST,
} from "../session/agent/surface";
import {
COMMENT_APPLY_STDIN_MESSAGE,
constraintViolationMessage,
HIGHLIGHT_RANGE_MESSAGE,
RELOAD_SEPARATOR_MESSAGE,
} from "../session/agent/errors";
import { DEFAULT_TAB_WIDTH, parseTabWidth } from "./tabWidth";
Expand Down Expand Up @@ -263,6 +268,20 @@ function parsePositiveInt(value: string) {
return parsed;
}

/** Parse one required non-negative integer CLI value, accepting 0. */
function parseNonNegativeInt(value: string) {
if (!/^(0|[1-9]\d*)$/.test(value)) {
throw new Error(`Invalid non-negative integer: ${value}`);
}

const parsed = Number(value);
if (!Number.isSafeInteger(parsed)) {
throw new Error(`Invalid non-negative integer: ${value}`);
}

return parsed;
}

/** Read one paired positive/negative boolean flag directly from raw argv. */
function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) {
let resolved: boolean | undefined;
Expand Down Expand Up @@ -880,6 +899,8 @@ function buildSessionCommand(spec: AgentCommandSpec) {
: command.option.bind(command);
if (option.parse === "positiveInt") {
register(option.flag, option.description, parsePositiveInt);
} else if (option.parse === "nonNegativeInt") {
register(option.flag, option.description, parseNonNegativeInt);
} else {
register(option.flag, option.description);
}
Expand Down Expand Up @@ -1294,6 +1315,93 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
throw new Error("Supported comment subcommands are add, apply, list, rm, and clear.");
}

if (subcommand === "highlight") {
const [highlightSubcommand, ...highlightRest] = rest;
if (!highlightSubcommand || highlightSubcommand === "--help" || highlightSubcommand === "-h") {
return {
kind: "help",
text: ["Usage:", ...sessionUsageLines(SESSION_HIGHLIGHT_COMMAND_LIST)].join("\n") + "\n",
};
}

if (highlightSubcommand === "add") {
const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-add"]);

let parsedSessionId: string | undefined;
let parsedOptions: SessionCommandOptions<"highlight-add"> = {
file: "",
start: 0,
end: 0,
};

command.action(
(sessionId: string | undefined, options: SessionCommandOptions<"highlight-add">) => {
parsedSessionId = sessionId;
parsedOptions = options;
},
);

if (highlightRest.includes("--help") || highlightRest.includes("-h")) {
return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-add"]);
}

await parseStandaloneCommand(command, highlightRest);

enforceConstraint(HIGHLIGHT_TARGET_CONSTRAINT, parsedOptions);
if (parsedOptions.end <= parsedOptions.start) {
throw new Error(HIGHLIGHT_RANGE_MESSAGE);
}
const tone = parsedOptions.tone;
if (tone !== undefined && !isHighlightTone(tone)) {
throw new Error(`Highlight tone must be one of ${HIGHLIGHT_TONES.join(", ")}.`);
}

return {
kind: "session",
action: "highlight-add",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
filePath: parsedOptions.file,
side: parsedOptions.oldLine !== undefined ? "old" : "new",
line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0,
start: parsedOptions.start,
end: parsedOptions.end,
...(tone !== undefined && isHighlightTone(tone) ? { tone } : {}),
reveal: parsedOptions.focus ?? false,
};
}

if (highlightSubcommand === "clear") {
const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-clear"]);

let parsedSessionId: string | undefined;
let parsedOptions: SessionCommandOptions<"highlight-clear"> = {};

command.action(
(sessionId: string | undefined, options: SessionCommandOptions<"highlight-clear">) => {
parsedSessionId = sessionId;
parsedOptions = options;
},
);

if (highlightRest.includes("--help") || highlightRest.includes("-h")) {
return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-clear"]);
}

await parseStandaloneCommand(command, highlightRest);

return {
kind: "session",
action: "highlight-clear",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
filePath: parsedOptions.file,
};
}

throw new Error("Supported highlight subcommands are add and clear.");
}

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

Expand Down
Loading
Loading