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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added focused-hunk agent prompt export from the TUI (`p` / `c`) and `hunk session prompt`.
- Added Windows x64 prebuilt artifact publishing to the release workflow.

### Changed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ A good generic prompt is:
Load the Hunk skill and use it for this review.
```

While reviewing in the TUI, press `p` to copy the focused hunk as a paste-ready agent prompt, or `c` to add a short human comment and copy that commented prompt. Agents and scripts can use the same export through `hunk session prompt --repo .`.

For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md).

## Feature comparison
Expand Down
14 changes: 14 additions & 0 deletions docs/agent-workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ Notes:
- `--hunk` is 1-based
- `--next-comment` and `--prev-comment` are handy when an agent is walking the user through existing notes

### Publish a prompt from the focused hunk

Inside the TUI, press `p` to copy a paste-ready prompt for the focused hunk. If you have selected text with the mouse, Hunk includes that selected text alongside the hunk diff. Press `c` to add a short human comment, attach it as a live inline note, and copy the same prompt with your comment included.

From another terminal, agents or scripts can export the same focused-hunk prompt:

```bash
hunk session prompt --repo .
hunk session prompt --repo . --comment "Please simplify this path"
```

Use `--json` when another tool should consume the prompt programmatically.

### Add comments

For one note, use `comment add`:
Expand Down Expand Up @@ -139,6 +152,7 @@ For a compact real example, see [`examples/3-agent-review-demo/agent-context.jso
## Practical defaults

- start with `hunk session review --repo . --json`
- use `hunk session prompt --repo .` when the user wants paste-ready agent context
- only add `--include-patch` when the raw patch is actually needed
- use `comment add` for one-off notes and `comment apply` for batches
- prefer `--repo` over `--session-path` unless you have a specific advanced reload case
16 changes: 10 additions & 6 deletions skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ If no session exists, ask the user to launch Hunk in their terminal first.
3. hunk session review --repo . --json # inspect file/hunk structure first
4. hunk session review --repo . --include-patch --json # opt into raw diff text only when needed
5. hunk session context --repo . # check current focus when needed
6. hunk session navigate ... # move to the right place
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
6. hunk session prompt --repo . # export focused hunk as paste-ready prompt
7. hunk session navigate ... # move to the right place
8. hunk session reload -- <command> # swap contents if needed
9. hunk session comment add ... # leave one review note
10. hunk session comment apply ... # apply many agent notes in one stdin batch
```

## Session selection
Expand All @@ -47,11 +48,13 @@ hunk session list [--json]
hunk session get (--repo . | <id>) [--json]
hunk session context (--repo . | <id>) [--json]
hunk session review (--repo . | <id>) [--json] [--include-patch]
hunk session prompt (--repo . | <id>) [--comment "..."] [--selected-text "..."] [--json]
```

- `get` shows the session `Path`, `Repo`, and `Source`, which helps when choosing between `--repo` and `--session-path`
- `Repo` is what `--repo` matches; `Path` is what `--session-path` matches
- `review --json` returns file and hunk structure by default; add `--include-patch` only when a caller truly needs raw unified diff text
- `prompt` returns a paste-ready coding-agent prompt for the currently focused hunk; add `--comment` or `--selected-text` when relaying user feedback

### Navigate

Expand Down Expand Up @@ -130,8 +133,9 @@ 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. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations
5. Summarize when done
4. If the user asks for context they can paste back to an agent, use `hunk session prompt --repo .`
5. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations
6. Summarize when done

Guidelines:

Expand Down
67 changes: 67 additions & 0 deletions src/core/agentPrompt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "bun:test";
import { buildAgentPrompt, createAgentPromptFile, extractHunkPatch } from "./agentPrompt";
import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers";

const patch = lines(
"diff --git a/example.ts b/example.ts",
"index 1111111..2222222 100644",
"--- a/example.ts",
"+++ b/example.ts",
"@@ -1 +1 @@",
"-const one = 1;",
"+const one = 2;",
"@@ -10 +10 @@",
"-const ten = 10;",
"+const ten = 11;",
);

