Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
91fac82
feat: add session user review notes
benvinegar May 10, 2026
ca81173
feat(ui): add persistent user review notes
benvinegar May 15, 2026
3496e8d
fix(ui): polish inline review note cards
benvinegar May 15, 2026
1e912f2
fix(ui): expand review note drafts on newline
benvinegar May 15, 2026
0d1d7b8
fix(ui): align review note draft actions
benvinegar May 15, 2026
61d56fd
fix(ui): label inline agent notes consistently
benvinegar May 15, 2026
88cca5b
feat(session): list typed review comments
benvinegar May 15, 2026
fdc4cb5
fix(ui): pad note draft action labels
benvinegar May 16, 2026
e8d77f9
fix(ui): restore shortcuts after note blur
benvinegar May 16, 2026
1a910ad
test: stabilize rebased main checks
benvinegar May 16, 2026
9b13929
fix(ui): trim inline note guide connector
benvinegar May 16, 2026
e77dee4
fix(ui): hide add-note badge off row hover
benvinegar May 16, 2026
ea897db
fix(session): route typed comments through comment list
benvinegar May 16, 2026
be839ca
fix(ui): keep add-note hover stable
benvinegar May 16, 2026
ba64d28
fix(ui): preserve add-note hover row key
benvinegar May 16, 2026
91cade6
fix(ui): hide add-note badge after mouse idle
benvinegar May 16, 2026
fd39cee
fix(ui): route note shortcut to hovered line
benvinegar May 16, 2026
e466acc
fix(ui): grow draft notes for soft wraps
benvinegar May 16, 2026
b1f3558
fix(build): keep opentui types inside src
benvinegar May 16, 2026
54d5001
refactor(session): remove note daemon actions
benvinegar May 16, 2026
464ea65
refactor(session): trim redundant note plumbing
benvinegar May 16, 2026
e923f57
refactor(ui): simplify review note state
benvinegar May 16, 2026
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 @@ -8,11 +8,16 @@ All notable user-visible changes to Hunk are documented in this file.

- Added an `e` shortcut to open the selected diff file in `$EDITOR`.
- 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
13 changes: 13 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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 UserNoteLineTarget {
side: "old" | "new";
line: number;
}

export interface AgentAnnotation {
id?: string;
oldRange?: [number, number];
Expand All @@ -13,8 +21,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 +131,7 @@ export interface SessionReviewCommandInput {
output: SessionCommandOutput;
selector: SessionSelectorInput;
includePatch: boolean;
includeNotes?: boolean;
}

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

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

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

describe("jjAdapter", () => {
test(
jjTest(
"detects Jujutsu repositories from nested directories",
() => {
const repo = createTempJjRepo("hunk-jj-adapter-detect-");
Expand All @@ -76,7 +79,7 @@ describe("jjAdapter", () => {
JjAdapterIntegrationTestTimeoutMs,
);

test(
jjTest(
"loads working-copy and revision patches through neutral operations",
async () => {
const repo = createTempJjRepo("hunk-jj-adapter-review-");
Expand Down Expand Up @@ -115,7 +118,7 @@ describe("jjAdapter", () => {
JjAdapterIntegrationTestTimeoutMs,
);

test(
jjTest(
"rejects staged and stash operations",
async () => {
const repo = createTempJjRepo("hunk-jj-adapter-unsupported-");
Expand Down
Loading
Loading