diff --git a/bin/hunk.cjs b/bin/hunk.cjs index 19e94359..949b14df 100755 --- a/bin/hunk.cjs +++ b/bin/hunk.cjs @@ -72,15 +72,31 @@ function hostCandidates() { return []; } +function readPackageVersion(packageRoot) { + try { + const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")); + return typeof packageJson.version === "string" ? packageJson.version : null; + } catch { + return null; + } +} + function findInstalledBinary(startDir) { + const expectedVersion = readPackageVersion(path.join(__dirname, "..")); let current = startDir; for (;;) { const modulesDir = path.join(current, "node_modules"); if (fs.existsSync(modulesDir)) { for (const candidate of hostCandidates()) { - const resolved = path.join(modulesDir, candidate.packageName, "bin", candidate.binary); + const packageRoot = path.join(modulesDir, candidate.packageName); + const resolved = path.join(packageRoot, "bin", candidate.binary); if (fs.existsSync(resolved)) { + const installedVersion = readPackageVersion(packageRoot); + if (expectedVersion && installedVersion && installedVersion !== expectedVersion) { + continue; + } + return resolved; } } diff --git a/package.json b/package.json index 4c5b60b7..018667d5 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,10 @@ "types": "./dist/npm/opentui/index.d.ts", "import": "./dist/npm/opentui/index.js" }, + "./embedded": { + "types": "./dist/npm/embedded/index.d.ts", + "import": "./dist/npm/embedded/index.js" + }, "./package.json": "./package.json" }, "publishConfig": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index c4be9c6d..7ddde453 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -7,7 +7,19 @@ const repoRoot = path.resolve(import.meta.dir, ".."); const outdir = path.join(repoRoot, "dist", "npm"); const typesOutdir = path.join(repoRoot, "dist", "npm-types"); const opentuiOutdir = path.join(outdir, "opentui"); -const opentuiTypesDir = path.join(typesOutdir, "opentui"); +const opentuiTypesDir = path.join(typesOutdir, "src", "opentui"); +const embeddedOutdir = path.join(outdir, "embedded"); +const embeddedTypesDir = path.join(typesOutdir, "src", "embedded"); +const libraryExternals = [ + "react", + "react/jsx-runtime", + "react/jsx-dev-runtime", + "@opentui/core", + "@opentui/react", + "@opentui/react/jsx-runtime", + "@opentui/react/jsx-dev-runtime", + "@pierre/diffs", +]; const bunEnv = { ...process.env, @@ -15,6 +27,19 @@ const bunEnv = { BUN_INSTALL: path.join(repoRoot, ".bun-install"), }; +type LibraryBuildLog = Awaited>["logs"][number]; + +interface BuildLibraryExportOptions { + entrypoint: string; + name: string; + outputDirectory: string; +} + +interface FormatBuildLibraryExportErrorOptions { + logs: readonly LibraryBuildLog[]; + name: string; +} + function runBun(args: string[]) { const proc = Bun.spawnSync(["bun", ...args], { cwd: repoRoot, @@ -29,9 +54,42 @@ function runBun(args: string[]) { } } +/** Format a Bun.build failure so the runtime reports the build diagnostics once. */ +function formatBuildLibraryExportError({ logs, name }: FormatBuildLibraryExportErrorOptions) { + const details = logs + .map((log) => log.message) + .filter((message) => message.length > 0) + .join("\n"); + + return details + ? `Failed to build ${name} export:\n${details}` + : `Failed to build ${name} export.`; +} + +/** Build one npm package subpath export. */ +async function buildLibraryExport({ + entrypoint, + name, + outputDirectory, +}: BuildLibraryExportOptions) { + const build = await Bun.build({ + entrypoints: [entrypoint], + target: "node", + format: "esm", + outdir: outputDirectory, + naming: { entry: "index.js" }, + external: libraryExternals, + }); + + if (!build.success) { + throw new Error(formatBuildLibraryExportError({ logs: build.logs, name })); + } +} + rmSync(outdir, { recursive: true, force: true }); rmSync(typesOutdir, { recursive: true, force: true }); mkdirSync(opentuiOutdir, { recursive: true }); +mkdirSync(embeddedOutdir, { recursive: true }); runBun([ "build", @@ -52,36 +110,18 @@ if (process.platform !== "win32") { chmodSync(mainJs, 0o755); } -runBun([ - "build", - path.join(repoRoot, "src", "opentui", "index.ts"), - "--target", - "node", - "--format", - "esm", - "--external", - "react", - "--external", - "react/jsx-runtime", - "--external", - "react/jsx-dev-runtime", - "--external", - "@opentui/core", - "--external", - "@opentui/react", - "--external", - "@opentui/react/jsx-runtime", - "--external", - "@opentui/react/jsx-dev-runtime", - "--external", - "@pierre/diffs", - "--outdir", - opentuiOutdir, - "--entry-naming", - "index.js", -]); +await buildLibraryExport({ + entrypoint: path.join(repoRoot, "src", "opentui", "index.ts"), + name: "OpenTUI", + outputDirectory: opentuiOutdir, +}); +await buildLibraryExport({ + entrypoint: path.join(repoRoot, "src", "embedded", "index.ts"), + name: "embedded Hunk", + outputDirectory: embeddedOutdir, +}); -runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]); +runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.npm-exports.json")]); for (const entry of readdirSync(opentuiTypesDir)) { if (entry.endsWith(".d.ts")) { @@ -89,7 +129,12 @@ for (const entry of readdirSync(opentuiTypesDir)) { } } +for (const entry of ["index.d.ts", "types.d.ts"]) { + copyFileSync(path.join(embeddedTypesDir, entry), path.join(embeddedOutdir, entry)); +} + rmSync(typesOutdir, { recursive: true, force: true }); console.log(`Built ${mainJs}`); console.log(`Built ${path.join(opentuiOutdir, "index.js")}`); +console.log(`Built ${path.join(embeddedOutdir, "index.js")}`); diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index d0baae92..cf43f1c0 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -48,6 +48,8 @@ const publishedPaths = new Set(pack.files.map((file) => file.path)); const requiredPaths = [ "bin/hunk.cjs", "dist/npm/main.js", + "dist/npm/embedded/index.d.ts", + "dist/npm/embedded/index.js", "dist/npm/opentui/index.d.ts", "dist/npm/opentui/index.js", "README.md", diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index 808e6c40..745065ab 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -67,6 +67,8 @@ const metaPack = runPackDryRun(metaDir); assertPaths(metaPack, [ "bin/hunk.cjs", "dist/npm/main.js", + "dist/npm/embedded/index.d.ts", + "dist/npm/embedded/index.js", "dist/npm/opentui/index.d.ts", "dist/npm/opentui/index.js", "skills/hunk-review/SKILL.md", diff --git a/src/embedded/daemon.test.ts b/src/embedded/daemon.test.ts new file mode 100644 index 00000000..118b238d --- /dev/null +++ b/src/embedded/daemon.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { createEmbeddedSessionBrokerAvailability } from "./daemon"; +import { resolveSessionBrokerConfig } from "../session-broker/brokerConfig"; +import type { EnsureSessionBrokerAvailableOptions } from "../session-broker/brokerLauncher"; + +const testConfig = resolveSessionBrokerConfig({ HUNK_MCP_PORT: "47657" }); + +describe("embedded session broker daemon launcher", () => { + test("passes Hunk package-bin launch options through the broker availability adapter", async () => { + let captured: EnsureSessionBrokerAvailableOptions | undefined; + const ensureBroker = createEmbeddedSessionBrokerAvailability({ + cwd: "/repo", + env: { HUNK_MCP_PORT: "48658" }, + hunkCliPath: "/deps/hunkdiff/bin/hunk.cjs", + runtimePath: "/usr/local/bin/node", + timeoutMs: 1234, + ensureAvailable: async (options) => { + captured = options; + }, + }); + + await ensureBroker(testConfig); + + expect(captured).toEqual({ + argv: ["/usr/local/bin/node", "/deps/hunkdiff/bin/hunk.cjs"], + config: testConfig, + cwd: "/repo", + env: { HUNK_MCP_PORT: "48658" }, + execPath: "/usr/local/bin/node", + timeoutMs: 1234, + }); + }); + + test("passes direct Hunk executable paths through without a runtime wrapper", async () => { + let captured: EnsureSessionBrokerAvailableOptions | undefined; + const ensureBroker = createEmbeddedSessionBrokerAvailability({ + cwd: "/repo", + hunkCliPath: "/deps/hunkdiff/bin/hunk", + runtimePath: "/usr/local/bin/node", + ensureAvailable: async (options) => { + captured = options; + }, + }); + + await ensureBroker(testConfig); + + expect(captured).toMatchObject({ + argv: ["/deps/hunkdiff/bin/hunk"], + execPath: "/deps/hunkdiff/bin/hunk", + }); + }); +}); diff --git a/src/embedded/daemon.ts b/src/embedded/daemon.ts new file mode 100644 index 00000000..f574ccde --- /dev/null +++ b/src/embedded/daemon.ts @@ -0,0 +1,51 @@ +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { + ensureSessionBrokerAvailable, + type EnsureSessionBrokerAvailableOptions, +} from "../session-broker/brokerLauncher"; +import type { ResolvedSessionBrokerConfig } from "../session-broker/brokerConfig"; +import type { EnsureSessionBrokerAdapter } from "../session-broker/brokerClient"; + +const require = createRequire(import.meta.url); +const JAVASCRIPT_ENTRYPOINT_PATTERN = /\.(?:[cm]?js|tsx?)$/; + +type EmbeddedEnsureSessionBroker = typeof ensureSessionBrokerAvailable; + +export interface EmbeddedSessionBrokerAvailabilityOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + ensureAvailable?: EmbeddedEnsureSessionBroker; + hunkCliPath?: string; + runtimePath?: string; + timeoutMs?: number; +} + +/** Create the embedded broker availability adapter used by embedded Hunk sessions. */ +export function createEmbeddedSessionBrokerAvailability({ + cwd, + env = process.env, + ensureAvailable = ensureSessionBrokerAvailable, + hunkCliPath = join(dirname(require.resolve("hunkdiff/package.json")), "bin", "hunk.cjs"), + runtimePath = process.execPath, + timeoutMs, +}: EmbeddedSessionBrokerAvailabilityOptions): EnsureSessionBrokerAdapter { + return (config: ResolvedSessionBrokerConfig) => { + // The published package bin is a JS wrapper, so launch it through the active runtime instead + // of spawning the script path directly. Direct executable overrides still run as-is. + const scriptEntrypoint = JAVASCRIPT_ENTRYPOINT_PATTERN.test(hunkCliPath); + const options: EnsureSessionBrokerAvailableOptions = { + argv: scriptEntrypoint ? [runtimePath, hunkCliPath] : [hunkCliPath], + config, + cwd, + env, + execPath: scriptEntrypoint ? runtimePath : hunkCliPath, + }; + + if (timeoutMs !== undefined) { + options.timeoutMs = timeoutMs; + } + + return ensureAvailable(options); + }; +} diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts new file mode 100644 index 00000000..553cee33 --- /dev/null +++ b/src/embedded/embedded.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { BoxRenderable } from "@opentui/core"; +import { createTestRenderer } from "@opentui/core/testing"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEmbeddedHunkSession, mountEmbeddedHunkApp } from "./index"; +import { createEmbeddedRendererScope, createScopedKeyInput } from "./mount"; +import { embeddedHunkSessionInternals } from "./session"; +import type { EmbeddedHunkSession, EmbeddedHunkSnapshot } from "./types"; + +const testPatchText = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1 +1 @@", + "-const value = 1;", + "+const value = 2;", + "", +].join("\n"); + +let previousHunkMcpDisable: string | undefined; + +/** Return the loaded patch text for one embedded session. */ +function getTestLoadedPatch(session: EmbeddedHunkSession) { + return embeddedHunkSessionInternals(session) + .getRenderSnapshot() + .bootstrap.changeset.files.map((file) => file.patch) + .join("\n"); +} + +/** Expect a snapshot to be ready and narrow it for the rest of the test. */ +function expectTestReadySnapshot(snapshot: EmbeddedHunkSnapshot) { + expect(snapshot.status).toBe("ready"); + return snapshot as Extract; +} + +/** Expect a snapshot to be errored and narrow it for the rest of the test. */ +function expectTestErrorSnapshot(snapshot: EmbeddedHunkSnapshot) { + expect(snapshot.status).toBe("error"); + return snapshot as Extract; +} + +describe("embedded Hunk sessions", () => { + beforeEach(() => { + previousHunkMcpDisable = process.env.HUNK_MCP_DISABLE; + process.env.HUNK_MCP_DISABLE = "1"; + }); + + afterEach(() => { + if (previousHunkMcpDisable === undefined) { + delete process.env.HUNK_MCP_DISABLE; + } else { + process.env.HUNK_MCP_DISABLE = previousHunkMcpDisable; + } + }); + + test("loads embedded sessions through Hunk config resolution", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-embedded-config-")); + const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + + try { + const configHome = join(root, "config"); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + join(configHome, "hunk", "config.toml"), + ['theme = "midnight"', 'mode = "stack"', "line_numbers = false"].join("\n"), + ); + process.env.XDG_CONFIG_HOME = configHome; + + const session = await createEmbeddedHunkSession({ + cwd: root, + source: { kind: "patch", text: testPatchText, options: { theme: "paper" } }, + }); + const snapshot = expectTestReadySnapshot(session.getSnapshot()); + + expect("bootstrap" in snapshot).toBe(false); + expect(snapshot.title).toBe("Patch review: stdin patch"); + expect(snapshot.fileCount).toBe(1); + + const bootstrap = embeddedHunkSessionInternals(session).getRenderSnapshot().bootstrap; + expect(bootstrap.initialMode).toBe("stack"); + expect(bootstrap.initialShowLineNumbers).toBe(false); + expect(bootstrap.initialTheme).toBe("paper"); + + session.dispose(); + } finally { + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + rmSync(root, { force: true, recursive: true }); + } + }); + + test("open reuses the loaded review when source identity is equivalent", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-embedded-open-same-source-")); + const left = join(root, "before.ts"); + const right = join(root, "after.ts"); + + try { + writeFileSync(left, "export const value = 1;\n"); + writeFileSync(right, "export const value = 2;\nexport const first = true;\n"); + + const source = { + kind: "diff", + left, + right, + options: { wrapLines: undefined }, + } as const; + const session = await createEmbeddedHunkSession({ cwd: root, source }); + expect(getTestLoadedPatch(session)).toContain("first"); + + writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); + const reusedSnapshot = expectTestReadySnapshot( + await session.open({ kind: "diff", left, right }), + ); + + expect(reusedSnapshot.source).toEqual(session.source); + expect(getTestLoadedPatch(session)).toContain("first"); + expect(getTestLoadedPatch(session)).not.toContain("second"); + + const reloadedSnapshot = expectTestReadySnapshot(await session.reload()); + + expect(reloadedSnapshot.source).toEqual(session.source); + expect(getTestLoadedPatch(session)).toContain("second"); + expect(getTestLoadedPatch(session)).not.toContain("first"); + + session.dispose(); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + test("keeps the previous source and reports errors when open fails", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-embedded-reload-error-")); + + try { + const initialSource = { kind: "patch", text: testPatchText, label: "initial patch" } as const; + const session = await createEmbeddedHunkSession({ + cwd: root, + source: initialSource, + }); + + await expect(session.open({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); + + expect(session.source).toMatchObject(initialSource); + const snapshot = expectTestErrorSnapshot(session.getSnapshot()); + expect(snapshot.error).toContain("missing.patch"); + expect(snapshot.title).toBe("Patch review: initial patch"); + expect("bootstrap" in snapshot).toBe(false); + + session.dispose(); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + test("preserves headless agent notes for the next embedded mount", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-embedded-headless-notes-")); + const noteSummary = "Persisted agent note from hidden Hunk"; + + try { + const session = await createEmbeddedHunkSession({ + cwd: root, + source: { kind: "patch", text: testPatchText }, + }); + const internals = embeddedHunkSessionInternals(session); + + await internals.dispatchCommand({ + type: "command", + requestId: "comment-batch-1", + command: "comment_batch", + input: { + comments: [ + { + filePath: "example.ts", + hunkIndex: 0, + summary: noteSummary, + }, + ], + revealMode: "first", + }, + }); + + expect(internals.getSessionSnapshot().state.showAgentNotes).toBe(true); + expect(internals.getSessionSnapshot().state.liveCommentCount).toBe(1); + + const setup = await createTestRenderer({ width: 120, height: 24 }); + const container = new BoxRenderable(setup.renderer, { + height: 18, + id: "embedded-hunk", + width: 100, + }); + setup.renderer.root.add(container); + + const mount = mountEmbeddedHunkApp({ + active: true, + container, + onQuit: () => undefined, + renderer: setup.renderer, + session, + }); + + try { + let frame = ""; + for (let attempt = 0; attempt < 5; attempt += 1) { + await setup.renderOnce(); + frame = setup.captureCharFrame(); + if (frame.includes(noteSummary)) break; + await Bun.sleep(0); + } + expect(frame).toContain(noteSummary); + } finally { + mount.unmount(); + setup.renderer.destroy(); + session.dispose(); + } + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); + + test("scopes embedded key input to the active mount", () => { + const sourceListeners = new Map void>>(); + const source = { + on(event: string, listener: (...args: unknown[]) => void) { + const listeners = sourceListeners.get(event) ?? new Set(); + listeners.add(listener); + sourceListeners.set(event, listeners); + }, + off(event: string, listener: (...args: unknown[]) => void) { + sourceListeners.get(event)?.delete(listener); + }, + }; + let active = false; + const scoped = createScopedKeyInput(source, () => active); + const received: unknown[] = []; + + scoped.keyInput.on("keypress", (event: unknown) => { + received.push(event); + }); + + sourceListeners.get("keypress")?.forEach((listener) => listener("hidden")); + active = true; + sourceListeners.get("keypress")?.forEach((listener) => listener("visible")); + + expect(received).toEqual(["visible"]); + + scoped.dispose(); + expect(sourceListeners.get("keypress")?.size).toBe(0); + }); + + test("sizes embedded renderer reads and resize events from the host container", async () => { + const setup = await createTestRenderer({ width: 120, height: 40 }); + + try { + const container = new BoxRenderable(setup.renderer, { + height: 12, + id: "embedded-container", + width: 60, + }); + setup.renderer.root.add(container); + await setup.renderOnce(); + + const scope = createEmbeddedRendererScope(setup.renderer, container, setup.renderer.keyInput); + const resizes: Array<{ height: number; width: number }> = []; + const onResize = (width: unknown, height: unknown) => { + resizes.push({ height: Number(height), width: Number(width) }); + }; + + try { + scope.renderer.on("resize", onResize); + + expect(scope.renderer.width).toBe(60); + expect(scope.renderer.height).toBe(12); + expect(scope.renderer.terminalWidth).toBe(60); + expect(scope.renderer.terminalHeight).toBe(12); + + container.width = 48; + container.height = 9; + await setup.renderOnce(); + + expect(scope.renderer.width).toBe(48); + expect(scope.renderer.height).toBe(9); + expect(resizes).toEqual([{ height: 9, width: 48 }]); + + scope.renderer.off("resize", onResize); + container.width = 36; + await setup.renderOnce(); + + expect(resizes).toEqual([{ height: 9, width: 48 }]); + } finally { + scope.dispose(); + } + } finally { + setup.renderer.destroy(); + } + }); +}); diff --git a/src/embedded/index.ts b/src/embedded/index.ts new file mode 100644 index 00000000..a5cbab7b --- /dev/null +++ b/src/embedded/index.ts @@ -0,0 +1,26 @@ +import { mountEmbeddedHunkApp as mountEmbeddedHunkAppImpl } from "./mount"; +import { createEmbeddedHunkSession as createEmbeddedHunkSessionImpl } from "./session"; +export type { + CreateEmbeddedHunkSessionInput, + EmbeddedHunkMount, + EmbeddedHunkOptions, + EmbeddedHunkSession, + EmbeddedHunkSnapshot, + EmbeddedHunkSource, + MountEmbeddedHunkAppInput, +} from "./types"; +import type { + CreateEmbeddedHunkSessionInput, + EmbeddedHunkMount, + EmbeddedHunkSession, + MountEmbeddedHunkAppInput, +} from "./types"; + +/** Create one embedded Hunk review session from a public embedded source. */ +export const createEmbeddedHunkSession = ( + input: CreateEmbeddedHunkSessionInput, +): Promise => createEmbeddedHunkSessionImpl(input); + +/** Mount one embedded Hunk app into a host-owned OpenTUI container. */ +export const mountEmbeddedHunkApp = (input: MountEmbeddedHunkAppInput): EmbeddedHunkMount => + mountEmbeddedHunkAppImpl(input); diff --git a/src/embedded/mount.tsx b/src/embedded/mount.tsx new file mode 100644 index 00000000..cc686f3a --- /dev/null +++ b/src/embedded/mount.tsx @@ -0,0 +1,188 @@ +import type { CliRenderer, Renderable } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { useSyncExternalStore } from "react"; +import { AppHost } from "../ui/AppHost"; +import { embeddedHunkSessionInternals } from "./session"; +import type { EmbeddedHunkMount, EmbeddedHunkSession, MountEmbeddedHunkAppInput } from "./types"; + +const scopedKeyInputEvents = ["keypress", "keyrelease", "paste"] as const; + +type ScopedKeyInputEvent = (typeof scopedKeyInputEvents)[number]; +type KeyInputListener = (...args: unknown[]) => void; +type KeyInputSource = Readonly<{ + on: (event: string, listener: KeyInputListener) => unknown; + off: (event: string, listener: KeyInputListener) => unknown; +}>; +type RendererListener = (...args: unknown[]) => void; +type ScopedRendererScope = { + renderer: CliRenderer; + dispose(): void; +}; + +/** Scope Hunk keyboard and paste listeners so inactive embedded mounts stay alive but quiet. */ +export function createScopedKeyInput(source: KeyInputSource, enabled: () => boolean) { + const listeners = new Map>(); + const forwarders = new Map(); + + for (const event of scopedKeyInputEvents) { + listeners.set(event, new Set()); + forwarders.set(event, (...args: unknown[]) => { + if (!enabled()) return; + for (const listener of listeners.get(event) ?? []) listener(...args); + }); + } + + const scoped = { + on(event: string, listener: KeyInputListener) { + if (!scopedKeyInputEvents.includes(event as ScopedKeyInputEvent)) { + source.on(event, listener); + return scoped; + } + + const scopedEvent = event as ScopedKeyInputEvent; + const eventListeners = listeners.get(scopedEvent)!; + if (eventListeners.size === 0) source.on(scopedEvent, forwarders.get(scopedEvent)!); + eventListeners.add(listener); + return scoped; + }, + off(event: string, listener: KeyInputListener) { + if (!scopedKeyInputEvents.includes(event as ScopedKeyInputEvent)) { + source.off(event, listener); + return scoped; + } + + const scopedEvent = event as ScopedKeyInputEvent; + const eventListeners = listeners.get(scopedEvent)!; + eventListeners.delete(listener); + if (eventListeners.size === 0) source.off(scopedEvent, forwarders.get(scopedEvent)!); + return scoped; + }, + }; + + return { + keyInput: scoped as CliRenderer["keyInput"], + dispose() { + for (const event of scopedKeyInputEvents) { + const forwarder = forwarders.get(event); + if (forwarder) source.off(event, forwarder); + listeners.get(event)?.clear(); + } + }, + }; +} + +/** Scope renderer APIs that embedded Hunk reads to the host-provided container. */ +export function createEmbeddedRendererScope( + renderer: CliRenderer, + root: Renderable, + keyInput: CliRenderer["keyInput"], +): ScopedRendererScope { + const scoped = Object.create(renderer) as CliRenderer; + const resizeListeners = new Set(); + const readWidth = () => Math.max(1, root.width); + const readHeight = () => Math.max(1, root.height); + const emitResize = () => { + for (const listener of resizeListeners) listener(readWidth(), readHeight()); + }; + + root.on("resize", emitResize); + + Object.defineProperties(scoped, { + height: { get: readHeight }, + intermediateRender: { + value() { + if (!renderer.isDestroyed) renderer.requestRender(); + }, + }, + keyInput: { value: keyInput }, + off: { + value(event: string | symbol, listener: RendererListener) { + if (event === "resize") { + resizeListeners.delete(listener); + return scoped; + } + + renderer.off(event, listener); + return scoped; + }, + }, + on: { + value(event: string | symbol, listener: RendererListener) { + if (event === "resize") { + resizeListeners.add(listener); + return scoped; + } + + renderer.on(event, listener); + return scoped; + }, + }, + root: { value: root }, + terminalHeight: { get: readHeight }, + terminalWidth: { get: readWidth }, + width: { get: readWidth }, + }); + + return { + renderer: scoped, + dispose() { + root.off("resize", emitResize); + resizeListeners.clear(); + }, + }; +} + +function EmbeddedHunkRoot({ + onQuit, + session, +}: { + onQuit: () => void; + session: EmbeddedHunkSession; +}) { + const internals = embeddedHunkSessionInternals(session); + const snapshot = useSyncExternalStore( + session.subscribe, + internals.getRenderSnapshot, + internals.getRenderSnapshot, + ); + + return ( + null} + /> + ); +} + +/** Mount one embedded Hunk app into a host-owned OpenTUI container. */ +export function mountEmbeddedHunkApp({ + active, + container, + onQuit, + renderer, + session, +}: MountEmbeddedHunkAppInput): EmbeddedHunkMount { + let currentActive = active; + const scopedKeyInput = createScopedKeyInput(renderer.keyInput, () => currentActive); + const scopedRenderer = createEmbeddedRendererScope(renderer, container, scopedKeyInput.keyInput); + const root = createRoot(scopedRenderer.renderer); + + const render = (next: { active: boolean; onQuit: () => void }) => { + currentActive = next.active; + root.render(); + }; + + render({ active, onQuit }); + + return { + update: render, + unmount() { + root.unmount(); + scopedRenderer.dispose(); + scopedKeyInput.dispose(); + }, + }; +} diff --git a/src/embedded/session.ts b/src/embedded/session.ts new file mode 100644 index 00000000..10cc1e3a --- /dev/null +++ b/src/embedded/session.ts @@ -0,0 +1,651 @@ +import { isDeepStrictEqual } from "node:util"; +import { resolveConfiguredCliInput } from "../core/config"; +import { + buildLiveComment, + findDiffFileByPath, + hunkLineRange, + resolveCommentTarget, +} from "../core/liveComments"; +import { loadAppBootstrap } from "../core/loaders"; +import type { AppBootstrap, CliInput, CommonOptions } from "../core/types"; +import { createHunkSessionBridge } from "../hunk-session/bridge"; +import { + createInitialSessionSnapshot, + createSessionRegistration, + updateSessionRegistration, +} from "../hunk-session/sessionRegistration"; +import type { + AppliedCommentBatchResult, + AppliedCommentResult, + ClearedCommentsResult, + HunkSessionBrokerClient, + HunkSessionCommandResult, + HunkSessionRegistration, + HunkSessionServerMessage, + HunkSessionSnapshot, + LiveComment, + NavigatedSelectionResult, + RemovedCommentResult, + SessionLiveCommentSummary, + SessionReviewNoteSummary, +} from "../hunk-session/types"; +import { SessionBrokerClient } from "../session-broker/brokerClient"; +import { reviewNoteSource } from "../ui/lib/agentAnnotations"; +import { buildSelectedHunkSummary, resolveReviewNavigationTarget } from "../ui/lib/reviewState"; +import { createEmbeddedSessionBrokerAvailability } from "./daemon"; +import type { + CreateEmbeddedHunkSessionInput, + EmbeddedHunkSession, + EmbeddedHunkSnapshot, + EmbeddedHunkSource, +} from "./types"; + +export type EmbeddedHunkRenderSnapshot = + | { status: "loading"; bootstrap: AppBootstrap; error?: undefined } + | { status: "ready"; bootstrap: AppBootstrap; error?: undefined } + | { status: "error"; bootstrap: AppBootstrap; error: string }; + +type NormalizedEmbeddedHunkSource = EmbeddedHunkSource & { options: CommonOptions }; + +/** Drop undefined option entries so equivalent embedded sources compare the same. */ +function normalizeEmbeddedOptions(options: EmbeddedHunkSource["options"] = {}): CommonOptions { + const normalized = { ...options }; + for (const key of Object.keys(normalized) as Array) { + if (normalized[key] === undefined) delete normalized[key]; + } + return normalized; +} + +/** Return a session-owned source copy with normalized options and pathspec identity. */ +function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): NormalizedEmbeddedHunkSource { + const normalized = { + ...source, + options: normalizeEmbeddedOptions(source.options), + } as NormalizedEmbeddedHunkSource; + + if ("pathspecs" in normalized) { + if (normalized.pathspecs === undefined) { + delete normalized.pathspecs; + } else { + normalized.pathspecs = [...normalized.pathspecs]; + } + } + + return normalized; +} + +/** Adapt a public embedded source into the internal CLI input pipeline. */ +function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { + const normalized = normalizeEmbeddedHunkSource(source); + + switch (normalized.kind) { + case "worktree": + return { + kind: "vcs", + staged: false, + pathspecs: normalized.pathspecs, + options: normalized.options, + }; + case "staged": + return { + kind: "vcs", + staged: true, + pathspecs: normalized.pathspecs, + options: normalized.options, + }; + case "patch": + return { + kind: "patch", + text: normalized.text, + file: normalized.file ?? normalized.label, + options: normalized.options, + }; + default: + return normalized as CliInput; + } +} + +/** Resolve embedded input through the same config layers as the CLI. */ +function resolveEmbeddedCliInput(source: EmbeddedHunkSource, cwd: string) { + return resolveConfiguredCliInput(embeddedSourceToCliInput(source), { cwd }).input; +} + +/** Build the host-facing embedded snapshot without exposing app bootstrap internals. */ +function publicSnapshot( + source: EmbeddedHunkSource, + snapshot: EmbeddedHunkRenderSnapshot, +): EmbeddedHunkSnapshot { + if (snapshot.status === "loading") { + return { status: "loading", source }; + } + + const base = { + source, + title: snapshot.bootstrap.changeset.title, + fileCount: snapshot.bootstrap.changeset.files.length, + }; + return snapshot.status === "error" + ? { ...base, status: "error", error: snapshot.error } + : { ...base, status: "ready" }; +} + +/** Convert one live comment into the broker snapshot's summary shape. */ +function summarizeLiveComment(comment: LiveComment): SessionLiveCommentSummary { + return { + commentId: comment.id, + filePath: comment.filePath, + hunkIndex: comment.hunkIndex, + side: comment.side, + line: comment.line, + summary: comment.summary, + rationale: comment.rationale, + author: comment.author, + createdAt: comment.createdAt, + }; +} + +/** Rehydrate broker snapshot comments into the session-owned live annotation map. */ +function liveCommentsByFileFromSnapshot( + bootstrap: AppBootstrap, + comments: SessionLiveCommentSummary[], +) { + const byFileId: Record = {}; + comments.forEach((comment) => { + const file = findDiffFileByPath(bootstrap.changeset.files, comment.filePath); + if (!file) { + return; + } + + byFileId[file.id] = [ + ...(byFileId[file.id] ?? []), + { + id: comment.commentId, + source: "mcp", + filePath: comment.filePath, + hunkIndex: comment.hunkIndex, + side: comment.side, + line: comment.line, + summary: comment.summary, + rationale: comment.rationale, + author: comment.author, + createdAt: comment.createdAt, + oldRange: comment.side === "old" ? [comment.line, comment.line] : undefined, + newRange: comment.side === "new" ? [comment.line, comment.line] : undefined, + tags: ["mcp"], + confidence: "high", + }, + ]; + }); + + return byFileId; +} + +/** Own one embedded Hunk review session, including source identity and broker registration. */ +class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { + private listeners = new Set<() => void>(); + private disposed = false; + private renderSnapshot: EmbeddedHunkRenderSnapshot; + private sessionSnapshot: HunkSessionSnapshot; + private liveCommentsByFileId: Record = {}; + private mountedBridge: Parameters[0] = null; + + readonly brokerClient: HunkSessionBrokerClient; + readonly hostClient: HunkSessionBrokerClient; + + constructor( + readonly cwd: string, + public source: EmbeddedHunkSource, + bootstrap: AppBootstrap, + ) { + this.renderSnapshot = { status: "ready", bootstrap }; + this.sessionSnapshot = createInitialSessionSnapshot(bootstrap); + this.brokerClient = new SessionBrokerClient( + createSessionRegistration(bootstrap, { cwd }), + this.sessionSnapshot, + { + ensureBrokerAvailable: createEmbeddedSessionBrokerAvailability({ cwd }), + }, + ); + this.brokerClient.setBridge({ + dispatchCommand: (message) => this.dispatchCommand(message), + }); + this.hostClient = this.createMountedHostClient(); + this.brokerClient.start(); + } + + getSnapshot = () => publicSnapshot(this.source, this.renderSnapshot); + + getRenderSnapshot = () => this.renderSnapshot; + + getSessionSnapshot = () => this.sessionSnapshot; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + /** Open a source idempotently; callers can re-open without learning source identity rules. */ + async open(source: EmbeddedHunkSource) { + const nextSource = normalizeEmbeddedHunkSource(source); + if (this.disposed || isDeepStrictEqual(normalizeEmbeddedHunkSource(this.source), nextSource)) { + return this.getSnapshot(); + } + return this.load(nextSource, { updateSource: true }); + } + + /** Reload the currently loaded source, preserving source identity for the host. */ + async reload() { + if (this.disposed) return this.getSnapshot(); + return this.load(this.source, { updateSource: false }); + } + + /** Dispatch session-broker commands through the mounted UI when available, otherwise headlessly. */ + async dispatchCommand(message: HunkSessionServerMessage): Promise { + if (this.mountedBridge) { + return this.mountedBridge.dispatchCommand(message); + } + + const bridge = createHunkSessionBridge({ + addLiveComment: (input, commentId, options) => + this.addHeadlessLiveComment(input, commentId, options), + addLiveCommentBatch: (inputs, requestId, options) => + this.addHeadlessLiveCommentBatch(inputs, requestId, options), + clearLiveComments: (filePath) => this.clearHeadlessLiveComments(filePath), + navigateToLocation: (input) => this.navigateHeadless(input), + openAgentNotes: () => this.setHeadlessAgentNotesVisible(true), + reloadSession: async (nextInput) => { + const result = await this.load(nextInput as EmbeddedHunkSource, { + updateSource: true, + }); + return { + sessionId: this.brokerClient.getRegistration().sessionId, + inputKind: this.renderSnapshot.bootstrap.input.kind, + title: + result.status === "ready" + ? result.title + : this.renderSnapshot.bootstrap.changeset.title, + sourceLabel: this.renderSnapshot.bootstrap.changeset.sourceLabel, + fileCount: this.renderSnapshot.bootstrap.changeset.files.length, + selectedFilePath: this.sessionSnapshot.state.selectedFilePath, + selectedHunkIndex: this.sessionSnapshot.state.selectedHunkIndex, + }; + }, + removeLiveComment: (commentId) => this.removeHeadlessLiveComment(commentId), + }); + + return bridge.dispatchCommand(message); + } + + dispose() { + this.disposed = true; + this.brokerClient.stop(); + this.listeners.clear(); + } + + private async load( + source: EmbeddedHunkSource, + { updateSource }: { updateSource: boolean }, + ): Promise { + this.setRenderSnapshot({ + status: "loading", + bootstrap: this.renderSnapshot.bootstrap, + }); + + try { + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, this.cwd), { + cwd: this.cwd, + }); + if (updateSource) { + this.source = source; + } + this.liveCommentsByFileId = {}; + this.sessionSnapshot = createInitialSessionSnapshot(bootstrap); + this.brokerClient.replaceSession( + updateSessionRegistration(this.brokerClient.getRegistration(), bootstrap), + this.sessionSnapshot, + ); + this.setRenderSnapshot({ status: "ready", bootstrap }); + return this.getSnapshot(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.setRenderSnapshot({ + status: "error", + bootstrap: this.renderSnapshot.bootstrap, + error: message, + }); + throw error; + } + } + + private setRenderSnapshot(snapshot: EmbeddedHunkRenderSnapshot) { + this.renderSnapshot = snapshot; + for (const listener of this.listeners) listener(); + } + + /** Build the host client facade used by mounted React apps without giving up headless handling. */ + private createMountedHostClient(): HunkSessionBrokerClient { + return { + getRegistration: () => this.brokerClient.getRegistration(), + replaceSession: (registration: HunkSessionRegistration, snapshot: HunkSessionSnapshot) => { + this.persistSessionSnapshot(snapshot); + this.brokerClient.replaceSession(registration, snapshot); + }, + setBridge: (bridge: Parameters[0]) => { + this.mountedBridge = bridge; + }, + start: () => this.brokerClient.start(), + stop: () => this.brokerClient.stop(), + updateSnapshot: (snapshot: HunkSessionSnapshot) => { + this.persistSessionSnapshot(snapshot); + this.brokerClient.updateSnapshot(snapshot); + }, + } as HunkSessionBrokerClient; + } + + /** Persist the latest mounted-app snapshot so future embedded mounts start from it. */ + private persistSessionSnapshot(snapshot: HunkSessionSnapshot) { + this.sessionSnapshot = snapshot; + this.liveCommentsByFileId = liveCommentsByFileFromSnapshot( + this.renderSnapshot.bootstrap, + snapshot.state.liveComments, + ); + } + + /** Return all session-owned live comments in file order. */ + private liveCommentSummaries() { + return this.renderSnapshot.bootstrap.changeset.files.flatMap((file) => + (this.liveCommentsByFileId[file.id] ?? []).map(summarizeLiveComment), + ); + } + + /** Return all review notes visible to session commands, including headless live comments. */ + private reviewNoteSummaries(): SessionReviewNoteSummary[] { + const summaries: SessionReviewNoteSummary[] = []; + + this.renderSnapshot.bootstrap.changeset.files.forEach((file) => { + (file.agent?.annotations ?? []).forEach((annotation, index) => { + const source = reviewNoteSource(annotation); + summaries.push({ + noteId: annotation.id ?? `${source}:${file.id}:${index}`, + source, + filePath: file.path, + oldRange: annotation.oldRange, + newRange: annotation.newRange, + body: [annotation.summary, annotation.rationale].filter(Boolean).join("\n\n"), + title: annotation.title, + author: annotation.author, + createdAt: annotation.createdAt ?? "1970-01-01T00:00:00.000Z", + updatedAt: annotation.updatedAt, + editable: false, + }); + }); + + (this.liveCommentsByFileId[file.id] ?? []).forEach((comment) => { + summaries.push({ + noteId: comment.id, + source: "agent", + filePath: file.path, + hunkIndex: comment.hunkIndex, + oldRange: comment.oldRange, + newRange: comment.newRange, + body: [comment.summary, comment.rationale].filter(Boolean).join("\n\n"), + author: comment.author, + createdAt: comment.createdAt, + editable: false, + }); + }); + }); + + return summaries; + } + + /** Publish the current session-owned review state to the daemon. */ + private updateHeadlessSnapshot() { + const liveComments = this.liveCommentSummaries(); + const reviewNotes = this.reviewNoteSummaries(); + this.sessionSnapshot = { + updatedAt: new Date().toISOString(), + state: { + ...this.sessionSnapshot.state, + liveCommentCount: liveComments.length, + liveComments, + reviewNoteCount: reviewNotes.length, + reviewNotes, + }, + }; + this.brokerClient.updateSnapshot(this.sessionSnapshot); + for (const listener of this.listeners) listener(); + } + + /** Update the persisted agent-note visibility bit. */ + private setHeadlessAgentNotesVisible(visible: boolean) { + this.sessionSnapshot = { + ...this.sessionSnapshot, + state: { + ...this.sessionSnapshot.state, + showAgentNotes: visible, + }, + }; + this.updateHeadlessSnapshot(); + } + + /** Add one live agent comment to the session-owned review state. */ + private addHeadlessLiveComment( + input: Extract["input"], + commentId: string, + options?: { reveal?: boolean }, + ): AppliedCommentResult { + const file = findDiffFileByPath(this.renderSnapshot.bootstrap.changeset.files, input.filePath); + if (!file) { + throw new Error(`No diff file matches ${input.filePath}.`); + } + + const target = resolveCommentTarget(file, input); + const liveComment = buildLiveComment( + { + ...input, + side: target.side, + line: target.line, + }, + commentId, + new Date().toISOString(), + target.hunkIndex, + ); + this.liveCommentsByFileId[file.id] = [ + ...(this.liveCommentsByFileId[file.id] ?? []), + liveComment, + ]; + + if (options?.reveal ?? false) { + this.selectHeadlessHunk(file.id, file.path, target.hunkIndex); + this.sessionSnapshot.state.showAgentNotes = true; + } + + this.updateHeadlessSnapshot(); + return { + commentId, + fileId: file.id, + filePath: file.path, + hunkIndex: target.hunkIndex, + side: target.side, + line: target.line, + }; + } + + /** Apply a validated batch of live comments to the session-owned review state. */ + private addHeadlessLiveCommentBatch( + inputs: Extract["input"]["comments"], + requestId: string, + options?: { revealMode?: "none" | "first" }, + ): AppliedCommentBatchResult { + const createdAt = new Date().toISOString(); + const prepared = inputs.map((input, index) => { + const file = findDiffFileByPath( + this.renderSnapshot.bootstrap.changeset.files, + input.filePath, + ); + if (!file) { + throw new Error(`No diff file matches ${input.filePath}.`); + } + + const target = resolveCommentTarget(file, input); + return { + file, + target, + liveComment: buildLiveComment( + { + ...input, + side: target.side, + line: target.line, + }, + `mcp:${requestId}:${index}`, + createdAt, + target.hunkIndex, + ), + }; + }); + + prepared.forEach(({ file, liveComment }) => { + this.liveCommentsByFileId[file.id] = [ + ...(this.liveCommentsByFileId[file.id] ?? []), + liveComment, + ]; + }); + + if (options?.revealMode === "first" && prepared.length > 0) { + const first = prepared[0]!; + this.selectHeadlessHunk(first.file.id, first.file.path, first.target.hunkIndex); + this.sessionSnapshot.state.showAgentNotes = true; + } + + this.updateHeadlessSnapshot(); + return { + applied: prepared.map(({ file, target, liveComment }) => ({ + commentId: liveComment.id, + fileId: file.id, + filePath: file.path, + hunkIndex: target.hunkIndex, + side: target.side, + line: target.line, + })), + }; + } + + /** Navigate the persisted hidden-session selection. */ + private navigateHeadless( + input: Extract["input"], + ): NavigatedSelectionResult { + const files = this.renderSnapshot.bootstrap.changeset.files; + const target = resolveReviewNavigationTarget({ + allFiles: files, + currentFileId: this.sessionSnapshot.state.selectedFileId, + currentHunkIndex: this.sessionSnapshot.state.selectedHunkIndex, + input, + visibleFiles: files, + }); + this.selectHeadlessHunk(target.file.id, target.file.path, target.hunkIndex); + this.updateHeadlessSnapshot(); + return { + fileId: target.file.id, + filePath: target.file.path, + hunkIndex: target.hunkIndex, + selectedHunk: buildSelectedHunkSummary(target.file, target.hunkIndex), + }; + } + + /** Remove one persisted live comment. */ + private removeHeadlessLiveComment(commentId: string): RemovedCommentResult { + let removed = false; + let remainingCommentCount = 0; + const next: Record = {}; + + for (const [fileId, comments] of Object.entries(this.liveCommentsByFileId)) { + const filtered = comments.filter((comment) => comment.id !== 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 ${commentId}.`); + } + + this.liveCommentsByFileId = next; + this.updateHeadlessSnapshot(); + return { commentId, removed: true, remainingCommentCount }; + } + + /** Clear persisted live comments, optionally scoped to one file. */ + private clearHeadlessLiveComments(filePath?: string): ClearedCommentsResult { + let removedCount = 0; + let remainingCommentCount = 0; + + if (filePath) { + const file = findDiffFileByPath(this.renderSnapshot.bootstrap.changeset.files, filePath); + if (!file) { + throw new Error(`No diff file matches ${filePath}.`); + } + + const next: Record = {}; + for (const [fileId, comments] of Object.entries(this.liveCommentsByFileId)) { + if (fileId === file.id) { + removedCount = comments.length; + continue; + } + next[fileId] = comments; + remainingCommentCount += comments.length; + } + this.liveCommentsByFileId = next; + } else { + removedCount = Object.values(this.liveCommentsByFileId).reduce( + (sum, comments) => sum + comments.length, + 0, + ); + this.liveCommentsByFileId = {}; + } + + this.updateHeadlessSnapshot(); + return { removedCount, remainingCommentCount, filePath }; + } + + /** Update the persisted selection fields from one file/hunk target. */ + private selectHeadlessHunk(fileId: string, filePath: string, hunkIndex: number) { + const file = this.renderSnapshot.bootstrap.changeset.files.find( + (candidate) => candidate.id === fileId, + ); + const hunk = file?.metadata.hunks[hunkIndex]; + const range = hunk ? hunkLineRange(hunk) : null; + this.sessionSnapshot.state.selectedFileId = fileId; + this.sessionSnapshot.state.selectedFilePath = filePath; + this.sessionSnapshot.state.selectedHunkIndex = hunkIndex; + this.sessionSnapshot.state.selectedHunkOldRange = range?.oldRange; + this.sessionSnapshot.state.selectedHunkNewRange = range?.newRange; + } +} + +/** Resolve private render state for sessions created by this embedded entrypoint. */ +export function embeddedHunkSessionInternals(session: EmbeddedHunkSession) { + if (session instanceof EmbeddedHunkSessionImpl) { + return { + dispatchCommand: session.dispatchCommand.bind(session), + getRenderSnapshot: session.getRenderSnapshot, + getSessionSnapshot: session.getSessionSnapshot, + hostClient: session.hostClient, + }; + } + throw new Error("mountEmbeddedHunkApp requires a session from createEmbeddedHunkSession."); +} + +/** Create one embedded Hunk review session from a public embedded source. */ +export async function createEmbeddedHunkSession({ + cwd = process.cwd(), + source, +}: CreateEmbeddedHunkSessionInput): Promise { + const normalizedSource = normalizeEmbeddedHunkSource(source); + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(normalizedSource, cwd), { cwd }); + return new EmbeddedHunkSessionImpl(cwd, normalizedSource, bootstrap); +} diff --git a/src/embedded/types.ts b/src/embedded/types.ts new file mode 100644 index 00000000..3e5b6841 --- /dev/null +++ b/src/embedded/types.ts @@ -0,0 +1,71 @@ +import type { CliRenderer, Renderable } from "@opentui/core"; + +export interface EmbeddedHunkOptions { + mode?: "auto" | "split" | "stack"; + vcs?: "git" | "jj"; + theme?: string; + watch?: boolean; + excludeUntracked?: boolean; + lineNumbers?: boolean; + wrapLines?: boolean; + hunkHeaders?: boolean; + agentNotes?: boolean; +} + +export type EmbeddedHunkSource = + | { kind: "worktree"; pathspecs?: string[]; options?: EmbeddedHunkOptions } + | { kind: "staged"; pathspecs?: string[]; options?: EmbeddedHunkOptions } + | { + kind: "vcs"; + range?: string; + staged: boolean; + pathspecs?: string[]; + options?: EmbeddedHunkOptions; + } + | { kind: "show"; ref?: string; pathspecs?: string[]; options?: EmbeddedHunkOptions } + | { kind: "stash-show"; ref?: string; options?: EmbeddedHunkOptions } + | { kind: "diff"; left: string; right: string; options?: EmbeddedHunkOptions } + | { kind: "patch"; file?: string; text?: string; label?: string; options?: EmbeddedHunkOptions } + | { kind: "difftool"; left: string; right: string; path?: string; options?: EmbeddedHunkOptions }; + +export type EmbeddedHunkSnapshot = + | { status: "loading"; source: EmbeddedHunkSource } + | { status: "ready"; source: EmbeddedHunkSource; fileCount: number; title: string } + | { + status: "error"; + source: EmbeddedHunkSource; + fileCount: number; + title: string; + error: string; + }; + +export interface EmbeddedHunkSession { + readonly cwd: string; + /** The currently loaded source. Failed opens keep the previous source. */ + readonly source: EmbeddedHunkSource; + getSnapshot(): EmbeddedHunkSnapshot; + /** Open a source idempotently; same-source opens reuse the current review. */ + open(source: EmbeddedHunkSource): Promise; + /** Refresh the currently loaded source contents without changing source identity. */ + reload(): Promise; + subscribe(listener: () => void): () => void; + dispose(): void; +} + +export interface EmbeddedHunkMount { + update(options: { active: boolean; onQuit: () => void }): void; + unmount(): void; +} + +export interface CreateEmbeddedHunkSessionInput { + cwd?: string; + source: EmbeddedHunkSource; +} + +export interface MountEmbeddedHunkAppInput { + active: boolean; + container: Renderable; + onQuit: () => void; + renderer: CliRenderer; + session: EmbeddedHunkSession; +} diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index 958d93cc..96c695dd 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -52,14 +52,17 @@ function buildSessionFiles(bootstrap: AppBootstrap): SessionReviewFile[] { } /** Build the broker-facing envelope for one live Hunk review session. */ -export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionRegistration { +export function createSessionRegistration( + bootstrap: AppBootstrap, + options: { cwd?: string } = {}, +): HunkSessionRegistration { const terminal = resolveSessionTerminalMetadata({ tty: ttyname() }); return { registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, sessionId: randomUUID(), pid: process.pid, - cwd: process.cwd(), + cwd: options.cwd ?? process.cwd(), repoRoot: inferRepoRoot(bootstrap), launchedAt: new Date().toISOString(), terminal, diff --git a/src/session-broker/brokerClient.test.ts b/src/session-broker/brokerClient.test.ts index 21372976..9609cce6 100644 --- a/src/session-broker/brokerClient.test.ts +++ b/src/session-broker/brokerClient.test.ts @@ -76,6 +76,34 @@ afterEach(() => { }); describe("Hunk session daemon client", () => { + test("uses an injected broker availability adapter during startup", async () => { + delete process.env.HUNK_MCP_DISABLE; + const ensuredOrigins: string[] = []; + const messages: string[] = []; + console.error = (...args: unknown[]) => { + messages.push(args.map((value) => String(value)).join(" ")); + }; + const client = new SessionBrokerClient(createRegistration(), createSnapshot(), { + ensureBrokerAvailable: async (resolvedConfig) => { + ensuredOrigins.push(resolvedConfig.httpOrigin); + throw new Error("custom broker availability failed"); + }, + }); + + try { + client.start(); + await waitUntil( + "custom broker availability adapter", + () => ensuredOrigins.length === 1 && messages.length === 1, + ); + + expect(ensuredOrigins).toEqual(["http://127.0.0.1:47657"]); + expect(messages[0]).toContain("[session:broker] custom broker availability failed"); + } finally { + client.stop(); + } + }); + test("logs one actionable warning when the session daemon is configured for a non-loopback host without opt-in", async () => { process.env.HUNK_MCP_HOST = "0.0.0.0"; process.env.HUNK_MCP_PORT = "47657"; diff --git a/src/session-broker/brokerClient.ts b/src/session-broker/brokerClient.ts index 3e960781..f29a2a18 100644 --- a/src/session-broker/brokerClient.ts +++ b/src/session-broker/brokerClient.ts @@ -37,6 +37,12 @@ type SessionAppBridge< Result = unknown, > = SessionBrokerConnectionBridge; +export type EnsureSessionBrokerAdapter = (config: ResolvedSessionBrokerConfig) => Promise; + +export interface SessionBrokerClientOptions { + ensureBrokerAvailable?: EnsureSessionBrokerAdapter; +} + /** Keep one running app session registered with the local session broker daemon. */ export class SessionBrokerClient< Info = unknown, @@ -60,6 +66,7 @@ export class SessionBrokerClient< constructor( private registration: SessionRegistration, private snapshot: SessionSnapshot, + private options: SessionBrokerClientOptions = {}, ) {} start() { @@ -117,18 +124,20 @@ export class SessionBrokerClient< } private async ensureDaemonAvailable(config: ResolvedSessionBrokerConfig) { - await ensureSessionBrokerAvailable({ - config, - timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, - }); + const ensureBrokerAvailable = + this.options.ensureBrokerAvailable ?? + ((resolvedConfig: ResolvedSessionBrokerConfig) => + ensureSessionBrokerAvailable({ + config: resolvedConfig, + timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, + })); + + await ensureBrokerAvailable(config); const capabilities = await readHunkSessionDaemonCapabilities(config); if (!capabilities) { await this.restartIncompatibleDaemon(config); - await ensureSessionBrokerAvailable({ - config, - timeoutMs: DAEMON_STARTUP_TIMEOUT_MS, - }); + await ensureBrokerAvailable(config); if (!(await readHunkSessionDaemonCapabilities(config))) { throw new Error( diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 0333307e..299d0357 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -7,7 +7,11 @@ import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; -import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; +import type { + HunkSessionBrokerClient, + HunkSessionState, + ReloadedSessionResult, +} from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; @@ -77,12 +81,14 @@ function withCurrentViewOptions( export function App({ bootstrap, hostClient, + initialSessionState, noticeText, onQuit = () => process.exit(0), onReloadSession, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; + initialSessionState?: HunkSessionState; noticeText?: string | null; onQuit?: () => void; onReloadSession: ( @@ -114,7 +120,9 @@ export function App({ ); // Soft reloads replace bootstrap without re-running startup terminal theme detection. const [detectedThemeMode] = useState(() => bootstrap.initialThemeMode); - const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false); + const [showAgentNotes, setShowAgentNotes] = useState( + initialSessionState?.showAgentNotes ?? bootstrap.initialShowAgentNotes ?? false, + ); const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true); const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false); const [copyDecorations, setCopyDecorations] = useState(bootstrap.initialCopyDecorations ?? false); @@ -132,7 +140,12 @@ export function App({ const sessionNoticeTimeoutRef = useRef | null>(null); const activeTheme = resolveTheme(themeId, detectedThemeMode ?? null); - const review = useReviewController({ files: bootstrap.changeset.files }); + const review = useReviewController({ + files: bootstrap.changeset.files, + initialLiveComments: initialSessionState?.liveComments, + initialSelectedFileId: initialSessionState?.selectedFileId, + initialSelectedHunkIndex: initialSessionState?.selectedHunkIndex, + }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 99dc227e..c7a66512 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -167,6 +167,16 @@ function createLineScrollBootstrap(pager = false): AppBootstrap { }); } +function createLiveCommentScrollBootstrap(): AppBootstrap { + const before = lines(...createNumberedAssignmentLines(1, 18)); + const after = lines(...createNumberedAssignmentLines(1, 18, 100)); + + return createTestVcsAppBootstrap({ + changesetId: "changeset:app-live-comment-scroll", + files: [createTestDiffFile("scroll-live", "scroll-live.ts", before, after)], + }); +} + /** Build a two-hunk fixture with a deep inline note for CLI comment-navigation scroll tests. */ function createDeepNoteBootstrap(): AppBootstrap { const beforeLines = Array.from( @@ -2137,6 +2147,49 @@ describe("App interactions", () => { } }); + test("focused live comments scroll the new inline note into view", async () => { + const { dispatchCommand, hostClient } = createMockHostClient(); + const setup = await testRender( + , + { + width: 104, + height: 12, + }, + ); + + try { + await flush(setup); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("Live note anchored near the bottom."); + + await act(async () => { + await dispatchCommand({ + type: "command", + requestId: "comment-1", + command: "comment", + input: { + sessionId: "session-1", + filePath: "scroll-live.ts", + side: "new", + line: 12, + summary: "Live note anchored near the bottom.", + reveal: true, + }, + }); + }); + + frame = await waitForFrame(setup, (currentFrame) => + currentFrame.includes("Live note anchored near the bottom."), + ); + expect(frame).toContain("Live note anchored near the bottom."); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("menu navigation wraps across the first and last top-level menus", async () => { const setup = await testRender(, { width: 220, diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 96aa9cf5..6fd5841a 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; @@ -8,7 +8,7 @@ import { createInitialSessionSnapshot, updateSessionRegistration, } from "../hunk-session/sessionRegistration"; -import type { HunkSessionBrokerClient } from "../hunk-session/types"; +import type { HunkSessionBrokerClient, HunkSessionState } from "../hunk-session/types"; import { App } from "./App"; import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; @@ -16,21 +16,34 @@ import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; export function AppHost({ bootstrap, hostClient, + initialSessionState, onQuit = () => process.exit(0), startupNoticeResolver, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; + initialSessionState?: HunkSessionState; onQuit?: () => void; startupNoticeResolver?: () => Promise; }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); + const previousBootstrapRef = useRef(bootstrap); const startupNoticeText = useStartupUpdateNotice({ enabled: !bootstrap.input.options.pager, resolver: startupNoticeResolver, }); + useEffect(() => { + if (previousBootstrapRef.current === bootstrap) { + return; + } + + previousBootstrapRef.current = bootstrap; + setActiveBootstrap(bootstrap); + setAppVersion((current) => current + 1); + }, [bootstrap]); + const reloadSession = useCallback( async (nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }) => { // Re-run the same startup normalization pipeline used on first launch so reloads honor @@ -84,6 +97,7 @@ export function AppHost({ key={appVersion} bootstrap={activeBootstrap} hostClient={hostClient} + initialSessionState={initialSessionState} noticeText={startupNoticeText} onQuit={onQuit} onReloadSession={reloadSession} diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index f8c1b9ea..a147c378 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -146,16 +146,61 @@ export interface ReviewController { updateDraftNote: (body: string) => void; } +/** Rehydrate daemon-snapshot live comments into the review controller's annotation map. */ +function liveCommentsByFileFromSummaries( + files: DiffFile[], + summaries: SessionLiveCommentSummary[] = [], +): Record { + const byFileId: Record = {}; + + summaries.forEach((summary) => { + const file = findDiffFileByPath(files, summary.filePath); + if (!file) { + return; + } + + const comment: LiveComment = { + id: summary.commentId, + source: "mcp", + filePath: summary.filePath, + hunkIndex: summary.hunkIndex, + side: summary.side, + line: summary.line, + summary: summary.summary, + rationale: summary.rationale, + author: summary.author, + createdAt: summary.createdAt, + oldRange: summary.side === "old" ? [summary.line, summary.line] : undefined, + newRange: summary.side === "new" ? [summary.line, summary.line] : undefined, + tags: ["mcp"], + confidence: "high", + }; + byFileId[file.id] = [...(byFileId[file.id] ?? []), comment]; + }); + + return byFileId; +} + /** Own the shared review stream state used by both the UI and session bridge. */ -export function useReviewController({ files }: { files: DiffFile[] }): ReviewController { +export function useReviewController({ + files, + initialLiveComments, + initialSelectedFileId, + initialSelectedHunkIndex, +}: { + files: DiffFile[]; + initialLiveComments?: SessionLiveCommentSummary[]; + initialSelectedFileId?: string; + initialSelectedHunkIndex?: number; +}): ReviewController { const [filter, setFilter] = useState(""); - const [selectedFileId, setSelectedFileId] = useState(files[0]?.id ?? ""); - const [selectedHunkIndex, setSelectedHunkIndex] = useState(0); + const [selectedFileId, setSelectedFileId] = useState(initialSelectedFileId ?? files[0]?.id ?? ""); + const [selectedHunkIndex, setSelectedHunkIndex] = useState(initialSelectedHunkIndex ?? 0); const [selectedFileTopAlignRequestId, setSelectedFileTopAlignRequestId] = useState(0); const [selectedHunkRevealRequestId, setSelectedHunkRevealRequestId] = useState(0); const [scrollToNote, setScrollToNote] = useState(false); const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>( - {}, + () => liveCommentsByFileFromSummaries(files, initialLiveComments), ); const [userNotesByFileId, setUserNotesByFileId] = useState>({}); const [draftNote, setDraftNote] = useState(null); @@ -394,7 +439,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon })); if (options?.reveal ?? false) { - selectHunk(file.id, target.hunkIndex); + selectHunk(file.id, target.hunkIndex, { scrollToNote: true }); } return { @@ -453,7 +498,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon if (options?.revealMode === "first" && prepared.length > 0) { const first = prepared[0]!; - selectHunk(first.file.id, first.target.hunkIndex); + selectHunk(first.file.id, first.target.hunkIndex, { scrollToNote: true }); } return { diff --git a/test/cli/entrypoint.test.ts b/test/cli/entrypoint.test.ts index 923521c6..b609952d 100644 --- a/test/cli/entrypoint.test.ts +++ b/test/cli/entrypoint.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -18,6 +26,30 @@ function git(cwd: string, ...args: string[]) { } } +/** Return the optional prebuilt package name for the current test host. */ +function currentPlatformPackageName() { + const platformMap: Partial> = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + const archMap: Partial> = { + arm64: "arm64", + x64: "x64", + }; + const platform = platformMap[process.platform]; + const arch = archMap[process.arch]; + return platform && arch ? `hunkdiff-${platform}-${arch}` : null; +} + +/** Write a small executable JS file for wrapper integration tests. */ +function writeExecutableScript(path: string, source: string) { + writeFileSync(path, source); + if (process.platform !== "win32") { + chmodSync(path, 0o755); + } +} + describe("CLI entrypoint contracts", () => { test("bare hunk prints standard help without terminal takeover sequences", () => { const proc = Bun.spawnSync(["bun", "run", "src/main.tsx"], { @@ -204,6 +236,70 @@ describe("CLI entrypoint contracts", () => { } }); + test("bin wrapper ignores a mismatched prebuilt package and falls back to bundled JS", () => { + if (process.platform === "win32") { + // This test builds fake shebang executables, which Windows cannot launch directly. + return; + } + + const platformPackageName = currentPlatformPackageName(); + if (!platformPackageName) { + return; + } + + const tempDir = mkdtempSync(join(tmpdir(), "hunk-wrapper-stale-prebuilt-")); + const packageRoot = join(tempDir, "hunkdiff"); + const tempBinDir = join(packageRoot, "bin"); + const stalePackageRoot = join(packageRoot, "node_modules", platformPackageName); + const staleBinDir = join(stalePackageRoot, "bin"); + const fakeBunDir = join(packageRoot, "node_modules", "bun", "bin"); + + try { + mkdirSync(tempBinDir, { recursive: true }); + mkdirSync(join(packageRoot, "dist", "npm"), { recursive: true }); + mkdirSync(staleBinDir, { recursive: true }); + mkdirSync(fakeBunDir, { recursive: true }); + copyFileSync(join(process.cwd(), "bin", "hunk.cjs"), join(tempBinDir, "hunk.cjs")); + writeFileSync(join(packageRoot, "package.json"), JSON.stringify({ version: "2.0.0" })); + writeFileSync(join(stalePackageRoot, "package.json"), JSON.stringify({ version: "1.0.0" })); + writeExecutableScript( + join(staleBinDir, "hunk"), + "#!/usr/bin/env node\nconsole.log('stale prebuilt');\n", + ); + writeExecutableScript( + join(fakeBunDir, "bun.exe"), + [ + "#!/usr/bin/env node", + 'const { spawnSync } = require("node:child_process");', + "const result = spawnSync(process.execPath, process.argv.slice(2), { stdio: 'inherit' });", + "process.exit(typeof result.status === 'number' ? result.status : 1);", + "", + ].join("\n"), + ); + writeExecutableScript( + join(packageRoot, "dist", "npm", "main.js"), + "#!/usr/bin/env node\nconsole.log('fallback bundled js');\n", + ); + + const proc = Bun.spawnSync(["node", join(tempBinDir, "hunk.cjs"), "--version"], { + cwd: tempDir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }); + + const stdout = Buffer.from(proc.stdout).toString("utf8"); + const stderr = Buffer.from(proc.stderr).toString("utf8"); + + expect(proc.exitCode).toBe(0); + expect(stdout).toBe("fallback bundled js\n"); + expect(stderr).toBe(""); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + test("general pager mode falls back to plain text for non-diff stdin", () => { const proc = Bun.spawnSync(["bun", "run", "src/main.tsx", "pager"], { cwd: process.cwd(), diff --git a/tsconfig.opentui.json b/tsconfig.npm-exports.json similarity index 70% rename from tsconfig.opentui.json rename to tsconfig.npm-exports.json index e1b363a3..33044a77 100644 --- a/tsconfig.opentui.json +++ b/tsconfig.npm-exports.json @@ -5,8 +5,8 @@ "declaration": true, "emitDeclarationOnly": true, "outDir": "./dist/npm-types", - "rootDir": "./src" + "rootDir": "." }, "include": [], - "files": ["src/opentui/index.ts"] + "files": ["src/opentui/index.ts", "src/embedded/index.ts"] }