Skip to content
Open
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
41 changes: 41 additions & 0 deletions src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,8 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
" hunk session get --repo <path>",
" hunk session context <session-id>",
" hunk session context --repo <path>",
" hunk session selection <session-id> [--state focused|published]",
" hunk session selection --repo <path> [--state focused|published]",
" hunk session navigate <session-id> --file <path> (--hunk <n> | --old-line <n> | --new-line <n>)",
" hunk session reload <session-id> -- diff [ref] [-- <pathspec...>]",
" hunk session reload <session-id> -- show [ref] [-- <pathspec...>]",
Expand Down Expand Up @@ -500,6 +502,45 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
};
}

if (subcommand === "selection") {
const command = new Command("session selection")
.description("show one live Hunk session selection payload")
.argument("[sessionId]")
.option("--repo <path>", "target the live session whose repo root matches this path")
.option("--state <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")
Expand Down
9 changes: 9 additions & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -165,6 +173,7 @@ export interface SessionCommentClearCommandInput {
export type SessionCommandInput =
| SessionListCommandInput
| SessionGetCommandInput
| SessionSelectionCommandInput
| SessionNavigateCommandInput
| SessionReloadCommandInput
| SessionCommentAddCommandInput
Expand Down
38 changes: 38 additions & 0 deletions src/mcp/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {
AppliedCommentResult,
ClearedCommentsResult,
HunkSelectionPayload,
HunkSessionRegistration,
HunkSessionSnapshot,
NavigatedSelectionResult,
Expand Down Expand Up @@ -56,6 +57,8 @@ export class HunkHostClient {
private startupPromise: Promise<void> | null = null;
private lastDaemonLaunchStartedAt = 0;
private lastConnectionWarning: string | null = null;
private latestFocusedSelection: HunkSelectionPayload | null = null;
private latestPublishedSelection: HunkSelectionPayload | null = null;

constructor(
private registration: HunkSessionRegistration,
Expand Down Expand Up @@ -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;
Expand All @@ -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();
};

Expand Down
127 changes: 127 additions & 0 deletions src/mcp/daemonState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import type {
ClearedCommentsResult,
ClearCommentsToolInput,
CommentToolInput,
HunkNotifyEvent,
HunkNotifyEventType,
HunkSelectionPayload,
HunkSelectionState,
HunkSessionRegistration,
HunkSessionSnapshot,
ListedSession,
Expand Down Expand Up @@ -36,13 +40,26 @@ interface SessionEntry {
socket: DaemonSessionSocket;
connectedAt: string;
lastSeenAt: string;
focusedSelection: HunkSelectionPayload | null;
publishedSelection: HunkSelectionPayload | null;
}

export interface SessionTargetSelector {
sessionId?: string;
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(", ");
}
Expand All @@ -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) {
Expand Down Expand Up @@ -106,6 +132,8 @@ export class HunkDaemonState {
private sessions = new Map<string, SessionEntry>();
private sessionIdsBySocket = new Map<DaemonSessionSocket, string>();
private pendingCommands = new Map<string, PendingCommand>();
private notifySubscribers = new Set<NotifySubscriber>();
private nextEventSequence = 1;

listSessions(): ListedSession[] {
return [...this.sessions.values()]
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -326,6 +421,7 @@ export class HunkDaemonState {
pending.reject(error);
}

this.notifySubscribers.clear();
this.sessionIdsBySocket.clear();
this.sessions.clear();
}
Expand Down Expand Up @@ -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);
Expand All @@ -410,4 +507,34 @@ export class HunkDaemonState {
pending.reject(error);
}
}

private emitEvent(sessionId: string, type: HunkNotifyEventType, data: Record<string, unknown>) {
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);
}
}
}
Loading
Loading