Skip to content

Commit b5ce500

Browse files
authored
feat: add session reload command (#63)
1 parent 6f91692 commit b5ce500

18 files changed

Lines changed: 555 additions & 18 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,17 @@ Use explicit session targeting with either a live `<session-id>` or `--repo <pat
158158
hunk session list
159159
hunk session context --repo .
160160
hunk session navigate --repo . --file README.md --hunk 2
161+
hunk session reload --repo . -- diff
162+
hunk session reload --repo . -- show HEAD~1 -- README.md
161163
hunk session comment add --repo . --file README.md --new-line 103 --summary "Frame this as MCP-first"
162164
hunk session comment list --repo .
163165
hunk session comment rm --repo . mcp:1234
164166
hunk session comment clear --repo . --file README.md --yes
165167
```
166168

167-
The session CLI works against live session comments only. It does not edit `.hunk/latest.json`.
169+
`hunk session reload ... -- <hunk command>` swaps the live session to a new `diff`, `show`, or other reviewable Hunk input without opening a new TUI window.
170+
171+
The session CLI can inspect, navigate, annotate, and reload a live session, but it does not edit `.hunk/latest.json`.
168172

169173
## Performance notes
170174

src/core/cli.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
22
import { dirname, resolve } from "node:path";
33
import { Command } from "commander";
44
import type {
5+
CliInput,
56
CommonOptions,
67
HelpCommandInput,
78
LayoutMode,
@@ -398,6 +399,24 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<P
398399
};
399400
}
400401

402+
function requireReloadableCliInput(input: ParsedCliInput): CliInput {
403+
if (input.kind === "help" || input.kind === "pager" || input.kind === "mcp-serve") {
404+
throw new Error(
405+
"Session reload requires a Hunk review command after --, such as `diff` or `show`.",
406+
);
407+
}
408+
409+
if (input.kind === "session") {
410+
throw new Error("Session reload cannot invoke another session command.");
411+
}
412+
413+
if (input.kind === "patch" && (!input.file || input.file === "-")) {
414+
throw new Error("Session reload does not support `patch -` or stdin-backed patch input.");
415+
}
416+
417+
return input;
418+
}
419+
401420
/** Parse `hunk session ...` as live-session daemon-backed commands. */
402421
async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
403422
const [subcommand, ...rest] = tokens;
@@ -417,6 +436,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
417436
" hunk session context <session-id>",
418437
" hunk session context --repo <path>",
419438
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
439+
" hunk session reload <session-id> -- diff [ref] [-- <pathspec...>]",
440+
" hunk session reload <session-id> -- show [ref] [-- <pathspec...>]",
420441
" hunk session comment add <session-id> --file <path> (--old-line <n> | --new-line <n>) --summary <text>",
421442
" hunk session comment list <session-id>",
422443
" hunk session comment rm <session-id> <comment-id>",
@@ -551,6 +572,64 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
551572
};
552573
}
553574