describe("agent prompt export", () => {
test("extracts one hunk with file headers from a raw patch", () => {
expect(extractHunkPatch(patch, 1)).toBe(
lines(
"diff --git a/example.ts b/example.ts",
"index 1111111..2222222 100644",
"--- a/example.ts",
"+++ b/example.ts",
"@@ -10 +10 @@",
"-const ten = 10;",
"+const ten = 11;",
).trimEnd(),
);
});

test("builds a paste-ready prompt with comment, selected text, and diff hunk", () => {
const file = createTestDiffFile({
before: "export const value = 1;\n",
after: "export const value = 2;\n",
path: "src/example.ts",
});
const prompt = buildAgentPrompt({
title: "demo working tree",
repoRoot: "/repo/demo",
file: createAgentPromptFile({ ...file, patch }),
hunkIndex: 0,
selectedText: "export const value = 2;",
comment: "Please make this configurable.",
});

expect(prompt).toContain("Please use this Hunk review context");
expect(prompt).toContain("- Repo: /repo/demo");
expect(prompt).toContain("- File: src/example.ts");
expect(prompt).toContain("My comment:\nPlease make this configurable.");
expect(prompt).toContain("Selected text from Hunk:\n```text\nexport const value = 2;\n```");
expect(prompt).toContain("```diff\ndiff --git a/example.ts b/example.ts");
expect(prompt).toContain("@@ -1 +1 @@");
});

test("falls back to the hunk header when raw patch text is unavailable", () => {
const file = createTestDiffFile({ path: "src/example.ts" });
const prompt = buildAgentPrompt({
file: createAgentPromptFile(file),
hunkIndex: 0,
});

expect(prompt).toContain("Diff hunk:");
expect(prompt).toContain("@@");
});
});
138 changes: 138 additions & 0 deletions src/core/agentPrompt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import type { DiffFile } from "./types";
import { formatHunkHeader } from "./hunkHeader";
import { hunkLineRange } from "./liveComments";

export interface AgentPromptHunk {
index: number;
header: string;
oldRange?: [number, number];
newRange?: [number, number];
}

export interface AgentPromptFile {
path: string;
previousPath?: string;
patch?: string;
hunks: AgentPromptHunk[];
}

export interface AgentPromptInput {
title?: string;
sourceLabel?: string;
repoRoot?: string;
file: AgentPromptFile;
hunkIndex: number;
selectedText?: string;
comment?: string;
}

function trimTrailingNewlines(value: string) {
return value.replace(/\n+$/, "");
}

