diff --git a/CHANGELOG.md b/CHANGELOG.md index 470fd52c..44283e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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. diff --git a/packages/session-broker-core/src/brokerState.ts b/packages/session-broker-core/src/brokerState.ts index 6577292b..3a274089 100644 --- a/packages/session-broker-core/src/brokerState.ts +++ b/packages/session-broker-core/src/brokerState.ts @@ -53,7 +53,7 @@ export interface SessionBrokerViewAdapter< buildSelectedContext: (session: ListedSession) => SelectedContext; buildSessionReview: ( entry: SessionBrokerEntry, - options: { includePatch?: boolean }, + options: { includePatch?: boolean; includeNotes?: boolean }, ) => SessionReview; listComments: (session: ListedSession, filter: { filePath?: string }) => SessionCommentSummary[]; } @@ -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); } diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index d1e45713..67f0d30e 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -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 . 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` diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 0e752027..402f1ac9 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -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", @@ -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", diff --git a/src/core/cli.ts b/src/core/cli.ts index 0b94564c..6844e29c 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -8,6 +8,7 @@ import type { LayoutMode, PagerCommandInput, ParsedCliInput, + SessionCommentListType, SessionCommentApplyItemInput, } from "./types"; import { resolveBundledHunkReviewSkillPath } from "./paths"; @@ -596,15 +597,15 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session get --repo ", " hunk session context ", " hunk session context --repo ", - " hunk session review [--include-patch]", - " hunk session review --repo [--include-patch]", + " hunk session review [--include-patch] [--include-notes]", + " hunk session review --repo [--include-patch] [--include-notes]", " hunk session navigate ( | --repo ) --file (--hunk | --old-line | --new-line )", " hunk session navigate ( | --repo ) (--next-comment | --prev-comment)", " hunk session reload ( | --repo | --session-path ) [--source ] -- diff [ref] [-- ]", " hunk session reload ( | --repo | --session-path ) [--source ] -- show [ref] [-- ]", " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary [--focus]", " hunk session comment apply ( | --repo ) --stdin [--focus]", - " hunk session comment list ( | --repo )", + " hunk session comment list ( | --repo ) [--type ]", " hunk session comment rm ( | --repo ) ", " hunk session comment clear ( | --repo ) --yes", ].join("\n") + "\n", @@ -647,19 +648,23 @@ async function parseSessionCommand(tokens: string[]): Promise { .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; @@ -678,6 +683,7 @@ async function parseSessionCommand(tokens: string[]): Promise { output: resolveJsonOutput(parsedOptions), selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), includePatch: parsedOptions.includePatch ?? false, + includeNotes: parsedOptions.includeNotes ?? false, }; } @@ -873,7 +879,7 @@ async function parseSessionCommand(tokens: string[]): Promise { "Usage:", " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary [--focus]", " hunk session comment apply ( | --repo ) --stdin [--focus]", - " hunk session comment list ( | --repo ) [--file ]", + " hunk session comment list ( | --repo ) [--file ] [--type ]", " hunk session comment rm ( | --repo ) ", " hunk session comment clear ( | --repo ) [--file ] --yes", ].join("\n") + "\n", @@ -1039,15 +1045,16 @@ async function parseSessionCommand(tokens: string[]): Promise { .argument("[sessionId]") .option("--repo ", "target the live session whose repo root matches this path") .option("--file ", "filter comments to one diff file") + .option("--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; @@ -1059,6 +1066,16 @@ async function parseSessionCommand(tokens: string[]): Promise { } 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", @@ -1066,6 +1083,7 @@ async function parseSessionCommand(tokens: string[]): Promise { output: resolveJsonOutput(parsedOptions), selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), filePath: parsedOptions.file, + ...(parsedOptions.type ? { type: parsedOptions.type as SessionCommentListType } : {}), }; } diff --git a/src/core/jj.test.ts b/src/core/jj.test.ts index f551d95a..c013afdc 100644 --- a/src/core/jj.test.ts +++ b/src/core/jj.test.ts @@ -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(() => @@ -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(() => @@ -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, @@ -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-"); diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index 5cdfac2a..c8b276fc 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -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(home: string, task: () => Promise) { const previousHome = process.env.HOME; process.env.HOME = home; @@ -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-"); @@ -802,7 +805,7 @@ describe("loadAppBootstrap", () => { JjLoaderIntegrationTestTimeoutMs, ); - test( + jjTest( "loads jj show output for a configured revset", async () => { const home = createTempDir("hunk-jj-home-"); diff --git a/src/core/types.ts b/src/core/types.ts index 51442aaf..546f9e81 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -4,6 +4,29 @@ 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 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]; @@ -13,8 +36,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 { @@ -120,6 +146,7 @@ export interface SessionReviewCommandInput { output: SessionCommandOutput; selector: SessionSelectorInput; includePatch: boolean; + includeNotes?: boolean; } export interface SessionNavigateCommandInput { @@ -182,6 +209,7 @@ export interface SessionCommentListCommandInput { output: SessionCommandOutput; selector: SessionSelectorInput; filePath?: string; + type?: SessionCommentListType; } export interface SessionCommentRemoveCommandInput { diff --git a/src/core/vcs/jj.test.ts b/src/core/vcs/jj.test.ts index ad4b5990..7b0534ab 100644 --- a/src/core/vcs/jj.test.ts +++ b/src/core/vcs/jj.test.ts @@ -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-"); @@ -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-"); @@ -115,7 +118,7 @@ describe("jjAdapter", () => { JjAdapterIntegrationTestTimeoutMs, ); - test( + jjTest( "rejects staged and stash operations", async () => { const repo = createTempJjRepo("hunk-jj-adapter-unsupported-"); diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index b647b7a0..2248c02b 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -17,6 +17,7 @@ import type { SelectedSessionContext, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, } from "./types"; import type { SessionCommentAddCommandInput, @@ -41,7 +42,9 @@ export interface HunkSessionCliClient { reloadSession(input: SessionReloadCommandInput): Promise; addComment(input: SessionCommentAddCommandInput): Promise; applyComments(input: SessionCommentApplyCommandInput): Promise; - listComments(input: SessionCommentListCommandInput): Promise; + listComments( + input: SessionCommentListCommandInput, + ): Promise>; removeComment(input: SessionCommentRemoveCommandInput): Promise; clearComments(input: SessionCommentClearCommandInput): Promise; } @@ -102,6 +105,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { action: "review", selector: input.selector, includePatch: input.includePatch, + includeNotes: input.includeNotes, }) ).review; } @@ -160,11 +164,14 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { async listComments(input: SessionCommentListCommandInput) { return ( - await this.request<{ comments: SessionLiveCommentSummary[] }>({ - action: "comment-list", - selector: input.selector, - filePath: input.filePath, - }) + await this.request<{ comments: Array }>( + { + action: "comment-list", + selector: input.selector, + filePath: input.filePath, + type: input.type, + }, + ) ).comments; } @@ -350,6 +357,15 @@ export function formatReviewOutput(review: SessionReview) { `Selected hunk: ${hunkNumber}`, `Agent notes visible: ${review.showAgentNotes ? "yes" : "no"}`, `Live comments: ${review.liveCommentCount}`, + `Review notes: ${review.reviewNoteCount ?? review.reviewNotes?.length ?? 0}`, + ...(review.reviewNotes + ? [ + "Notes:", + ...review.reviewNotes.map( + (note) => ` - ${note.noteId} [${note.source}] ${note.filePath}: ${note.body}`, + ), + ] + : []), "Files:", ...review.files.flatMap((file) => [ ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`, @@ -422,6 +438,26 @@ export function formatRemoveCommentOutput( return `Removed live comment ${result.commentId} from ${describeSessionSelector(selector)}. Remaining comments: ${result.remainingCommentCount}.\n`; } +export function formatNoteListOutput( + selector: SessionSelectorInput, + notes: SessionReviewNoteSummary[], +) { + if (notes.length === 0) { + return `No review notes for ${describeSessionSelector(selector)}.\n`; + } + + return `${notes + .map((note) => + [ + `${note.noteId} ${note.filePath} [${note.source}]`, + ...(note.hunkIndex !== undefined ? [` hunk: ${note.hunkIndex + 1}`] : []), + ` body: ${note.body}`, + ...(note.author ? [` author: ${note.author}`] : []), + ].join("\n"), + ) + .join("\n\n")}\n`; +} + export function formatClearCommentsOutput( selector: SessionSelectorInput, result: ClearedCommentsResult, diff --git a/src/hunk-session/projections.test.ts b/src/hunk-session/projections.test.ts index de0cf72a..6447613d 100644 --- a/src/hunk-session/projections.test.ts +++ b/src/hunk-session/projections.test.ts @@ -9,6 +9,7 @@ import { buildListedHunkSession, buildSelectedHunkSessionContext, listHunkSessionComments, + listHunkSessionNotes, } from "./projections"; function createEntry() { @@ -71,6 +72,31 @@ describe("hunk session projections", () => { expect(withPatch.files[0]).toEqual(expect.objectContaining({ patch: "@@ -1,1 +1,1 @@" })); }); + test("buildHunkSessionReview can include live review notes on demand", () => { + const entry = { + registration: createTestSessionRegistration(), + snapshot: createTestSessionSnapshot({ + reviewNoteCount: 1, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + body: "Please cover this case.", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ], + }), + }; + + expect(buildHunkSessionReview(entry).reviewNotes).toBeUndefined(); + expect(buildHunkSessionReview(entry, { includeNotes: true }).reviewNotes).toEqual([ + expect.objectContaining({ noteId: "user:1", source: "user" }), + ]); + }); + test("listHunkSessionComments returns live comments and honors file filters", () => { const session = buildListedHunkSession({ registration: createTestSessionRegistration(), @@ -93,4 +119,38 @@ describe("hunk session projections", () => { expect.objectContaining({ commentId: "comment-1" }), ]); }); + + test("listHunkSessionNotes filters by file and source", () => { + const session = buildListedHunkSession({ + registration: createTestSessionRegistration(), + snapshot: createTestSessionSnapshot({ + reviewNoteCount: 2, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + body: "Human note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + { + noteId: "agent:1", + source: "agent", + filePath: "src/other.ts", + body: "Agent note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: false, + }, + ], + }), + }); + + expect(listHunkSessionNotes(session, { source: "user" })).toEqual([ + expect.objectContaining({ noteId: "user:1" }), + ]); + expect(listHunkSessionNotes(session, { filePath: "src/other.ts" })).toEqual([ + expect.objectContaining({ noteId: "agent:1" }), + ]); + }); }); diff --git a/src/hunk-session/projections.ts b/src/hunk-session/projections.ts index 2da4f777..e9b436b1 100644 --- a/src/hunk-session/projections.ts +++ b/src/hunk-session/projections.ts @@ -6,6 +6,7 @@ import type { SessionFileSummary, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, SessionReviewFile, } from "./types"; @@ -107,7 +108,7 @@ export function buildSelectedHunkSessionContext(session: ListedSession): Selecte /** Project one raw broker entry into the Hunk review export used by `hunk session review`. */ export function buildHunkSessionReview( entry: HunkSessionEntryLike, - options: { includePatch?: boolean } = {}, + options: { includePatch?: boolean; includeNotes?: boolean } = {}, ): SessionReview { const selectedFile = findSelectedReviewFile(entry); const includePatch = options.includePatch ?? false; @@ -125,6 +126,9 @@ export function buildHunkSessionReview( : null, showAgentNotes: entry.snapshot.state.showAgentNotes, liveCommentCount: entry.snapshot.state.liveCommentCount, + reviewNoteCount: + entry.snapshot.state.reviewNoteCount ?? entry.snapshot.state.reviewNotes?.length ?? 0, + reviewNotes: options.includeNotes ? (entry.snapshot.state.reviewNotes ?? []) : undefined, files: entry.registration.info.files.map((file) => serializeReviewFile(file, includePatch)), }; } @@ -142,3 +146,21 @@ export function listHunkSessionComments( (comment) => comment.filePath === filter.filePath, ); } + +/** Return review notes for one Hunk session, optionally filtered to a file and source. */ +export function listHunkSessionNotes( + session: ListedSession, + filter: { filePath?: string; source?: SessionReviewNoteSummary["source"] } = {}, +): SessionReviewNoteSummary[] { + return (session.snapshot.state.reviewNotes ?? []).filter((note) => { + if (filter.filePath && note.filePath !== filter.filePath) { + return false; + } + + if (filter.source && note.source !== filter.source) { + return false; + } + + return true; + }); +} diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index 44549fe8..958d93cc 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -107,6 +107,8 @@ export function createInitialSessionSnapshot(bootstrap: AppBootstrap): HunkSessi showAgentNotes: bootstrap.initialShowAgentNotes ?? false, liveCommentCount: 0, liveComments: [], + reviewNoteCount: 0, + reviewNotes: [], }, }; } diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index 8d3ae1e7..788e2190 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -1,4 +1,4 @@ -import type { AgentAnnotation, CliInput } from "../core/types"; +import type { AgentAnnotation, CliInput, ReviewNoteSource } from "../core/types"; import type { SessionBrokerClient } from "../session-broker/brokerClient"; import type { SessionClientMessage, @@ -56,6 +56,8 @@ export interface HunkSessionState { showAgentNotes: boolean; liveCommentCount: number; liveComments: SessionLiveCommentSummary[]; + reviewNoteCount?: number; + reviewNotes?: SessionReviewNoteSummary[]; } export type HunkSessionRegistration = SessionRegistration; @@ -130,6 +132,21 @@ export interface SessionLiveCommentSummary { createdAt: string; } +export interface SessionReviewNoteSummary { + noteId: 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 AppliedCommentResult { commentId: string; fileId: string; @@ -156,6 +173,12 @@ export interface RemovedCommentResult { remainingCommentCount: number; } +export interface RemovedUserNoteResult { + noteId: string; + removed: boolean; + remainingNoteCount: number; +} + export interface ClearedCommentsResult { removedCount: number; remainingCommentCount: number; @@ -211,6 +234,8 @@ export interface SessionReview { selectedHunk: SessionReviewHunk | null; showAgentNotes: boolean; liveCommentCount: number; + reviewNoteCount?: number; + reviewNotes?: SessionReviewNoteSummary[]; files: SessionReviewFile[]; } diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index 184abf28..e25f20d8 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -9,6 +9,7 @@ import type { HunkSessionInfo, HunkSessionState, SessionLiveCommentSummary, + SessionReviewNoteSummary, SessionReviewFile, SessionReviewHunk, } from "./types"; @@ -138,6 +139,47 @@ function parseSessionLiveCommentSummary(value: unknown): SessionLiveCommentSumma }; } +/** Parse one review note summary from the app-owned snapshot payload. */ +function parseSessionReviewNoteSummary(value: unknown): SessionReviewNoteSummary | null { + const record = brokerWireParsers.asRecord(value); + if (!record) { + return null; + } + + const noteId = brokerWireParsers.parseRequiredString(record.noteId); + const filePath = brokerWireParsers.parseRequiredString(record.filePath); + const body = brokerWireParsers.parseRequiredString(record.body); + const createdAt = brokerWireParsers.parseRequiredString(record.createdAt); + const source = + record.source === "ai" || record.source === "agent" || record.source === "user" + ? record.source + : null; + if ( + noteId === null || + filePath === null || + body === null || + createdAt === null || + source === null + ) { + return null; + } + + return { + noteId, + source, + filePath, + hunkIndex: brokerWireParsers.parseNonNegativeInt(record.hunkIndex) ?? undefined, + oldRange: parseOptionalRange(record.oldRange), + newRange: parseOptionalRange(record.newRange), + body, + title: brokerWireParsers.parseOptionalString(record.title), + author: brokerWireParsers.parseOptionalString(record.author), + createdAt, + updatedAt: brokerWireParsers.parseOptionalString(record.updatedAt), + editable: typeof record.editable === "boolean" ? record.editable : source === "user", + }; +} + /** Parse the app-owned registration info embedded inside one broker registration envelope. */ function parseHunkSessionInfo(value: unknown): HunkSessionInfo | null { const record = brokerWireParsers.asRecord(value); @@ -181,6 +223,9 @@ function parseHunkSessionState(value: unknown): HunkSessionState | null { const liveComments = record.liveComments .map(parseSessionLiveCommentSummary) .filter((comment): comment is SessionLiveCommentSummary => comment !== null); + const reviewNotes = (Array.isArray(record.reviewNotes) ? record.reviewNotes : []) + .map(parseSessionReviewNoteSummary) + .filter((note): note is SessionReviewNoteSummary => note !== null); return { selectedFileId: brokerWireParsers.parseOptionalString(record.selectedFileId), @@ -191,6 +236,8 @@ function parseHunkSessionState(value: unknown): HunkSessionState | null { showAgentNotes, liveCommentCount: liveComments.length, liveComments, + reviewNoteCount: reviewNotes.length, + reviewNotes, }; } diff --git a/src/opentui/HunkDiffBody.tsx b/src/opentui/HunkDiffBody.tsx index b0eea59d..6f4dd96e 100644 --- a/src/opentui/HunkDiffBody.tsx +++ b/src/opentui/HunkDiffBody.tsx @@ -7,8 +7,6 @@ import { resolveTheme } from "../ui/themes"; import { toInternalDiffFile } from "./model"; import type { HunkDiffBodyProps } from "./types"; -const EMPTY_ANNOTATED_HUNK_INDICES = new Set(); - /** Render one diff file body without owning navigation, app chrome, or global shortcuts. */ export function HunkDiffBody({ file, @@ -75,7 +73,6 @@ export function HunkDiffBody({ codeHorizontalOffset={horizontalOffset} theme={resolvedTheme} selected={row.hunkIndex === selectedHunkIndex} - annotated={EMPTY_ANNOTATED_HUNK_INDICES.has(row.hunkIndex)} /> ))} diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index e262802c..b35fc929 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -116,7 +116,11 @@ async function openSessionSocket(port: number) { return socket; } -async function openRegisteredSession(port: number, sessionId = "session-1") { +async function openRegisteredSession( + port: number, + sessionId = "session-1", + snapshotOverrides: Parameters[0] = {}, +) { const socket = await openSessionSocket(port); socket.send( @@ -127,7 +131,10 @@ async function openRegisteredSession(port: number, sessionId = "session-1") { pid: process.pid, sessionId, }), - snapshot: createTestSessionSnapshot({ updatedAt: "2026-03-24T00:00:00.000Z" }), + snapshot: createTestSessionSnapshot({ + updatedAt: "2026-03-24T00:00:00.000Z", + ...snapshotOverrides, + }), }), ); @@ -213,7 +220,7 @@ describe("Hunk session daemon server", () => { expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: 1, - daemonVersion: 2, + daemonVersion: 3, actions: [ "list", "get", @@ -417,7 +424,7 @@ describe("Hunk session daemon server", () => { } }); - test("forwards review includePatch through the session API", async () => { + test("forwards review options through the session API", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; process.env.HUNK_MCP_PORT = String(port); @@ -425,7 +432,7 @@ describe("Hunk session daemon server", () => { const original = SessionBrokerState.prototype.getSessionReview; SessionBrokerState.prototype.getSessionReview = function (selector, options) { expect(selector).toEqual({ sessionId: "session-1" }); - expect(options).toEqual({ includePatch: true }); + expect(options).toEqual({ includePatch: true, includeNotes: true }); return { sessionId: "session-1", @@ -490,6 +497,7 @@ describe("Hunk session daemon server", () => { action: "review", selector: { sessionId: "session-1" }, includePatch: true, + includeNotes: true, }), }); @@ -572,6 +580,56 @@ describe("Hunk session daemon server", () => { } }); + test("serves review notes through the session API", async () => { + const port = await reserveLoopbackPort(); + process.env.HUNK_MCP_HOST = "127.0.0.1"; + process.env.HUNK_MCP_PORT = String(port); + + const server = serveSessionBrokerDaemon(); + const socket = await openRegisteredSession(port, "session-1", { + reviewNoteCount: 2, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "src/example.ts", + hunkIndex: 0, + body: "Human note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + { + noteId: "agent:1", + source: "agent", + filePath: "src/other.ts", + body: "Agent note", + createdAt: "2026-05-10T00:00:00.000Z", + editable: false, + }, + ], + }); + + try { + const listResponse = await fetch(`http://127.0.0.1:${port}/session-api`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + action: "comment-list", + selector: { sessionId: "session-1" }, + type: "user", + }), + }); + + expect(listResponse.status).toBe(200); + await expect(listResponse.json()).resolves.toMatchObject({ + comments: [{ noteId: "user:1", body: "Human note" }], + }); + } finally { + socket.close(); + server.stop(true); + } + }); + test("forwards comment batches through the session API", async () => { const port = await reserveLoopbackPort(); process.env.HUNK_MCP_HOST = "127.0.0.1"; diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index fa3a600e..e82d08ce 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -22,6 +22,7 @@ import type { ReloadedSessionResult, RemovedCommentResult, } from "../hunk-session/types"; +import { listHunkSessionNotes } from "../hunk-session/projections"; import { HUNK_SESSION_API_PATH, HUNK_SESSION_API_VERSION, @@ -115,11 +116,15 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R case "context": response = { context: state.getSelectedContext(input.selector) }; break; - case "review": + case "review": { response = { - review: state.getSessionReview(input.selector, { includePatch: input.includePatch }), + review: state.getSessionReview(input.selector, { + includePatch: input.includePatch, + includeNotes: input.includeNotes, + }), }; break; + } case "navigate": { if ( !input.commentDirection && @@ -204,9 +209,17 @@ async function handleSessionApiRequest(state: HunkSessionBrokerState, request: R }; break; case "comment-list": - response = { - comments: state.listComments(input.selector, { filePath: input.filePath }), - }; + response = + input.type && input.type !== "live" + ? { + comments: listHunkSessionNotes(state.getSession(input.selector), { + filePath: input.filePath, + source: input.type === "all" ? undefined : input.type, + }), + } + : { + comments: state.listComments(input.selector, { filePath: input.filePath }), + }; break; case "comment-rm": response = { diff --git a/src/session/commands.test.ts b/src/session/commands.test.ts index 6afdba4c..1cf8e781 100644 --- a/src/session/commands.test.ts +++ b/src/session/commands.test.ts @@ -366,6 +366,7 @@ describe("session command compatibility checks", () => { getSessionReview: async (input) => { expect(input.selector).toEqual({ sessionId: "session-1" }); expect(input.includePatch).toBe(false); + expect(input.includeNotes).toBe(false); return { sessionId: "session-1", @@ -425,6 +426,7 @@ describe("session command compatibility checks", () => { selector: { sessionId: "session-1" }, output: "json", includePatch: false, + includeNotes: false, } satisfies SessionCommandInput); expect(JSON.parse(output)).toEqual({ @@ -485,6 +487,7 @@ describe("session command compatibility checks", () => { getSessionReview: async (input) => { expect(input.selector).toEqual({ sessionId: "session-1" }); expect(input.includePatch).toBe(true); + expect(input.includeNotes).toBe(false); return { sessionId: "session-1", @@ -546,6 +549,7 @@ describe("session command compatibility checks", () => { selector: { sessionId: "session-1" }, output: "json", includePatch: true, + includeNotes: false, } satisfies SessionCommandInput); expect(JSON.parse(output)).toEqual({ @@ -601,6 +605,90 @@ describe("session command compatibility checks", () => { }); }); + test("runs review commands through the daemon with notes when requested", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSessionReview: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.includePatch).toBe(false); + expect(input.includeNotes).toBe(true); + + return { + ...createTestSessionReview(false), + reviewNoteCount: 1, + reviewNotes: [ + { + noteId: "user:1", + source: "user", + filePath: "README.md", + body: "Please simplify this.", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ], + }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "review", + selector: { sessionId: "session-1" }, + output: "json", + includePatch: false, + includeNotes: true, + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toMatchObject({ + review: { + reviewNoteCount: 1, + reviewNotes: [{ noteId: "user:1", body: "Please simplify this." }], + }, + }); + }); + + test("routes typed comment listing through the comment list API", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + listComments: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.filePath).toBe("README.md"); + expect(input.type).toBe("user"); + return [ + { + noteId: "user:1", + source: "user", + filePath: "README.md", + hunkIndex: 0, + body: "Human note", + author: "user", + createdAt: "2026-05-10T00:00:00.000Z", + editable: true, + }, + ]; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "comment-list", + selector: { sessionId: "session-1" }, + filePath: "README.md", + type: "user", + output: "text", + } satisfies SessionCommandInput); + + expect(output).toContain("user:1 README.md [user]"); + expect(output).toContain("body: Human note"); + }); + test("runs reload commands through the daemon and returns the replacement session summary", async () => { setSessionCommandTestHooks({ createClient: () => diff --git a/src/session/commands.ts b/src/session/commands.ts index b76d0c94..ee7de033 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -3,6 +3,7 @@ import type { SessionCommandOutput, SessionSelectorInput, } from "../core/types"; +import type { SessionLiveCommentSummary, SessionReviewNoteSummary } from "../hunk-session/types"; import { ensureSessionBrokerAvailable, isSessionBrokerHealthy, @@ -21,6 +22,7 @@ import { formatContextOutput, formatListOutput, formatNavigationOutput, + formatNoteListOutput, formatReloadOutput, formatRemoveCommentOutput, formatReviewOutput, @@ -179,10 +181,8 @@ export async function runSessionCommand(input: SessionCommandInput) { } const normalizedSelector = "selector" in input ? normalizeSessionSelector(input.selector) : null; - await ensureRequiredAction( - REQUIRED_ACTION_BY_COMMAND[input.action], - normalizedSelector ?? undefined, - ); + const requiredAction = REQUIRED_ACTION_BY_COMMAND[input.action]; + await ensureRequiredAction(requiredAction, normalizedSelector ?? undefined); const client = createDaemonCliClient(); @@ -247,8 +247,16 @@ export async function runSessionCommand(input: SessionCommandInput) { ...input, selector: normalizedSelector!, }); + + if (input.type && input.type !== "live") { + const notes = comments as SessionReviewNoteSummary[]; + return renderOutput(input.output, { comments: notes }, () => + formatNoteListOutput(input.selector, notes), + ); + } + return renderOutput(input.output, { comments }, () => - formatCommentListOutput(input.selector, comments), + formatCommentListOutput(input.selector, comments as SessionLiveCommentSummary[]), ); } case "comment-rm": { diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 2814cd55..a9c4adec 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -20,6 +20,7 @@ import type { SelectedSessionContext, SessionLiveCommentSummary, SessionReview, + SessionReviewNoteSummary, } from "../hunk-session/types"; export const HUNK_SESSION_API_PATH = "/session-api"; @@ -30,7 +31,7 @@ export const HUNK_SESSION_API_VERSION = 1; * Version daemon/session compatibility separately from the HTTP action surface so newer Hunk * builds can refresh an older daemon even when it still exposes the same API endpoints. */ -export const HUNK_SESSION_DAEMON_VERSION = 2; +export const HUNK_SESSION_DAEMON_VERSION = 3; export type SessionDaemonAction = | "list" @@ -67,6 +68,7 @@ export type SessionDaemonRequest = action: "review"; selector: SessionSelectorInput; includePatch: SessionReviewCommandInput["includePatch"]; + includeNotes: SessionReviewCommandInput["includeNotes"]; } | { action: "navigate"; @@ -104,6 +106,7 @@ export type SessionDaemonRequest = action: "comment-list"; selector: SessionCommentListCommandInput["selector"]; filePath?: string; + type?: SessionCommentListCommandInput["type"]; } | { action: "comment-rm"; @@ -125,6 +128,6 @@ export type SessionDaemonResponse = | { result: ReloadedSessionResult } | { result: AppliedCommentResult } | { result: AppliedCommentBatchResult } - | { comments: SessionLiveCommentSummary[] } + | { comments: Array } | { result: RemovedCommentResult } | { result: ClearedCommentsResult }; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d1d884ee..1bc36ce0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -5,7 +5,7 @@ import { } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; -import type { AppBootstrap, CliInput, LayoutMode } from "../core/types"; +import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; @@ -18,6 +18,7 @@ import { maxFileCodeLineWidth, resolveCodeViewportWidth, } from "./diff/codeColumns"; +import type { ActiveAddNoteAffordance } from "./diff/PierreDiffView"; import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; @@ -29,7 +30,8 @@ import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; import { resolveTheme, THEMES } from "./themes"; -type FocusArea = "files" | "filter"; +type FocusArea = "files" | "filter" | "note"; +type ActiveAddNoteTarget = ActiveAddNoteAffordance & { fileId: string }; const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; @@ -119,6 +121,7 @@ export function App({ const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); const [focusArea, setFocusArea] = useState("files"); + const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(34); const [resizeDragOriginX, setResizeDragOriginX] = useState(null); const [resizeStartWidth, setResizeStartWidth] = useState(null); @@ -178,6 +181,8 @@ export function App({ openAgentNotes, reloadSession: onReloadSession, removeLiveComment: review.removeLiveComment, + reviewNoteCount: review.reviewNoteCount, + reviewNoteSummaries: review.reviewNoteSummaries, selectedFile, selectedHunk: review.selectedHunk, selectedHunkIndex, @@ -353,14 +358,6 @@ export function App({ setShowHunkHeaders((current) => !current); }; - /** Jump to an annotated hunk without changing the global note visibility toggle. */ - const openAgentNotesAtHunk = useCallback( - (fileId: string, hunkIndex: number) => { - review.selectHunk(fileId, hunkIndex); - }, - [review.selectHunk], - ); - const canRefreshCurrentInput = canReloadInput(bootstrap.input); const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); @@ -512,6 +509,45 @@ export function App({ setFocusArea((current) => (current === "files" ? "filter" : "files")); }, []); + /** Start a user-authored inline note and move keyboard focus into it. */ + const startUserNote = useCallback( + (fileId?: string, hunkIndex?: number, target?: UserNoteLineTarget) => { + const hoverTarget = fileId === undefined ? activeAddNoteTarget : null; + const draft = review.startUserNote( + fileId ?? hoverTarget?.fileId, + hunkIndex ?? hoverTarget?.hunkIndex, + target ?? hoverTarget?.target, + ); + if (draft) { + setActiveAddNoteTarget(null); + setFocusArea("note"); + } + }, + [activeAddNoteTarget, review.startUserNote], + ); + + /** Mark the inline draft note textarea as the active keyboard input. */ + const focusDraftNote = useCallback(() => { + setFocusArea("note"); + }, []); + + /** Return keyboard focus to review navigation when the draft textarea loses focus. */ + const blurDraftNote = useCallback(() => { + setFocusArea((current) => (current === "note" ? "files" : current)); + }, []); + + /** Save the active draft note and return focus to review navigation. */ + const saveDraftNote = useCallback(() => { + review.saveDraftNote(); + setFocusArea("files"); + }, [review.saveDraftNote]); + + /** Cancel the active draft note and return focus to review navigation. */ + const cancelDraftNote = useCallback(() => { + review.cancelDraftNote(); + setFocusArea("files"); + }, [review.cancelDraftNote]); + /** Cycle through the available built-in themes. */ const cycleTheme = useCallback(() => { const currentIndex = THEMES.findIndex((theme) => theme.id === activeTheme.id); @@ -599,6 +635,7 @@ export function App({ closeHelp, closeMenu, cycleTheme, + cancelDraftNote, focusArea, focusFilter, moveToAnnotatedHunk, @@ -609,9 +646,11 @@ export function App({ pagerMode, requestQuit, scrollCodeHorizontally, + saveDraftNote, scrollDiff, selectLayoutMode, showHelp, + startUserNote: () => startUserNote(), switchMenu, toggleAgentNotes, toggleFocusArea, @@ -765,6 +804,8 @@ export function App({ selectedFileId={selectedFile?.id} selectedHunkIndex={selectedHunkIndex} scrollToNote={review.scrollToNote} + draftNote={review.draftNote} + draftNoteFocused={focusArea === "note"} separatorWidth={diffSeparatorWidth} showAgentNotes={showAgentNotes} showLineNumbers={showLineNumbers} @@ -777,7 +818,14 @@ export function App({ selectedHunkRevealRequestId={review.selectedHunkRevealRequestId} theme={activeTheme} width={diffPaneWidth} - onOpenAgentNotesAtHunk={openAgentNotesAtHunk} + onActiveAddNoteAffordanceChange={setActiveAddNoteTarget} + onRemoveUserNote={review.removeUserNote} + onSaveDraftNote={saveDraftNote} + onStartUserNoteAtHunk={startUserNote} + onUpdateDraftNote={review.updateDraftNote} + onBlurDraftNote={blurDraftNote} + onCancelDraftNote={cancelDraftNote} + onFocusDraftNote={focusDraftNote} onScrollCodeHorizontally={(delta) => { scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); }} diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 0338d12c..99dc227e 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1023,12 +1023,12 @@ describe("App interactions", () => { await flush(setup); const frame = setup.captureCharFrame(); - expect(frame).toContain("AI note · ▶ new 2"); + expect(frame).toContain("Agent note - prefs.ts R2"); expect(frame).toContain("Annotation for prefs.ts"); expect(frame).toContain("Why prefs.ts changed"); expect(frame).not.toContain("@@ -1,1 +1,2 @@"); expect(frame).not.toContain("1 - export const message"); - expect(frame.indexOf("AI note · ▶ new 2")).toBeLessThan( + expect(frame.indexOf("Agent note - prefs.ts R2")).toBeLessThan( frame.indexOf("export const added = true;"), ); } finally { @@ -2179,6 +2179,83 @@ describe("App interactions", () => { } }); + test("draft note focus suppresses app shortcuts while accepting typed shortcut keys", async () => { + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("c"); + }); + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + const betaCountWithSidebar = (frame.match(/beta\.ts/g) ?? []).length; + expect(betaCountWithSidebar).toBeGreaterThan(1); + + await act(async () => { + await setup.mockInput.typeText("s"); + }); + await flush(setup); + + frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + expect(frame).toContain("s"); + expect((frame.match(/beta\.ts/g) ?? []).length).toBe(betaCountWithSidebar); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("draft note blur restores app shortcuts without discarding the draft", async () => { + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("c"); + }); + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + const betaCountWithSidebar = (frame.match(/beta\.ts/g) ?? []).length; + expect(betaCountWithSidebar).toBeGreaterThan(1); + + await act(async () => { + await setup.mockMouse.click(6, 4); + }); + await flush(setup); + + frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + + await act(async () => { + await setup.mockInput.typeText("s"); + }); + await flush(setup); + + frame = setup.captureCharFrame(); + expect(frame).toContain("Draft note"); + expect((frame.match(/beta\.ts/g) ?? []).length).toBeLessThan(betaCountWithSidebar); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("sidebar visibility can toggle off and back on", async () => { const setup = await testRender(, { width: 240, diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 0804ef84..22d7eb11 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -54,6 +54,7 @@ export function HelpDialog({ title: "Review", items: [ ["/", "focus file filter"], + ["c", "create review note"], ["Tab", "toggle files/filter focus"], ["F10", "open menus"], [canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"], diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index 969a2dc2..4cfbd962 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,11 +1,22 @@ -import type { AgentAnnotation, LayoutMode } from "../../../core/types"; +import type { TextareaRenderable } from "@opentui/core"; +import { flushSync } from "@opentui/react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; +import { isEscapeKey } from "../../lib/keyboard"; import { wrapText } from "../../lib/agentPopover"; -import { annotationRangeLabel } from "../../lib/agentAnnotations"; +import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; -function inlineNoteTitle(noteIndex: number, noteCount: number) { - return noteCount > 1 ? `AI note ${noteIndex + 1}/${noteCount}` : "AI note"; +function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) { + if (annotation.source === "user-draft") { + return "Draft note"; + } + + const source = reviewNoteSource(annotation); + const author = annotation.author?.trim(); + const label = source === "user" ? "Your note" : author ? `${author} note` : "Agent note"; + return noteCount > 1 ? `${label} ${noteIndex + 1}/${noteCount}` : label; } interface AgentInlineNoteLine { @@ -17,6 +28,37 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } +function draftLineCount(text: string) { + return Math.max(1, text.split("\n").length); +} + +/** Estimate the textarea's wrapped visual row count for a given content width. */ +function draftVisualLineCount(text: string, width: number) { + const usableWidth = Math.max(1, width); + return Math.max( + 1, + text + .split("\n") + .reduce((total, line) => total + Math.max(1, Math.ceil(line.length / usableWidth)), 0), + ); +} + +function isNewlineKey(key: { ctrl?: boolean; name?: string; sequence?: string }) { + return ( + key.name === "return" || + key.name === "enter" || + key.name === "linefeed" || + key.sequence === "\r" || + key.sequence === "\n" || + (key.ctrl && key.name === "j") + ); +} + +/** Wrap text while preserving author-entered line breaks in review notes. */ +function wrapNoteText(text: string, width: number) { + return text.split("\n").flatMap((line) => wrapText(line, width)); +} + function splitColumnWidths(width: number) { const markerWidth = 1; const separatorWidth = 1; @@ -48,16 +90,26 @@ export function measureAgentInlineNoteHeight({ const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); const innerWidth = Math.max(1, boxWidth - 2); const bodyWidth = innerWidth; + const contentWidth = Math.max(1, bodyWidth - 2); const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), + ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ + kind: "summary" as const, + text, + })), ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ + ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ kind: "rationale" as const, text, })) : []), ]; + if (annotation.source === "user-draft") { + const draftBodyRows = Math.max(3, draftLineCount(annotation.summary) + 2); + // Title border + expandable body + button footer. + return 1 + draftBodyRows + 3; + } + // top border + title row + body lines + bottom border return 3 + lines.length; } @@ -66,24 +118,80 @@ export function measureAgentInlineNoteHeight({ export function AgentInlineNote({ annotation, anchorSide, + file, layout, noteCount = 1, noteIndex = 0, + draft, onClose, theme, width, }: { annotation: AgentAnnotation; anchorSide?: "old" | "new"; + file?: DiffFile; layout: Exclude; noteCount?: number; noteIndex?: number; + draft?: { + body: string; + focused: boolean; + onBlur?: () => void; + onCancel: () => void; + onFocus?: () => void; + onInput: (value: string) => void; + onSave: () => void; + }; onClose?: () => void; theme: AppTheme; width: number; }) { + const textareaRef = useRef(null); + const [draftLineCountHint, setDraftLineCountHint] = useState(() => + draftLineCount(draft?.body ?? ""), + ); + + useEffect(() => { + setDraftLineCountHint(draftLineCount(draft?.body ?? "")); + }, [draft?.body]); + + useLayoutEffect(() => { + if (!draft) { + return; + } + + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + const originalFocus = textarea.focus.bind(textarea); + const originalBlur = textarea.blur.bind(textarea); + let active = true; + + textarea.focus = () => { + originalFocus(); + if (active) { + draft.onFocus?.(); + } + }; + + textarea.blur = () => { + originalBlur(); + if (active) { + draft.onBlur?.(); + } + }; + + return () => { + active = false; + textarea.focus = originalFocus; + textarea.blur = originalBlur; + }; + }, [draft]); + const closeText = onClose ? "[x]" : ""; - const titleText = `${inlineNoteTitle(noteIndex, noteCount)} · ${annotationRangeLabel(annotation)}`; + const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} - ${annotationRangeLabel(annotation, file)}`; const splitWidths = splitColumnWidths(width); const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; @@ -99,99 +207,368 @@ export function AgentInlineNote({ ? 0 : Math.min(4, Math.max(0, width - boxWidth)); const innerWidth = Math.max(1, boxWidth - 2); - const titleWidth = Math.max(1, innerWidth - (closeText ? closeText.length + 1 : 0)); + const closeGapWidth = closeText ? 1 : 0; + const closeWidth = closeText.length; const bodyWidth = innerWidth; + const contentWidth = Math.max(1, bodyWidth - 2); + const draftInnerWidth = Math.max(1, boxWidth - 2); + const draftContentWidth = Math.max(1, draftInnerWidth - 2); + const draftVisibleRows = draft + ? Math.max(draftLineCountHint, draftVisualLineCount(draft.body, draftContentWidth)) + : 0; + + useLayoutEffect(() => { + if (!draft || draftVisibleRows <= 0) { + return; + } + + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + const viewport = textarea.editorView.getViewport(); + if (viewport.offsetY === 0 && viewport.height === draftVisibleRows) { + return; + } + + // The textarea follows the cursor after Enter while its old one-line viewport is still active. + // Once the composer grows to fit the new line, reset the viewport so previous lines stay visible. + textarea.editorView.setViewport(viewport.offsetX, 0, viewport.width, draftVisibleRows, false); + textarea.requestRender(); + }, [draft, draftVisibleRows]); + + const updateDraftLineCountHint = (nextLineCount: number) => { + flushSync(() => { + setDraftLineCountHint(nextLineCount); + }); + }; + const lines: AgentInlineNoteLine[] = [ - ...wrapText(annotation.summary, bodyWidth).map((text) => ({ kind: "summary" as const, text })), + ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ + kind: "summary" as const, + text, + })), ...(annotation.rationale - ? wrapText(annotation.rationale, bodyWidth).map((text) => ({ + ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ kind: "rationale" as const, text, })) : []), ]; - const topBorder = `┌${"─".repeat(Math.max(0, boxWidth - 2))}┐`; - const bottomBorder = - anchorSide === "new" && canDockRight - ? `└${"─".repeat(Math.max(0, boxWidth - 2))}┤` - : anchorSide === "old" && canDockLeft - ? `├${"─".repeat(Math.max(0, boxWidth - 2))}┘` - : `└${"─".repeat(Math.max(0, boxWidth - 2))}┘`; + const savedTitleText = fitText( + ` ${titleText} `, + Math.max(0, boxWidth - 4 - closeGapWidth - closeWidth), + ); + const savedTopBorderSuffixWidth = Math.max( + 0, + boxWidth - 3 - savedTitleText.length - closeGapWidth - closeWidth, + ); + const savedTopPrefixWidth = 2 + savedTitleText.length + savedTopBorderSuffixWidth; + const bottomBorder = `╰${"─".repeat(Math.max(0, boxWidth - 2))}╯`; - return ( - - - - {" ".repeat(boxLeft)} - - - - {topBorder} - + if (draft) { + const draftVisibleLineCount = draftVisibleRows; + const draftTitleText = fitText(` ${titleText} `, Math.max(0, boxWidth - 4)); + const saveInnerWidth = 11; + const cancelInnerWidth = 14; + const footerRemainderWidth = Math.max(0, boxWidth - saveInnerWidth - cancelInnerWidth - 4); + const draftTopBorderSuffix = `${"─".repeat(Math.max(0, boxWidth - 3 - draftTitleText.length))}╮`; + const footerButtonWidth = 1 + saveInnerWidth + 1 + cancelInnerWidth + 1; + const footerButtonLeft = boxLeft + footerRemainderWidth + 1; + const draftActionBorder = `╰${"─".repeat(footerRemainderWidth)}┬${"─".repeat(saveInnerWidth)}┬${"─".repeat(cancelInnerWidth)}┤`; + const draftButtonBottom = `╰${"─".repeat(saveInnerWidth)}┴${"─".repeat(cancelInnerWidth)}╯`; + const draftTextareaRows = draftVisibleLineCount; + const draftTopPaddingRows = 1; + const draftBottomPaddingRows = 1; + const renderDraftBodyPaddingRows = (keyPrefix: string, rowCount: number) => + Array.from({ length: rowCount }, (_, rowIndex) => ( + + + {" ".repeat(boxLeft)} + + + + │ + + + + + {" ".repeat(draftContentWidth)} + + + + + │ + + - + )); - - - {" ".repeat(boxLeft)} - - - - │ - - - - - {padText(fitText(titleText, titleWidth), titleWidth)} - + return ( + + + + {" ".repeat(boxLeft)} + + + + + ╭─ + + + {draftTitleText} + + + {draftTopBorderSuffix} + + + - {closeText ? ( + + {renderDraftBodyPaddingRows("draft-body-top-padding", draftTopPaddingRows)} + + + - {` ${closeText}`} + {Array.from({ length: draftTextareaRows }, (_, rowIndex) => ( + + │ + + ))} + + +