Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-kids-admire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Avoid loading OpenTUI's embedded native library for headless commands. Help, version, session polling, daemon serving, markup rendering, and non-interactive pager paths now stay behind a lightweight CLI entrypoint, preventing Bun from leaking a native temp file for commands that never open the review UI.
16 changes: 12 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,13 @@ jobs:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- macos-latest
- windows-latest
include:
- os: ubuntu-latest
executable: dist/hunk
- os: macos-latest
executable: dist/hunk
- os: windows-latest
executable: dist/hunk.exe
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand All @@ -181,6 +184,11 @@ jobs:
- name: Stage prebuilt npm packages
run: bun run build:prebuilt:npm

- name: Verify compiled headless commands skip OpenTUI
run: bun test ./test/cli/compiled-headless-native-lib.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

- name: Verify staged prebuilt packs
run: bun run check:prebuilt-pack

Expand Down
38 changes: 38 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,39 @@ jobs:
- name: Test suite
run: bun test ./src ./packages ./scripts ./test/cli ./test/session

compiled-headless-portability:
name: Compiled headless portability (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-latest
executable: dist/hunk
- os: windows-latest
executable: dist/hunk.exe
steps:
- name: Check out repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: 1.3.14

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Build binary
run: bun run build:bin

- name: Verify compiled headless commands skip OpenTUI
run: bun test ./test/cli/compiled-headless-native-lib.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

pr-validate:
name: Typecheck + Test + Smoke
needs: changes
Expand Down Expand Up @@ -180,6 +213,11 @@ jobs:
- name: Stage prebuilt npm packages
run: bun run build:prebuilt:npm

- name: Verify compiled headless commands skip OpenTUI
run: bun test ./test/cli/compiled-headless-native-lib.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk

- name: Verify compiled binary watch mode
run: bun test ./test/pty/watch.test.ts
env:
Expand Down
5 changes: 2 additions & 3 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import fs from "node:fs";
import { dirname, join } from "node:path";
import { BUNDLED_SHIKI_THEME_IDS } from "../ui/lib/shikiThemes";
import { normalizeBuiltInThemeId } from "../ui/themes";
import { BUNDLED_SHIKI_THEME_IDS, resolveBundledShikiThemeId } from "../ui/lib/shikiThemes";
import { LEGACY_CUSTOM_SYNTAX_COLOR_KEYS, resolveSyntaxScopeOverrides } from "./legacySyntaxScopes";
import { resolveGlobalConfigPath } from "./paths";
import { LEGACY_CUSTOM_SYNTAX_NOTICES, type StartupNotice } from "./startupNotice";
Expand Down Expand Up @@ -183,7 +182,7 @@ function normalizeCustomThemeBase(value: unknown) {
);
}

