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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,16 @@ All notable user-visible changes to Hunk are documented in this file.
### Added

- Added `g` and `G` keyboard aliases for jump-to-top and jump-to-bottom review navigation.
- Added session-persistent user-authored inline notes with `c` to draft/save notes.
- Added `hunk session comment list --type <live|all|ai|agent|user>` so agents can read human-authored notes through the comment workflow.

### Changed

- Clarified inline note draft actions by labeling buttons as `Save (^S)` and `Cancel (Esc)`.

### Fixed

- Fixed draft note focus handling so app shortcuts resume after the note textarea blurs without discarding the draft.
- Preserved the resolved auto theme across `--watch` refreshes instead of falling back to the default dark theme.
- Included the bundled Hunk review skill in standalone prebuilt release archives so `hunk skill path` works after extracting a tarball or installing via Homebrew.

Expand Down
4 changes: 2 additions & 2 deletions packages/session-broker-core/src/brokerState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export interface SessionBrokerViewAdapter<
buildSelectedContext: (session: ListedSession) => SelectedContext;
buildSessionReview: (
entry: SessionBrokerEntry<Info, State>,
options: { includePatch?: boolean },
options: { includePatch?: boolean; includeNotes?: boolean },
) => SessionReview;
listComments: (session: ListedSession, filter: { filePath?: string }) => SessionCommentSummary[];
}
Expand Down Expand Up @@ -174,7 +174,7 @@ export class SessionBrokerState<
/** Return the live session's loaded review model, with raw patch text included only on demand. */
getSessionReview(
selector: SessionTargetSelector,
options: { includePatch?: boolean } = {},
options: { includePatch?: boolean; includeNotes?: boolean } = {},
): SessionReview {
return this.view.buildSessionReview(this.getSessionEntry(selector), options);
}
Expand Down
3 changes: 2 additions & 1 deletion skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,12 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other-
```bash
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","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus]
hunk session comment list --repo . [--file README.md]
hunk session comment list --repo . [--file README.md] [--type live|all|ai|agent|user]
hunk session comment rm --repo . <comment-id>
hunk session comment clear --repo . --yes [--file README.md]
```

