diff --git a/src/core/cli.ts b/src/core/cli.ts index c8a29c2..f1c2f37 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -435,6 +435,8 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session get --repo ", " hunk session context ", " hunk session context --repo ", + " hunk session selection [--state focused|published]", + " hunk session selection --repo [--state focused|published]", " hunk session navigate --file (--hunk | --old-line | --new-line )", " hunk session reload -- diff [ref] [-- ]", " hunk session reload -- show [ref] [-- ]", @@ -500,6 +502,45 @@ async function parseSessionCommand(tokens: string[]): Promise { }; } + if (subcommand === "selection") { + const command = new Command("session selection") + .description("show one live Hunk session selection payload") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--state ", "selection state: focused or published", "published") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { repo?: string; state?: string; json?: boolean } = {}; + + command.action( + ( + sessionId: string | undefined, + options: { repo?: string; state?: string; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + if (parsedOptions.state !== "focused" && parsedOptions.state !== "published") { + throw new Error("Selection state must be one of: focused, published."); + } + + return { + kind: "session", + action: "selection", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + state: parsedOptions.state, + }; + } + if (subcommand === "navigate") { const command = new Command("session navigate") .description("move a live Hunk session to one diff hunk") diff --git a/src/core/types.ts b/src/core/types.ts index 1fd2c69..7776225 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -104,6 +104,14 @@ export interface SessionGetCommandInput { selector: SessionSelectorInput; } +export interface SessionSelectionCommandInput { + kind: "session"; + action: "selection"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + state: "focused" | "published"; +} + export interface SessionNavigateCommandInput { kind: "session"; action: "navigate"; @@ -165,6 +173,7 @@ export interface SessionCommentClearCommandInput { export type SessionCommandInput = | SessionListCommandInput | SessionGetCommandInput + | SessionSelectionCommandInput | SessionNavigateCommandInput | SessionReloadCommandInput | SessionCommentAddCommandInput diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 69b113a..c612509 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -1,6 +1,7 @@ import type { AppliedCommentResult, ClearedCommentsResult, + HunkSelectionPayload, HunkSessionRegistration, HunkSessionSnapshot, NavigatedSelectionResult, @@ -56,6 +57,8 @@ export class HunkHostClient { private startupPromise: Promise | null = null; private lastDaemonLaunchStartedAt = 0; private lastConnectionWarning: string | null = null; + private latestFocusedSelection: HunkSelectionPayload | null = null; + private latestPublishedSelection: HunkSelectionPayload | null = null; constructor( private registration: HunkSessionRegistration, @@ -171,6 +174,25 @@ export class HunkHostClient { }); } + updateSelection(state: "focused" | "published", selection: HunkSelectionPayload) { + if (state === "focused") { + this.latestFocusedSelection = selection; + } else { + this.latestPublishedSelection = selection; + } + + this.send({ + type: "selection", + sessionId: this.registration.sessionId, + state, + selection, + }); + } + + publishSelection(selection: HunkSelectionPayload) { + this.updateSelection("published", selection); + } + private connect(config: ResolvedHunkMcpConfig) { if (this.stopped || this.websocket) { return; @@ -188,6 +210,22 @@ export class HunkHostClient { registration: this.registration, snapshot: this.snapshot, }); + if (this.latestFocusedSelection) { + this.send({ + type: "selection", + sessionId: this.registration.sessionId, + state: "focused", + selection: this.latestFocusedSelection, + }); + } + if (this.latestPublishedSelection) { + this.send({ + type: "selection", + sessionId: this.registration.sessionId, + state: "published", + selection: this.latestPublishedSelection, + }); + } void this.flushQueuedMessages(); }; diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 20318dc..7d803cf 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -4,6 +4,10 @@ import type { ClearedCommentsResult, ClearCommentsToolInput, CommentToolInput, + HunkNotifyEvent, + HunkNotifyEventType, + HunkSelectionPayload, + HunkSelectionState, HunkSessionRegistration, HunkSessionSnapshot, ListedSession, @@ -36,6 +40,8 @@ interface SessionEntry { socket: DaemonSessionSocket; connectedAt: string; lastSeenAt: string; + focusedSelection: HunkSelectionPayload | null; + publishedSelection: HunkSelectionPayload | null; } export interface SessionTargetSelector { @@ -43,6 +49,17 @@ export interface SessionTargetSelector { repoRoot?: string; } +interface NotifySubscriberFilter { + sessionId?: string; + repoRoot?: string; + types?: HunkNotifyEventType[]; +} + +interface NotifySubscriber { + filter: NotifySubscriberFilter; + listener: (event: HunkNotifyEvent) => void; +} + function describeSessionChoices(sessions: ListedSession[]) { return sessions.map((session) => `${session.sessionId} (${session.title})`).join(", "); } @@ -58,6 +75,15 @@ function findSelectedFile(session: ListedSession) { ); } +function snapshotFocusIdentity(snapshot: HunkSessionSnapshot) { + return [ + snapshot.selectedFilePath ?? "", + snapshot.selectedHunkIndex, + snapshot.selectedHunkOldRange?.join(":") ?? "", + snapshot.selectedHunkNewRange?.join(":") ?? "", + ].join("|"); +} + /** Resolve which live Hunk session one external command should target. */ export function resolveSessionTarget(sessions: ListedSession[], selector: SessionTargetSelector) { if (selector.sessionId) { @@ -106,6 +132,8 @@ export class HunkDaemonState { private sessions = new Map(); private sessionIdsBySocket = new Map(); private pendingCommands = new Map(); + private notifySubscribers = new Set(); + private nextEventSequence = 1; listSessions(): ListedSession[] { return [...this.sessions.values()] @@ -152,6 +180,19 @@ export class HunkDaemonState { }; } + getSelection( + selector: SessionTargetSelector, + state: HunkSelectionState, + ): HunkSelectionPayload | null { + const session = resolveSessionTarget(this.listSessions(), selector); + const entry = this.sessions.get(session.sessionId); + if (!entry) { + throw new Error("The targeted Hunk session is no longer connected."); + } + + return state === "focused" ? entry.focusedSelection : entry.publishedSelection; + } + listComments(selector: SessionTargetSelector, filter: { filePath?: string } = {}) { const session = this.getSession(selector); const comments = session.snapshot.liveComments; @@ -163,6 +204,17 @@ export class HunkDaemonState { return comments.filter((comment) => comment.filePath === filter.filePath); } + subscribeToNotifications( + listener: (event: HunkNotifyEvent) => void, + filter: NotifySubscriberFilter = {}, + ) { + const subscriber: NotifySubscriber = { listener, filter }; + this.notifySubscribers.add(subscriber); + return () => { + this.notifySubscribers.delete(subscriber); + }; + } + getPendingCommandCount() { return this.pendingCommands.size; } @@ -193,8 +245,15 @@ export class HunkDaemonState { socket, connectedAt: now, lastSeenAt: now, + focusedSelection: existing?.focusedSelection ?? null, + publishedSelection: existing?.publishedSelection ?? null, }); this.sessionIdsBySocket.set(socket, registration.sessionId); + this.emitEvent(registration.sessionId, "session.opened", { + title: registration.title, + inputKind: registration.inputKind, + sourceLabel: registration.sourceLabel, + }); } updateSnapshot(sessionId: string, snapshot: HunkSessionSnapshot) { @@ -203,11 +262,47 @@ export class HunkDaemonState { return; } + const previousSnapshot = entry.snapshot; this.sessions.set(sessionId, { ...entry, snapshot, lastSeenAt: new Date().toISOString(), }); + + if (snapshotFocusIdentity(previousSnapshot) !== snapshotFocusIdentity(snapshot)) { + this.emitEvent(sessionId, "focus.changed", { + filePath: snapshot.selectedFilePath, + hunkIndex: snapshot.selectedHunkIndex, + oldRange: snapshot.selectedHunkOldRange, + newRange: snapshot.selectedHunkNewRange, + }); + } + } + + updateSelection(sessionId: string, state: HunkSelectionState, selection: HunkSelectionPayload) { + const entry = this.sessions.get(sessionId); + if (!entry) { + return; + } + + const nextEntry: SessionEntry = { + ...entry, + focusedSelection: state === "focused" ? selection : entry.focusedSelection, + publishedSelection: state === "published" ? selection : entry.publishedSelection, + lastSeenAt: new Date().toISOString(), + }; + this.sessions.set(sessionId, nextEntry); + + if (state === "focused") { + return; + } + + this.emitEvent(sessionId, "selection.published", { + filePath: selection.filePath, + hunkIndex: selection.hunkIndex, + oldRange: selection.oldRange, + newRange: selection.newRange, + }); } markSessionSeen(sessionId: string) { @@ -326,6 +421,7 @@ export class HunkDaemonState { pending.reject(error); } + this.notifySubscribers.clear(); this.sessionIdsBySocket.clear(); this.sessions.clear(); } @@ -391,6 +487,7 @@ export class HunkDaemonState { return; } + this.emitEvent(sessionId, "session.closed", { reason }); this.sessions.delete(sessionId); if (this.sessionIdsBySocket.get(entry.socket) === sessionId) { this.sessionIdsBySocket.delete(entry.socket); @@ -410,4 +507,34 @@ export class HunkDaemonState { pending.reject(error); } } + + private emitEvent(sessionId: string, type: HunkNotifyEventType, data: Record) { + const entry = this.sessions.get(sessionId); + const event: HunkNotifyEvent = { + type, + version: 1, + sessionId, + repoRoot: entry?.registration.repoRoot, + sequence: this.nextEventSequence, + timestamp: new Date().toISOString(), + data, + }; + this.nextEventSequence += 1; + + for (const subscriber of this.notifySubscribers) { + if (subscriber.filter.sessionId && subscriber.filter.sessionId !== event.sessionId) { + continue; + } + + if (subscriber.filter.repoRoot && subscriber.filter.repoRoot !== event.repoRoot) { + continue; + } + + if (subscriber.filter.types && !subscriber.filter.types.includes(event.type)) { + continue; + } + + subscriber.listener(event); + } + } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index b7bec51..ddff0a0 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1,7 +1,9 @@ import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config"; import { HunkDaemonState } from "./daemonState"; import type { SessionClientMessage } from "./types"; +import type { HunkNotifyEventType } from "./types"; import { + HUNK_NOTIFY_API_PATH, HUNK_SESSION_API_PATH, HUNK_SESSION_API_VERSION, HUNK_SESSION_CAPABILITIES_PATH, @@ -18,6 +20,7 @@ const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [ "list", "get", "context", + "selection", "navigate", "reload", "comment-add", @@ -81,6 +84,9 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request) case "context": response = { context: state.getSelectedContext(input.selector) }; break; + case "selection": + response = { selection: state.getSelection(input.selector, input.state) }; + break; case "navigate": { if ( input.hunkNumber === undefined && @@ -153,6 +159,68 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request) } } +function handleNotifyRequest(state: HunkDaemonState, request: Request) { + if (request.method !== "GET") { + return jsonError("Notify requests must use GET.", 405); + } + + const url = new URL(request.url); + const sessionId = url.searchParams.get("sessionId") || undefined; + const repoRoot = url.searchParams.get("repoRoot") || undefined; + const requestedTypes = url.searchParams + .getAll("type") + .filter( + (type): type is HunkNotifyEventType => + type === "session.opened" || + type === "session.closed" || + type === "focus.changed" || + type === "selection.published", + ); + const encoder = new TextEncoder(); + + let unsubscribe: () => void = () => undefined; + let keepAliveTimer: Timer | null = null; + + const stream = new ReadableStream({ + start(controller) { + const send = (chunk: string) => controller.enqueue(encoder.encode(chunk)); + send(`: connected ${new Date().toISOString()}\n\n`); + + unsubscribe = state.subscribeToNotifications( + (event) => { + send(`event: ${event.type}\n`); + send(`data: ${JSON.stringify(event)}\n\n`); + }, + { + sessionId, + repoRoot, + types: requestedTypes.length > 0 ? requestedTypes : undefined, + }, + ); + + keepAliveTimer = setInterval(() => { + send(`: keepalive ${new Date().toISOString()}\n\n`); + }, 15_000); + keepAliveTimer.unref?.(); + }, + cancel() { + unsubscribe(); + if (keepAliveTimer) { + clearInterval(keepAliveTimer); + keepAliveTimer = null; + } + }, + }); + + return new Response(stream, { + headers: { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + connection: "keep-alive", + }, + }); +} + /** Serve the local Hunk session daemon and websocket session broker. */ export function serveHunkMcpServer() { const config = resolveHunkMcpConfig(); @@ -182,6 +250,7 @@ export function serveHunkMcpServer() { uptimeMs: Date.now() - startedAt, sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`, sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`, + notify: `${config.httpOrigin}${HUNK_NOTIFY_API_PATH}`, sessionSocket: `${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`, sessions: state.listSessions().length, pendingCommands: state.getPendingCommandCount(), @@ -197,6 +266,10 @@ export function serveHunkMcpServer() { return handleSessionApiRequest(state, request); } + if (url.pathname === HUNK_NOTIFY_API_PATH) { + return handleNotifyRequest(state, request); + } + if (url.pathname === "/mcp") { return jsonError( "Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.", @@ -234,6 +307,9 @@ export function serveHunkMcpServer() { case "snapshot": state.updateSnapshot(parsed.sessionId, parsed.snapshot); break; + case "selection": + state.updateSelection(parsed.sessionId, parsed.state, parsed.selection); + break; case "heartbeat": state.markSessionSeen(parsed.sessionId); break; diff --git a/src/mcp/types.ts b/src/mcp/types.ts index f113364..f5dadc1 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -16,6 +16,39 @@ export interface SessionFileSummary { hunkCount: number; } +export interface HunkSelectionPayload { + version: 1; + source: "hunk"; + createdAt: string; + repoRoot?: string; + changesetTitle: string; + filePath: string; + previousPath?: string; + hunkIndex: number; + oldRange: [number, number]; + newRange: [number, number]; + patch: string; + prompt: string; +} + +export type HunkSelectionState = "focused" | "published"; + +export type HunkNotifyEventType = + | "session.opened" + | "session.closed" + | "focus.changed" + | "selection.published"; + +export interface HunkNotifyEvent { + type: HunkNotifyEventType; + version: 1; + sessionId: string; + repoRoot?: string; + sequence: number; + timestamp: string; + data: Record; +} + export interface SelectedHunkSummary { index: number; oldRange?: [number, number]; @@ -167,6 +200,12 @@ export type SessionClientMessage = sessionId: string; snapshot: HunkSessionSnapshot; } + | { + type: "selection"; + sessionId: string; + state: HunkSelectionState; + selection: HunkSelectionPayload; + } | { type: "heartbeat"; sessionId: string; diff --git a/src/session/commands.ts b/src/session/commands.ts index a90b014..47dacf1 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -8,6 +8,7 @@ import type { SessionCommentRemoveCommandInput, SessionNavigateCommandInput, SessionReloadCommandInput, + SessionSelectionCommandInput, SessionSelectorInput, } from "../core/types"; import { @@ -20,6 +21,7 @@ import { resolveHunkMcpConfig } from "../mcp/config"; import type { AppliedCommentResult, ClearedCommentsResult, + HunkSelectionPayload, ListedSession, NavigatedSelectionResult, ReloadedSessionResult, @@ -41,6 +43,7 @@ export interface HunkDaemonCliClient { listSessions(): Promise; getSession(selector: SessionSelectorInput): Promise; getSelectedContext(selector: SessionSelectorInput): Promise; + getSelection(input: SessionSelectionCommandInput): Promise; navigateToHunk(input: SessionNavigateCommandInput): Promise; reloadSession(input: SessionReloadCommandInput): Promise; addComment(input: SessionCommentAddCommandInput): Promise; @@ -53,6 +56,7 @@ const REQUIRED_ACTION_BY_COMMAND: Record({ + action: "selection", + selector: input.selector, + state: input.state, + }) + ).selection; + } + async navigateToHunk(input: SessionNavigateCommandInput) { return ( await this.request<{ result: NavigatedSelectionResult }>({ @@ -421,6 +435,28 @@ function formatContextOutput(context: SelectedSessionContext) { ].join("\n"); } +function formatSelectionOutput( + selector: SessionSelectorInput, + state: SessionSelectionCommandInput["state"], + selection: HunkSelectionPayload | null, +) { + if (!selection) { + return `No ${state} selection is available for ${formatSelector(selector)}.\n`; + } + + return [ + `Session: ${formatSelector(selector)}`, + `Selection state: ${state}`, + `File: ${selection.filePath}`, + `Hunk: ${selection.hunkIndex + 1}`, + `Old range: ${selection.oldRange[0]}..${selection.oldRange[1]}`, + `New range: ${selection.newRange[0]}..${selection.newRange[1]}`, + "", + selection.prompt.trimEnd(), + "", + ].join("\n"); +} + function formatNavigationOutput(selector: SessionSelectorInput, result: NavigatedSelectionResult) { return `Focused ${result.filePath} hunk ${result.hunkIndex + 1} in ${formatSelector(selector)}.\n`; } @@ -535,6 +571,15 @@ export async function runSessionCommand(input: SessionCommandInput) { const context = await client.getSelectedContext(normalizedSelector!); return renderOutput(input.output, { context }, () => formatContextOutput(context)); } + case "selection": { + const selection = await client.getSelection({ + ...input, + selector: normalizedSelector!, + }); + return renderOutput(input.output, { selection }, () => + formatSelectionOutput(input.selector, input.state, selection), + ); + } case "navigate": { const result = await client.navigateToHunk({ ...input, diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 1394c08..5844276 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -5,11 +5,13 @@ import type { SessionCommentRemoveCommandInput, SessionNavigateCommandInput, SessionReloadCommandInput, + SessionSelectionCommandInput, SessionSelectorInput, } from "../core/types"; import type { AppliedCommentResult, ClearedCommentsResult, + HunkSelectionPayload, ListedSession, NavigatedSelectionResult, ReloadedSessionResult, @@ -20,12 +22,14 @@ import type { export const HUNK_SESSION_API_PATH = "/session-api"; export const HUNK_SESSION_CAPABILITIES_PATH = `${HUNK_SESSION_API_PATH}/capabilities`; +export const HUNK_NOTIFY_API_PATH = "/notify"; export const HUNK_SESSION_API_VERSION = 1; export type SessionDaemonAction = | "list" | "get" | "context" + | "selection" | "navigate" | "reload" | "comment-add" @@ -50,6 +54,11 @@ export type SessionDaemonRequest = action: "context"; selector: SessionSelectorInput; } + | { + action: "selection"; + selector: SessionSelectionCommandInput["selector"]; + state: SessionSelectionCommandInput["state"]; + } | { action: "navigate"; selector: SessionNavigateCommandInput["selector"]; @@ -94,6 +103,7 @@ export type SessionDaemonResponse = | { sessions: ListedSession[] } | { session: ListedSession } | { context: SelectedSessionContext } + | { selection: HunkSelectionPayload | null } | { result: NavigatedSelectionResult } | { result: ReloadedSessionResult } | { result: AppliedCommentResult } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 2e7d136..159c8ac 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -38,7 +38,11 @@ import { buildAppMenus } from "./lib/appMenus"; import { buildFileListEntry } from "./lib/files"; import { buildHunkCursors, findNextHunkCursor } from "./lib/hunks"; import { fileRowId } from "./lib/ids"; -import { buildPiSelectionPayload, writePiSelectionPayload } from "./lib/piSelection"; +import { + buildHunkSelectionPayload, + buildPiSelectionPayload, + writePiSelectionPayload, +} from "./lib/piSelection"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; import { resolveTheme, THEMES } from "./themes"; @@ -372,6 +376,23 @@ function AppShell({ }, 2200); }, []); + useEffect(() => { + if (!hostClient || !baseSelectedFile) { + return; + } + + const selection = buildHunkSelectionPayload( + bootstrap.changeset, + baseSelectedFile, + selectedHunkIndex, + ); + if (!selection) { + return; + } + + hostClient.updateSelection("focused", selection); + }, [bootstrap.changeset, hostClient, baseSelectedFile, selectedHunkIndex]); + /** Export the focused hunk to the project-local pi selection bridge file. */ const sendSelectionToPi = useCallback(() => { if (!selectedFile) { @@ -385,11 +406,12 @@ function AppShell({ return; } + hostClient?.publishSelection(payload); const selectionPath = writePiSelectionPayload(payload); flashStatusMessage( `Sent ${selectedFile.path} hunk ${selectedHunkIndex + 1} to Pi (${selectionPath}).`, ); - }, [bootstrap.changeset, flashStatusMessage, selectedFile, selectedHunkIndex]); + }, [bootstrap.changeset, flashStatusMessage, hostClient, selectedFile, selectedHunkIndex]); const menus = useMemo( () => diff --git a/src/ui/lib/piSelection.ts b/src/ui/lib/piSelection.ts index 3487968..6858d6d 100644 --- a/src/ui/lib/piSelection.ts +++ b/src/ui/lib/piSelection.ts @@ -2,27 +2,12 @@ import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; import { dirname, isAbsolute, resolve } from "node:path"; import { hunkLineRange } from "../../core/liveComments"; import type { Changeset, DiffFile } from "../../core/types"; +import type { HunkSelectionPayload } from "../../mcp/types"; const PI_SELECTION_RELATIVE_PATH = ".hunk/pi-selection.json"; type DiffHunk = DiffFile["metadata"]["hunks"][number]; -export interface PiSelectionPayload { - version: 1; - source: "hunk"; - createdAt: string; - repoRoot?: string; - selectionPath: string; - changesetTitle: string; - filePath: string; - previousPath?: string; - hunkIndex: number; - oldRange: [number, number]; - newRange: [number, number]; - patch: string; - prompt: string; -} - /** Reuse the git repo root source label when this changeset came from a repo-backed review. */ function resolveRepoRoot(changeset: Changeset) { if (!isAbsolute(changeset.sourceLabel) || !existsSync(changeset.sourceLabel)) { @@ -40,8 +25,8 @@ function hunkHeader(hunk: DiffHunk) { return hunk.hunkContext ? `${specs} ${hunk.hunkContext}` : specs; } -/** Rebuild one hunk as a compact diff snippet suitable for pasting into pi. */ -export function buildPiSelectionPatch(file: DiffFile, hunkIndex: number) { +/** Rebuild one hunk as a compact diff snippet suitable for pasting into an agent. */ +export function buildHunkSelectionPatch(file: DiffFile, hunkIndex: number) { const hunk = file.metadata.hunks[hunkIndex]; if (!hunk) { return null; @@ -77,16 +62,15 @@ export function buildPiSelectionPatch(file: DiffFile, hunkIndex: number) { return lines.join("\n"); } -/** Build the payload the pi extension watches and pastes into the editor. */ -export function buildPiSelectionPayload(changeset: Changeset, file: DiffFile, hunkIndex: number) { +/** Build the current focused hunk as a generic agent-readable payload. */ +export function buildHunkSelectionPayload(changeset: Changeset, file: DiffFile, hunkIndex: number) { const hunk = file.metadata.hunks[hunkIndex]; - const patch = buildPiSelectionPatch(file, hunkIndex); + const patch = buildHunkSelectionPatch(file, hunkIndex); if (!hunk || !patch) { return null; } const repoRoot = resolveRepoRoot(changeset); - const selectionPath = resolve(repoRoot ?? process.cwd(), PI_SELECTION_RELATIVE_PATH); const { oldRange, newRange } = hunkLineRange(hunk); const prompt = [ `Selected hunk from Hunk: ${file.path}`, @@ -105,7 +89,6 @@ export function buildPiSelectionPayload(changeset: Changeset, file: DiffFile, hu source: "hunk" as const, createdAt: new Date().toISOString(), repoRoot, - selectionPath, changesetTitle: changeset.title, filePath: file.path, previousPath: file.previousPath, @@ -114,12 +97,20 @@ export function buildPiSelectionPayload(changeset: Changeset, file: DiffFile, hu newRange, patch, prompt, - } satisfies PiSelectionPayload; + } satisfies HunkSelectionPayload; +} + +export function resolvePiSelectionPath(repoRoot?: string) { + return resolve(repoRoot ?? process.cwd(), PI_SELECTION_RELATIVE_PATH); } /** Persist the current Hunk selection where the project-local pi extension can pick it up. */ -export function writePiSelectionPayload(payload: PiSelectionPayload) { - mkdirSync(dirname(payload.selectionPath), { recursive: true }); - writeFileSync(payload.selectionPath, `${JSON.stringify(payload, null, 2)}\n`); - return payload.selectionPath; +export function writePiSelectionPayload(payload: HunkSelectionPayload) { + const selectionPath = resolvePiSelectionPath(payload.repoRoot); + mkdirSync(dirname(selectionPath), { recursive: true }); + writeFileSync(selectionPath, `${JSON.stringify({ ...payload, selectionPath }, null, 2)}\n`); + return selectionPath; } + +export const buildPiSelectionPatch = buildHunkSelectionPatch; +export const buildPiSelectionPayload = buildHunkSelectionPayload; diff --git a/test/cli.test.ts b/test/cli.test.ts index c73cde7..d6108ce 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -207,6 +207,27 @@ describe("parseCli", () => { }); }); + test("parses session selection with an explicit state", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "selection", + "session-1", + "--state", + "focused", + "--json", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "selection", + selector: { sessionId: "session-1" }, + state: "focused", + output: "json", + }); + }); + test("parses session reload with nested show syntax", async () => { const parsed = await parseCli([ "bun", diff --git a/test/mcp-daemon.test.ts b/test/mcp-daemon.test.ts index 788426e..fb777de 100644 --- a/test/mcp-daemon.test.ts +++ b/test/mcp-daemon.test.ts @@ -3,8 +3,10 @@ import { HunkDaemonState, resolveSessionTarget } from "../src/mcp/daemonState"; import type { AppliedCommentResult, ClearedCommentsResult, + HunkNotifyEvent, HunkSessionRegistration, HunkSessionSnapshot, + HunkSelectionPayload, ListedSession, NavigatedSelectionResult, ReloadedSessionResult, @@ -98,6 +100,23 @@ function createLiveComment( }; } +function createSelection(overrides: Partial = {}): HunkSelectionPayload { + return { + version: 1, + source: "hunk", + createdAt: "2026-03-22T00:00:00.000Z", + repoRoot: "/repo", + changesetTitle: "repo working tree", + filePath: "src/example.ts", + hunkIndex: 0, + oldRange: [1, 1], + newRange: [1, 2], + patch: "@@ -1,1 +1,2 @@\n-old\n+new", + prompt: "Selected hunk from Hunk: src/example.ts", + ...overrides, + }; +} + describe("Hunk MCP daemon state", () => { test("resolves one target session by session id, repo root, or sole-session fallback", () => { const one = [createListedSession()]; @@ -176,6 +195,53 @@ describe("Hunk MCP daemon state", () => { ]); }); + test("stores focused and published selections for CLI-backed reads", () => { + const state = new HunkDaemonState(); + const socket = { + send() {}, + }; + + state.registerSession(socket, createRegistration(), createSnapshot()); + const focused = createSelection(); + const published = createSelection({ filePath: "src/other.ts", hunkIndex: 1 }); + + state.updateSelection("session-1", "focused", focused); + state.updateSelection("session-1", "published", published); + + expect(state.getSelection({ sessionId: "session-1" }, "focused")).toEqual(focused); + expect(state.getSelection({ sessionId: "session-1" }, "published")).toEqual(published); + }); + + test("streams typed notify events for session lifecycle and published selections", () => { + const state = new HunkDaemonState(); + const socket = { + send() {}, + }; + const events: HunkNotifyEvent[] = []; + const unsubscribe = state.subscribeToNotifications((event) => { + events.push(event); + }); + + state.registerSession(socket, createRegistration(), createSnapshot()); + state.updateSelection("session-1", "published", createSelection()); + state.unregisterSocket(socket); + unsubscribe(); + + expect(events.map((event) => event.type)).toEqual([ + "session.opened", + "selection.published", + "session.closed", + ]); + expect(events[1]).toMatchObject({ + sequence: 2, + sessionId: "session-1", + data: { + filePath: "src/example.ts", + hunkIndex: 0, + }, + }); + }); + test("routes a comment command to the live session and resolves the async result", async () => { const state = new HunkDaemonState(); const sent: string[] = []; diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 05a3131..2339b69 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -83,6 +83,7 @@ describe("Hunk session daemon server", () => { "list", "get", "context", + "selection", "navigate", "reload", "comment-add", @@ -92,6 +93,17 @@ describe("Hunk session daemon server", () => { ], }); + const health = await fetch(`http://127.0.0.1:${port}/health`); + expect(health.status).toBe(200); + await expect(health.json()).resolves.toMatchObject({ + notify: `http://127.0.0.1:${port}/notify`, + }); + + const notify = await fetch(`http://127.0.0.1:${port}/notify`); + expect(notify.status).toBe(200); + expect(notify.headers.get("content-type")).toContain("text/event-stream"); + notify.body?.cancel(); + const legacyMcp = await fetch(`http://127.0.0.1:${port}/mcp`, { method: "POST", headers: { diff --git a/test/pi-selection.test.ts b/test/pi-selection.test.ts index 1228491..b65989a 100644 --- a/test/pi-selection.test.ts +++ b/test/pi-selection.test.ts @@ -4,7 +4,11 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseDiffFromFile } from "@pierre/diffs"; import type { Changeset, DiffFile } from "../src/core/types"; -import { buildPiSelectionPatch, buildPiSelectionPayload } from "../src/ui/lib/piSelection"; +import { + buildPiSelectionPatch, + buildPiSelectionPayload, + resolvePiSelectionPath, +} from "../src/ui/lib/piSelection"; function createDiffFile(id: string, path: string, before: string, after: string): DiffFile { const metadata = parseDiffFromFile( @@ -74,7 +78,9 @@ describe("pi selection bridge payloads", () => { expect(payload?.hunkIndex).toBe(0); expect(payload?.oldRange).toEqual([1, 1]); expect(payload?.newRange).toEqual([1, 2]); - expect(payload?.selectionPath).toBe(join(repoRoot, ".hunk", "pi-selection.json")); + expect(resolvePiSelectionPath(payload?.repoRoot)).toBe( + join(repoRoot, ".hunk", "pi-selection.json"), + ); expect(payload?.prompt).toContain("Selected hunk from Hunk: alpha.ts"); expect(payload?.prompt).toContain("```diff"); } finally { diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index d37870e..19751f9 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -48,6 +48,7 @@ function createClient(overrides: Partial): HunkDaemonCliCli "list", "get", "context", + "selection", "navigate", "reload", "comment-add", @@ -79,6 +80,7 @@ function createClient(overrides: Partial): HunkDaemonCliCli showAgentNotes: false, liveCommentCount: 0, }), + getSelection: async () => null, navigateToHunk: async () => ({ fileId: "file-1", filePath: "README.md", @@ -266,6 +268,7 @@ describe("session command compatibility checks", () => { "list", "get", "context", + "selection", "navigate", "reload", "comment-add", @@ -291,4 +294,54 @@ describe("session command compatibility checks", () => { expect(JSON.parse(output)).toEqual({ comments: [] }); expect(restartCalls).toEqual([]); }); + + test("returns a published selection payload through the CLI state read path", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSelection: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.state).toBe("published"); + return { + version: 1, + source: "hunk", + createdAt: "2026-03-22T00:00:00.000Z", + repoRoot: "/repo", + changesetTitle: "repo diff", + filePath: "README.md", + hunkIndex: 0, + oldRange: [1, 1], + newRange: [1, 2], + patch: "@@ -1,1 +1,2 @@\n-old\n+new", + prompt: "Selected hunk from Hunk: README.md", + }; + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "selection", + selector: { sessionId: "session-1" }, + state: "published", + output: "json", + } satisfies SessionCommandInput); + + expect(JSON.parse(output)).toEqual({ + selection: { + version: 1, + source: "hunk", + createdAt: "2026-03-22T00:00:00.000Z", + repoRoot: "/repo", + changesetTitle: "repo diff", + filePath: "README.md", + hunkIndex: 0, + oldRange: [1, 1], + newRange: [1, 2], + patch: "@@ -1,1 +1,2 @@\n-old\n+new", + prompt: "Selected hunk from Hunk: README.md", + }, + }); + }); });