diff --git a/README.md b/README.md index f8a5daa..8548c5c 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,24 @@ agent_notes = false - Hunk keeps the daemon loopback-only by default - if you intentionally need remote access, set `HUNK_MCP_UNSAFE_ALLOW_REMOTE=1` and choose a non-loopback `HUNK_MCP_HOST` +### Live session control CLI + +`hunk session ...` is the human/script interface to the same local daemon that MCP uses for live review sessions. + +Use explicit session targeting with either a live `` or `--repo ` when exactly one live session matches that repo root. + +```bash +hunk session list +hunk session context --repo . +hunk session navigate --repo . --file README.md --hunk 2 +hunk session comment add --repo . --file README.md --new-line 103 --summary "Frame this as MCP-first" +hunk session comment list --repo . +hunk session comment rm --repo . mcp:1234 +hunk session comment clear --repo . --file README.md --yes +``` + +The session CLI works against live session comments only. It does not edit `.hunk/latest.json`. + ## Performance notes Hunk spends more startup time than plain diff output tools because it launches an interactive UI with syntax highlighting, navigation state, and optional agent context. In exchange, it is optimized for reviewing a full changeset instead of printing static diff text and exiting. diff --git a/src/core/cli.ts b/src/core/cli.ts index f29a87d..b1a307c 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -392,6 +392,9 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session context --repo ", " hunk session navigate --file (--hunk | --old-line | --new-line )", " hunk session comment add --file (--old-line | --new-line ) --summary ", + " hunk session comment list ", + " hunk session comment rm ", + " hunk session comment clear --yes", ].join("\n") + "\n", }; } @@ -492,86 +495,188 @@ async function parseSessionCommand(tokens: string[]): Promise { return { kind: "help", text: [ - "Usage: hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", - "", - "Attach one live inline review note to a diff line.", + "Usage:", + " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", + " hunk session comment list ( | --repo ) [--file ]", + " hunk session comment rm ( | --repo ) ", + " hunk session comment clear ( | --repo ) [--file ] --yes", ].join("\n") + "\n", }; } - if (commentSubcommand !== "add") { - throw new Error("Only `hunk session comment add` is supported."); + if (commentSubcommand === "add") { + const command = new Command("session comment add") + .description("attach one live inline review note") + .argument("[sessionId]") + .requiredOption("--file ", "diff file path as shown by Hunk") + .requiredOption("--summary ", "short review note") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--old-line ", "1-based line number on the old side", parsePositiveInt) + .option("--new-line ", "1-based line number on the new side", parsePositiveInt) + .option("--rationale ", "optional longer explanation") + .option("--author ", "optional author label") + .option("--reveal", "jump to and reveal the note") + .option("--no-reveal", "add the note without moving focus") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { + repo?: string; + file: string; + summary: string; + oldLine?: number; + newLine?: number; + rationale?: string; + author?: string; + reveal?: boolean; + json?: boolean; + } = { + file: "", + summary: "", + }; + + command.action((sessionId: string | undefined, options: { + repo?: string; + file: string; + summary: string; + oldLine?: number; + newLine?: number; + rationale?: string; + author?: string; + reveal?: boolean; + json?: boolean; + }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (commentRest.includes("--help") || commentRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, commentRest); + + const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); + if (selectors.length !== 1) { + throw new Error("Specify exactly one comment target: --old-line or --new-line ."); + } + + return { + kind: "session", + action: "comment-add", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + side: parsedOptions.oldLine !== undefined ? "old" : "new", + line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, + summary: parsedOptions.summary, + rationale: parsedOptions.rationale, + author: parsedOptions.author, + reveal: parsedOptions.reveal ?? true, + }; } - const command = new Command("session comment add") - .description("attach one live inline review note") - .argument("[sessionId]") - .requiredOption("--file ", "diff file path as shown by Hunk") - .requiredOption("--summary ", "short review note") - .option("--repo ", "target the live session whose repo root matches this path") - .option("--old-line ", "1-based line number on the old side", parsePositiveInt) - .option("--new-line ", "1-based line number on the new side", parsePositiveInt) - .option("--rationale ", "optional longer explanation") - .option("--author ", "optional author label") - .option("--reveal", "jump to and reveal the note") - .option("--no-reveal", "add the note without moving focus") - .option("--json", "emit structured JSON"); + if (commentSubcommand === "list") { + const command = new Command("session comment list") + .description("list live inline review notes") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--file ", "filter comments to one diff file") + .option("--json", "emit structured JSON"); - let parsedSessionId: string | undefined; - let parsedOptions: { - repo?: string; - file: string; - summary: string; - oldLine?: number; - newLine?: number; - rationale?: string; - author?: string; - reveal?: boolean; - json?: boolean; - } = { - file: "", - summary: "", - }; + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; file?: string; json?: boolean } = {}; - command.action((sessionId: string | undefined, options: { - repo?: string; - file: string; - summary: string; - oldLine?: number; - newLine?: number; - rationale?: string; - author?: string; - reveal?: boolean; - json?: boolean; - }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action((sessionId: string | undefined, options: { repo?: string; file?: string; json?: boolean }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); - if (commentRest.includes("--help") || commentRest.includes("-h")) { - return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + if (commentRest.includes("--help") || commentRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, commentRest); + + return { + kind: "session", + action: "comment-list", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + }; } - await parseStandaloneCommand(command, commentRest); + if (commentSubcommand === "rm") { + const command = new Command("session comment rm") + .description("remove one live inline review note") + .argument("[sessionId]") + .argument("") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedCommentId = ""; + let parsedOptions: { repo?: string; json?: boolean } = {}; + + command.action((sessionId: string | undefined, commentId: string, options: { repo?: string; json?: boolean }) => { + parsedSessionId = sessionId; + parsedCommentId = commentId; + parsedOptions = options; + }); + + if (commentRest.includes("--help") || commentRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } - const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); - if (selectors.length !== 1) { - throw new Error("Specify exactly one comment target: --old-line or --new-line ."); + await parseStandaloneCommand(command, commentRest); + + return { + kind: "session", + action: "comment-rm", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + commentId: parsedCommentId, + }; } - return { - kind: "session", - action: "comment-add", - output: resolveJsonOutput(parsedOptions), - selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), - filePath: parsedOptions.file, - side: parsedOptions.oldLine !== undefined ? "old" : "new", - line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, - summary: parsedOptions.summary, - rationale: parsedOptions.rationale, - author: parsedOptions.author, - reveal: parsedOptions.reveal ?? true, - }; + if (commentSubcommand === "clear") { + const command = new Command("session comment clear") + .description("clear live inline review notes") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--file ", "clear only one diff file's comments") + .option("--yes", "confirm destructive live comment clearing") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; file?: string; yes?: boolean; json?: boolean } = {}; + + command.action((sessionId: string | undefined, options: { repo?: string; file?: string; yes?: boolean; json?: boolean }) => { + parsedSessionId = sessionId; + parsedOptions = options; + }); + + if (commentRest.includes("--help") || commentRest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, commentRest); + if (!parsedOptions.yes) { + throw new Error("Pass --yes to clear live comments."); + } + + return { + kind: "session", + action: "comment-clear", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + confirmed: true, + }; + } + + throw new Error("Supported comment subcommands are add, list, rm, and clear."); } throw new Error(`Unknown session command: ${subcommand}`); diff --git a/src/core/liveComments.ts b/src/core/liveComments.ts index 7a2cb71..0c48397 100644 --- a/src/core/liveComments.ts +++ b/src/core/liveComments.ts @@ -28,12 +28,21 @@ export function findHunkIndexForLine(file: DiffFile, side: DiffSide, line: numbe } /** Convert one incoming MCP comment command into a live annotation. */ -export function buildLiveComment(input: CommentToolInput, commentId: string, createdAt: string): LiveComment { +export function buildLiveComment( + input: CommentToolInput, + commentId: string, + createdAt: string, + hunkIndex: number, +): LiveComment { return { id: commentId, source: "mcp", author: input.author, createdAt, + filePath: input.filePath, + hunkIndex, + side: input.side, + line: input.line, summary: input.summary, rationale: input.rationale, oldRange: input.side === "old" ? [input.line, input.line] : undefined, diff --git a/src/core/types.ts b/src/core/types.ts index 1eb071c..ff04bea 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -129,11 +129,39 @@ export interface SessionCommentAddCommandInput { reveal: boolean; } +export interface SessionCommentListCommandInput { + kind: "session"; + action: "comment-list"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath?: string; +} + +export interface SessionCommentRemoveCommandInput { + kind: "session"; + action: "comment-rm"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + commentId: string; +} + +export interface SessionCommentClearCommandInput { + kind: "session"; + action: "comment-clear"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath?: string; + confirmed: boolean; +} + export type SessionCommandInput = | SessionListCommandInput | SessionGetCommandInput | SessionNavigateCommandInput - | SessionCommentAddCommandInput; + | SessionCommentAddCommandInput + | SessionCommentListCommandInput + | SessionCommentRemoveCommandInput + | SessionCommentClearCommandInput; export interface GitCommandInput { kind: "git"; diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 74232eb..779c693 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -1,8 +1,10 @@ import type { AppliedCommentResult, + ClearedCommentsResult, HunkSessionRegistration, HunkSessionSnapshot, NavigatedSelectionResult, + RemovedCommentResult, SessionClientMessage, SessionCommandResult, SessionServerMessage, @@ -18,6 +20,8 @@ const HEARTBEAT_INTERVAL_MS = 10_000; interface HunkAppBridge { applyComment: (message: Extract) => Promise; navigateToHunk: (message: Extract) => Promise; + removeComment: (message: Extract) => Promise; + clearComments: (message: Extract) => Promise; } /** Keep one running Hunk TUI session registered with the local MCP daemon. */ @@ -260,6 +264,10 @@ export class HunkHostClient { return this.bridge.applyComment(message); case "navigate_to_hunk": return this.bridge.navigateToHunk(message); + case "remove_comment": + return this.bridge.removeComment(message); + case "clear_comments": + return this.bridge.clearComments(message); } } diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index ff5460c..2dae911 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -1,14 +1,20 @@ import { randomUUID } from "node:crypto"; import type { AppliedCommentResult, + ClearedCommentsResult, + ClearCommentsToolInput, CommentToolInput, HunkSessionRegistration, HunkSessionSnapshot, ListedSession, + ListCommentsToolInput, NavigateToHunkToolInput, NavigatedSelectionResult, + RemoveCommentToolInput, + RemovedCommentResult, SelectedSessionContext, SessionCommandResult, + SessionLiveCommentSummary, SessionServerMessage, SessionTargetInput, } from "./types"; @@ -141,6 +147,17 @@ export class HunkDaemonState { }; } + listComments(selector: SessionTargetSelector, filter: { filePath?: string } = {}) { + const session = this.getSession(selector); + const comments = session.snapshot.liveComments; + + if (!filter.filePath) { + return comments; + } + + return comments.filter((comment) => comment.filePath === filter.filePath); + } + getPendingCommandCount() { return this.pendingCommands.size; } @@ -243,6 +260,24 @@ export class HunkDaemonState { ); } + sendRemoveComment(input: RemoveCommentToolInput) { + return this.sendCommand( + { sessionId: input.sessionId, repoRoot: input.repoRoot }, + "remove_comment", + input, + "Timed out waiting for the Hunk session to remove the requested comment.", + ); + } + + sendClearComments(input: ClearCommentsToolInput) { + return this.sendCommand( + { sessionId: input.sessionId, repoRoot: input.repoRoot }, + "clear_comments", + input, + "Timed out waiting for the Hunk session to clear the requested comments.", + ); + } + handleCommandResult(message: { requestId: string; ok: boolean; result?: SessionCommandResult; error?: string }) { const pending = this.pendingCommands.get(message.requestId); if (!pending) { diff --git a/src/mcp/server.ts b/src/mcp/server.ts index aa754f6..573cee5 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -33,6 +33,18 @@ const navigateToHunkSchema = sessionSelectorSchema.extend({ }, ); +const listCommentsSchema = sessionSelectorSchema.extend({ + filePath: z.string().optional().describe("Optional diff file path to filter live comments."), +}); + +const removeCommentSchema = sessionSelectorSchema.extend({ + commentId: z.string().describe("Stable live comment id to remove."), +}); + +const clearCommentsSchema = sessionSelectorSchema.extend({ + filePath: z.string().optional().describe("Optional diff file path to clear only one file's live comments."), +}); + function formatToolJson(value: unknown) { return JSON.stringify(value, null, 2); } @@ -191,6 +203,63 @@ function createHunkMcpServer(state: HunkDaemonState) { }) as any, ); + server.registerTool( + "list_comments", + { + title: "List live Hunk comments", + description: "List live inline review comments currently attached to a Hunk session.", + inputSchema: listCommentsSchema as any, + } as any, + (async (input: { sessionId?: string; repoRoot?: string; filePath?: string }) => { + const comments = state.listComments({ sessionId: input.sessionId, repoRoot: input.repoRoot }, { filePath: input.filePath }); + + return { + content: textContent(formatToolJson({ comments })), + structuredContent: { + comments, + }, + }; + }) as any, + ); + + server.registerTool( + "remove_comment", + { + title: "Remove one live Hunk comment", + description: "Remove one live inline review comment from a Hunk session by comment id.", + inputSchema: removeCommentSchema as any, + } as any, + (async (input: { sessionId?: string; repoRoot?: string; commentId: string }) => { + const result = await state.sendRemoveComment(input); + + return { + content: textContent(formatToolJson(result)), + structuredContent: { + result, + }, + }; + }) as any, + ); + + server.registerTool( + "clear_comments", + { + title: "Clear live Hunk comments", + description: "Clear live inline review comments from a Hunk session, optionally limited to one file.", + inputSchema: clearCommentsSchema as any, + } as any, + (async (input: { sessionId?: string; repoRoot?: string; filePath?: string }) => { + const result = await state.sendClearComments(input); + + return { + content: textContent(formatToolJson(result)), + structuredContent: { + result, + }, + }; + }) as any, + ); + return server; } diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts index 1bb6253..4868c82 100644 --- a/src/mcp/sessionRegistration.ts +++ b/src/mcp/sessionRegistration.ts @@ -45,6 +45,7 @@ export function createInitialSessionSnapshot(bootstrap: AppBootstrap): HunkSessi selectedHunkNewRange: firstRange?.newRange, showAgentNotes: bootstrap.initialShowAgentNotes ?? false, liveCommentCount: 0, + liveComments: [], updatedAt: new Date().toISOString(), }; } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 54e1dd6..4ad247d 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -42,6 +42,7 @@ export interface HunkSessionSnapshot { selectedHunkNewRange?: [number, number]; showAgentNotes: boolean; liveCommentCount: number; + liveComments: SessionLiveCommentSummary[]; updatedAt: string; } @@ -72,6 +73,22 @@ export interface LiveComment extends AgentAnnotation { source: "mcp"; author?: string; createdAt: string; + filePath: string; + hunkIndex: number; + side: DiffSide; + line: number; +} + +export interface SessionLiveCommentSummary { + commentId: string; + filePath: string; + hunkIndex: number; + side: DiffSide; + line: number; + summary: string; + rationale?: string; + author?: string; + createdAt: string; } export interface AppliedCommentResult { @@ -90,6 +107,18 @@ export interface NavigatedSelectionResult { selectedHunk?: SelectedHunkSummary; } +export interface RemovedCommentResult { + commentId: string; + removed: boolean; + remainingCommentCount: number; +} + +export interface ClearedCommentsResult { + removedCount: number; + remainingCommentCount: number; + filePath?: string; +} + export interface ListedSessionFile extends SessionFileSummary { selected: boolean; } @@ -106,7 +135,7 @@ export interface SelectedSessionContext { liveCommentCount: number; } -export type SessionCommandResult = AppliedCommentResult | NavigatedSelectionResult; +export type SessionCommandResult = AppliedCommentResult | NavigatedSelectionResult | RemovedCommentResult | ClearedCommentsResult; export type SessionClientMessage = | { @@ -136,6 +165,18 @@ export type SessionClientMessage = error: string; }; +export interface ListCommentsToolInput extends SessionTargetInput { + filePath?: string; +} + +export interface RemoveCommentToolInput extends SessionTargetInput { + commentId: string; +} + +export interface ClearCommentsToolInput extends SessionTargetInput { + filePath?: string; +} + export type SessionServerMessage = | { type: "command"; @@ -148,6 +189,18 @@ export type SessionServerMessage = requestId: string; command: "navigate_to_hunk"; input: NavigateToHunkToolInput; + } + | { + type: "command"; + requestId: string; + command: "remove_comment"; + input: RemoveCommentToolInput; + } + | { + type: "command"; + requestId: string; + command: "clear_comments"; + input: ClearCommentsToolInput; }; export interface ListedSession { diff --git a/src/session/commands.ts b/src/session/commands.ts index bfb8a81..659a4ab 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -5,13 +5,23 @@ import type { SessionCommandInput, SessionCommandOutput, SessionCommentAddCommandInput, - SessionGetCommandInput, + SessionCommentClearCommandInput, + SessionCommentListCommandInput, + SessionCommentRemoveCommandInput, SessionNavigateCommandInput, SessionSelectorInput, } from "../core/types"; import { isHunkDaemonHealthy, isLoopbackPortReachable } from "../mcp/daemonLauncher"; import { resolveHunkMcpConfig } from "../mcp/config"; -import type { AppliedCommentResult, ListedSession, NavigatedSelectionResult, SelectedSessionContext } from "../mcp/types"; +import type { + AppliedCommentResult, + ClearedCommentsResult, + ListedSession, + NavigatedSelectionResult, + RemovedCommentResult, + SelectedSessionContext, + SessionLiveCommentSummary, +} from "../mcp/types"; interface HunkDaemonCliClient { connect(): Promise; @@ -21,6 +31,9 @@ interface HunkDaemonCliClient { getSelectedContext(selector: SessionSelectorInput): Promise; navigateToHunk(input: SessionNavigateCommandInput): Promise; addComment(input: SessionCommentAddCommandInput): Promise; + listComments(input: SessionCommentListCommandInput): Promise; + removeComment(input: SessionCommentRemoveCommandInput): Promise; + clearComments(input: SessionCommentClearCommandInput): Promise; } function extractToolValue( @@ -141,6 +154,52 @@ class McpHunkDaemonCliClient implements HunkDaemonCliClient { return comment; } + + async listComments(input: SessionCommentListCommandInput) { + const result = await this.client.callTool({ + name: "list_comments", + arguments: { + ...input.selector, + filePath: input.filePath, + }, + }); + + return extractToolValue(result, "comments") ?? []; + } + + async removeComment(input: SessionCommentRemoveCommandInput) { + const result = await this.client.callTool({ + name: "remove_comment", + arguments: { + ...input.selector, + commentId: input.commentId, + }, + }); + + const removed = extractToolValue(result, "result"); + if (!removed) { + throw new Error("The Hunk daemon returned no remove-comment result."); + } + + return removed; + } + + async clearComments(input: SessionCommentClearCommandInput) { + const result = await this.client.callTool({ + name: "clear_comments", + arguments: { + ...input.selector, + filePath: input.filePath, + }, + }); + + const cleared = extractToolValue(result, "result"); + if (!cleared) { + throw new Error("The Hunk daemon returned no clear-comments result."); + } + + return cleared; + } } function stringifyJson(value: unknown) { @@ -226,6 +285,30 @@ function formatCommentOutput(selector: SessionSelectorInput, result: AppliedComm return `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${formatSelector(selector)}.\n`; } +function formatCommentListOutput(selector: SessionSelectorInput, comments: SessionLiveCommentSummary[]) { + if (comments.length === 0) { + return `No live comments for ${formatSelector(selector)}.\n`; + } + + return `${comments + .map((comment) => [ + `${comment.commentId} ${comment.filePath}:${comment.line} (${comment.side})`, + ` hunk: ${comment.hunkIndex + 1}`, + ` summary: ${comment.summary}`, + ...(comment.author ? [` author: ${comment.author}`] : []), + ].join("\n")) + .join("\n\n")}\n`; +} + +function formatRemoveCommentOutput(selector: SessionSelectorInput, result: RemovedCommentResult) { + return `Removed live comment ${result.commentId} from ${formatSelector(selector)}. Remaining comments: ${result.remainingCommentCount}.\n`; +} + +function formatClearCommentsOutput(selector: SessionSelectorInput, result: ClearedCommentsResult) { + const scope = result.filePath ? `${result.filePath} in ${formatSelector(selector)}` : formatSelector(selector); + return `Cleared ${result.removedCount} live comments from ${scope}. Remaining comments: ${result.remainingCommentCount}.\n`; +} + function normalizeRepoRoot(selector: SessionSelectorInput) { if (!selector.repoRoot) { return selector; @@ -300,6 +383,27 @@ export async function runSessionCommand(input: SessionCommandInput) { }); return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); } + case "comment-list": { + const comments = await client.listComments({ + ...input, + selector: normalizeRepoRoot(input.selector), + }); + return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments)); + } + case "comment-rm": { + const result = await client.removeComment({ + ...input, + selector: normalizeRepoRoot(input.selector), + }); + return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result)); + } + case "comment-clear": { + const result = await client.clearComments({ + ...input, + selector: normalizeRepoRoot(input.selector), + }); + return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result)); + } } } finally { await client.close(); diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index 743ee2f..595c487 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -1,8 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DiffFile } from "../../core/types"; import { buildLiveComment, findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../../core/liveComments"; import { HunkHostClient } from "../../mcp/client"; -import type { LiveComment, SessionServerMessage } from "../../mcp/types"; +import type { LiveComment, SessionLiveCommentSummary, SessionServerMessage } from "../../mcp/types"; /** Bridge one live Hunk review session to the local MCP daemon. */ export function useHunkSessionBridge({ @@ -25,6 +25,7 @@ export function useHunkSessionBridge({ showAgentNotes: boolean; }) { const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>({}); + const liveCommentsByFileIdRef = useRef>({}); const buildSelectedHunkSummary = useCallback((file: DiffFile, hunkIndex: number) => { const hunk = file.metadata.hunks[hunkIndex]; @@ -84,7 +85,7 @@ export function useHunkSessionBridge({ } const commentId = `mcp:${message.requestId}`; - const liveComment = buildLiveComment(message.input, commentId, new Date().toISOString()); + const liveComment = buildLiveComment(message.input, commentId, new Date().toISOString(), hunkIndex); setLiveCommentsByFileId((current) => ({ ...current, @@ -108,6 +109,85 @@ export function useHunkSessionBridge({ [files, jumpToFile, openAgentNotes], ); + useEffect(() => { + liveCommentsByFileIdRef.current = liveCommentsByFileId; + }, [liveCommentsByFileId]); + + const removeIncomingComment = useCallback( + async (message: Extract) => { + const current = liveCommentsByFileIdRef.current; + let removed = false; + let remainingCommentCount = 0; + const next: Record = {}; + + for (const [fileId, comments] of Object.entries(current)) { + const filtered = comments.filter((comment) => comment.id !== message.input.commentId); + if (filtered.length !== comments.length) { + removed = true; + } + + if (filtered.length > 0) { + next[fileId] = filtered; + remainingCommentCount += filtered.length; + } + } + + if (!removed) { + throw new Error(`No live comment matches id ${message.input.commentId}.`); + } + + setLiveCommentsByFileId(next); + return { + commentId: message.input.commentId, + removed: true, + remainingCommentCount, + }; + }, + [], + ); + + const clearIncomingComments = useCallback( + async (message: Extract) => { + const current = liveCommentsByFileIdRef.current; + let removedCount = 0; + let remainingCommentCount = 0; + + if (message.input.filePath) { + const file = findDiffFileByPath(files, message.input.filePath); + if (!file) { + throw new Error(`No visible diff file matches ${message.input.filePath}.`); + } + + const next: Record = {}; + for (const [fileId, comments] of Object.entries(current)) { + if (fileId === file.id) { + removedCount = comments.length; + continue; + } + + next[fileId] = comments; + remainingCommentCount += comments.length; + } + + if (removedCount > 0) { + setLiveCommentsByFileId(next); + } + } else { + removedCount = Object.values(current).reduce((sum, comments) => sum + comments.length, 0); + if (removedCount > 0) { + setLiveCommentsByFileId({}); + } + } + + return { + removedCount, + remainingCommentCount, + filePath: message.input.filePath, + }; + }, + [files], + ); + useEffect(() => { if (!hostClient) { return; @@ -116,12 +196,32 @@ export function useHunkSessionBridge({ hostClient.setBridge({ applyComment: applyIncomingComment, navigateToHunk: navigateToHunkSelection, + removeComment: removeIncomingComment, + clearComments: clearIncomingComments, }); return () => { hostClient.setBridge(null); }; - }, [applyIncomingComment, hostClient, navigateToHunkSelection]); + }, [applyIncomingComment, clearIncomingComments, hostClient, navigateToHunkSelection, removeIncomingComment]); + + const liveCommentSummaries = useMemo( + () => + files.flatMap((file) => + (liveCommentsByFileId[file.id] ?? []).map((comment) => ({ + commentId: comment.id, + filePath: file.path, + hunkIndex: comment.hunkIndex, + side: comment.side, + line: comment.line, + summary: comment.summary, + rationale: comment.rationale, + author: comment.author, + createdAt: comment.createdAt, + })), + ), + [files, liveCommentsByFileId], + ); const liveCommentCount = useMemo( () => Object.values(liveCommentsByFileId).reduce((sum, notes) => sum + notes.length, 0), @@ -139,9 +239,10 @@ export function useHunkSessionBridge({ selectedHunkNewRange: selectedRange?.newRange, showAgentNotes, liveCommentCount, + liveComments: liveCommentSummaries, updatedAt: new Date().toISOString(), }); - }, [currentHunk, hostClient, liveCommentCount, selectedFile?.id, selectedFile?.path, selectedHunkIndex, showAgentNotes]); + }, [currentHunk, hostClient, liveCommentCount, liveCommentSummaries, selectedFile?.id, selectedFile?.path, selectedHunkIndex, showAgentNotes]); return { liveCommentsByFileId, diff --git a/test/cli.test.ts b/test/cli.test.ts index 78a714a..567b82e 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -235,6 +235,63 @@ describe("parseCli", () => { }); }); + test("parses session comment list with file filter", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "list", + "session-1", + "--file", + "README.md", + "--json", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "comment-list", + selector: { sessionId: "session-1" }, + filePath: "README.md", + output: "json", + }); + }); + + test("parses session comment rm", async () => { + const parsed = await parseCli(["bun", "hunk", "session", "comment", "rm", "session-1", "comment-1"]); + + expect(parsed).toEqual({ + kind: "session", + action: "comment-rm", + selector: { sessionId: "session-1" }, + commentId: "comment-1", + output: "text", + }); + }); + + test("parses session comment clear", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "clear", + "session-1", + "--file", + "README.md", + "--yes", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "comment-clear", + selector: { sessionId: "session-1" }, + filePath: "README.md", + confirmed: true, + output: "text", + }); + }); + test("rejects session commands without an explicit target", async () => { await expect(parseCli(["bun", "hunk", "session", "get"])).rejects.toThrow( "Specify one live Hunk session with or --repo .", @@ -259,6 +316,12 @@ describe("parseCli", () => { ).rejects.toThrow("Specify exactly one navigation target"); }); + test("rejects session comment clear without confirmation", async () => { + await expect( + parseCli(["bun", "hunk", "session", "comment", "clear", "session-1"]), + ).rejects.toThrow("Pass --yes to clear live comments."); + }); + test("parses stash show mode", async () => { const parsed = await parseCli(["bun", "hunk", "stash", "show", "stash@{1}"]); diff --git a/test/live-comments.test.ts b/test/live-comments.test.ts index e422fcd..f55ec90 100644 --- a/test/live-comments.test.ts +++ b/test/live-comments.test.ts @@ -82,12 +82,17 @@ describe("live comment helpers", () => { }, "comment-1", "2026-03-22T00:00:00.000Z", + 0, ); expect(comment).toMatchObject({ id: "comment-1", source: "mcp", author: "Pi", + filePath: "src/example.ts", + hunkIndex: 0, + side: "new", + line: 4, summary: "Note", rationale: "Why this matters", newRange: [4, 4], diff --git a/test/mcp-client.test.ts b/test/mcp-client.test.ts index 7b3caf6..5870514 100644 --- a/test/mcp-client.test.ts +++ b/test/mcp-client.test.ts @@ -38,6 +38,7 @@ function createSnapshot(): HunkSessionSnapshot { selectedHunkIndex: 0, showAgentNotes: true, liveCommentCount: 0, + liveComments: [], updatedAt: "2026-03-22T00:00:00.000Z", }; } diff --git a/test/mcp-daemon.test.ts b/test/mcp-daemon.test.ts index beac4f0..a6bd476 100644 --- a/test/mcp-daemon.test.ts +++ b/test/mcp-daemon.test.ts @@ -1,6 +1,15 @@ import { describe, expect, test } from "bun:test"; import { HunkDaemonState, resolveSessionTarget } from "../src/mcp/daemonState"; -import type { AppliedCommentResult, HunkSessionRegistration, HunkSessionSnapshot, ListedSession, NavigatedSelectionResult } from "../src/mcp/types"; +import type { + AppliedCommentResult, + ClearedCommentsResult, + HunkSessionRegistration, + HunkSessionSnapshot, + ListedSession, + NavigatedSelectionResult, + RemovedCommentResult, + SessionLiveCommentSummary, +} from "../src/mcp/types"; function createListedSession(overrides: Partial = {}): ListedSession { return { @@ -28,6 +37,7 @@ function createListedSession(overrides: Partial = {}): ListedSess selectedHunkIndex: 0, showAgentNotes: false, liveCommentCount: 0, + liveComments: [], updatedAt: "2026-03-22T00:00:00.000Z", }, ...overrides, @@ -64,11 +74,25 @@ function createSnapshot(overrides: Partial = {}): HunkSessi selectedHunkIndex: 0, showAgentNotes: false, liveCommentCount: 0, + liveComments: [], updatedAt: "2026-03-22T00:00:00.000Z", ...overrides, }; } +function createLiveComment(overrides: Partial = {}): SessionLiveCommentSummary { + return { + commentId: "comment-1", + filePath: "src/example.ts", + hunkIndex: 0, + side: "new", + line: 4, + summary: "Review note", + createdAt: "2026-03-22T00:00:00.000Z", + ...overrides, + }; +} + describe("Hunk MCP daemon state", () => { test("resolves one target session by session id, repo root, or sole-session fallback", () => { const one = [createListedSession()]; @@ -110,6 +134,30 @@ describe("Hunk MCP daemon state", () => { ); }); + test("lists live comments from snapshot state and can filter by file", () => { + const state = new HunkDaemonState(); + const socket = { + send() {}, + }; + + state.registerSession( + socket, + createRegistration(), + createSnapshot({ + liveCommentCount: 2, + liveComments: [ + createLiveComment(), + createLiveComment({ commentId: "comment-2", filePath: "src/other.ts", line: 9, summary: "Other" }), + ], + }), + ); + + expect(state.listComments({ sessionId: "session-1" })).toHaveLength(2); + expect(state.listComments({ sessionId: "session-1" }, { filePath: "src/example.ts" })).toEqual([ + expect.objectContaining({ commentId: "comment-1" }), + ]); + }); + test("routes a comment command to the live session and resolves the async result", async () => { const state = new HunkDaemonState(); const sent: string[] = []; @@ -197,6 +245,82 @@ describe("Hunk MCP daemon state", () => { await expect(pending).resolves.toEqual(result); }); + test("routes remove-comment commands to the live session and resolves the async result", async () => { + const state = new HunkDaemonState(); + const sent: string[] = []; + const socket = { + send(data: string) { + sent.push(data); + }, + }; + + state.registerSession(socket, createRegistration(), createSnapshot()); + + const pending = state.sendRemoveComment({ + sessionId: "session-1", + commentId: "comment-1", + }); + + expect(sent).toHaveLength(1); + const outgoing = JSON.parse(sent[0]!) as { + requestId: string; + command: string; + }; + expect(outgoing.command).toBe("remove_comment"); + + const result: RemovedCommentResult = { + commentId: "comment-1", + removed: true, + remainingCommentCount: 0, + }; + + state.handleCommandResult({ + requestId: outgoing.requestId, + ok: true, + result, + }); + + await expect(pending).resolves.toEqual(result); + }); + + test("routes clear-comments commands to the live session and resolves the async result", async () => { + const state = new HunkDaemonState(); + const sent: string[] = []; + const socket = { + send(data: string) { + sent.push(data); + }, + }; + + state.registerSession(socket, createRegistration(), createSnapshot()); + + const pending = state.sendClearComments({ + sessionId: "session-1", + filePath: "src/example.ts", + }); + + expect(sent).toHaveLength(1); + const outgoing = JSON.parse(sent[0]!) as { + requestId: string; + command: string; + }; + expect(outgoing.command).toBe("clear_comments"); + + const result: ClearedCommentsResult = { + removedCount: 2, + remainingCommentCount: 0, + filePath: "src/example.ts", + }; + + state.handleCommandResult({ + requestId: outgoing.requestId, + ok: true, + result, + }); + + await expect(pending).resolves.toEqual(result); + }); + test("rejects in-flight commands when the session disconnects", async () => { const state = new HunkDaemonState(); const socket = { diff --git a/test/session-cli.test.ts b/test/session-cli.test.ts index 47ddc61..b60d452 100644 --- a/test/session-cli.test.ts +++ b/test/session-cli.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -34,16 +34,6 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; } -function stripTerminalControl(text: string) { - return text - .replace(/^Script started.*?\n/s, "") - .replace(/\nScript done.*$/s, "") - .replace(/\x1bP[\s\S]*?\x1b\\/g, "") - .replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, "") - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "") - .replace(/\x1b[@-_]/g, ""); -} - function waitUntil(label: string, poll: () => T | null | Promise, timeoutMs = 10_000, intervalMs = 100) { const deadline = Date.now() + timeoutMs; @@ -229,7 +219,7 @@ describe("session CLI", () => { "export const thirteen = 130;", ], ); - const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); + const session = spawnHunkSession(fixture, { port, quitAfterSeconds: 18, timeoutSeconds: 20 }); try { const listed = await waitUntil("registered live session", () => { @@ -288,7 +278,8 @@ describe("session CLI", () => { ); expect(comment.proc.exitCode).toBe(0); expect(comment.stderr).toBe(""); - expect(JSON.parse(comment.stdout)).toMatchObject({ + const addedComment = JSON.parse(comment.stdout) as { result?: { commentId?: string; filePath?: string; hunkIndex?: number; side?: string; line?: number } }; + expect(addedComment).toMatchObject({ result: { filePath: fixture.afterName, hunkIndex: 1, @@ -297,13 +288,11 @@ describe("session CLI", () => { }, }); - await waitUntil("rendered live comment", () => { - const transcript = stripTerminalControl(readFileSync(fixture.transcript, "utf8")); - return transcript.includes("Second hunk note") ? transcript : null; - }); + expect(typeof addedComment.result?.commentId).toBe("string"); + } finally { session.kill(); await session.exited; } - }); + }, 20_000); });