const resolvedThemeId = normalizeBuiltInThemeId(value);
const resolvedThemeId = resolveBundledShikiThemeId(value);
if (!resolvedThemeId) {
throw new Error(
`Expected custom_theme.base to be a built-in theme id. Known themes: ${BUILT_IN_THEME_IDS.join(", ")}.`,
Expand Down
91 changes: 6 additions & 85 deletions src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,10 @@
#!/usr/bin/env bun

import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { formatCliError } from "./core/errors";
import {
installJobControlInterruptSupport,
installJobControlSuspendSupport,
type JobControlInterruptSupport,
type JobControlSuspendSupport,
} from "./core/jobControl";
import { pagePlainText } from "./core/pager";
import { sanitizeTerminalText } from "./lib/terminalText";
import { shutdownSession } from "./core/shutdown";
import { renderStaticDiffPager } from "./ui/staticDiffPager";
import { prepareStartupPlan } from "./core/startup";
import { shouldUseMouseForApp } from "./core/terminal";
import { resolveStartupUpdateNotice } from "./core/updateNotice";
import { AppHost } from "./ui/AppHost";
import { SessionBrokerClient } from "./session-broker/brokerClient";
import { sanitizeTerminalText } from "./lib/terminalText";
import { serveSessionBrokerDaemon } from "./session-broker/brokerServer";
import {
createInitialSessionSnapshot,
createSessionRegistration,
} from "./hunk-session/sessionRegistration";
import type {
HunkSessionCommandResult,
HunkSessionInfo,
HunkSessionServerMessage,
HunkSessionState,
} from "./hunk-session/types";
import { runSessionCommand } from "./session/commands";

async function main() {
Expand Down Expand Up @@ -78,6 +54,7 @@ async function main() {
}

if (startupPlan.kind === "static-diff-pager") {
const { renderStaticDiffPager } = await import("./ui/staticDiffPager");
process.stdout.write(
await renderStaticDiffPager(startupPlan.text, startupPlan.options, {
customTheme: startupPlan.customTheme,
Expand All @@ -91,66 +68,10 @@ async function main() {
throw new Error("Unreachable startup plan.");
}

const { bootstrap, controllingTerminal } = startupPlan;
const hostClient = new SessionBrokerClient<
HunkSessionInfo,
HunkSessionState,
HunkSessionServerMessage,
HunkSessionCommandResult
>(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
hostClient.start();

// Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux).
const renderer = await createCliRenderer({
stdin: controllingTerminal?.stdin,
stdout: process.stdout,
useMouse: shouldUseMouseForApp({
hasControllingTerminal: Boolean(controllingTerminal),
}),
screenMode: "alternate-screen",
exitOnCtrlC: false,
openConsoleOnError: true,
onDestroy: () => controllingTerminal?.close(),
});

const appRenderer = renderer;
const root = createRoot(appRenderer);
const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
let shuttingDown = false;
let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined };
let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined };

/** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
function shutdown() {
if (shuttingDown) {
return;
}

shuttingDown = true;
for (const signal of shutdownSignals) {
process.off(signal, shutdown);
}
jobControlInterruptSupport.dispose();
jobControlSuspendSupport.dispose();
hostClient.stop();
shutdownSession({ root, renderer: appRenderer });
}

for (const signal of shutdownSignals) {
process.once(signal, shutdown);
}
jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown);
jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);

// The app owns the full alternate screen session from this point on.
root.render(
<AppHost
bootstrap={bootstrap}
hostClient={hostClient}
onQuit={shutdown}
startupNoticeResolver={resolveStartupUpdateNotice}
/>,
);
// OpenTUI stays behind the interactive plan so headless commands never
// materialize its embedded native library.
const { runInteractiveApp } = await import("./ui/runInteractiveApp");
await runInteractiveApp(startupPlan);
}

await main().catch((error) => {
Expand Down
10 changes: 10 additions & 0 deletions src/ui/lib/shikiThemes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,16 @@ export function resolveLegacyThemeId(themeId: string | undefined) {
: undefined;
}

/** Resolve a current or legacy id when it names one bundled theme. */
export function resolveBundledShikiThemeId(
themeId: string | undefined,
): BundledShikiThemeId | undefined {
const resolvedThemeId = resolveLegacyThemeId(themeId);
return BUNDLED_SHIKI_THEME_IDS.includes(resolvedThemeId as BundledShikiThemeId)
? (resolvedThemeId as BundledShikiThemeId)
: undefined;
}

export interface BundledShikiThemeDiffColors {
added?: string;
removed?: string;
Expand Down
9 changes: 2 additions & 7 deletions src/ui/lib/ui-lib.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ describe("ui helpers", () => {
).toBe(16);
});

test("resolveTheme falls back to GitHub defaults while lazily exposing syntax styles", () => {
test("resolveTheme falls back to GitHub defaults while exposing semantic syntax colors", () => {
const dracula = resolveTheme("dracula", null);
const missingLight = resolveTheme("missing", "light");
const missingDark = resolveTheme("missing", "dark");
Expand All @@ -527,11 +527,6 @@ describe("ui helpers", () => {
expect(custom.accent).toBe("#7755aa");
expect(custom.syntaxScopeOverrides).toEqual({ "keyword.control": "#123456" });
expect(missingCustom.id).toBe("github-dark-default");
expect(resolveTheme("github-dark-default", null).syntaxStyle).toBeDefined();
expect(custom.syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-latte", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-frappe", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-macchiato", null).syntaxStyle).toBeDefined();
expect(resolveTheme("catppuccin-mocha", null).syntaxStyle).toBeDefined();
expect(custom.syntaxColors.default).toBe("#1f2328");
});
});
95 changes: 95 additions & 0 deletions src/ui/runInteractiveApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import {
installJobControlInterruptSupport,
installJobControlSuspendSupport,
type JobControlInterruptSupport,
type JobControlSuspendSupport,
} from "../core/jobControl";
import { shutdownSession } from "../core/shutdown";
import { shouldUseMouseForApp, type ControllingTerminal } from "../core/terminal";
import type { AppBootstrap } from "../core/types";
import { resolveStartupUpdateNotice } from "../core/updateNotice";
import {
createInitialSessionSnapshot,
createSessionRegistration,
} from "../hunk-session/sessionRegistration";
import type {
HunkSessionCommandResult,
HunkSessionInfo,
HunkSessionServerMessage,
HunkSessionState,
} from "../hunk-session/types";
import { SessionBrokerClient } from "../session-broker/brokerClient";
import { AppHost } from "./AppHost";

export interface InteractiveAppInput {
bootstrap: AppBootstrap;
controllingTerminal: ControllingTerminal | null;
}

/** Load and run the OpenTUI review app after startup has selected an interactive plan. */
export async function runInteractiveApp({
bootstrap,
controllingTerminal,
}: InteractiveAppInput): Promise<void> {
const hostClient = new SessionBrokerClient<
HunkSessionInfo,
HunkSessionState,
HunkSessionServerMessage,
HunkSessionCommandResult
>(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap));
hostClient.start();

// Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux).
const renderer = await createCliRenderer({
stdin: controllingTerminal?.stdin,
stdout: process.stdout,
useMouse: shouldUseMouseForApp({
hasControllingTerminal: Boolean(controllingTerminal),
}),
screenMode: "alternate-screen",
exitOnCtrlC: false,
openConsoleOnError: true,
onDestroy: () => controllingTerminal?.close(),
});

const appRenderer = renderer;
const root = createRoot(appRenderer);
const shutdownSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM"];
let shuttingDown = false;
let jobControlSuspendSupport: JobControlSuspendSupport = { dispose: () => undefined };
let jobControlInterruptSupport: JobControlInterruptSupport = { dispose: () => undefined };

/** Tear down the renderer before exit so the primary terminal screen comes back cleanly. */
function shutdown() {
if (shuttingDown) {
return;
}

shuttingDown = true;
for (const signal of shutdownSignals) {
process.off(signal, shutdown);
}
jobControlInterruptSupport.dispose();
jobControlSuspendSupport.dispose();
hostClient.stop();
shutdownSession({ root, renderer: appRenderer });
}

for (const signal of shutdownSignals) {
process.once(signal, shutdown);
}
jobControlInterruptSupport = installJobControlInterruptSupport(appRenderer, shutdown);
jobControlSuspendSupport = installJobControlSuspendSupport(appRenderer);

// The app owns the full alternate screen session from this point on.
root.render(
<AppHost
bootstrap={bootstrap}
hostClient={hostClient}
onQuit={shutdown}
startupNoticeResolver={resolveStartupUpdateNotice}
/>,
);
}
2 changes: 1 addition & 1 deletion src/ui/themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("themes", () => {
expect(theme.id).toBe(themeId);
expect(theme.label).toBe(themeId);
expect(theme.syntaxTheme).toBe(themeId);
expect(theme.syntaxStyle).toBeDefined();
expect(theme.syntaxColors.default).toBeTruthy();
}
});

Expand Down
Loading