575+
if (subcommand === "reload") {
576+
const separatorIndex = rest.indexOf("--");
577+
const outerTokens = separatorIndex === -1 ? rest : rest.slice(0, separatorIndex);
578+
579+
const command = new Command("session reload")
580+
.description("replace the contents of one live Hunk session")
581+
.argument("[sessionId]")
582+
.option("--repo <path>", "target the live session whose repo root matches this path")
583+
.option("--json", "emit structured JSON");
584+
585+
let parsedSessionId: string | undefined;
586+
let parsedOptions: { repo?: string; json?: boolean } = {};
587+
588+
command.action((sessionId: string | undefined, options: { repo?: string; json?: boolean }) => {
589+
parsedSessionId = sessionId;
590+
parsedOptions = options;
591+
});
592+
593+
if (outerTokens.includes("--help") || outerTokens.includes("-h")) {
594+
return {
595+
kind: "help",
596+
text:
597+
`${command.helpInformation().trimEnd()}\n\n` +
598+
[
599+
"Examples:",
600+
" hunk session reload --repo . -- diff",
601+
" hunk session reload --repo . -- diff main...feature -- src/ui",
602+
" hunk session reload --repo . -- show HEAD~1 -- README.md",
603+
].join("\n") +
604+
"\n",
605+
};
606+
}
607+
608+
if (separatorIndex === -1) {
609+
throw new Error(
610+
"Pass the replacement Hunk command after `--`, for example `hunk session reload <session-id> -- diff`.",
611+
);
612+
}
613+
614+
const nestedTokens = rest.slice(separatorIndex + 1);
615+
if (nestedTokens.length === 0) {
616+
throw new Error(
617+
"Pass the replacement Hunk command after `--`, for example `hunk session reload <session-id> -- diff`.",
618+
);
619+
}
620+
621+
await parseStandaloneCommand(command, outerTokens);
622+
const nextInput = requireReloadableCliInput(await parseCli(["bun", "hunk", ...nestedTokens]));
623+
624+
return {
625+
kind: "session",
626+
action: "reload",
627+
output: resolveJsonOutput(parsedOptions),
628+
selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo),
629+
nextInput,
630+
};
631+
}
632+
554633
if (subcommand === "comment") {
555634
const [commentSubcommand, ...commentRest] = rest;
556635
if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") {

src/core/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ export interface SessionNavigateCommandInput {
115115
line?: number;
116116
}
117117

118+
export interface SessionReloadCommandInput {
119+
kind: "session";
120+
action: "reload";
121+
output: SessionCommandOutput;
122+
selector: SessionSelectorInput;
123+
nextInput: CliInput;
124+
}
125+
118126
export interface SessionCommentAddCommandInput {
119127
kind: "session";
120128
action: "comment-add";
@@ -158,6 +166,7 @@ export type SessionCommandInput =
158166
| SessionListCommandInput
159167
| SessionGetCommandInput
160168
| SessionNavigateCommandInput
169+
| SessionReloadCommandInput
161170
| SessionCommentAddCommandInput
162171
| SessionCommentListCommandInput
163172
| SessionCommentRemoveCommandInput

src/mcp/client.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type {
44
HunkSessionRegistration,
55
HunkSessionSnapshot,
66
NavigatedSelectionResult,
7+
ReloadedSessionResult,
78
RemovedCommentResult,
89
SessionClientMessage,
910
SessionCommandResult,
@@ -33,6 +34,9 @@ interface HunkAppBridge {
3334
navigateToHunk: (
3435
message: Extract<SessionServerMessage, { command: "navigate_to_hunk" }>,
3536
) => Promise<NavigatedSelectionResult>;
37+
reloadSession: (
38+
message: Extract<SessionServerMessage, { command: "reload_session" }>,
39+
) => Promise<ReloadedSessionResult>;
3640
removeComment: (
3741
message: Extract<SessionServerMessage, { command: "remove_comment" }>,
3842
) => Promise<RemovedCommentResult>;
@@ -54,7 +58,7 @@ export class HunkHostClient {
5458
private lastConnectionWarning: string | null = null;
5559

5660
constructor(
57-
private readonly registration: HunkSessionRegistration,
61+
private registration: HunkSessionRegistration,
5862
private snapshot: HunkSessionSnapshot,
5963
) {}
6064

@@ -93,6 +97,20 @@ export class HunkHostClient {
9397
this.websocket = null;
9498
}
9599

100+
getRegistration() {
101+
return this.registration;
102+
}
103+
104+
replaceSession(registration: HunkSessionRegistration, snapshot: HunkSessionSnapshot) {
105+
this.registration = registration;
106+
this.snapshot = snapshot;
107+
this.send({
108+
type: "register",
109+
registration,
110+
snapshot,
111+
});
112+
}
113+
96114
private resolveConfig() {
97115
return resolveHunkMcpConfig();
98116
}
@@ -281,6 +299,8 @@ export class HunkHostClient {
281299
return this.bridge.applyComment(message);
282300
case "navigate_to_hunk":
283301
return this.bridge.navigateToHunk(message);
302+
case "reload_session":
303+
return this.bridge.reloadSession(message);
284304
case "remove_comment":
285305
return this.bridge.removeComment(message);
286306
case "clear_comments":

src/mcp/daemonState.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type {
99
ListedSession,
1010
NavigateToHunkToolInput,
1111
NavigatedSelectionResult,
12+
ReloadSessionToolInput,
13+
ReloadedSessionResult,
1214
RemoveCommentToolInput,
1315
RemovedCommentResult,
1416
SelectedSessionContext,
@@ -267,6 +269,16 @@ export class HunkDaemonState {
267269
);
268270
}
269271

272+
sendReloadSession(input: ReloadSessionToolInput) {
273+
return this.sendCommand<ReloadedSessionResult, "reload_session">(
274+
{ sessionId: input.sessionId, repoRoot: input.repoRoot },
275+
"reload_session",
276+
input,
277+
"Timed out waiting for the Hunk session to reload the requested contents.",
278+
30_000,
279+
);
280+
}
281+
270282
sendRemoveComment(input: RemoveCommentToolInput) {
271283
return this.sendCommand<RemovedCommentResult, "remove_comment">(
272284
{ sessionId: input.sessionId, repoRoot: input.repoRoot },
@@ -326,6 +338,7 @@ export class HunkDaemonState {
326338
command: CommandName,
327339
input: Extract<SessionServerMessage, { command: CommandName }>["input"],
328340
timeoutMessage: string,
341+
timeoutMs = 15_000,
329342
) {
330343
const session = resolveSessionTarget(this.listSessions(), selector);
331344
const requestId = randomUUID();
@@ -334,7 +347,7 @@ export class HunkDaemonState {
334347
const timeout = setTimeout(() => {
335348
this.pendingCommands.delete(requestId);
336349
reject(new Error(timeoutMessage));
337-
}, 15_000);
350+
}, timeoutMs);
338351

339352
this.pendingCommands.set(requestId, {
340353
sessionId: session.sessionId,

src/mcp/server.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [
1919
"get",
2020
"context",
2121
"navigate",
22+
"reload",
2223
"comment-add",
2324
"comment-list",
2425
"comment-rm",
@@ -99,6 +100,14 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request)
99100
};
100101
break;
101102
}
103+
case "reload":
104+
response = {
105+
result: await state.sendReloadSession({
106+
...input.selector,
107+
nextInput: input.nextInput,
108+
}),
109+
};
110+
break;
102111
case "comment-add":
103112
response = {
104113
result: await state.sendComment({

src/mcp/sessionRegistration.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { randomUUID } from "node:crypto";
22
import type { AppBootstrap } from "../core/types";
33
import { hunkLineRange } from "../core/liveComments";
4-
import type { HunkSessionRegistration, HunkSessionSnapshot } from "./types";
4+
import type { HunkSessionRegistration, HunkSessionSnapshot, SessionFileSummary } from "./types";
55

66
function inferRepoRoot(bootstrap: AppBootstrap) {
77
return bootstrap.input.kind === "git" ||
@@ -11,6 +11,17 @@ function inferRepoRoot(bootstrap: AppBootstrap) {
1111
: undefined;
1212
}
1313

14+
function buildSessionFiles(bootstrap: AppBootstrap): SessionFileSummary[] {
15+
return bootstrap.changeset.files.map((file) => ({
16+
id: file.id,
17+
path: file.path,
18+
previousPath: file.previousPath,
19+
additions: file.stats.additions,
20+
deletions: file.stats.deletions,
21+
hunkCount: file.metadata.hunks.length,
22+
}));
23+
}
24+
1425
/** Build the daemon-facing metadata for one live Hunk TUI session. */
1526
export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionRegistration {
1627
return {
@@ -22,14 +33,22 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR
2233
title: bootstrap.changeset.title,
2334
sourceLabel: bootstrap.changeset.sourceLabel,
2435
launchedAt: new Date().toISOString(),
25-
files: bootstrap.changeset.files.map((file) => ({
26-
id: file.id,
27-
path: file.path,
28-
previousPath: file.previousPath,
29-
additions: file.stats.additions,
30-
deletions: file.stats.deletions,
31-
hunkCount: file.metadata.hunks.length,
32-
})),
36+
files: buildSessionFiles(bootstrap),
37+
};
38+
}
39+
40+
/** Rebuild registration metadata after a live session reload while preserving session identity. */
41+
export function updateSessionRegistration(
42+
current: HunkSessionRegistration,
43+
bootstrap: AppBootstrap,
44+
): HunkSessionRegistration {
45+
return {
46+
...current,
47+
repoRoot: inferRepoRoot(bootstrap),
48+
inputKind: bootstrap.input.kind,
49+
title: bootstrap.changeset.title,
50+
sourceLabel: bootstrap.changeset.sourceLabel,
51+
files: buildSessionFiles(bootstrap),
3352
};
3453
}
3554

src/mcp/types.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ export interface NavigateToHunkToolInput extends SessionTargetInput {
6868
line?: number;
6969
}
7070

71+
export interface ReloadSessionToolInput extends SessionTargetInput {
72+
nextInput: CliInput;
73+
}
74+
7175
export interface LiveComment extends AgentAnnotation {
7276
id: string;
7377
source: "mcp";
@@ -119,6 +123,16 @@ export interface ClearedCommentsResult {
119123
filePath?: string;
120124
}
121125

126+
export interface ReloadedSessionResult {
127+
sessionId: string;
128+
inputKind: CliInput["kind"];
129+
title: string;
130+
sourceLabel: string;
131+
fileCount: number;
132+
selectedFilePath?: string;
133+
selectedHunkIndex: number;
134+
}
135+
122136
export interface ListedSessionFile extends SessionFileSummary {
123137
selected: boolean;
124138
}
@@ -139,7 +153,8 @@ export type SessionCommandResult =
139153
| AppliedCommentResult
140154
| NavigatedSelectionResult
141155
| RemovedCommentResult
142-
| ClearedCommentsResult;
156+
| ClearedCommentsResult
157+
| ReloadedSessionResult;
143158

144159
export type SessionClientMessage =
145160
| {
@@ -194,6 +209,12 @@ export type SessionServerMessage =
194209
command: "navigate_to_hunk";
195210
input: NavigateToHunkToolInput;
196211
}
212+
| {
213+
type: "command";
214+
requestId: string;
215+
command: "reload_session";
216+
input: ReloadSessionToolInput;
217+
}
197218
| {
198219
type: "command";
199220
requestId: string;

0 commit comments

Comments
 (0)