Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
cd00647
feat(embedded): add host API for mounting Hunk
khoaHyh May 18, 2026
01eac6e
feat: add embedded Hunk export for Opencode
khoaHyh May 18, 2026
7a4ec08
refactor(embedded): split session lifecycle and npm exports
khoaHyh May 18, 2026
6b9750b
refactor: refine embedded Hunk exports and input scoping
khoaHyh May 18, 2026
d61aa0f
feat(embedded): launch broker from bundled Hunk binary
khoaHyh May 18, 2026
b81b810
fix(embedded): normalize source identity and daemon launch
khoaHyh May 18, 2026
1cf0e2c
feat(embedded): scope renderer state to host container
khoaHyh May 19, 2026
ec16ade
fix(build): surface Bun export diagnostics in one error
khoaHyh May 20, 2026
4bac6f5
fix: persist embedded session state and ignore stale prebuilt bins
khoaHyh May 22, 2026
9d845cb
Merge branch 'main' into feat/opentui-solid-export
khoaHyh May 22, 2026
e99a092
refactor(embedded): simplify npm export implementation
khoaHyh May 22, 2026
0df8add
refactor(review): centralize shared review command state
khoaHyh May 23, 2026
dc29512
Merge branch 'main' into feat/opentui-solid-export
khoaHyh May 23, 2026
e115c4f
fix(embedded): preserve remounted review state
khoaHyh May 23, 2026
b2f6287
fix(embedded): scope host cursor to active embedded sessions
khoaHyh May 23, 2026
0dad929
feat: add custom themes and expandable diff gaps
khoaHyh May 23, 2026
297ec1a
fix(embedded): keep a single React root for mounted sessions
khoaHyh May 23, 2026
a48bb7c
Merge branch 'main' into feat/opentui-solid-export
khoaHyh May 23, 2026
4478175
fix(embedded): sync mounted reloads into session state
khoaHyh May 23, 2026
9c635ad
Backmerge from main
khoaHyh May 24, 2026
05cb8a2
Merge branch 'main' into feat/opentui-solid-export
khoaHyh May 24, 2026
b3825a4
feat(embedded): add reload-safe embedded session API
khoaHyh May 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ All notable user-visible changes to Hunk are documented in this file.

### Added

- Added a supported `hunkdiff/embedded` API for host OpenTUI apps to create embedded Hunk sessions and mount the review UI inside another terminal app.
- Added Catppuccin Latte and Mocha as built-in themes.
- Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code.
- Added inline expansion for collapsed unchanged file content. Click an unchanged-context row (`▾ N unchanged lines` when expandable, otherwise the static `··· N unchanged lines ···` form) or press `e` while a hunk is selected to reveal surrounding and trailing file lines without leaving the review. The affordance is shown only for input modes that have reachable source content (`hunk diff`, `show`, `stash show`, file-pair `diff` and `difftool`, untracked files); raw `hunk patch` input still renders as before. Failed and in-flight loads surface a one-line status ("Loading…", "Could not load N unchanged lines") on the gap row. Expanded context rows use the same syntax highlighting as the surrounding diff.
Expand Down
51 changes: 25 additions & 26 deletions bin/hunk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,46 +41,45 @@ function run(target, args) {
}