- `comment list --type user` shows human-authored inline notes; without `--type`, `comment list` preserves the legacy live-agent-comment view
- `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 `--old-line` or `--new-line`
- `comment apply` payload items require `filePath`, `summary`, and exactly one target such as `hunk`, `hunkNumber`, `oldLine`, or `newLine`
Expand Down
54 changes: 54 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,27 @@ describe("parseCli", () => {
});
});

test("parses session review with live notes included", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"review",
"session-1",
"--include-notes",
"--json",
]);

expect(parsed).toMatchObject({
kind: "session",
action: "review",
selector: { sessionId: "session-1" },
output: "json",
includePatch: false,
includeNotes: true,
});
});

test("parses session navigate by hunk number", async () => {
const parsed = await parseCli([
"bun",
Expand Down Expand Up @@ -563,6 +584,39 @@ describe("parseCli", () => {
});
});

test("rejects the removed session note namespace", async () => {
await expect(parseCli(["bun", "hunk", "session", "note", "list", "session-1"])).rejects.toThrow(
"Unknown session command: note",
);
});

test("parses session comment list with review-note type filter", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"comment",
"list",
"session-1",
"--type",
"user",
]);

expect(parsed).toEqual({
kind: "session",
action: "comment-list",
selector: { sessionId: "session-1" },
type: "user",
output: "text",
});
});

test("rejects session comment list with an unsupported type", async () => {
await expect(
parseCli(["bun", "hunk", "session", "comment", "list", "session-1", "--type", "robot"]),
).rejects.toThrow("Comment type must be one of live, all, ai, agent, or user.");
});

test("parses session comment rm", async () => {
const parsed = await parseCli([
"bun",
Expand Down
42 changes: 30 additions & 12 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,
SessionCommentListType,
SessionCommentApplyItemInput,
} from "./types";
import { resolveBundledHunkReviewSkillPath } from "./paths";
Expand Down Expand Up @@ -596,15 +597,15 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session get --repo <path>",
" hunk session context <session-id>",
" hunk session context --repo <path>",
" hunk session review <session-id> [--include-patch]",
" hunk session review --repo <path> [--include-patch]",
" hunk session review <session-id> [--include-patch] [--include-notes]",
" hunk session review --repo <path> [--include-patch] [--include-notes]",
" 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...>]",
" 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> [--focus]",
" hunk session comment apply (<session-id> | --repo <path>) --stdin [--focus]",
" hunk session comment list (<session-id> | --repo <path>)",
" hunk session comment list (<session-id> | --repo <path>) [--type <live|all|ai|agent|user>]",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) --yes",
].join("\n") + "\n",
Expand Down Expand Up @@ -647,19 +648,23 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.option("--json", "emit structured JSON");

if (subcommand === "review") {
command.option(
"--include-patch",
"include raw unified diff text for each file in review output",
);
command
.option("--include-patch", "include raw unified diff text for each file in review output")
.option("--include-notes", "include live review notes in review output");
}

let parsedSessionId: string | undefined;
let parsedOptions: { repo?: string; includePatch?: boolean; json?: boolean } = {};
let parsedOptions: {
repo?: string;
includePatch?: boolean;
includeNotes?: boolean;
json?: boolean;
} = {};

command.action(
(
sessionId: string | undefined,
options: { repo?: string; includePatch?: boolean; json?: boolean },
options: { repo?: string; includePatch?: boolean; includeNotes?: boolean; json?: boolean },
) => {
parsedSessionId = sessionId;
parsedOptions = options;
Expand All @@ -678,6 +683,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
includePatch: parsedOptions.includePatch ?? false,
includeNotes: parsedOptions.includeNotes ?? false,
};
}

Expand Down Expand Up @@ -873,7 +879,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
"Usage:",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--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 list (<session-id> | --repo <path>) [--file <path>] [--type <live|all|ai|agent|user>]",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --yes",
].join("\n") + "\n",
Expand Down Expand Up @@ -1039,15 +1045,16 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--file <path>", "filter comments to one diff file")
.option("--type <type>", "filter to live, all, ai, agent, or user comments")
.option("--json", "emit structured JSON");

let parsedSessionId: string | undefined;
let parsedOptions: { repo?: string; file?: string; json?: boolean } = {};
let parsedOptions: { repo?: string; file?: string; type?: string; json?: boolean } = {};

command.action(
(
sessionId: string | undefined,
options: { repo?: string; file?: string; json?: boolean },
options: { repo?: string; file?: string; type?: string; json?: boolean },
) => {
parsedSessionId = sessionId;
parsedOptions = options;
Expand All @@ -1059,13 +1066,24 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
}

await parseStandaloneCommand(command, commentRest);
if (
parsedOptions.type !== undefined &&
parsedOptions.type !== "live" &&
parsedOptions.type !== "all" &&
parsedOptions.type !== "ai" &&
parsedOptions.type !== "agent" &&
parsedOptions.type !== "user"
) {
throw new Error("Comment type must be one of live, all, ai, agent, or user.");
}

return {
kind: "session",
action: "comment-list",
output: resolveJsonOutput(parsedOptions),
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
filePath: parsedOptions.file,
...(parsedOptions.type ? { type: parsedOptions.type as SessionCommentListType } : {}),
};
}

Expand Down
9 changes: 6 additions & 3 deletions src/core/jj.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ afterEach(() => {
cleanupTempDirs();
});

// Keep jj-backed integration checks opt-in on machines that have the external CLI installed.
const jjTest = Bun.which("jj") ? test : test.skip;

describe("jj command helpers", () => {
test("reports a friendly error when jj is not installed or not on PATH", () => {
expect(() =>
Expand All @@ -99,7 +102,7 @@ describe("jj command helpers", () => {
);
});

test("reports a friendly error outside a jj repository", () => {
jjTest("reports a friendly error outside a jj repository", () => {
const dir = createTempDir("hunk-jj-nonrepo-");

expect(() =>
Expand All @@ -115,7 +118,7 @@ describe("jj command helpers", () => {
).toThrow('`hunk diff` must be run inside a Jujutsu repository when `vcs = "jj"`.');
});

test("reports a friendly error for invalid revsets", () => {
jjTest("reports a friendly error for invalid revsets", () => {
const dir = createTempJjRepo("hunk-jj-invalid-revset-");
const input = {
kind: "vcs" as const,
Expand All @@ -133,7 +136,7 @@ describe("jj command helpers", () => {
).toThrow("`hunk diff missing_revision` could not resolve Jujutsu revset `missing_revision`.");
});

test(
jjTest(
"reports a friendly error for ambiguous change id prefixes",
() => {
const dir = createTempJjRepo("hunk-jj-ambiguous-prefix-");
Expand Down
7 changes: 5 additions & 2 deletions src/core/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,9 @@ function createTempJjRepo(prefix: string) {
return dir;
}

// Keep jj-backed loader coverage opt-in on machines that have the external CLI installed.
const jjTest = Bun.which("jj") ? test : test.skip;

async function runWithHome<T>(home: string, task: () => Promise<T>) {
const previousHome = process.env.HOME;
process.env.HOME = home;
Expand Down Expand Up @@ -773,7 +776,7 @@ describe("loadAppBootstrap", () => {
expect(bootstrap.changeset.files.map((file) => file.path)).toEqual(["beta.ts"]);
});

test(
jjTest(
"loads jj diff output for a configured revset",
async () => {
const home = createTempDir("hunk-jj-home-");
Expand Down Expand Up @@ -802,7 +805,7 @@ describe("loadAppBootstrap", () => {
JjLoaderIntegrationTestTimeoutMs,
);

test(
jjTest(
"loads jj show output for a configured revset",
async () => {
const home = createTempDir("hunk-jj-home-");
Expand Down
48 changes: 48 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,24 @@ export type LayoutMode = "auto" | "split" | "stack";
export type VcsMode = "git" | "jj";
export type TerminalThemeMode = "light" | "dark";

export type ReviewNoteSource = "ai" | "agent" | "user";
export type SessionCommentListType = "live" | "all" | ReviewNoteSource;

export interface ReviewNote {
id: string;
source: ReviewNoteSource;
filePath: string;
hunkIndex?: number;
oldRange?: [number, number];
newRange?: [number, number];
body: string;
title?: string;
author?: string;
createdAt: string;
updatedAt?: string;
editable: boolean;
}

export interface AgentAnnotation {
id?: string;
oldRange?: [number, number];
Expand All @@ -13,8 +31,11 @@ export interface AgentAnnotation {
tags?: string[];
confidence?: "low" | "medium" | "high";
source?: string;
title?: string;
author?: string;
createdAt?: string;
updatedAt?: string;
editable?: boolean;
}

export interface AgentFileContext {
Expand Down Expand Up @@ -120,6 +141,7 @@ export interface SessionReviewCommandInput {
output: SessionCommandOutput;
selector: SessionSelectorInput;
includePatch: boolean;
includeNotes?: boolean;
}

export interface SessionNavigateCommandInput {
Expand Down Expand Up @@ -182,6 +204,7 @@ export interface SessionCommentListCommandInput {
output: SessionCommandOutput;
selector: SessionSelectorInput;
filePath?: string;
type?: SessionCommentListType;
}

export interface SessionCommentRemoveCommandInput {
Expand All @@ -201,6 +224,31 @@ export interface SessionCommentClearCommandInput {
confirmed: boolean;
}

export interface SessionNoteListCommandInput {
kind: "session";
action: "note-list";
output: SessionCommandOutput;
selector: SessionSelectorInput;
filePath?: string;
source?: ReviewNoteSource;
}

export interface SessionNoteGetCommandInput {
kind: "session";
action: "note-get";
output: SessionCommandOutput;
selector: SessionSelectorInput;
noteId: string;
}

export interface SessionNoteRemoveCommandInput {
kind: "session";
action: "note-rm";
output: SessionCommandOutput;
selector: SessionSelectorInput;
noteId: string;
}

export type SessionCommandInput =
| SessionListCommandInput
| SessionGetCommandInput
Expand Down
Loading