From cd006476159963c9e6cc574ca46e1b7c5bd188b4 Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 01:01:48 -0400 Subject: [PATCH 1/8] feat(embedded): add host API for mounting Hunk --- package.json | 4 + scripts/build-npm.ts | 24 ++ src/embedded/embedded.test.ts | 141 ++++++++++++ src/embedded/index.d.ts | 73 ++++++ src/embedded/index.tsx | 289 ++++++++++++++++++++++++ src/hunk-session/sessionRegistration.ts | 7 +- src/ui/App.tsx | 3 + src/ui/AppHost.interactions.test.tsx | 53 +++++ src/ui/AppHost.tsx | 9 +- src/ui/hooks/useAppKeyboardShortcuts.ts | 8 + src/ui/hooks/useReviewController.ts | 4 +- 11 files changed, 610 insertions(+), 5 deletions(-) create mode 100644 src/embedded/embedded.test.ts create mode 100644 src/embedded/index.d.ts create mode 100644 src/embedded/index.tsx diff --git a/package.json b/package.json index 8092aed1..033485a9 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..2e95cb4c 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -8,6 +8,7 @@ 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 embeddedOutdir = path.join(outdir, "embedded"); const bunEnv = { ...process.env, @@ -32,6 +33,7 @@ function runBun(args: string[]) { rmSync(outdir, { recursive: true, force: true }); rmSync(typesOutdir, { recursive: true, force: true }); mkdirSync(opentuiOutdir, { recursive: true }); +mkdirSync(embeddedOutdir, { recursive: true }); runBun([ "build", @@ -81,6 +83,22 @@ runBun([ "index.js", ]); +const embeddedBuild = await Bun.build({ + entrypoints: [path.join(repoRoot, "src", "embedded", "index.tsx")], + target: "node", + format: "esm", + outdir: embeddedOutdir, + naming: { entry: "index.js" }, + external: ["@opentui/core", "@opentui/react", "@pierre/diffs", "react", "react/jsx-runtime"], +}); + +if (!embeddedBuild.success) { + for (const log of embeddedBuild.logs) { + console.error(log.message); + } + throw new Error("Failed to build embedded Hunk export."); +} + runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]); for (const entry of readdirSync(opentuiTypesDir)) { @@ -89,7 +107,13 @@ for (const entry of readdirSync(opentuiTypesDir)) { } } +copyFileSync( + path.join(repoRoot, "src", "embedded", "index.d.ts"), + path.join(embeddedOutdir, "index.d.ts"), +); + 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/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts new file mode 100644 index 00000000..250926c2 --- /dev/null +++ b/src/embedded/embedded.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createEmbeddedHunkSession, embeddedSourceToCliInput } from "./index"; + +const patchText = [ + "diff --git a/example.ts b/example.ts", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1 +1 @@", + "-const value = 1;", + "+const value = 2;", + "", +].join("\n"); + +describe("embeddedSourceToCliInput", () => { + test("maps embedded review sources to Hunk CLI inputs", () => { + expect(embeddedSourceToCliInput({ kind: "worktree", options: { theme: "midnight" } })).toEqual({ + kind: "vcs", + staged: false, + options: { theme: "midnight" }, + }); + expect(embeddedSourceToCliInput({ kind: "staged" })).toEqual({ + kind: "vcs", + staged: true, + options: {}, + }); + expect(embeddedSourceToCliInput({ kind: "show", ref: "HEAD~1" })).toEqual({ + kind: "show", + ref: "HEAD~1", + options: {}, + }); + expect( + embeddedSourceToCliInput({ + kind: "vcs", + range: "main", + staged: false, + pathspecs: ["src/app.ts"], + options: { vcs: "jj" }, + }), + ).toEqual({ + kind: "vcs", + range: "main", + staged: false, + pathspecs: ["src/app.ts"], + options: { vcs: "jj" }, + }); + expect( + embeddedSourceToCliInput({ kind: "diff", left: "before.ts", right: "after.ts" }), + ).toEqual({ + kind: "diff", + left: "before.ts", + right: "after.ts", + options: {}, + }); + expect(embeddedSourceToCliInput({ kind: "stash-show", ref: "stash@{1}" })).toEqual({ + kind: "stash-show", + ref: "stash@{1}", + options: {}, + }); + expect(embeddedSourceToCliInput({ kind: "patch", file: "changes.patch" })).toEqual({ + kind: "patch", + file: "changes.patch", + options: {}, + }); + expect( + embeddedSourceToCliInput({ + kind: "difftool", + left: "left.ts", + right: "right.ts", + path: "src/app.ts", + }), + ).toEqual({ + kind: "difftool", + left: "left.ts", + right: "right.ts", + path: "src/app.ts", + options: {}, + }); + }); + + 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: patchText, options: { theme: "paper" } }, + }); + const snapshot = session.getSnapshot(); + + expect(snapshot.status).toBe("ready"); + if (snapshot.status !== "ready") throw new Error("Expected embedded session to load."); + expect(snapshot.bootstrap.initialMode).toBe("stack"); + expect(snapshot.bootstrap.initialShowLineNumbers).toBe(false); + expect(snapshot.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("keeps the previous source and reports errors when reload fails", async () => { + const root = mkdtempSync(join(tmpdir(), "hunk-embedded-reload-error-")); + + try { + const initialSource = { kind: "patch", text: patchText, label: "initial patch" } as const; + const session = await createEmbeddedHunkSession({ + cwd: root, + source: initialSource, + }); + + await expect(session.load({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); + + expect(session.source).toEqual(initialSource); + const snapshot = session.getSnapshot(); + expect(snapshot.status).toBe("error"); + expect(snapshot.bootstrap).toBeDefined(); + + session.dispose(); + } finally { + rmSync(root, { force: true, recursive: true }); + } + }); +}); diff --git a/src/embedded/index.d.ts b/src/embedded/index.d.ts new file mode 100644 index 00000000..cf920500 --- /dev/null +++ b/src/embedded/index.d.ts @@ -0,0 +1,73 @@ +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"; bootstrap?: unknown; error?: undefined } + | { status: "ready"; bootstrap: unknown; error?: undefined } + | { status: "error"; bootstrap?: unknown; error: string }; + +export interface EmbeddedHunkSession { + readonly cwd: string; + readonly source: EmbeddedHunkSource; + getSnapshot(): EmbeddedHunkSnapshot; + load(source: EmbeddedHunkSource): Promise; + subscribe(listener: () => void): () => void; + dispose(): void; +} + +export interface EmbeddedHunkMount { + update(options: { active: boolean; onQuit: () => void }): void; + unmount(): void; +} + +export declare function embeddedSourceToCliInput(source: EmbeddedHunkSource): unknown; +export declare function createEmbeddedHunkSession(input: { + cwd?: string; + source: EmbeddedHunkSource; +}): Promise; +export declare function mountEmbeddedHunkApp(input: { + active: boolean; + container: Renderable; + onQuit: () => void; + renderer: CliRenderer; + session: EmbeddedHunkSession; +}): EmbeddedHunkMount; diff --git a/src/embedded/index.tsx b/src/embedded/index.tsx new file mode 100644 index 00000000..1fc5073c --- /dev/null +++ b/src/embedded/index.tsx @@ -0,0 +1,289 @@ +import type { CliRenderer, Renderable } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { useSyncExternalStore } from "react"; +import { resolveConfiguredCliInput } from "../core/config"; +import { loadAppBootstrap } from "../core/loaders"; +import type { AppBootstrap, CliInput, CommonOptions } from "../core/types"; +import { + createInitialSessionSnapshot, + createSessionRegistration, + updateSessionRegistration, +} from "../hunk-session/sessionRegistration"; +import type { HunkSessionBrokerClient } from "../hunk-session/types"; +import { SessionBrokerClient } from "../session-broker/brokerClient"; +import { AppHost } from "../ui/AppHost"; + +export type EmbeddedHunkSource = + | { kind: "worktree"; pathspecs?: string[]; options?: CommonOptions } + | { kind: "staged"; pathspecs?: string[]; options?: CommonOptions } + | { + kind: "vcs"; + range?: string; + staged: boolean; + pathspecs?: string[]; + options?: CommonOptions; + } + | { kind: "show"; ref?: string; pathspecs?: string[]; options?: CommonOptions } + | { kind: "stash-show"; ref?: string; options?: CommonOptions } + | { kind: "diff"; left: string; right: string; options?: CommonOptions } + | { kind: "patch"; file?: string; text?: string; label?: string; options?: CommonOptions } + | { kind: "difftool"; left: string; right: string; path?: string; options?: CommonOptions }; + +export type EmbeddedHunkSnapshot = + | { status: "loading"; bootstrap?: AppBootstrap; error?: undefined } + | { status: "ready"; bootstrap: AppBootstrap; error?: undefined } + | { status: "error"; bootstrap?: AppBootstrap; error: string }; + +export interface EmbeddedHunkSession { + readonly cwd: string; + readonly source: EmbeddedHunkSource; + getSnapshot(): EmbeddedHunkSnapshot; + load(source: EmbeddedHunkSource): Promise; + subscribe(listener: () => void): () => void; + dispose(): void; +} + +interface EmbeddedHunkSessionHandle extends EmbeddedHunkSession { + readonly hostClient: HunkSessionBrokerClient; +} + +export interface EmbeddedHunkMount { + update(options: { active: boolean; onQuit: () => void }): void; + unmount(): void; +} + +export function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { + switch (source.kind) { + case "worktree": + return { + kind: "vcs", + staged: false, + pathspecs: source.pathspecs, + options: source.options ?? {}, + }; + case "staged": + return { + kind: "vcs", + staged: true, + pathspecs: source.pathspecs, + options: source.options ?? {}, + }; + case "vcs": + return { + kind: "vcs", + range: source.range, + staged: source.staged, + pathspecs: source.pathspecs, + options: source.options ?? {}, + }; + case "show": + return { + kind: "show", + ref: source.ref, + pathspecs: source.pathspecs, + options: source.options ?? {}, + }; + case "stash-show": + return { + kind: "stash-show", + ref: source.ref, + options: source.options ?? {}, + }; + case "diff": + return { + kind: "diff", + left: source.left, + right: source.right, + options: source.options ?? {}, + }; + case "patch": + return { + kind: "patch", + text: source.text, + file: source.file ?? source.label, + options: source.options ?? {}, + }; + case "difftool": + return { + kind: "difftool", + left: source.left, + right: source.right, + path: source.path, + options: source.options ?? {}, + }; + } +} + +function resolveEmbeddedCliInput(source: EmbeddedHunkSource, cwd: string) { + return resolveConfiguredCliInput(embeddedSourceToCliInput(source), { cwd }).input; +} + +function errorMessage(error: unknown) { + if (error instanceof Error && error.message) return error.message; + return String(error || "Failed to load Hunk."); +} + +class EmbeddedHunkSessionImpl implements EmbeddedHunkSessionHandle { + private listeners = new Set<() => void>(); + private disposed = false; + private snapshot: EmbeddedHunkSnapshot; + + readonly hostClient: HunkSessionBrokerClient; + + private constructor( + readonly cwd: string, + public source: EmbeddedHunkSource, + bootstrap: AppBootstrap, + ) { + this.snapshot = { status: "ready", bootstrap }; + this.hostClient = new SessionBrokerClient( + createSessionRegistration(bootstrap, { cwd }), + createInitialSessionSnapshot(bootstrap), + ); + this.hostClient.start(); + } + + static async create({ cwd, source }: { cwd: string; source: EmbeddedHunkSource }) { + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, cwd), { cwd }); + return new EmbeddedHunkSessionImpl(cwd, source, bootstrap); + } + + getSnapshot = () => this.snapshot; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async load(source: EmbeddedHunkSource) { + if (this.disposed) return; + this.setSnapshot({ status: "loading", bootstrap: this.snapshot.bootstrap }); + + try { + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, this.cwd), { + cwd: this.cwd, + }); + this.source = source; + this.hostClient.replaceSession( + updateSessionRegistration(this.hostClient.getRegistration(), bootstrap), + createInitialSessionSnapshot(bootstrap), + ); + this.setSnapshot({ status: "ready", bootstrap }); + } catch (error) { + const message = errorMessage(error); + this.setSnapshot({ + status: "error", + bootstrap: this.snapshot.bootstrap, + error: message, + }); + throw error instanceof Error ? error : new Error(message); + } + } + + dispose() { + this.disposed = true; + this.hostClient.stop(); + this.listeners.clear(); + } + + private setSnapshot(snapshot: EmbeddedHunkSnapshot) { + this.snapshot = snapshot; + for (const listener of this.listeners) listener(); + } +} + +/** Resolve the internal broker client owned by sessions created through this entrypoint. */ +function sessionHostClient(session: EmbeddedHunkSession) { + const hostClient = (session as Partial).hostClient; + if (!hostClient) { + throw new Error("mountEmbeddedHunkApp requires a session from createEmbeddedHunkSession."); + } + return hostClient; +} + +function EmbeddedHunkRoot({ + active, + onQuit, + session, +}: { + active: boolean; + onQuit: () => void; + session: EmbeddedHunkSession; +}) { + const snapshot = useSyncExternalStore( + session.subscribe, + session.getSnapshot, + session.getSnapshot, + ); + + if (snapshot.status === "loading" && !snapshot.bootstrap) { + return ( + + Loading Hunk... + + ); + } + + if (snapshot.status === "error" && !snapshot.bootstrap) { + return ( + + {`Hunk failed: ${snapshot.error}`} + + ); + } + + return ( + null} + /> + ); +} + +function scopedRenderer(renderer: CliRenderer, root: Renderable) { + const scoped = Object.create(renderer) as CliRenderer; + Object.defineProperty(scoped, "root", { value: root }); + return scoped; +} + +export async function createEmbeddedHunkSession({ + cwd = process.cwd(), + source, +}: { + cwd?: string; + source: EmbeddedHunkSource; +}): Promise { + return EmbeddedHunkSessionImpl.create({ cwd, source }); +} + +export function mountEmbeddedHunkApp({ + active, + container, + onQuit, + renderer, + session, +}: { + active: boolean; + container: Renderable; + onQuit: () => void; + renderer: CliRenderer; + session: EmbeddedHunkSession; +}): EmbeddedHunkMount { + const root = createRoot(scopedRenderer(renderer, container)); + + const render = (next: { active: boolean; onQuit: () => void }) => { + root.render(); + }; + + render({ active, onQuit }); + + return { + update: render, + unmount() { + root.unmount(); + }, + }; +} 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/ui/App.tsx b/src/ui/App.tsx index 1bc36ce0..a6c194ec 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -75,12 +75,14 @@ function withCurrentViewOptions( /** Orchestrate global app state, layout, navigation, and pane coordination. */ export function App({ + active = true, bootstrap, hostClient, noticeText, onQuit = () => process.exit(0), onReloadSession, }: { + active?: boolean; bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; noticeText?: string | null; @@ -629,6 +631,7 @@ export function App({ } = useMenuController(menus); useAppKeyboardShortcuts({ + active, activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, 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..1c2a05e1 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; @@ -14,11 +14,13 @@ import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; /** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ export function AppHost({ + active = true, bootstrap, hostClient, onQuit = () => process.exit(0), startupNoticeResolver, }: { + active?: boolean; bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; onQuit?: () => void; @@ -31,6 +33,10 @@ export function AppHost({ resolver: startupNoticeResolver, }); + useEffect(() => { + setActiveBootstrap(bootstrap); + }, [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 @@ -81,6 +87,7 @@ export function AppHost({ return ( void; canRefreshCurrentInput: boolean; @@ -77,6 +78,7 @@ export interface UseAppKeyboardShortcutsOptions { /** Register the app's scoped keyboard handling while keeping mode precedence explicit. */ export function useAppKeyboardShortcuts({ + active = true, activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, @@ -110,11 +112,13 @@ export function useAppKeyboardShortcuts({ triggerEditSelectedFile, triggerRefreshCurrentInput, }: UseAppKeyboardShortcutsOptions) { + const activeRef = useRef(active); const activeMenuIdRef = useRef(activeMenuId); const focusAreaRef = useRef(focusArea); const pagerModeRef = useRef(pagerMode); const showHelpRef = useRef(showHelp); + activeRef.current = active; activeMenuIdRef.current = activeMenuId; focusAreaRef.current = focusArea; pagerModeRef.current = pagerMode; @@ -487,6 +491,10 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { + if (!activeRef.current) { + return; + } + if (handleMenuToggleShortcut(key)) { return; } diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index f8c1b9ea..3b1ff431 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -394,7 +394,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 +453,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 { From 01eac6e021d5fba51d5f7aa815722b836a685e03 Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 01:23:24 -0400 Subject: [PATCH 2/8] feat: add embedded Hunk export for Opencode --- scripts/build-npm.ts | 78 ++++++++++++++--------------- src/embedded/embedded.test.ts | 72 ++------------------------- src/embedded/index.d.ts | 5 +- src/embedded/index.tsx | 93 +++++++---------------------------- src/ui/AppHost.tsx | 9 +++- 5 files changed, 68 insertions(+), 189 deletions(-) diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 2e95cb4c..7821b86c 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -9,6 +9,16 @@ const typesOutdir = path.join(repoRoot, "dist", "npm-types"); const opentuiOutdir = path.join(outdir, "opentui"); const opentuiTypesDir = path.join(typesOutdir, "opentui"); const embeddedOutdir = path.join(outdir, "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, @@ -30,6 +40,24 @@ function runBun(args: string[]) { } } +async function buildLibraryExport(name: string, entrypoint: string, outputDirectory: string) { + const build = await Bun.build({ + entrypoints: [entrypoint], + target: "node", + format: "esm", + outdir: outputDirectory, + naming: { entry: "index.js" }, + external: libraryExternals, + }); + + if (!build.success) { + for (const log of build.logs) { + console.error(log.message); + } + throw new Error(`Failed to build ${name} export.`); + } +} + rmSync(outdir, { recursive: true, force: true }); rmSync(typesOutdir, { recursive: true, force: true }); mkdirSync(opentuiOutdir, { recursive: true }); @@ -54,50 +82,16 @@ if (process.platform !== "win32") { chmodSync(mainJs, 0o755); } -runBun([ - "build", +await buildLibraryExport( + "OpenTUI", 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", -]); - -const embeddedBuild = await Bun.build({ - entrypoints: [path.join(repoRoot, "src", "embedded", "index.tsx")], - target: "node", - format: "esm", - outdir: embeddedOutdir, - naming: { entry: "index.js" }, - external: ["@opentui/core", "@opentui/react", "@pierre/diffs", "react", "react/jsx-runtime"], -}); - -if (!embeddedBuild.success) { - for (const log of embeddedBuild.logs) { - console.error(log.message); - } - throw new Error("Failed to build embedded Hunk export."); -} +); +await buildLibraryExport( + "embedded Hunk", + path.join(repoRoot, "src", "embedded", "index.tsx"), + embeddedOutdir, +); runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.opentui.json")]); diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts index 250926c2..6cbd81ab 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createEmbeddedHunkSession, embeddedSourceToCliInput } from "./index"; +import { createEmbeddedHunkSession } from "./index"; const patchText = [ "diff --git a/example.ts b/example.ts", @@ -14,72 +14,7 @@ const patchText = [ "", ].join("\n"); -describe("embeddedSourceToCliInput", () => { - test("maps embedded review sources to Hunk CLI inputs", () => { - expect(embeddedSourceToCliInput({ kind: "worktree", options: { theme: "midnight" } })).toEqual({ - kind: "vcs", - staged: false, - options: { theme: "midnight" }, - }); - expect(embeddedSourceToCliInput({ kind: "staged" })).toEqual({ - kind: "vcs", - staged: true, - options: {}, - }); - expect(embeddedSourceToCliInput({ kind: "show", ref: "HEAD~1" })).toEqual({ - kind: "show", - ref: "HEAD~1", - options: {}, - }); - expect( - embeddedSourceToCliInput({ - kind: "vcs", - range: "main", - staged: false, - pathspecs: ["src/app.ts"], - options: { vcs: "jj" }, - }), - ).toEqual({ - kind: "vcs", - range: "main", - staged: false, - pathspecs: ["src/app.ts"], - options: { vcs: "jj" }, - }); - expect( - embeddedSourceToCliInput({ kind: "diff", left: "before.ts", right: "after.ts" }), - ).toEqual({ - kind: "diff", - left: "before.ts", - right: "after.ts", - options: {}, - }); - expect(embeddedSourceToCliInput({ kind: "stash-show", ref: "stash@{1}" })).toEqual({ - kind: "stash-show", - ref: "stash@{1}", - options: {}, - }); - expect(embeddedSourceToCliInput({ kind: "patch", file: "changes.patch" })).toEqual({ - kind: "patch", - file: "changes.patch", - options: {}, - }); - expect( - embeddedSourceToCliInput({ - kind: "difftool", - left: "left.ts", - right: "right.ts", - path: "src/app.ts", - }), - ).toEqual({ - kind: "difftool", - left: "left.ts", - right: "right.ts", - path: "src/app.ts", - options: {}, - }); - }); - +describe("embedded Hunk sessions", () => { test("loads embedded sessions through Hunk config resolution", async () => { const root = mkdtempSync(join(tmpdir(), "hunk-embedded-config-")); const previousXdgConfigHome = process.env.XDG_CONFIG_HOME; @@ -131,7 +66,8 @@ describe("embeddedSourceToCliInput", () => { expect(session.source).toEqual(initialSource); const snapshot = session.getSnapshot(); expect(snapshot.status).toBe("error"); - expect(snapshot.bootstrap).toBeDefined(); + if (snapshot.status !== "error") throw new Error("Expected embedded reload to fail."); + expect(snapshot.error).toContain("missing.patch"); session.dispose(); } finally { diff --git a/src/embedded/index.d.ts b/src/embedded/index.d.ts index cf920500..113bc3a2 100644 --- a/src/embedded/index.d.ts +++ b/src/embedded/index.d.ts @@ -41,9 +41,9 @@ export type EmbeddedHunkSource = }; export type EmbeddedHunkSnapshot = - | { status: "loading"; bootstrap?: unknown; error?: undefined } + | { status: "loading"; bootstrap: unknown; error?: undefined } | { status: "ready"; bootstrap: unknown; error?: undefined } - | { status: "error"; bootstrap?: unknown; error: string }; + | { status: "error"; bootstrap: unknown; error: string }; export interface EmbeddedHunkSession { readonly cwd: string; @@ -59,7 +59,6 @@ export interface EmbeddedHunkMount { unmount(): void; } -export declare function embeddedSourceToCliInput(source: EmbeddedHunkSource): unknown; export declare function createEmbeddedHunkSession(input: { cwd?: string; source: EmbeddedHunkSource; diff --git a/src/embedded/index.tsx b/src/embedded/index.tsx index 1fc5073c..1eb9c511 100644 --- a/src/embedded/index.tsx +++ b/src/embedded/index.tsx @@ -30,9 +30,9 @@ export type EmbeddedHunkSource = | { kind: "difftool"; left: string; right: string; path?: string; options?: CommonOptions }; export type EmbeddedHunkSnapshot = - | { status: "loading"; bootstrap?: AppBootstrap; error?: undefined } + | { status: "loading"; bootstrap: AppBootstrap; error?: undefined } | { status: "ready"; bootstrap: AppBootstrap; error?: undefined } - | { status: "error"; bootstrap?: AppBootstrap; error: string }; + | { status: "error"; bootstrap: AppBootstrap; error: string }; export interface EmbeddedHunkSession { readonly cwd: string; @@ -43,74 +43,38 @@ export interface EmbeddedHunkSession { dispose(): void; } -interface EmbeddedHunkSessionHandle extends EmbeddedHunkSession { - readonly hostClient: HunkSessionBrokerClient; -} - export interface EmbeddedHunkMount { update(options: { active: boolean; onQuit: () => void }): void; unmount(): void; } -export function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { +function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { + const options = source.options ?? {}; + switch (source.kind) { case "worktree": return { kind: "vcs", staged: false, pathspecs: source.pathspecs, - options: source.options ?? {}, + options, }; case "staged": return { kind: "vcs", staged: true, pathspecs: source.pathspecs, - options: source.options ?? {}, - }; - case "vcs": - return { - kind: "vcs", - range: source.range, - staged: source.staged, - pathspecs: source.pathspecs, - options: source.options ?? {}, - }; - case "show": - return { - kind: "show", - ref: source.ref, - pathspecs: source.pathspecs, - options: source.options ?? {}, - }; - case "stash-show": - return { - kind: "stash-show", - ref: source.ref, - options: source.options ?? {}, - }; - case "diff": - return { - kind: "diff", - left: source.left, - right: source.right, - options: source.options ?? {}, + options, }; case "patch": return { kind: "patch", text: source.text, file: source.file ?? source.label, - options: source.options ?? {}, - }; - case "difftool": - return { - kind: "difftool", - left: source.left, - right: source.right, - path: source.path, - options: source.options ?? {}, + options, }; + default: + return { ...source, options } as CliInput; } } @@ -123,14 +87,14 @@ function errorMessage(error: unknown) { return String(error || "Failed to load Hunk."); } -class EmbeddedHunkSessionImpl implements EmbeddedHunkSessionHandle { +class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { private listeners = new Set<() => void>(); private disposed = false; private snapshot: EmbeddedHunkSnapshot; readonly hostClient: HunkSessionBrokerClient; - private constructor( + constructor( readonly cwd: string, public source: EmbeddedHunkSource, bootstrap: AppBootstrap, @@ -143,11 +107,6 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSessionHandle { this.hostClient.start(); } - static async create({ cwd, source }: { cwd: string; source: EmbeddedHunkSource }) { - const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, cwd), { cwd }); - return new EmbeddedHunkSessionImpl(cwd, source, bootstrap); - } - getSnapshot = () => this.snapshot; subscribe = (listener: () => void) => { @@ -194,11 +153,10 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSessionHandle { /** Resolve the internal broker client owned by sessions created through this entrypoint. */ function sessionHostClient(session: EmbeddedHunkSession) { - const hostClient = (session as Partial).hostClient; - if (!hostClient) { - throw new Error("mountEmbeddedHunkApp requires a session from createEmbeddedHunkSession."); + if (session instanceof EmbeddedHunkSessionImpl) { + return session.hostClient; } - return hostClient; + throw new Error("mountEmbeddedHunkApp requires a session from createEmbeddedHunkSession."); } function EmbeddedHunkRoot({ @@ -216,26 +174,10 @@ function EmbeddedHunkRoot({ session.getSnapshot, ); - if (snapshot.status === "loading" && !snapshot.bootstrap) { - return ( - - Loading Hunk... - - ); - } - - if (snapshot.status === "error" && !snapshot.bootstrap) { - return ( - - {`Hunk failed: ${snapshot.error}`} - - ); - } - return ( null} @@ -256,7 +198,8 @@ export async function createEmbeddedHunkSession({ cwd?: string; source: EmbeddedHunkSource; }): Promise { - return EmbeddedHunkSessionImpl.create({ cwd, source }); + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, cwd), { cwd }); + return new EmbeddedHunkSessionImpl(cwd, source, bootstrap); } export function mountEmbeddedHunkApp({ diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 1c2a05e1..37970796 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, 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"; @@ -28,13 +28,20 @@ export function AppHost({ }) { 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( From 7a4ec086e927f366e3397b9114a2bf0b4c52362a Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 02:31:26 -0400 Subject: [PATCH 3/8] refactor(embedded): split session lifecycle and npm exports --- scripts/build-npm.ts | 14 +- scripts/check-pack.ts | 2 + scripts/check-prebuilt-pack.ts | 2 + src/embedded/embedded.test.ts | 111 ++++++++- src/embedded/index.ts | 29 +++ src/embedded/index.tsx | 232 ------------------ src/embedded/mount.tsx | 142 +++++++++++ src/embedded/session.ts | 162 ++++++++++++ src/embedded/source.ts | 103 ++++++++ src/embedded/{index.d.ts => types.ts} | 48 ++-- ....opentui.json => tsconfig.npm-exports.json | 4 +- 11 files changed, 581 insertions(+), 268 deletions(-) create mode 100644 src/embedded/index.ts delete mode 100644 src/embedded/index.tsx create mode 100644 src/embedded/mount.tsx create mode 100644 src/embedded/session.ts create mode 100644 src/embedded/source.ts rename src/embedded/{index.d.ts => types.ts} (65%) rename tsconfig.opentui.json => tsconfig.npm-exports.json (70%) diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 7821b86c..c7c0c03d 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -7,8 +7,9 @@ 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", @@ -89,11 +90,11 @@ await buildLibraryExport( ); await buildLibraryExport( "embedded Hunk", - path.join(repoRoot, "src", "embedded", "index.tsx"), + path.join(repoRoot, "src", "embedded", "index.ts"), 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")) { @@ -101,10 +102,9 @@ for (const entry of readdirSync(opentuiTypesDir)) { } } -copyFileSync( - path.join(repoRoot, "src", "embedded", "index.d.ts"), - path.join(embeddedOutdir, "index.d.ts"), -); +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 }); 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/embedded.test.ts b/src/embedded/embedded.test.ts index 6cbd81ab..94f55579 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -1,8 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createEmbeddedHunkSession } from "./index"; +import { createScopedKeyInput } from "./mount"; +import { embeddedHunkSessionInternals } from "./session"; +import type { EmbeddedHunkSession } from "./types"; const patchText = [ "diff --git a/example.ts b/example.ts", @@ -14,7 +17,34 @@ const patchText = [ "", ].join("\n"); +let previousHunkMcpDisable: string | undefined; + +/** Return the private app bootstrap for assertions that public snapshots intentionally hide. */ +function renderBootstrap(session: EmbeddedHunkSession) { + return embeddedHunkSessionInternals(session).getRenderSnapshot().bootstrap; +} + +/** Return the loaded patch text for one embedded session. */ +function loadedPatch(session: EmbeddedHunkSession) { + return renderBootstrap(session) + .changeset.files.map((file) => file.patch) + .join("\n"); +} + 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; @@ -36,9 +66,14 @@ describe("embedded Hunk sessions", () => { expect(snapshot.status).toBe("ready"); if (snapshot.status !== "ready") throw new Error("Expected embedded session to load."); - expect(snapshot.bootstrap.initialMode).toBe("stack"); - expect(snapshot.bootstrap.initialShowLineNumbers).toBe(false); - expect(snapshot.bootstrap.initialTheme).toBe("paper"); + expect("bootstrap" in snapshot).toBe(false); + expect(snapshot.title).toBe("Patch review: stdin patch"); + expect(snapshot.fileCount).toBe(1); + + const bootstrap = renderBootstrap(session); + expect(bootstrap.initialMode).toBe("stack"); + expect(bootstrap.initialShowLineNumbers).toBe(false); + expect(bootstrap.initialTheme).toBe("paper"); session.dispose(); } finally { @@ -51,7 +86,37 @@ describe("embedded Hunk sessions", () => { } }); - test("keeps the previous source and reports errors when reload fails", async () => { + test("open reuses the loaded review when source identity has not changed", 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 } as const; + const session = await createEmbeddedHunkSession({ cwd: root, source }); + expect(loadedPatch(session)).toContain("first"); + + writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); + await session.open(source); + + expect(loadedPatch(session)).toContain("first"); + expect(loadedPatch(session)).not.toContain("second"); + + await session.reload(); + + expect(loadedPatch(session)).toContain("second"); + expect(loadedPatch(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 { @@ -61,17 +126,49 @@ describe("embedded Hunk sessions", () => { source: initialSource, }); - await expect(session.load({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); + await expect(session.open({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); - expect(session.source).toEqual(initialSource); + expect(session.source).toMatchObject(initialSource); const snapshot = session.getSnapshot(); expect(snapshot.status).toBe("error"); if (snapshot.status !== "error") throw new Error("Expected embedded reload to fail."); 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("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); + }); }); diff --git a/src/embedded/index.ts b/src/embedded/index.ts new file mode 100644 index 00000000..762ec3b5 --- /dev/null +++ b/src/embedded/index.ts @@ -0,0 +1,29 @@ +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 function createEmbeddedHunkSession( + input: CreateEmbeddedHunkSessionInput, +): Promise { + return createEmbeddedHunkSessionImpl(input); +} + +/** Mount one embedded Hunk app into a host-owned OpenTUI container. */ +export function mountEmbeddedHunkApp(input: MountEmbeddedHunkAppInput): EmbeddedHunkMount { + return mountEmbeddedHunkAppImpl(input); +} diff --git a/src/embedded/index.tsx b/src/embedded/index.tsx deleted file mode 100644 index 1eb9c511..00000000 --- a/src/embedded/index.tsx +++ /dev/null @@ -1,232 +0,0 @@ -import type { CliRenderer, Renderable } from "@opentui/core"; -import { createRoot } from "@opentui/react"; -import { useSyncExternalStore } from "react"; -import { resolveConfiguredCliInput } from "../core/config"; -import { loadAppBootstrap } from "../core/loaders"; -import type { AppBootstrap, CliInput, CommonOptions } from "../core/types"; -import { - createInitialSessionSnapshot, - createSessionRegistration, - updateSessionRegistration, -} from "../hunk-session/sessionRegistration"; -import type { HunkSessionBrokerClient } from "../hunk-session/types"; -import { SessionBrokerClient } from "../session-broker/brokerClient"; -import { AppHost } from "../ui/AppHost"; - -export type EmbeddedHunkSource = - | { kind: "worktree"; pathspecs?: string[]; options?: CommonOptions } - | { kind: "staged"; pathspecs?: string[]; options?: CommonOptions } - | { - kind: "vcs"; - range?: string; - staged: boolean; - pathspecs?: string[]; - options?: CommonOptions; - } - | { kind: "show"; ref?: string; pathspecs?: string[]; options?: CommonOptions } - | { kind: "stash-show"; ref?: string; options?: CommonOptions } - | { kind: "diff"; left: string; right: string; options?: CommonOptions } - | { kind: "patch"; file?: string; text?: string; label?: string; options?: CommonOptions } - | { kind: "difftool"; left: string; right: string; path?: string; options?: CommonOptions }; - -export type EmbeddedHunkSnapshot = - | { status: "loading"; bootstrap: AppBootstrap; error?: undefined } - | { status: "ready"; bootstrap: AppBootstrap; error?: undefined } - | { status: "error"; bootstrap: AppBootstrap; error: string }; - -export interface EmbeddedHunkSession { - readonly cwd: string; - readonly source: EmbeddedHunkSource; - getSnapshot(): EmbeddedHunkSnapshot; - load(source: EmbeddedHunkSource): Promise; - subscribe(listener: () => void): () => void; - dispose(): void; -} - -export interface EmbeddedHunkMount { - update(options: { active: boolean; onQuit: () => void }): void; - unmount(): void; -} - -function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { - const options = source.options ?? {}; - - switch (source.kind) { - case "worktree": - return { - kind: "vcs", - staged: false, - pathspecs: source.pathspecs, - options, - }; - case "staged": - return { - kind: "vcs", - staged: true, - pathspecs: source.pathspecs, - options, - }; - case "patch": - return { - kind: "patch", - text: source.text, - file: source.file ?? source.label, - options, - }; - default: - return { ...source, options } as CliInput; - } -} - -function resolveEmbeddedCliInput(source: EmbeddedHunkSource, cwd: string) { - return resolveConfiguredCliInput(embeddedSourceToCliInput(source), { cwd }).input; -} - -function errorMessage(error: unknown) { - if (error instanceof Error && error.message) return error.message; - return String(error || "Failed to load Hunk."); -} - -class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { - private listeners = new Set<() => void>(); - private disposed = false; - private snapshot: EmbeddedHunkSnapshot; - - readonly hostClient: HunkSessionBrokerClient; - - constructor( - readonly cwd: string, - public source: EmbeddedHunkSource, - bootstrap: AppBootstrap, - ) { - this.snapshot = { status: "ready", bootstrap }; - this.hostClient = new SessionBrokerClient( - createSessionRegistration(bootstrap, { cwd }), - createInitialSessionSnapshot(bootstrap), - ); - this.hostClient.start(); - } - - getSnapshot = () => this.snapshot; - - subscribe = (listener: () => void) => { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - }; - - async load(source: EmbeddedHunkSource) { - if (this.disposed) return; - this.setSnapshot({ status: "loading", bootstrap: this.snapshot.bootstrap }); - - try { - const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, this.cwd), { - cwd: this.cwd, - }); - this.source = source; - this.hostClient.replaceSession( - updateSessionRegistration(this.hostClient.getRegistration(), bootstrap), - createInitialSessionSnapshot(bootstrap), - ); - this.setSnapshot({ status: "ready", bootstrap }); - } catch (error) { - const message = errorMessage(error); - this.setSnapshot({ - status: "error", - bootstrap: this.snapshot.bootstrap, - error: message, - }); - throw error instanceof Error ? error : new Error(message); - } - } - - dispose() { - this.disposed = true; - this.hostClient.stop(); - this.listeners.clear(); - } - - private setSnapshot(snapshot: EmbeddedHunkSnapshot) { - this.snapshot = snapshot; - for (const listener of this.listeners) listener(); - } -} - -/** Resolve the internal broker client owned by sessions created through this entrypoint. */ -function sessionHostClient(session: EmbeddedHunkSession) { - if (session instanceof EmbeddedHunkSessionImpl) { - return session.hostClient; - } - throw new Error("mountEmbeddedHunkApp requires a session from createEmbeddedHunkSession."); -} - -function EmbeddedHunkRoot({ - active, - onQuit, - session, -}: { - active: boolean; - onQuit: () => void; - session: EmbeddedHunkSession; -}) { - const snapshot = useSyncExternalStore( - session.subscribe, - session.getSnapshot, - session.getSnapshot, - ); - - return ( - null} - /> - ); -} - -function scopedRenderer(renderer: CliRenderer, root: Renderable) { - const scoped = Object.create(renderer) as CliRenderer; - Object.defineProperty(scoped, "root", { value: root }); - return scoped; -} - -export async function createEmbeddedHunkSession({ - cwd = process.cwd(), - source, -}: { - cwd?: string; - source: EmbeddedHunkSource; -}): Promise { - const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, cwd), { cwd }); - return new EmbeddedHunkSessionImpl(cwd, source, bootstrap); -} - -export function mountEmbeddedHunkApp({ - active, - container, - onQuit, - renderer, - session, -}: { - active: boolean; - container: Renderable; - onQuit: () => void; - renderer: CliRenderer; - session: EmbeddedHunkSession; -}): EmbeddedHunkMount { - const root = createRoot(scopedRenderer(renderer, container)); - - const render = (next: { active: boolean; onQuit: () => void }) => { - root.render(); - }; - - render({ active, onQuit }); - - return { - update: render, - unmount() { - root.unmount(); - }, - }; -} diff --git a/src/embedded/mount.tsx b/src/embedded/mount.tsx new file mode 100644 index 00000000..22897f6b --- /dev/null +++ b/src/embedded/mount.tsx @@ -0,0 +1,142 @@ +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; +}>; + +/** Return whether one renderer input event should be visibility-scoped for embedded Hunk. */ +function isScopedKeyInputEvent(event: string): event is ScopedKeyInputEvent { + return scopedKeyInputEvents.includes(event as ScopedKeyInputEvent); +} + +/** 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 (!isScopedKeyInputEvent(event)) { + source.on(event, listener); + return scoped; + } + + const eventListeners = listeners.get(event)!; + if (eventListeners.size === 0) source.on(event, forwarders.get(event)!); + eventListeners.add(listener); + return scoped; + }, + off(event: string, listener: KeyInputListener) { + if (!isScopedKeyInputEvent(event)) { + source.off(event, listener); + return scoped; + } + + const eventListeners = listeners.get(event)!; + eventListeners.delete(listener); + if (eventListeners.size === 0) source.off(event, forwarders.get(event)!); + 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 the renderer root and input stream to the host-provided embedded container. */ +function scopedRenderer( + renderer: CliRenderer, + root: Renderable, + keyInput: CliRenderer["keyInput"], +) { + const scoped = Object.create(renderer) as CliRenderer; + Object.defineProperty(scoped, "root", { value: root }); + Object.defineProperty(scoped, "keyInput", { value: keyInput }); + Object.defineProperty(scoped, "intermediateRender", { + value() { + if (!renderer.isDestroyed) renderer.requestRender(); + }, + }); + return scoped; +} + +function EmbeddedHunkRoot({ + active, + onQuit, + session, +}: { + active: boolean; + 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 root = createRoot(scopedRenderer(renderer, container, scopedKeyInput.keyInput)); + + const render = (next: { active: boolean; onQuit: () => void }) => { + currentActive = next.active; + root.render(); + }; + + render({ active, onQuit }); + + return { + update: render, + unmount() { + root.unmount(); + scopedKeyInput.dispose(); + }, + }; +} diff --git a/src/embedded/session.ts b/src/embedded/session.ts new file mode 100644 index 00000000..acd2fd4c --- /dev/null +++ b/src/embedded/session.ts @@ -0,0 +1,162 @@ +import { loadAppBootstrap } from "../core/loaders"; +import type { AppBootstrap } from "../core/types"; +import { + createInitialSessionSnapshot, + createSessionRegistration, + updateSessionRegistration, +} from "../hunk-session/sessionRegistration"; +import type { HunkSessionBrokerClient } from "../hunk-session/types"; +import { SessionBrokerClient } from "../session-broker/brokerClient"; +import { + embeddedHunkSourcesEqual, + normalizeEmbeddedHunkSource, + resolveEmbeddedCliInput, +} from "./source"; +import type { + CreateEmbeddedHunkSessionInput, + EmbeddedHunkSession, + EmbeddedHunkSnapshot, + EmbeddedHunkSource, +} from "./types"; + +export type EmbeddedHunkRenderSnapshot = + | { status: "loading"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error?: undefined } + | { status: "ready"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error?: undefined } + | { status: "error"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error: string }; + +/** Convert unknown thrown values into stable user-facing error text. */ +function errorMessage(error: unknown) { + if (error instanceof Error && error.message) return error.message; + return String(error || "Failed to load Hunk."); +} + +/** Build the host-facing embedded snapshot without exposing app bootstrap internals. */ +function publicSnapshot(snapshot: EmbeddedHunkRenderSnapshot): EmbeddedHunkSnapshot { + switch (snapshot.status) { + case "loading": + return { status: "loading", source: snapshot.source }; + case "ready": + return { + status: "ready", + source: snapshot.source, + title: snapshot.bootstrap.changeset.title, + fileCount: snapshot.bootstrap.changeset.files.length, + }; + case "error": + return { + status: "error", + source: snapshot.source, + title: snapshot.bootstrap.changeset.title, + fileCount: snapshot.bootstrap.changeset.files.length, + error: snapshot.error, + }; + } +} + +/** 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 snapshot: EmbeddedHunkSnapshot; + + readonly hostClient: HunkSessionBrokerClient; + + constructor( + readonly cwd: string, + public source: EmbeddedHunkSource, + bootstrap: AppBootstrap, + ) { + this.renderSnapshot = { status: "ready", source, bootstrap }; + this.snapshot = publicSnapshot(this.renderSnapshot); + this.hostClient = new SessionBrokerClient( + createSessionRegistration(bootstrap, { cwd }), + createInitialSessionSnapshot(bootstrap), + ); + this.hostClient.start(); + } + + getSnapshot = () => this.snapshot; + + getRenderSnapshot = () => this.renderSnapshot; + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + async open(source: EmbeddedHunkSource) { + const nextSource = normalizeEmbeddedHunkSource(source); + if (this.disposed || embeddedHunkSourcesEqual(this.source, nextSource)) return; + await this.load(nextSource, { updateSource: true }); + } + + async reload() { + if (this.disposed) return; + await this.load(this.source, { updateSource: false }); + } + + dispose() { + this.disposed = true; + this.hostClient.stop(); + this.listeners.clear(); + } + + private async load(source: EmbeddedHunkSource, { updateSource }: { updateSource: boolean }) { + this.setRenderSnapshot({ + status: "loading", + source: this.source, + bootstrap: this.renderSnapshot.bootstrap, + }); + + try { + const bootstrap = await loadAppBootstrap(resolveEmbeddedCliInput(source, this.cwd), { + cwd: this.cwd, + }); + if (updateSource) { + this.source = source; + } + this.hostClient.replaceSession( + updateSessionRegistration(this.hostClient.getRegistration(), bootstrap), + createInitialSessionSnapshot(bootstrap), + ); + this.setRenderSnapshot({ status: "ready", source: this.source, bootstrap }); + } catch (error) { + const message = errorMessage(error); + this.setRenderSnapshot({ + status: "error", + source: this.source, + bootstrap: this.renderSnapshot.bootstrap, + error: message, + }); + throw error instanceof Error ? error : new Error(message); + } + } + + private setRenderSnapshot(snapshot: EmbeddedHunkRenderSnapshot) { + this.renderSnapshot = snapshot; + this.snapshot = publicSnapshot(snapshot); + for (const listener of this.listeners) listener(); + } +} + +/** Resolve private render state for sessions created by this embedded entrypoint. */ +export function embeddedHunkSessionInternals(session: EmbeddedHunkSession) { + if (session instanceof EmbeddedHunkSessionImpl) { + return { + getRenderSnapshot: session.getRenderSnapshot, + 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/source.ts b/src/embedded/source.ts new file mode 100644 index 00000000..cc423ebc --- /dev/null +++ b/src/embedded/source.ts @@ -0,0 +1,103 @@ +import { isDeepStrictEqual } from "node:util"; +import { resolveConfiguredCliInput } from "../core/config"; +import type { CliInput, CommonOptions } from "../core/types"; +import type { EmbeddedHunkOptions, EmbeddedHunkSource } from "./types"; + +/** Return a defensive copy of embedded options. */ +function normalizeOptions(options: EmbeddedHunkOptions | undefined): CommonOptions { + return options ? { ...options } : {}; +} + +/** Return a copy of optional pathspecs so source identity cannot be mutated externally. */ +function normalizePathspecs(pathspecs: string[] | undefined) { + return pathspecs ? [...pathspecs] : undefined; +} + +/** Normalize one embedded source into the canonical shape Hunk stores on a session. */ +export function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): EmbeddedHunkSource { + const options = normalizeOptions(source.options); + + switch (source.kind) { + case "worktree": + return { kind: "worktree", pathspecs: normalizePathspecs(source.pathspecs), options }; + case "staged": + return { kind: "staged", pathspecs: normalizePathspecs(source.pathspecs), options }; + case "vcs": + return { + kind: "vcs", + range: source.range, + staged: source.staged, + pathspecs: normalizePathspecs(source.pathspecs), + options, + }; + case "show": + return { + kind: "show", + ref: source.ref, + pathspecs: normalizePathspecs(source.pathspecs), + options, + }; + case "stash-show": + return { kind: "stash-show", ref: source.ref, options }; + case "diff": + return { kind: "diff", left: source.left, right: source.right, options }; + case "patch": + return { + kind: "patch", + file: source.file, + text: source.text, + label: source.label, + options, + }; + case "difftool": + return { + kind: "difftool", + left: source.left, + right: source.right, + path: source.path, + options, + }; + } +} + +/** Adapt a public embedded source into the internal CLI input pipeline. */ +export function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { + const normalized = normalizeEmbeddedHunkSource(source); + const options = normalized.options ?? {}; + + switch (normalized.kind) { + case "worktree": + return { + kind: "vcs", + staged: false, + pathspecs: normalized.pathspecs, + options, + }; + case "staged": + return { + kind: "vcs", + staged: true, + pathspecs: normalized.pathspecs, + options, + }; + case "patch": + return { + kind: "patch", + text: normalized.text, + file: normalized.file ?? normalized.label, + options, + }; + default: + return { ...normalized, options } as CliInput; + } +} + +/** Resolve embedded input through the same config layers as the CLI. */ +export function resolveEmbeddedCliInput(source: EmbeddedHunkSource, cwd: string) { + return resolveConfiguredCliInput(embeddedSourceToCliInput(source), { cwd }).input; +} + +/** Return whether two embedded sources resolve to the same review identity. */ +export function embeddedHunkSourcesEqual(left: EmbeddedHunkSource, right: EmbeddedHunkSource) { + return isDeepStrictEqual(normalizeEmbeddedHunkSource(left), normalizeEmbeddedHunkSource(right)); +} diff --git a/src/embedded/index.d.ts b/src/embedded/types.ts similarity index 65% rename from src/embedded/index.d.ts rename to src/embedded/types.ts index 113bc3a2..be768758 100644 --- a/src/embedded/index.d.ts +++ b/src/embedded/types.ts @@ -25,31 +25,38 @@ export type EmbeddedHunkSource = | { 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 = | { - kind: "patch"; - file?: string; - text?: string; - label?: string; - options?: EmbeddedHunkOptions; + status: "loading"; + source: EmbeddedHunkSource; + error?: undefined; + fileCount?: undefined; + title?: undefined; } | { - kind: "difftool"; - left: string; - right: string; - path?: string; - options?: EmbeddedHunkOptions; + status: "ready"; + source: EmbeddedHunkSource; + error?: undefined; + fileCount: number; + title: string; + } + | { + status: "error"; + source: EmbeddedHunkSource; + error: string; + fileCount: number; + title: string; }; -export type EmbeddedHunkSnapshot = - | { status: "loading"; bootstrap: unknown; error?: undefined } - | { status: "ready"; bootstrap: unknown; error?: undefined } - | { status: "error"; bootstrap: unknown; error: string }; - export interface EmbeddedHunkSession { readonly cwd: string; readonly source: EmbeddedHunkSource; getSnapshot(): EmbeddedHunkSnapshot; - load(source: EmbeddedHunkSource): Promise; + open(source: EmbeddedHunkSource): Promise; + reload(): Promise; subscribe(listener: () => void): () => void; dispose(): void; } @@ -59,14 +66,15 @@ export interface EmbeddedHunkMount { unmount(): void; } -export declare function createEmbeddedHunkSession(input: { +export interface CreateEmbeddedHunkSessionInput { cwd?: string; source: EmbeddedHunkSource; -}): Promise; -export declare function mountEmbeddedHunkApp(input: { +} + +export interface MountEmbeddedHunkAppInput { active: boolean; container: Renderable; onQuit: () => void; renderer: CliRenderer; session: EmbeddedHunkSession; -}): EmbeddedHunkMount; +} 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"] } From 6b9750b422a79a2b78d1889fbab56b783ef08563 Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 02:59:41 -0400 Subject: [PATCH 4/8] refactor: refine embedded Hunk exports and input scoping --- src/embedded/index.ts | 11 +-- src/embedded/mount.tsx | 24 ++---- src/embedded/session.ts | 106 ++++++++++++++++-------- src/embedded/source.ts | 103 ----------------------- src/embedded/types.ts | 24 +----- src/ui/App.tsx | 3 - src/ui/AppHost.tsx | 3 - src/ui/hooks/useAppKeyboardShortcuts.ts | 8 -- 8 files changed, 88 insertions(+), 194 deletions(-) delete mode 100644 src/embedded/source.ts diff --git a/src/embedded/index.ts b/src/embedded/index.ts index 762ec3b5..a5cbab7b 100644 --- a/src/embedded/index.ts +++ b/src/embedded/index.ts @@ -17,13 +17,10 @@ import type { } from "./types"; /** Create one embedded Hunk review session from a public embedded source. */ -export function createEmbeddedHunkSession( +export const createEmbeddedHunkSession = ( input: CreateEmbeddedHunkSessionInput, -): Promise { - return createEmbeddedHunkSessionImpl(input); -} +): Promise => createEmbeddedHunkSessionImpl(input); /** Mount one embedded Hunk app into a host-owned OpenTUI container. */ -export function mountEmbeddedHunkApp(input: MountEmbeddedHunkAppInput): EmbeddedHunkMount { - return mountEmbeddedHunkAppImpl(input); -} +export const mountEmbeddedHunkApp = (input: MountEmbeddedHunkAppInput): EmbeddedHunkMount => + mountEmbeddedHunkAppImpl(input); diff --git a/src/embedded/mount.tsx b/src/embedded/mount.tsx index 22897f6b..79afed03 100644 --- a/src/embedded/mount.tsx +++ b/src/embedded/mount.tsx @@ -14,11 +14,6 @@ type KeyInputSource = Readonly<{ off: (event: string, listener: KeyInputListener) => unknown; }>; -/** Return whether one renderer input event should be visibility-scoped for embedded Hunk. */ -function isScopedKeyInputEvent(event: string): event is ScopedKeyInputEvent { - return scopedKeyInputEvents.includes(event as ScopedKeyInputEvent); -} - /** 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>(); @@ -34,25 +29,27 @@ export function createScopedKeyInput(source: KeyInputSource, enabled: () => bool const scoped = { on(event: string, listener: KeyInputListener) { - if (!isScopedKeyInputEvent(event)) { + if (!scopedKeyInputEvents.includes(event as ScopedKeyInputEvent)) { source.on(event, listener); return scoped; } - const eventListeners = listeners.get(event)!; - if (eventListeners.size === 0) source.on(event, forwarders.get(event)!); + 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 (!isScopedKeyInputEvent(event)) { + if (!scopedKeyInputEvents.includes(event as ScopedKeyInputEvent)) { source.off(event, listener); return scoped; } - const eventListeners = listeners.get(event)!; + const scopedEvent = event as ScopedKeyInputEvent; + const eventListeners = listeners.get(scopedEvent)!; eventListeners.delete(listener); - if (eventListeners.size === 0) source.off(event, forwarders.get(event)!); + if (eventListeners.size === 0) source.off(scopedEvent, forwarders.get(scopedEvent)!); return scoped; }, }; @@ -87,11 +84,9 @@ function scopedRenderer( } function EmbeddedHunkRoot({ - active, onQuit, session, }: { - active: boolean; onQuit: () => void; session: EmbeddedHunkSession; }) { @@ -104,7 +99,6 @@ function EmbeddedHunkRoot({ return ( void }) => { currentActive = next.active; - root.render(); + root.render(); }; render({ active, onQuit }); diff --git a/src/embedded/session.ts b/src/embedded/session.ts index acd2fd4c..6744b57e 100644 --- a/src/embedded/session.ts +++ b/src/embedded/session.ts @@ -1,5 +1,7 @@ +import { isDeepStrictEqual } from "node:util"; +import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; -import type { AppBootstrap } from "../core/types"; +import type { AppBootstrap, CliInput, CommonOptions } from "../core/types"; import { createInitialSessionSnapshot, createSessionRegistration, @@ -7,11 +9,6 @@ import { } from "../hunk-session/sessionRegistration"; import type { HunkSessionBrokerClient } from "../hunk-session/types"; import { SessionBrokerClient } from "../session-broker/brokerClient"; -import { - embeddedHunkSourcesEqual, - normalizeEmbeddedHunkSource, - resolveEmbeddedCliInput, -} from "./source"; import type { CreateEmbeddedHunkSessionInput, EmbeddedHunkSession, @@ -20,9 +17,11 @@ import type { } from "./types"; export type EmbeddedHunkRenderSnapshot = - | { status: "loading"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error?: undefined } - | { status: "ready"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error?: undefined } - | { status: "error"; source: EmbeddedHunkSource; bootstrap: AppBootstrap; error: string }; + | { status: "loading"; bootstrap: AppBootstrap; error?: undefined } + | { status: "ready"; bootstrap: AppBootstrap; error?: undefined } + | { status: "error"; bootstrap: AppBootstrap; error: string }; + +type NormalizedEmbeddedHunkSource = EmbeddedHunkSource & { options: CommonOptions }; /** Convert unknown thrown values into stable user-facing error text. */ function errorMessage(error: unknown) { @@ -30,35 +29,76 @@ function errorMessage(error: unknown) { return String(error || "Failed to load Hunk."); } -/** Build the host-facing embedded snapshot without exposing app bootstrap internals. */ -function publicSnapshot(snapshot: EmbeddedHunkRenderSnapshot): EmbeddedHunkSnapshot { - switch (snapshot.status) { - case "loading": - return { status: "loading", source: snapshot.source }; - case "ready": +/** Return a session-owned source copy with normalized options and pathspec identity. */ +function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): NormalizedEmbeddedHunkSource { + const pathspecs = "pathspecs" in source ? source.pathspecs : undefined; + return { + ...source, + ...(pathspecs ? { pathspecs: [...pathspecs] } : {}), + options: { ...(source.options ?? {}) }, + } as NormalizedEmbeddedHunkSource; +} + +/** 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 { - status: "ready", - source: snapshot.source, - title: snapshot.bootstrap.changeset.title, - fileCount: snapshot.bootstrap.changeset.files.length, + kind: "vcs", + staged: false, + pathspecs: normalized.pathspecs, + options: normalized.options, }; - case "error": + case "staged": return { - status: "error", - source: snapshot.source, - title: snapshot.bootstrap.changeset.title, - fileCount: snapshot.bootstrap.changeset.files.length, - error: snapshot.error, + 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" }; +} + /** 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 snapshot: EmbeddedHunkSnapshot; readonly hostClient: HunkSessionBrokerClient; @@ -67,8 +107,7 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { public source: EmbeddedHunkSource, bootstrap: AppBootstrap, ) { - this.renderSnapshot = { status: "ready", source, bootstrap }; - this.snapshot = publicSnapshot(this.renderSnapshot); + this.renderSnapshot = { status: "ready", bootstrap }; this.hostClient = new SessionBrokerClient( createSessionRegistration(bootstrap, { cwd }), createInitialSessionSnapshot(bootstrap), @@ -76,7 +115,7 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { this.hostClient.start(); } - getSnapshot = () => this.snapshot; + getSnapshot = () => publicSnapshot(this.source, this.renderSnapshot); getRenderSnapshot = () => this.renderSnapshot; @@ -87,7 +126,9 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { async open(source: EmbeddedHunkSource) { const nextSource = normalizeEmbeddedHunkSource(source); - if (this.disposed || embeddedHunkSourcesEqual(this.source, nextSource)) return; + if (this.disposed || isDeepStrictEqual(normalizeEmbeddedHunkSource(this.source), nextSource)) { + return; + } await this.load(nextSource, { updateSource: true }); } @@ -105,7 +146,6 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { private async load(source: EmbeddedHunkSource, { updateSource }: { updateSource: boolean }) { this.setRenderSnapshot({ status: "loading", - source: this.source, bootstrap: this.renderSnapshot.bootstrap, }); @@ -120,12 +160,11 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { updateSessionRegistration(this.hostClient.getRegistration(), bootstrap), createInitialSessionSnapshot(bootstrap), ); - this.setRenderSnapshot({ status: "ready", source: this.source, bootstrap }); + this.setRenderSnapshot({ status: "ready", bootstrap }); } catch (error) { const message = errorMessage(error); this.setRenderSnapshot({ status: "error", - source: this.source, bootstrap: this.renderSnapshot.bootstrap, error: message, }); @@ -135,7 +174,6 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { private setRenderSnapshot(snapshot: EmbeddedHunkRenderSnapshot) { this.renderSnapshot = snapshot; - this.snapshot = publicSnapshot(snapshot); for (const listener of this.listeners) listener(); } } diff --git a/src/embedded/source.ts b/src/embedded/source.ts deleted file mode 100644 index cc423ebc..00000000 --- a/src/embedded/source.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; -import { resolveConfiguredCliInput } from "../core/config"; -import type { CliInput, CommonOptions } from "../core/types"; -import type { EmbeddedHunkOptions, EmbeddedHunkSource } from "./types"; - -/** Return a defensive copy of embedded options. */ -function normalizeOptions(options: EmbeddedHunkOptions | undefined): CommonOptions { - return options ? { ...options } : {}; -} - -/** Return a copy of optional pathspecs so source identity cannot be mutated externally. */ -function normalizePathspecs(pathspecs: string[] | undefined) { - return pathspecs ? [...pathspecs] : undefined; -} - -/** Normalize one embedded source into the canonical shape Hunk stores on a session. */ -export function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): EmbeddedHunkSource { - const options = normalizeOptions(source.options); - - switch (source.kind) { - case "worktree": - return { kind: "worktree", pathspecs: normalizePathspecs(source.pathspecs), options }; - case "staged": - return { kind: "staged", pathspecs: normalizePathspecs(source.pathspecs), options }; - case "vcs": - return { - kind: "vcs", - range: source.range, - staged: source.staged, - pathspecs: normalizePathspecs(source.pathspecs), - options, - }; - case "show": - return { - kind: "show", - ref: source.ref, - pathspecs: normalizePathspecs(source.pathspecs), - options, - }; - case "stash-show": - return { kind: "stash-show", ref: source.ref, options }; - case "diff": - return { kind: "diff", left: source.left, right: source.right, options }; - case "patch": - return { - kind: "patch", - file: source.file, - text: source.text, - label: source.label, - options, - }; - case "difftool": - return { - kind: "difftool", - left: source.left, - right: source.right, - path: source.path, - options, - }; - } -} - -/** Adapt a public embedded source into the internal CLI input pipeline. */ -export function embeddedSourceToCliInput(source: EmbeddedHunkSource): CliInput { - const normalized = normalizeEmbeddedHunkSource(source); - const options = normalized.options ?? {}; - - switch (normalized.kind) { - case "worktree": - return { - kind: "vcs", - staged: false, - pathspecs: normalized.pathspecs, - options, - }; - case "staged": - return { - kind: "vcs", - staged: true, - pathspecs: normalized.pathspecs, - options, - }; - case "patch": - return { - kind: "patch", - text: normalized.text, - file: normalized.file ?? normalized.label, - options, - }; - default: - return { ...normalized, options } as CliInput; - } -} - -/** Resolve embedded input through the same config layers as the CLI. */ -export function resolveEmbeddedCliInput(source: EmbeddedHunkSource, cwd: string) { - return resolveConfiguredCliInput(embeddedSourceToCliInput(source), { cwd }).input; -} - -/** Return whether two embedded sources resolve to the same review identity. */ -export function embeddedHunkSourcesEqual(left: EmbeddedHunkSource, right: EmbeddedHunkSource) { - return isDeepStrictEqual(normalizeEmbeddedHunkSource(left), normalizeEmbeddedHunkSource(right)); -} diff --git a/src/embedded/types.ts b/src/embedded/types.ts index be768758..21a8dbac 100644 --- a/src/embedded/types.ts +++ b/src/embedded/types.ts @@ -29,27 +29,9 @@ export type EmbeddedHunkSource = | { kind: "difftool"; left: string; right: string; path?: string; options?: EmbeddedHunkOptions }; export type EmbeddedHunkSnapshot = - | { - status: "loading"; - source: EmbeddedHunkSource; - error?: undefined; - fileCount?: undefined; - title?: undefined; - } - | { - status: "ready"; - source: EmbeddedHunkSource; - error?: undefined; - fileCount: number; - title: string; - } - | { - status: "error"; - source: EmbeddedHunkSource; - error: string; - fileCount: number; - title: string; - }; + | { 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; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a6c194ec..1bc36ce0 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -75,14 +75,12 @@ function withCurrentViewOptions( /** Orchestrate global app state, layout, navigation, and pane coordination. */ export function App({ - active = true, bootstrap, hostClient, noticeText, onQuit = () => process.exit(0), onReloadSession, }: { - active?: boolean; bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; noticeText?: string | null; @@ -631,7 +629,6 @@ export function App({ } = useMenuController(menus); useAppKeyboardShortcuts({ - active, activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 37970796..ed4fe1ad 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -14,13 +14,11 @@ import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; /** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ export function AppHost({ - active = true, bootstrap, hostClient, onQuit = () => process.exit(0), startupNoticeResolver, }: { - active?: boolean; bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; onQuit?: () => void; @@ -94,7 +92,6 @@ export function AppHost({ return ( void; canRefreshCurrentInput: boolean; @@ -78,7 +77,6 @@ export interface UseAppKeyboardShortcutsOptions { /** Register the app's scoped keyboard handling while keeping mode precedence explicit. */ export function useAppKeyboardShortcuts({ - active = true, activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, @@ -112,13 +110,11 @@ export function useAppKeyboardShortcuts({ triggerEditSelectedFile, triggerRefreshCurrentInput, }: UseAppKeyboardShortcutsOptions) { - const activeRef = useRef(active); const activeMenuIdRef = useRef(activeMenuId); const focusAreaRef = useRef(focusArea); const pagerModeRef = useRef(pagerMode); const showHelpRef = useRef(showHelp); - activeRef.current = active; activeMenuIdRef.current = activeMenuId; focusAreaRef.current = focusArea; pagerModeRef.current = pagerMode; @@ -491,10 +487,6 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { - if (!activeRef.current) { - return; - } - if (handleMenuToggleShortcut(key)) { return; } From d61aa0f26438c09d315763b252351b3039eba89c Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 03:39:47 -0400 Subject: [PATCH 5/8] feat(embedded): launch broker from bundled Hunk binary Add an embedded session broker availability adapter so embedded sessions start the daemon through the package-provided Hunk CLI. Return snapshots from embedded session open and reload calls so callers can observe reused or refreshed review state directly. --- src/embedded/daemon.test.ts | 36 ++++++++++++++++++++ src/embedded/daemon.ts | 45 +++++++++++++++++++++++++ src/embedded/embedded.test.ts | 10 ++++-- src/embedded/session.ts | 22 ++++++++---- src/embedded/types.ts | 15 +++++++-- src/session-broker/brokerClient.test.ts | 28 +++++++++++++++ src/session-broker/brokerClient.ts | 25 +++++++++----- 7 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 src/embedded/daemon.test.ts create mode 100644 src/embedded/daemon.ts diff --git a/src/embedded/daemon.test.ts b/src/embedded/daemon.test.ts new file mode 100644 index 00000000..5820123f --- /dev/null +++ b/src/embedded/daemon.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from "bun:test"; +import { createEmbeddedSessionBrokerAvailability } from "./daemon"; +import type { EnsureSessionBrokerAvailableOptions } from "../session-broker/brokerLauncher"; + +const testConfig = { + host: "127.0.0.1", + port: 47657, + httpOrigin: "http://127.0.0.1:47657", + wsOrigin: "ws://127.0.0.1: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", + timeoutMs: 1234, + ensureAvailable: async (options) => { + captured = options; + }, + }); + + await ensureBroker(testConfig); + + expect(captured).toEqual({ + argv: ["/deps/hunkdiff/bin/hunk.cjs"], + config: testConfig, + cwd: "/repo", + env: { HUNK_MCP_PORT: "48658" }, + execPath: "/deps/hunkdiff/bin/hunk.cjs", + timeoutMs: 1234, + }); + }); +}); diff --git a/src/embedded/daemon.ts b/src/embedded/daemon.ts new file mode 100644 index 00000000..c3c23e26 --- /dev/null +++ b/src/embedded/daemon.ts @@ -0,0 +1,45 @@ +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); + +type EmbeddedEnsureSessionBroker = typeof ensureSessionBrokerAvailable; + +export interface EmbeddedSessionBrokerAvailabilityOptions { + cwd: string; + env?: NodeJS.ProcessEnv; + ensureAvailable?: EmbeddedEnsureSessionBroker; + hunkCliPath?: 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"), + timeoutMs, +}: EmbeddedSessionBrokerAvailabilityOptions): EnsureSessionBrokerAdapter { + return (config: ResolvedSessionBrokerConfig) => { + const options: EnsureSessionBrokerAvailableOptions = { + argv: [hunkCliPath], + config, + cwd, + env, + execPath: hunkCliPath, + }; + + if (timeoutMs !== undefined) { + options.timeoutMs = timeoutMs; + } + + return ensureAvailable(options); + }; +} diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts index 94f55579..eeef8d31 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -100,13 +100,19 @@ describe("embedded Hunk sessions", () => { expect(loadedPatch(session)).toContain("first"); writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); - await session.open(source); + const reusedSnapshot = await session.open(source); + expect(reusedSnapshot.status).toBe("ready"); + if (reusedSnapshot.status !== "ready") throw new Error("Expected reused snapshot."); + expect(reusedSnapshot.source).toEqual(session.source); expect(loadedPatch(session)).toContain("first"); expect(loadedPatch(session)).not.toContain("second"); - await session.reload(); + const reloadedSnapshot = await session.reload(); + expect(reloadedSnapshot.status).toBe("ready"); + if (reloadedSnapshot.status !== "ready") throw new Error("Expected reloaded snapshot."); + expect(reloadedSnapshot.source).toEqual(session.source); expect(loadedPatch(session)).toContain("second"); expect(loadedPatch(session)).not.toContain("first"); diff --git a/src/embedded/session.ts b/src/embedded/session.ts index 6744b57e..14fd9ad1 100644 --- a/src/embedded/session.ts +++ b/src/embedded/session.ts @@ -9,6 +9,7 @@ import { } from "../hunk-session/sessionRegistration"; import type { HunkSessionBrokerClient } from "../hunk-session/types"; import { SessionBrokerClient } from "../session-broker/brokerClient"; +import { createEmbeddedSessionBrokerAvailability } from "./daemon"; import type { CreateEmbeddedHunkSessionInput, EmbeddedHunkSession, @@ -35,7 +36,7 @@ function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): NormalizedEmbe return { ...source, ...(pathspecs ? { pathspecs: [...pathspecs] } : {}), - options: { ...(source.options ?? {}) }, + options: { ...source.options }, } as NormalizedEmbeddedHunkSource; } @@ -111,6 +112,9 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { this.hostClient = new SessionBrokerClient( createSessionRegistration(bootstrap, { cwd }), createInitialSessionSnapshot(bootstrap), + { + ensureBrokerAvailable: createEmbeddedSessionBrokerAvailability({ cwd }), + }, ); this.hostClient.start(); } @@ -124,17 +128,19 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { 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; + return this.getSnapshot(); } - await this.load(nextSource, { updateSource: true }); + return this.load(nextSource, { updateSource: true }); } + /** Reload the currently loaded source, preserving source identity for the host. */ async reload() { - if (this.disposed) return; - await this.load(this.source, { updateSource: false }); + if (this.disposed) return this.getSnapshot(); + return this.load(this.source, { updateSource: false }); } dispose() { @@ -143,7 +149,10 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { this.listeners.clear(); } - private async load(source: EmbeddedHunkSource, { updateSource }: { updateSource: boolean }) { + private async load( + source: EmbeddedHunkSource, + { updateSource }: { updateSource: boolean }, + ): Promise { this.setRenderSnapshot({ status: "loading", bootstrap: this.renderSnapshot.bootstrap, @@ -161,6 +170,7 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { createInitialSessionSnapshot(bootstrap), ); this.setRenderSnapshot({ status: "ready", bootstrap }); + return this.getSnapshot(); } catch (error) { const message = errorMessage(error); this.setRenderSnapshot({ diff --git a/src/embedded/types.ts b/src/embedded/types.ts index 21a8dbac..3e5b6841 100644 --- a/src/embedded/types.ts +++ b/src/embedded/types.ts @@ -31,14 +31,23 @@ export type EmbeddedHunkSource = 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 }; + | { + 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(source: EmbeddedHunkSource): Promise; - reload(): Promise; + /** 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; } 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( From b81b810ec6cd59fb761ae66d0cf4f61e7f2ad788 Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Mon, 18 May 2026 03:59:13 -0400 Subject: [PATCH 6/8] fix(embedded): normalize source identity and daemon launch --- src/embedded/daemon.test.ts | 24 ++++++++++++++++++++++-- src/embedded/daemon.ts | 10 ++++++++-- src/embedded/embedded.test.ts | 11 ++++++++--- src/embedded/session.ts | 19 +++++++++++++++---- 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/embedded/daemon.test.ts b/src/embedded/daemon.test.ts index 5820123f..671d60b4 100644 --- a/src/embedded/daemon.test.ts +++ b/src/embedded/daemon.test.ts @@ -16,6 +16,7 @@ describe("embedded session broker daemon launcher", () => { 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; @@ -25,12 +26,31 @@ describe("embedded session broker daemon launcher", () => { await ensureBroker(testConfig); expect(captured).toEqual({ - argv: ["/deps/hunkdiff/bin/hunk.cjs"], + argv: ["/usr/local/bin/node", "/deps/hunkdiff/bin/hunk.cjs"], config: testConfig, cwd: "/repo", env: { HUNK_MCP_PORT: "48658" }, - execPath: "/deps/hunkdiff/bin/hunk.cjs", + 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 index c3c23e26..f574ccde 100644 --- a/src/embedded/daemon.ts +++ b/src/embedded/daemon.ts @@ -8,6 +8,7 @@ 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; @@ -16,6 +17,7 @@ export interface EmbeddedSessionBrokerAvailabilityOptions { env?: NodeJS.ProcessEnv; ensureAvailable?: EmbeddedEnsureSessionBroker; hunkCliPath?: string; + runtimePath?: string; timeoutMs?: number; } @@ -25,15 +27,19 @@ export function createEmbeddedSessionBrokerAvailability({ 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: [hunkCliPath], + argv: scriptEntrypoint ? [runtimePath, hunkCliPath] : [hunkCliPath], config, cwd, env, - execPath: hunkCliPath, + execPath: scriptEntrypoint ? runtimePath : hunkCliPath, }; if (timeoutMs !== undefined) { diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts index eeef8d31..c6b640e1 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -86,7 +86,7 @@ describe("embedded Hunk sessions", () => { } }); - test("open reuses the loaded review when source identity has not changed", async () => { + 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"); @@ -95,12 +95,17 @@ describe("embedded Hunk sessions", () => { writeFileSync(left, "export const value = 1;\n"); writeFileSync(right, "export const value = 2;\nexport const first = true;\n"); - const source = { kind: "diff", left, right } as const; + const source = { + kind: "diff", + left, + right, + options: { wrapLines: undefined }, + } as const; const session = await createEmbeddedHunkSession({ cwd: root, source }); expect(loadedPatch(session)).toContain("first"); writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); - const reusedSnapshot = await session.open(source); + const reusedSnapshot = await session.open({ kind: "diff", left, right }); expect(reusedSnapshot.status).toBe("ready"); if (reusedSnapshot.status !== "ready") throw new Error("Expected reused snapshot."); diff --git a/src/embedded/session.ts b/src/embedded/session.ts index 14fd9ad1..714e8f98 100644 --- a/src/embedded/session.ts +++ b/src/embedded/session.ts @@ -30,14 +30,25 @@ function errorMessage(error: unknown) { return String(error || "Failed to load Hunk."); } +/** Copy only explicitly defined fields so source identity has one canonical shape. */ +function compactRecord(source: T): T { + const next = {} as T; + for (const [key, value] of Object.entries(source) as [keyof T, T[keyof T]][]) { + if (value !== undefined) { + next[key] = value; + } + } + return next; +} + /** Return a session-owned source copy with normalized options and pathspec identity. */ function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): NormalizedEmbeddedHunkSource { const pathspecs = "pathspecs" in source ? source.pathspecs : undefined; - return { + return compactRecord({ ...source, - ...(pathspecs ? { pathspecs: [...pathspecs] } : {}), - options: { ...source.options }, - } as NormalizedEmbeddedHunkSource; + ...(pathspecs !== undefined ? { pathspecs: [...pathspecs] } : {}), + options: compactRecord({ ...source.options }), + }) as NormalizedEmbeddedHunkSource; } /** Adapt a public embedded source into the internal CLI input pipeline. */ From 1cf0e2c7e7e84edb302fbd4b89ced04b27d454b7 Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Tue, 19 May 2026 12:54:40 -0400 Subject: [PATCH 7/8] feat(embedded): scope renderer state to host container --- src/embedded/embedded.test.ts | 83 +++++++++++++++++++++++++++-------- src/embedded/mount.tsx | 71 +++++++++++++++++++++++++----- src/embedded/session.ts | 42 +++++++++--------- 3 files changed, 146 insertions(+), 50 deletions(-) diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts index c6b640e1..3f8ffca2 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -1,13 +1,15 @@ 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 } from "./index"; -import { createScopedKeyInput } from "./mount"; +import { createEmbeddedRendererScope, createScopedKeyInput } from "./mount"; import { embeddedHunkSessionInternals } from "./session"; import type { EmbeddedHunkSession } from "./types"; -const patchText = [ +const testPatchText = [ "diff --git a/example.ts b/example.ts", "--- a/example.ts", "+++ b/example.ts", @@ -19,15 +21,11 @@ const patchText = [ let previousHunkMcpDisable: string | undefined; -/** Return the private app bootstrap for assertions that public snapshots intentionally hide. */ -function renderBootstrap(session: EmbeddedHunkSession) { - return embeddedHunkSessionInternals(session).getRenderSnapshot().bootstrap; -} - /** Return the loaded patch text for one embedded session. */ -function loadedPatch(session: EmbeddedHunkSession) { - return renderBootstrap(session) - .changeset.files.map((file) => file.patch) +function getTestLoadedPatch(session: EmbeddedHunkSession) { + return embeddedHunkSessionInternals(session) + .getRenderSnapshot() + .bootstrap.changeset.files.map((file) => file.patch) .join("\n"); } @@ -60,7 +58,7 @@ describe("embedded Hunk sessions", () => { const session = await createEmbeddedHunkSession({ cwd: root, - source: { kind: "patch", text: patchText, options: { theme: "paper" } }, + source: { kind: "patch", text: testPatchText, options: { theme: "paper" } }, }); const snapshot = session.getSnapshot(); @@ -70,7 +68,7 @@ describe("embedded Hunk sessions", () => { expect(snapshot.title).toBe("Patch review: stdin patch"); expect(snapshot.fileCount).toBe(1); - const bootstrap = renderBootstrap(session); + const bootstrap = embeddedHunkSessionInternals(session).getRenderSnapshot().bootstrap; expect(bootstrap.initialMode).toBe("stack"); expect(bootstrap.initialShowLineNumbers).toBe(false); expect(bootstrap.initialTheme).toBe("paper"); @@ -102,7 +100,7 @@ describe("embedded Hunk sessions", () => { options: { wrapLines: undefined }, } as const; const session = await createEmbeddedHunkSession({ cwd: root, source }); - expect(loadedPatch(session)).toContain("first"); + expect(getTestLoadedPatch(session)).toContain("first"); writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); const reusedSnapshot = await session.open({ kind: "diff", left, right }); @@ -110,16 +108,16 @@ describe("embedded Hunk sessions", () => { expect(reusedSnapshot.status).toBe("ready"); if (reusedSnapshot.status !== "ready") throw new Error("Expected reused snapshot."); expect(reusedSnapshot.source).toEqual(session.source); - expect(loadedPatch(session)).toContain("first"); - expect(loadedPatch(session)).not.toContain("second"); + expect(getTestLoadedPatch(session)).toContain("first"); + expect(getTestLoadedPatch(session)).not.toContain("second"); const reloadedSnapshot = await session.reload(); expect(reloadedSnapshot.status).toBe("ready"); if (reloadedSnapshot.status !== "ready") throw new Error("Expected reloaded snapshot."); expect(reloadedSnapshot.source).toEqual(session.source); - expect(loadedPatch(session)).toContain("second"); - expect(loadedPatch(session)).not.toContain("first"); + expect(getTestLoadedPatch(session)).toContain("second"); + expect(getTestLoadedPatch(session)).not.toContain("first"); session.dispose(); } finally { @@ -131,13 +129,13 @@ describe("embedded Hunk sessions", () => { const root = mkdtempSync(join(tmpdir(), "hunk-embedded-reload-error-")); try { - const initialSource = { kind: "patch", text: patchText, label: "initial patch" } as const; + 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.open({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); expect(session.source).toMatchObject(initialSource); const snapshot = session.getSnapshot(); @@ -182,4 +180,51 @@ describe("embedded Hunk sessions", () => { 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/mount.tsx b/src/embedded/mount.tsx index 79afed03..dadbb210 100644 --- a/src/embedded/mount.tsx +++ b/src/embedded/mount.tsx @@ -13,6 +13,11 @@ 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) { @@ -66,21 +71,65 @@ export function createScopedKeyInput(source: KeyInputSource, enabled: () => bool }; } -/** Scope the renderer root and input stream to the host-provided embedded container. */ -function scopedRenderer( +/** 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; - Object.defineProperty(scoped, "root", { value: root }); - Object.defineProperty(scoped, "keyInput", { value: keyInput }); - Object.defineProperty(scoped, "intermediateRender", { - value() { - if (!renderer.isDestroyed) renderer.requestRender(); + 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 scoped; + + return { + renderer: scoped, + dispose() { + root.off("resize", emitResize); + resizeListeners.clear(); + }, + }; } function EmbeddedHunkRoot({ @@ -117,7 +166,8 @@ export function mountEmbeddedHunkApp({ }: MountEmbeddedHunkAppInput): EmbeddedHunkMount { let currentActive = active; const scopedKeyInput = createScopedKeyInput(renderer.keyInput, () => currentActive); - const root = createRoot(scopedRenderer(renderer, container, scopedKeyInput.keyInput)); + const scopedRenderer = createEmbeddedRendererScope(renderer, container, scopedKeyInput.keyInput); + const root = createRoot(scopedRenderer.renderer); const render = (next: { active: boolean; onQuit: () => void }) => { currentActive = next.active; @@ -130,6 +180,7 @@ export function mountEmbeddedHunkApp({ update: render, unmount() { root.unmount(); + scopedRenderer.dispose(); scopedKeyInput.dispose(); }, }; diff --git a/src/embedded/session.ts b/src/embedded/session.ts index 714e8f98..33d38a1c 100644 --- a/src/embedded/session.ts +++ b/src/embedded/session.ts @@ -24,31 +24,31 @@ export type EmbeddedHunkRenderSnapshot = type NormalizedEmbeddedHunkSource = EmbeddedHunkSource & { options: CommonOptions }; -/** Convert unknown thrown values into stable user-facing error text. */ -function errorMessage(error: unknown) { - if (error instanceof Error && error.message) return error.message; - return String(error || "Failed to load Hunk."); -} - -/** Copy only explicitly defined fields so source identity has one canonical shape. */ -function compactRecord(source: T): T { - const next = {} as T; - for (const [key, value] of Object.entries(source) as [keyof T, T[keyof T]][]) { - if (value !== undefined) { - next[key] = value; - } +/** 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 next; + return normalized; } /** Return a session-owned source copy with normalized options and pathspec identity. */ function normalizeEmbeddedHunkSource(source: EmbeddedHunkSource): NormalizedEmbeddedHunkSource { - const pathspecs = "pathspecs" in source ? source.pathspecs : undefined; - return compactRecord({ + const normalized = { ...source, - ...(pathspecs !== undefined ? { pathspecs: [...pathspecs] } : {}), - options: compactRecord({ ...source.options }), - }) as NormalizedEmbeddedHunkSource; + 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. */ @@ -183,13 +183,13 @@ class EmbeddedHunkSessionImpl implements EmbeddedHunkSession { this.setRenderSnapshot({ status: "ready", bootstrap }); return this.getSnapshot(); } catch (error) { - const message = errorMessage(error); + const message = error instanceof Error ? error.message : String(error); this.setRenderSnapshot({ status: "error", bootstrap: this.renderSnapshot.bootstrap, error: message, }); - throw error instanceof Error ? error : new Error(message); + throw error; } } From ec16aded463406308cf22e7d1fd5aa3260a055aa Mon Sep 17 00:00:00 2001 From: Khoa Huynh Date: Wed, 20 May 2026 12:19:32 -0400 Subject: [PATCH 8/8] fix(build): surface Bun export diagnostics in one error --- scripts/build-npm.ts | 57 ++++++++++++++++++++++++++--------- src/embedded/daemon.test.ts | 8 ++--- src/embedded/embedded.test.ts | 34 ++++++++++++--------- 3 files changed, 64 insertions(+), 35 deletions(-) diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index c7c0c03d..7ddde453 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -27,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, @@ -41,7 +54,24 @@ function runBun(args: string[]) { } } -async function buildLibraryExport(name: string, entrypoint: string, outputDirectory: 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", @@ -52,10 +82,7 @@ async function buildLibraryExport(name: string, entrypoint: string, outputDirect }); if (!build.success) { - for (const log of build.logs) { - console.error(log.message); - } - throw new Error(`Failed to build ${name} export.`); + throw new Error(formatBuildLibraryExportError({ logs: build.logs, name })); } } @@ -83,16 +110,16 @@ if (process.platform !== "win32") { chmodSync(mainJs, 0o755); } -await buildLibraryExport( - "OpenTUI", - path.join(repoRoot, "src", "opentui", "index.ts"), - opentuiOutdir, -); -await buildLibraryExport( - "embedded Hunk", - path.join(repoRoot, "src", "embedded", "index.ts"), - embeddedOutdir, -); +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.npm-exports.json")]); diff --git a/src/embedded/daemon.test.ts b/src/embedded/daemon.test.ts index 671d60b4..118b238d 100644 --- a/src/embedded/daemon.test.ts +++ b/src/embedded/daemon.test.ts @@ -1,13 +1,9 @@ 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 = { - host: "127.0.0.1", - port: 47657, - httpOrigin: "http://127.0.0.1:47657", - wsOrigin: "ws://127.0.0.1:47657", -}; +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 () => { diff --git a/src/embedded/embedded.test.ts b/src/embedded/embedded.test.ts index 3f8ffca2..cae2d158 100644 --- a/src/embedded/embedded.test.ts +++ b/src/embedded/embedded.test.ts @@ -7,7 +7,7 @@ import { join } from "node:path"; import { createEmbeddedHunkSession } from "./index"; import { createEmbeddedRendererScope, createScopedKeyInput } from "./mount"; import { embeddedHunkSessionInternals } from "./session"; -import type { EmbeddedHunkSession } from "./types"; +import type { EmbeddedHunkSession, EmbeddedHunkSnapshot } from "./types"; const testPatchText = [ "diff --git a/example.ts b/example.ts", @@ -29,6 +29,18 @@ function getTestLoadedPatch(session: EmbeddedHunkSession) { .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; @@ -60,10 +72,8 @@ describe("embedded Hunk sessions", () => { cwd: root, source: { kind: "patch", text: testPatchText, options: { theme: "paper" } }, }); - const snapshot = session.getSnapshot(); + const snapshot = expectTestReadySnapshot(session.getSnapshot()); - expect(snapshot.status).toBe("ready"); - if (snapshot.status !== "ready") throw new Error("Expected embedded session to load."); expect("bootstrap" in snapshot).toBe(false); expect(snapshot.title).toBe("Patch review: stdin patch"); expect(snapshot.fileCount).toBe(1); @@ -103,18 +113,16 @@ describe("embedded Hunk sessions", () => { expect(getTestLoadedPatch(session)).toContain("first"); writeFileSync(right, "export const value = 2;\nexport const second = true;\n"); - const reusedSnapshot = await session.open({ kind: "diff", left, right }); + const reusedSnapshot = expectTestReadySnapshot( + await session.open({ kind: "diff", left, right }), + ); - expect(reusedSnapshot.status).toBe("ready"); - if (reusedSnapshot.status !== "ready") throw new Error("Expected reused snapshot."); expect(reusedSnapshot.source).toEqual(session.source); expect(getTestLoadedPatch(session)).toContain("first"); expect(getTestLoadedPatch(session)).not.toContain("second"); - const reloadedSnapshot = await session.reload(); + const reloadedSnapshot = expectTestReadySnapshot(await session.reload()); - expect(reloadedSnapshot.status).toBe("ready"); - if (reloadedSnapshot.status !== "ready") throw new Error("Expected reloaded snapshot."); expect(reloadedSnapshot.source).toEqual(session.source); expect(getTestLoadedPatch(session)).toContain("second"); expect(getTestLoadedPatch(session)).not.toContain("first"); @@ -135,12 +143,10 @@ describe("embedded Hunk sessions", () => { source: initialSource, }); - expect(session.open({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); + await expect(session.open({ kind: "patch", file: "missing.patch" })).rejects.toThrow(); expect(session.source).toMatchObject(initialSource); - const snapshot = session.getSnapshot(); - expect(snapshot.status).toBe("error"); - if (snapshot.status !== "error") throw new Error("Expected embedded reload to fail."); + 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);