function codeFence(language: string, value: string) {
const longestFence = Math.max(
2,
...Array.from(value.matchAll(/`+/g), (match) => match[0].length),
);
const fence = "`".repeat(longestFence + 1);
return `${fence}${language}\n${trimTrailingNewlines(value)}\n${fence}`;
}

function formatRange(range: [number, number] | undefined) {
if (!range) {
return "-";
}

return range[0] === range[1] ? `${range[0]}` : `${range[0]}..${range[1]}`;
}

/** Convert one loaded diff file into the generic prompt-export shape. */
export function createAgentPromptFile(file: DiffFile): AgentPromptFile {
return {
path: file.path,
previousPath: file.previousPath,
patch: file.patch,
hunks: file.metadata.hunks.map((hunk, index) => ({
index,
header: formatHunkHeader(hunk),
...hunkLineRange(hunk),
})),
};
}

/** Extract one raw unified-diff hunk from a per-file patch, preserving file headers. */
export function extractHunkPatch(patch: string | undefined, hunkIndex: number) {
if (!patch) {
return undefined;
}

const normalizedPatch = patch.replaceAll("\r\n", "\n");
const lines = normalizedPatch.split("\n");
const hunkLineIndexes = lines.reduce<number[]>((indexes, line, index) => {
if (line.startsWith("@@ ")) {
indexes.push(index);
}

return indexes;
}, []);
const hunkStart = hunkLineIndexes[hunkIndex];
if (hunkStart === undefined) {
return undefined;
}

const firstHunkStart = hunkLineIndexes[0] ?? hunkStart;
const hunkEnd = hunkLineIndexes[hunkIndex + 1] ?? lines.length;
const headerLines = lines.slice(0, firstHunkStart).filter((line) => line.length > 0);
const hunkLines = lines.slice(hunkStart, hunkEnd);
const selectedLines = [...headerLines, ...hunkLines];

return trimTrailingNewlines(selectedLines.join("\n"));
}

/** Build a paste-ready prompt for sending the focused Hunk context to a coding agent. */
export function buildAgentPrompt({
title,
sourceLabel,
repoRoot,
file,
hunkIndex,
selectedText,
comment,
}: AgentPromptInput) {
const hunk = file.hunks[hunkIndex];
if (!hunk) {
throw new Error(`No hunk ${hunkIndex + 1} exists in ${file.path}.`);
}

const normalizedComment = comment?.trim();
const normalizedSelection = selectedText?.trim();
const diffSnippet = extractHunkPatch(file.patch, hunkIndex) ?? hunk.header;
const locationLines = [
`- Repo: ${repoRoot ?? sourceLabel ?? "(unknown)"}`,
...(title ? [`- Review: ${title}`] : []),
`- File: ${file.path}`,
...(file.previousPath ? [`- Previous file: ${file.previousPath}`] : []),
`- Hunk: ${hunk.index + 1}`,
`- Old lines: ${formatRange(hunk.oldRange)}`,
`- New lines: ${formatRange(hunk.newRange)}`,
];

return trimTrailingNewlines(
[
"Please use this Hunk review context to help me update the code.",
"",
"Context:",
...locationLines,
...(normalizedComment ? ["", "My comment:", normalizedComment] : []),
...(normalizedSelection
? ["", "Selected text from Hunk:", codeFence("text", normalizedSelection)]
: []),
"",
"Diff hunk:",
codeFence("diff", diffSnippet),
"",
"Please address my comment against this diff. If you need more surrounding code, ask before editing.",
].join("\n"),
Comment on lines +131 to +136
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The closing instruction unconditionally tells the agent to "address my comment" even when no comment was provided. When p is used to copy a plain hunk prompt, there is no "My comment:" section, so the imperative becomes a dangling reference that may confuse the agent. The instruction should only appear when a comment or selected text is actually present, with a lighter-touch fallback for the context-only case.

Suggested change
"",
"Diff hunk:",
codeFence("diff", diffSnippet),
"",
"Please address my comment against this diff. If you need more surrounding code, ask before editing.",
].join("\n"),
"",
"Diff hunk:",
codeFence("diff", diffSnippet),
"",
normalizedComment || normalizedSelection
? "Please address my comment against this diff. If you need more surrounding code, ask before editing."
: "Please use this diff hunk as context. If you need more surrounding code, ask before editing.",
].join("\n"),
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/agentPrompt.ts
Line: 131-136

Comment:
The closing instruction unconditionally tells the agent to "address my comment" even when no comment was provided. When `p` is used to copy a plain hunk prompt, there is no "My comment:" section, so the imperative becomes a dangling reference that may confuse the agent. The instruction should only appear when a comment or selected text is actually present, with a lighter-touch fallback for the context-only case.

```suggestion
      "",
      "Diff hunk:",
      codeFence("diff", diffSnippet),
      "",
      normalizedComment || normalizedSelection
        ? "Please address my comment against this diff. If you need more surrounding code, ask before editing."
        : "Please use this diff hunk as context. If you need more surrounding code, ask before editing.",
    ].join("\n"),
```

How can I resolve this? If you propose a fix, please make it concise.

);
}
27 changes: 27 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,33 @@ describe("parseCli", () => {
});
});

test("parses session prompt with comment and selected text", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"prompt",
"--repo",
".",
"--comment",
"Please simplify this.",
"--selected-text",
"const value = 1;",
"--json",
]);

expect(parsed).toEqual({
kind: "session",
action: "prompt",
selector: {
repoRoot: process.cwd(),
},
output: "json",
comment: "Please simplify this.",
selectedText: "const value = 1;",
});
});

test("parses session navigate by hunk number", async () => {
const parsed = await parseCli([
"bun",
Expand Down
43 changes: 43 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session context --repo <path>",
" hunk session review <session-id> [--include-patch]",
" hunk session review --repo <path> [--include-patch]",
" hunk session prompt (<session-id> | --repo <path>) [--comment <text>]",
" hunk session navigate (<session-id> | --repo <path>) --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" 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...>]",
Expand Down Expand Up @@ -689,6 +690,48 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
};
}

if (subcommand === "prompt") {
const command = new Command("session prompt")
.description("export a paste-ready prompt for the focused hunk")
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--comment <text>", "include a user comment in the prompt")
.option("--selected-text <text>", "include explicit selected text in the prompt")
.option("--json", "emit structured JSON");

let parsedSessionId: string | undefined;
let parsedOptions: {
repo?: string;
comment?: string;
selectedText?: string;
json?: boolean;
} = {};

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

if (rest.includes("--help") || rest.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
}

await parseStandaloneCommand(command, rest);
return {
kind: "session",
action: "prompt",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
comment: parsedOptions.comment,
selectedText: parsedOptions.selectedText,
};
}

if (subcommand === "navigate") {
const command = new Command("session navigate")
.description("move a live Hunk session to one diff hunk")
Expand Down
Loading