Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<session-id>` or `--repo <path>` 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.
Expand Down
237 changes: 171 additions & 66 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,9 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session context --repo <path>",
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" hunk session comment add <session-id> --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment list <session-id>",
" hunk session comment rm <session-id> <comment-id>",
" hunk session comment clear <session-id> --yes",
].join("\n") + "\n",
};
}
Expand Down Expand Up @@ -492,86 +495,188 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
return {
kind: "help",
text: [
"Usage: hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
"",
"Attach one live inline review note to a diff line.",
"Usage:",
" hunk session comment add (<session-id> | --repo <path>) --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
" hunk session comment list (<session-id> | --repo <path>) [--file <path>]",
" hunk session comment rm (<session-id> | --repo <path>) <comment-id>",
" hunk session comment clear (<session-id> | --repo <path>) [--file <path>] --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 <path>", "diff file path as shown by Hunk")
.requiredOption("--summary <text>", "short review note")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--author <name>", "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 <n> or --new-line <n>.");
}

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 <path>", "diff file path as shown by Hunk")
.requiredOption("--summary <text>", "short review note")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--author <name>", "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 <path>", "target the live session whose repo root matches this path")
.option("--file <path>", "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("<commentId>")
.option("--repo <path>", "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 <n> or --new-line <n>.");
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 <path>", "target the live session whose repo root matches this path")
.option("--file <path>", "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}`);
Expand Down
11 changes: 10 additions & 1 deletion src/core/liveComments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
8 changes: 8 additions & 0 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type {
AppliedCommentResult,
ClearedCommentsResult,
HunkSessionRegistration,
HunkSessionSnapshot,
NavigatedSelectionResult,
RemovedCommentResult,
SessionClientMessage,
SessionCommandResult,
SessionServerMessage,
Expand All @@ -18,6 +20,8 @@ const HEARTBEAT_INTERVAL_MS = 10_000;
interface HunkAppBridge {
applyComment: (message: Extract<SessionServerMessage, { command: "comment" }>) => Promise<AppliedCommentResult>;
navigateToHunk: (message: Extract<SessionServerMessage, { command: "navigate_to_hunk" }>) => Promise<NavigatedSelectionResult>;
removeComment: (message: Extract<SessionServerMessage, { command: "remove_comment" }>) => Promise<RemovedCommentResult>;
clearComments: (message: Extract<SessionServerMessage, { command: "clear_comments" }>) => Promise<ClearedCommentsResult>;
}

/** Keep one running Hunk TUI session registered with the local MCP daemon. */
Expand Down Expand Up @@ -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);
}
}

Expand Down
Loading
Loading