function hostCandidates() {
const platformMap = {
darwin: "darwin",
linux: "linux",
win32: "windows",
};
const archMap = {
x64: "x64",
arm64: "arm64",
};

const platform = platformMap[os.platform()] || os.platform();
const arch = archMap[os.arch()] || os.arch();
const binary = platform === "windows" ? "hunk.exe" : "hunk";

if (platform === "darwin") {
if (arch === "arm64") return [{ packageName: "hunkdiff-darwin-arm64", binary }];
if (arch === "x64") return [{ packageName: "hunkdiff-darwin-x64", binary }];
const platform = { darwin: "darwin", linux: "linux", win32: "windows" }[os.platform()];
const arch = { x64: "x64", arm64: "arm64" }[os.arch()];
if (!platform || !arch || (platform === "windows" && arch !== "x64")) {
return [];
}

if (platform === "linux") {
if (arch === "arm64") return [{ packageName: "hunkdiff-linux-arm64", binary }];
if (arch === "x64") return [{ packageName: "hunkdiff-linux-x64", binary }];
}
return [
{
packageName: `hunkdiff-${platform}-${arch}`,
binary: platform === "windows" ? "hunk.exe" : "hunk",
},
];
}

if (platform === "windows") {
if (arch === "x64") return [{ packageName: "hunkdiff-windows-x64", binary }];
function readPackageVersion(packageRoot) {
try {
const packageJson = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8"));
return typeof packageJson.version === "string" ? packageJson.version : null;
} catch {
return null;
}

return [];
}

function findInstalledBinary(startDir) {
const expectedVersion = readPackageVersion(path.join(__dirname, ".."));
let current = startDir;

for (;;) {
const modulesDir = path.join(current, "node_modules");
if (fs.existsSync(modulesDir)) {
for (const candidate of hostCandidates()) {
const resolved = path.join(modulesDir, candidate.packageName, "bin", candidate.binary);
const packageRoot = path.join(modulesDir, candidate.packageName);
const resolved = path.join(packageRoot, "bin", candidate.binary);
if (fs.existsSync(resolved)) {
const installedVersion = readPackageVersion(packageRoot);
if (expectedVersion && installedVersion && installedVersion !== expectedVersion) {
continue;
}

return resolved;
}
}
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
78 changes: 50 additions & 28 deletions scripts/build-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,19 @@ const repoRoot = path.resolve(import.meta.dir, "..");
const outdir = path.join(repoRoot, "dist", "npm");
const typesOutdir = path.join(repoRoot, "dist", "npm-types");
const opentuiOutdir = path.join(outdir, "opentui");
const opentuiTypesDir = path.join(typesOutdir, "opentui");
const opentuiTypesDir = path.join(typesOutdir, "src", "opentui");
const embeddedOutdir = path.join(outdir, "embedded");
const embeddedTypesDir = path.join(typesOutdir, "src", "embedded");
const libraryExternals = [
"react",
"react/jsx-runtime",
"react/jsx-dev-runtime",
"@opentui/core",
"@opentui/react",
"@opentui/react/jsx-runtime",
"@opentui/react/jsx-dev-runtime",
"@pierre/diffs",
];

const bunEnv = {
...process.env,
Expand All @@ -29,9 +41,32 @@ function runBun(args: string[]) {
}
}

/** Build one npm package subpath export. */
async function buildLibraryExport(entrypoint: string, name: 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) {
const details = build.logs
.map((log) => log.message)
.filter((message) => message.length > 0)
.join("\n");
throw new Error(
details ? `Failed to build ${name} export:\n${details}` : `Failed to build ${name} export.`,
);
}
}

rmSync(outdir, { recursive: true, force: true });
rmSync(typesOutdir, { recursive: true, force: true });
mkdirSync(opentuiOutdir, { recursive: true });
mkdirSync(embeddedOutdir, { recursive: true });

runBun([
"build",
Expand All @@ -52,44 +87,31 @@ if (process.platform !== "win32") {
chmodSync(mainJs, 0o755);
}

runBun([
"build",
await buildLibraryExport(
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",
"OpenTUI",
opentuiOutdir,
"--entry-naming",
"index.js",
]);
);
await buildLibraryExport(
path.join(repoRoot, "src", "embedded", "index.ts"),
"embedded Hunk",
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")) {
copyFileSync(path.join(opentuiTypesDir, entry), path.join(opentuiOutdir, entry));
}
}

for (const entry of ["index.d.ts", "types.d.ts"]) {
copyFileSync(path.join(embeddedTypesDir, entry), path.join(embeddedOutdir, entry));
}

rmSync(typesOutdir, { recursive: true, force: true });

console.log(`Built ${mainJs}`);
console.log(`Built ${path.join(opentuiOutdir, "index.js")}`);
console.log(`Built ${path.join(embeddedOutdir, "index.js")}`);
2 changes: 2 additions & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-prebuilt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions src/embedded/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test";
import { createEmbeddedSessionBrokerAvailability } from "./daemon";
import { resolveSessionBrokerConfig } from "../session-broker/brokerConfig";
import type { EnsureSessionBrokerAvailableOptions } from "../session-broker/brokerLauncher";

const testConfig = resolveSessionBrokerConfig({ HUNK_MCP_PORT: "47657" });

describe("embedded session broker daemon launcher", () => {
test("passes Hunk package-bin launch options through the broker availability adapter", async () => {
let captured: EnsureSessionBrokerAvailableOptions | undefined;
const ensureBroker = createEmbeddedSessionBrokerAvailability({
cwd: "/repo",
env: { HUNK_MCP_PORT: "48658" },
hunkCliPath: "/deps/hunkdiff/bin/hunk.cjs",
runtimePath: "/usr/local/bin/node",
timeoutMs: 1234,
ensureAvailable: async (options) => {
captured = options;
},
});

await ensureBroker(testConfig);

expect(captured).toEqual({
argv: ["/usr/local/bin/node", "/deps/hunkdiff/bin/hunk.cjs"],
config: testConfig,
cwd: "/repo",
env: { HUNK_MCP_PORT: "48658" },
execPath: "/usr/local/bin/node",
timeoutMs: 1234,
});
});

test("passes direct Hunk executable paths through without a runtime wrapper", async () => {
let captured: EnsureSessionBrokerAvailableOptions | undefined;
const ensureBroker = createEmbeddedSessionBrokerAvailability({
cwd: "/repo",
hunkCliPath: "/deps/hunkdiff/bin/hunk",
runtimePath: "/usr/local/bin/node",
ensureAvailable: async (options) => {
captured = options;
},
});

await ensureBroker(testConfig);

expect(captured).toMatchObject({
argv: ["/deps/hunkdiff/bin/hunk"],
execPath: "/deps/hunkdiff/bin/hunk",
});
});
});
40 changes: 40 additions & 0 deletions src/embedded/daemon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { ensureSessionBrokerAvailable } from "../session-broker/brokerLauncher";
import type { EnsureSessionBrokerAdapter } from "../session-broker/brokerClient";

const require = createRequire(import.meta.url);
const JAVASCRIPT_ENTRYPOINT_PATTERN = /\.(?:[cm]?js|tsx?)$/;

export interface EmbeddedSessionBrokerAvailabilityOptions {
cwd: string;
env?: NodeJS.ProcessEnv;
ensureAvailable?: typeof ensureSessionBrokerAvailable;
hunkCliPath?: string;
runtimePath?: string;
timeoutMs?: number;
}

/** Create the embedded broker availability adapter used by embedded Hunk sessions. */
export function createEmbeddedSessionBrokerAvailability({
cwd,
env = process.env,
ensureAvailable = ensureSessionBrokerAvailable,
hunkCliPath = join(dirname(require.resolve("hunkdiff/package.json")), "bin", "hunk.cjs"),
runtimePath = process.execPath,
timeoutMs,
}: EmbeddedSessionBrokerAvailabilityOptions): EnsureSessionBrokerAdapter {
return (config) => {
// 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);
return ensureAvailable({
argv: scriptEntrypoint ? [runtimePath, hunkCliPath] : [hunkCliPath],
config,
cwd,
env,
execPath: scriptEntrypoint ? runtimePath : hunkCliPath,
...(timeoutMs === undefined ? {} : { timeoutMs }),
});
};
}
Loading