diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fe9c8dfc..26ecfaa0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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
@@ -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
diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml
index 37eff90a..2b2708ff 100644
--- a/.github/workflows/pr-ci.yml
+++ b/.github/workflows/pr-ci.yml
@@ -94,6 +94,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
@@ -183,6 +216,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 staged prebuilt packs
run: bun run check:prebuilt-pack
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0224796d..d447893d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,12 @@
# Changelog
+## 0.17.5
+
+### Patch Changes
+
+- 034ec93: 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.
+- aa123df: Optimize terminal cell width measurement so diffs with CJK, emoji, and chrome-glyph runs render faster: single-scalar clusters now measure through a fast zero-width check plus the East Asian Width table instead of string-width's expensive emoji regexes, while multi-scalar clusters still defer to string-width for identical results.
+
## 0.17.4
### Patch Changes
diff --git a/benchmarks/README.md b/benchmarks/README.md
index f19bce33..27f3e983 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -40,6 +40,7 @@ bun run bench:highlight-prefetch
bun run bench:large-stream
bun run bench:interaction-latency
bun run bench:non-ascii-stream
+bun run bench:terminal-width
bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
@@ -59,6 +60,7 @@ bun run bench:competitors
- `large-stream.ts` — measures large split-stream first-frame and scroll cost.
- `interaction-latency.ts` — measures per-press `]` hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of `memory.ts`).
- `non-ascii-stream.ts` — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs.
+- `terminal-width.ts` — measures scalar-heavy CJK and emoji width calls plus the complex-cluster fallback against equivalent `string-width` reference paths, verifying identical width checksums.
- `huge-stream.ts` — opt-in huge tier (`--include-huge` or `HUNK_BENCH_INCLUDE_HUGE=1`): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
- `large-stream-profile.ts` — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
- `memory.ts` — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
diff --git a/benchmarks/run.ts b/benchmarks/run.ts
index 9bf1e59e..729dbb78 100644
--- a/benchmarks/run.ts
+++ b/benchmarks/run.ts
@@ -13,6 +13,7 @@ const defaultScripts = [
"large-stream.ts",
"interaction-latency.ts",
"non-ascii-stream.ts",
+ "terminal-width.ts",
];
interface RunOptions {
diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts
new file mode 100644
index 00000000..b231db68
--- /dev/null
+++ b/benchmarks/terminal-width.ts
@@ -0,0 +1,72 @@
+// Benchmark Hunk's scalar fast path and complex-cluster fallback against string-width.
+import { performance } from "node:perf_hooks";
+import stringWidth from "string-width";
+import { measureTextWidth } from "../src/ui/lib/text";
+
+const ITERATIONS = 2_000;
+const WARMUP_ITERATIONS = 50;
+const CJK_SCALAR_LINES = [
+ "export const message = 日本語のコメントです。変更内容を確認してください。",
+ "export function 測定値を計算する(入力: number) { return 入力 * 2; }",
+ "中文注释内容:这个变更优化了终端单元格宽度测量。",
+ "请检查新增、删除和未修改的代码行是否正确对齐。",
+ "한국어 주석 내용과 함수 이름을 함께 측정합니다.",
+ "터미널 셀 너비를 빠르게 계산하고 정렬을 유지합니다.",
+] as const;
+const EMOJI_SCALAR_LINES = [
+ "🚀 ✨ 🔧 💡 🎯 📦 🔍 standalone emoji scalars",
+ "✅ 🚧 🐛 🎉 🧪 📊 terminal status glyphs",
+] as const;
+const COMPLEX_CLUSTER_LINES = [
+ "🧑💻 👩🔬 👨👩👧👦 complex emoji clusters stay aligned",
+ "e\u0301 a\u0308 o\u0302 u\u0308 combining clusters keep their reference widths",
+] as const;
+
+type WidthMeasure = (text: string) => number;
+type WidthCorpus = readonly string[];
+
+/** Measure repeated width calls and retain their total so the work stays observable. */
+function measureWidthCalls(measure: WidthMeasure, corpus: WidthCorpus, iterations: number) {
+ let checksum = 0;
+ const start = performance.now();
+
+ for (let iteration = 0; iteration < iterations; iteration += 1) {
+ for (const line of corpus) {
+ checksum += measure(line);
+ }
+ }
+
+ return { elapsedMs: performance.now() - start, checksum };
+}
+
+/** Verify and time one deterministic terminal-text shape. */
+function measureScenario(name: string, corpus: WidthCorpus) {
+ for (const line of corpus) {
+ const actual = measureTextWidth(line);
+ const reference = stringWidth(line);
+ if (actual !== reference) {
+ throw new Error(`Width mismatch for ${JSON.stringify(line)}: ${actual} !== ${reference}`);
+ }
+ }
+
+ measureWidthCalls(stringWidth, corpus, WARMUP_ITERATIONS);
+ measureWidthCalls(measureTextWidth, corpus, WARMUP_ITERATIONS);
+
+ const reference = measureWidthCalls(stringWidth, corpus, ITERATIONS);
+ const optimized = measureWidthCalls(measureTextWidth, corpus, ITERATIONS);
+ if (optimized.checksum !== reference.checksum) {
+ throw new Error(`Width checksum mismatch: ${optimized.checksum} !== ${reference.checksum}`);
+ }
+
+ const speedup = reference.elapsedMs / optimized.elapsedMs;
+ console.log(`METRIC ${name}_text_width_ms=${optimized.elapsedMs.toFixed(2)}`);
+ // External reference timings are informational and should not gate Hunk releases.
+ console.log(`METRIC competitor_string_width_${name}_ms=${reference.elapsedMs.toFixed(2)}`);
+ console.log(`METRIC ${name}_width_measurements=${ITERATIONS * corpus.length}`);
+ console.log(`METRIC ${name}_width_checksum=${optimized.checksum}`);
+ console.log(`${name} width speedup versus string-width: ${speedup.toFixed(2)}x`);
+}
+
+measureScenario("cjk_scalar", CJK_SCALAR_LINES);
+measureScenario("emoji_scalar", EMOJI_SCALAR_LINES);
+measureScenario("complex_cluster", COMPLEX_CLUSTER_LINES);
diff --git a/bun.lock b/bun.lock
index 35733868..c1b2e0e9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -9,6 +9,7 @@
"bun": "^1.3.14",
"commander": "^14.0.3",
"diff": "^8.0.3",
+ "get-east-asian-width": "^1.5.0",
"shell-quote": "1.8.4",
"string-width": "^8.2.1",
"zod": "^4.3.6",
diff --git a/package.json b/package.json
index 567a0011..1b87d323 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "hunkdiff",
- "version": "0.17.4",
+ "version": "0.17.5",
"description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.",
"keywords": [
"ai",
@@ -83,6 +83,7 @@
"bench:large-stream": "bun run benchmarks/large-stream.ts",
"bench:interaction-latency": "bun run benchmarks/interaction-latency.ts",
"bench:non-ascii-stream": "bun run benchmarks/non-ascii-stream.ts",
+ "bench:terminal-width": "bun run benchmarks/terminal-width.ts",
"bench:huge-stream": "bun run benchmarks/huge-stream.ts",
"bench:large-stream-profile": "bun run benchmarks/large-stream-profile.ts",
"bench:memory": "bun run benchmarks/memory.ts",
@@ -98,6 +99,7 @@
"bun": "^1.3.14",
"commander": "^14.0.3",
"diff": "^8.0.3",
+ "get-east-asian-width": "^1.5.0",
"shell-quote": "1.8.4",
"string-width": "^8.2.1",
"zod": "^4.3.6"
diff --git a/src/core/config.ts b/src/core/config.ts
index fa261289..468100c3 100644
--- a/src/core/config.ts
+++ b/src/core/config.ts
@@ -1,7 +1,6 @@
import fs from "node:fs";
import { 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 { resolveGlobalConfigPath } from "./paths";
import { detectVcs, findVcsRepoRootCandidate, getDefaultVcsAdapter, isVcsId } from "./vcs";
import type {
@@ -136,7 +135,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(", ")}.`,
diff --git a/src/main.tsx b/src/main.tsx
index f864f14a..5ce2b315 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -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() {
@@ -61,6 +37,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,
@@ -74,66 +51,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(
- ,
- );
+ // 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) => {
diff --git a/src/ui/lib/shikiThemes.ts b/src/ui/lib/shikiThemes.ts
index 99aa4490..e759568f 100644
--- a/src/ui/lib/shikiThemes.ts
+++ b/src/ui/lib/shikiThemes.ts
@@ -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;
diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts
index 43dd260a..4f88716f 100644
--- a/src/ui/lib/text.ts
+++ b/src/ui/lib/text.ts
@@ -1,3 +1,4 @@
+import { eastAsianWidth } from "get-east-asian-width";
import stringWidth from "string-width";
import { sanitizeTerminalLine } from "../../lib/terminalText";
@@ -16,10 +17,32 @@ function textClusters(text: string) {
return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment);
}
-// Measured terminal-cell widths for single non-ASCII characters. Hunk re-measures the same
-// chrome glyphs ("─", "▌", "│", ...) constantly, and string-width's grapheme/emoji regexes make
-// each cold measure expensive, so cache per-character widths once.
-const singleCharWidthCache = new Map();
+// Zero-width cluster classes restricted to a single code point. A plain u-flag character
+// class stays fast per call, unlike string-width's \p{RGI_Emoji} property-of-strings regex.
+const zeroWidthScalarRegex =
+ /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]$/u;
+
+/**
+ * Measure one grapheme cluster in terminal cells, matching string-width on every input.
+ *
+ * A single-scalar cluster can never be an emoji sequence, and every single-scalar emoji is East
+ * Asian Wide, so a zero-width check plus the EAW table reproduces string-width exactly.
+ * Multi-scalar clusters delegate to string-width itself.
+ */
+export function measureClusterWidth(cluster: string): number {
+ const codePoint = cluster.codePointAt(0);
+ if (codePoint === undefined) {
+ return 0;
+ }
+
+ const scalarUnitLength = codePoint > 0xffff ? 2 : 1;
+
+ if (cluster.length === scalarUnitLength) {
+ return zeroWidthScalarRegex.test(cluster) ? 0 : eastAsianWidth(codePoint);
+ }
+
+ return stringWidth(cluster);
+}
/**
* Return the single UTF-16 code unit repeated across `text`, or null when text mixes characters.
@@ -44,35 +67,36 @@ function repeatedSingleUnitChar(text: string): string | null {
return text[0] ?? null;
}
-/** Measure one character through string-width, memoized for repeat lookups. */
-function cachedSingleCharWidth(char: string): number {
- let width = singleCharWidthCache.get(char);
- if (width === undefined) {
- width = stringWidth(char);
- singleCharWidthCache.set(char, width);
- }
- return width;
-}
-
function measureSanitizedTextWidth(text: string) {
if (printableAsciiRegex.test(text)) {
return text.length;
}
- // Fast path for chrome glyph runs like "─".repeat(separatorWidth): string-width costs
- // milliseconds for long non-ASCII runs, but a run of one repeated non-combining character is
- // always run-length × single-character width. Each repeated unit with a non-zero width is its
- // own grapheme cluster, so the multiplication is exact.
+ // Fast path for chrome glyph runs like "─".repeat(separatorWidth): a run of one repeated
+ // non-combining character is always run-length × single-character width. Each repeated unit
+ // with a non-zero width is its own grapheme cluster, so the multiplication is exact.
const repeatedChar = repeatedSingleUnitChar(text);
if (repeatedChar !== null) {
- const charWidth = cachedSingleCharWidth(repeatedChar);
- // Zero-width units (combining marks) can merge with neighbors; defer to string-width.
+ const charWidth = measureClusterWidth(repeatedChar);
+ // Zero-width units (combining marks) can merge with neighbors; fall through to the
+ // cluster loop, which segments them correctly.
if (charWidth > 0) {
return charWidth * text.length;
}
}
- return stringWidth(text);
+ let width = 0;
+ for (const cluster of textClusters(text)) {
+ const codePoint = cluster.codePointAt(0)!;
+ const scalarUnitLength = codePoint > 0xffff ? 2 : 1;
+ if (cluster.length !== scalarUnitLength) {
+ // Use one whole-text fallback instead of re-segmenting every complex cluster separately.
+ // This preserves the previous path for ZWJ emoji and combining-heavy text.
+ return stringWidth(text);
+ }
+ width += measureClusterWidth(cluster);
+ }
+ return width;
}
/** Measure text in terminal cells, treating CJK and emoji clusters as wide. */
@@ -99,7 +123,7 @@ export function sliceTextByWidth(text: string, offset: number, width: number) {
let visibleText = "";
for (const cluster of textClusters(safeText)) {
- const clusterWidth = measureSanitizedTextWidth(cluster);
+ const clusterWidth = measureClusterWidth(cluster);
const clusterStart = cursor;
const clusterEnd = cursor + clusterWidth;
cursor = clusterEnd;
@@ -162,7 +186,7 @@ export function cellRangeToCharRange(text: string, startCell: number, endCell: n
break;
}
- const clusterWidth = measureSanitizedTextWidth(cluster);
+ const clusterWidth = measureClusterWidth(cluster);
// Zero-width clusters (e.g. U+200B) occupy no cell, so they never satisfy the covering
// check; attach them to a range that starts at their position instead of dropping them.
const coversStart =
diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts
index 4a67a4c9..b3782ec2 100644
--- a/src/ui/lib/ui-lib.test.ts
+++ b/src/ui/lib/ui-lib.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import { parseDiffFromFile } from "@pierre/diffs";
import type { KeyEvent } from "@opentui/core";
+import stringWidth from "string-width";
import type { DiffFile } from "../../core/types";
import {
buildMenuSpecs,
@@ -23,7 +24,14 @@ import {
isStepDownKey,
isStepUpKey,
} from "./keyboard";
-import { cellRangeToCharRange, fitText, measureTextWidth, padText, sliceTextByWidth } from "./text";
+import {
+ cellRangeToCharRange,
+ fitText,
+ measureClusterWidth,
+ measureTextWidth,
+ padText,
+ sliceTextByWidth,
+} from "./text";
import { computeHunkRevealScrollTop } from "./hunkScroll";
import {
estimateDiffSectionBodyRows,
@@ -299,6 +307,33 @@ describe("ui helpers", () => {
expect(cellRangeToCharRange("\u200bab", 1, 1)).toEqual({ startIndex: 2, endIndex: 3 });
});
+ test("cluster width measurement matches string-width across terminal text shapes", () => {
+ const clusters = [
+ "",
+ "\0",
+ "\u200b",
+ "\u0301",
+ "\ud800",
+ "─",
+ "·",
+ "日",
+ "👍",
+ "e\u0301",
+ "1\u20e3",
+ "🧑💻",
+ "\u1100\u1161\u11a8",
+ ];
+
+ for (const cluster of clusters) {
+ expect(measureClusterWidth(cluster)).toBe(stringWidth(cluster));
+ }
+
+ const complexLines = ["🧑💻 👩🔬 terminal tools", "e\u0301 a\u0308 combining text"];
+ for (const line of complexLines) {
+ expect(measureTextWidth(line)).toBe(stringWidth(line));
+ }
+ });
+
test("repeated single-character runs use the fast width path without losing correctness", () => {
// Chrome glyph separators: single-cell non-ASCII characters repeated to fill a row.
expect(measureTextWidth("─".repeat(240))).toBe(240);
@@ -500,7 +535,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");
@@ -527,11 +562,6 @@ describe("ui helpers", () => {
expect(custom.accent).toBe("#7755aa");
expect(custom.syntaxColors.keyword).toBe("#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");
});
});
diff --git a/src/ui/runInteractiveApp.tsx b/src/ui/runInteractiveApp.tsx
new file mode 100644
index 00000000..c8d28d4c
--- /dev/null
+++ b/src/ui/runInteractiveApp.tsx
@@ -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 {
+ 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(
+ ,
+ );
+}
diff --git a/src/ui/themes.test.ts b/src/ui/themes.test.ts
index 355c9d3a..eec31ed7 100644
--- a/src/ui/themes.test.ts
+++ b/src/ui/themes.test.ts
@@ -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();
}
});
diff --git a/src/ui/themes.ts b/src/ui/themes.ts
index 29126738..e25ed937 100644
--- a/src/ui/themes.ts
+++ b/src/ui/themes.ts
@@ -3,14 +3,13 @@ import type { CustomThemeConfig } from "../core/types";
import { blendHex, contrastRatio, relativeLuminance } from "./lib/color";
import {
BUNDLED_SHIKI_THEME_IDS,
- resolveLegacyThemeId,
+ resolveBundledShikiThemeId,
getBundledShikiThemeBackground,
getBundledShikiThemeDiffColors,
getBundledShikiThemeForeground,
type BundledShikiThemeDiffColors,
type BundledShikiThemeId,
} from "./lib/shikiThemes";
-import { withLazySyntaxStyle } from "./themes/syntax";
import type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
export type { AppTheme, SyntaxColors, ThemeBase } from "./themes/types";
@@ -231,7 +230,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme {
syntaxTheme: themeId,
};
- return withLazySyntaxStyle(themeBase, syntaxColors);
+ return { ...themeBase, syntaxColors };
}
export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
@@ -240,7 +239,7 @@ export const THEMES: AppTheme[] = BUNDLED_SHIKI_THEME_IDS.map((themeId) =>
/** Return the built-in theme by id so config-defined themes can inherit from it. */
function builtInThemeById(themeId: string | undefined) {
- const resolvedThemeId = resolveLegacyThemeId(themeId);
+ const resolvedThemeId = resolveBundledShikiThemeId(themeId);
return THEMES.find((theme) => theme.id === resolvedThemeId);
}
@@ -295,10 +294,13 @@ function buildCustomTheme(customTheme: CustomThemeConfig) {
syntaxTheme: customTheme.syntax ? undefined : baseTheme.syntaxTheme,
};
- return withLazySyntaxStyle(themeBase, {
- ...baseTheme.syntaxColors,
- ...customTheme.syntax,
- });
+ return {
+ ...themeBase,
+ syntaxColors: {
+ ...baseTheme.syntaxColors,
+ ...customTheme.syntax,
+ },
+ };
}
/** Return the theme ids the app should expose based on whether config defines a custom palette. */
@@ -337,16 +339,6 @@ export function resolveTheme(
return fallbackTheme(themeMode);
}
-/** Return whether a custom theme base id can inherit from a built-in theme. */
-export function isBuiltInThemeId(themeId: string) {
- return builtInThemeById(themeId) !== undefined;
-}
-
-/** Return the canonical built-in theme id, preserving legacy config compatibility. */
-export function normalizeBuiltInThemeId(themeId: string) {
- return isBuiltInThemeId(themeId) ? resolveLegacyThemeId(themeId) : undefined;
-}
-
/** Return known semantic diff colors for a bundled Shiki-backed theme. */
export function bundledThemeDiffColors(themeId: string): BundledShikiThemeDiffColors | undefined {
return getBundledShikiThemeDiffColors(themeId);
diff --git a/src/ui/themes/syntax.ts b/src/ui/themes/syntax.ts
deleted file mode 100644
index 8a9d0699..00000000
--- a/src/ui/themes/syntax.ts
+++ /dev/null
@@ -1,36 +0,0 @@
-import { RGBA, SyntaxStyle } from "@opentui/core";
-import type { AppTheme, SyntaxColors, ThemeBase } from "./types";
-
-/** Build the syntax palette OpenTUI should use for in-terminal code rendering. */
-export function createSyntaxStyle(colors: SyntaxColors) {
- return SyntaxStyle.fromStyles({
- default: { fg: RGBA.fromHex(colors.default) },
- keyword: { fg: RGBA.fromHex(colors.keyword), bold: true },
- string: { fg: RGBA.fromHex(colors.string) },
- comment: { fg: RGBA.fromHex(colors.comment), italic: true },
- number: { fg: RGBA.fromHex(colors.number) },
- function: { fg: RGBA.fromHex(colors.function) },
- method: { fg: RGBA.fromHex(colors.function) },
- property: { fg: RGBA.fromHex(colors.property) },
- variable: { fg: RGBA.fromHex(colors.variable ?? colors.default) },
- constant: { fg: RGBA.fromHex(colors.number), bold: true },
- type: { fg: RGBA.fromHex(colors.type) },
- class: { fg: RGBA.fromHex(colors.type) },
- operator: { fg: RGBA.fromHex(colors.operator ?? colors.punctuation) },
- punctuation: { fg: RGBA.fromHex(colors.punctuation) },
- });
-}
-
-/** Lazily attach syntax colors so startup only pays for the active theme's token style. */
-export function withLazySyntaxStyle(theme: ThemeBase, syntaxColors: SyntaxColors): AppTheme {
- let syntaxStyle: SyntaxStyle | null = null;
-
- return {
- ...theme,
- syntaxColors,
- get syntaxStyle() {
- syntaxStyle ??= createSyntaxStyle(syntaxColors);
- return syntaxStyle;
- },
- };
-}
diff --git a/src/ui/themes/types.ts b/src/ui/themes/types.ts
index b884af7a..5f8be953 100644
--- a/src/ui/themes/types.ts
+++ b/src/ui/themes/types.ts
@@ -1,5 +1,3 @@
-import type { SyntaxStyle } from "@opentui/core";
-
export interface AppTheme {
id: string;
label: string;
@@ -40,7 +38,6 @@ export interface AppTheme {
/** Optional Shiki/Pierre theme name for source-accurate code highlighting. */
syntaxTheme?: string;
syntaxColors: SyntaxColors;
- syntaxStyle: SyntaxStyle;
}
export type SyntaxColors = {
@@ -57,4 +54,4 @@ export type SyntaxColors = {
punctuation: string;
};
-export type ThemeBase = Omit;
+export type ThemeBase = Omit;
diff --git a/test/cli/compiled-headless-native-lib.test.ts b/test/cli/compiled-headless-native-lib.test.ts
new file mode 100644
index 00000000..003c30ff
--- /dev/null
+++ b/test/cli/compiled-headless-native-lib.test.ts
@@ -0,0 +1,229 @@
+import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
+import { createServer } from "node:net";
+import { mkdirSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { resolve } from "node:path";
+
+const executable = process.env.HUNK_TEST_EXECUTABLE
+ ? resolve(process.env.HUNK_TEST_EXECUTABLE)
+ : undefined;
+const compiledTest = executable ? test : test.skip;
+const compiledLinuxTest = executable && process.platform === "linux" ? test : test.skip;
+const BUN_NATIVE_ARTIFACT_PATTERN = /^\.[0-9a-f]{16}-[0-9a-f]{8}\.(?:so|dylib|dll)$/;
+const positiveControlBuildRoot = executable
+ ? mkdtempSync(resolve(tmpdir(), "hunk-compiled-opentui-control-"))
+ : undefined;
+const positiveControlExecutable = positiveControlBuildRoot
+ ? resolve(
+ positiveControlBuildRoot,
+ process.platform === "win32" ? "opentui-control.exe" : "opentui-control",
+ )
+ : undefined;
+
+let rootsToClean: string[] = [];
+
+beforeAll(() => {
+ if (!positiveControlExecutable) {
+ return;
+ }
+
+ const source = resolve(import.meta.dir, "fixtures", "compiled-opentui-positive-control.ts");
+ const build = Bun.spawnSync(
+ [
+ process.execPath,
+ "build",
+ "--compile",
+ "--no-compile-autoload-bunfig",
+ source,
+ "--outfile",
+ positiveControlExecutable,
+ ],
+ {
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+ if (build.exitCode !== 0) {
+ throw new Error(
+ `Failed to build the OpenTUI positive control: ${Buffer.from(build.stderr).toString("utf8")}`,
+ );
+ }
+});
+
+afterAll(() => {
+ if (positiveControlBuildRoot) {
+ rmSync(positiveControlBuildRoot, { recursive: true, force: true });
+ }
+});
+
+afterEach(() => {
+ for (const root of rootsToClean) {
+ rmSync(root, { recursive: true, force: true });
+ }
+ rootsToClean = [];
+});
+
+/** Create isolated home, cache, runtime, and temp directories for one compiled-binary test. */
+function createTestEnvironment(port?: number) {
+ const root = mkdtempSync(resolve(tmpdir(), "hunk-compiled-headless-test-"));
+ rootsToClean.push(root);
+ const home = resolve(root, "home");
+ const cache = resolve(root, "cache");
+ const runtime = resolve(root, "runtime");
+ const temp = resolve(root, "tmp");
+ for (const dir of [home, cache, runtime, temp]) {
+ mkdirSync(dir, { recursive: true });
+ }
+
+ return {
+ temp,
+ env: {
+ ...process.env,
+ HOME: home,
+ USERPROFILE: home,
+ XDG_CACHE_HOME: cache,
+ XDG_RUNTIME_DIR: runtime,
+ TMPDIR: temp,
+ BUN_TMPDIR: temp,
+ TEMP: temp,
+ TMP: temp,
+ ...(port === undefined ? {} : { HUNK_MCP_PORT: String(port) }),
+ },
+ };
+}
+
+/** Return Bun's hidden native-library extraction artifacts from an isolated temp directory. */
+function nativeArtifacts(temp: string) {
+ return readdirSync(temp).filter((name) => BUN_NATIVE_ARTIFACT_PATTERN.test(name));
+}
+
+/** Quote one path for the Bash command used to provide file-backed pager stdin. */
+function shellQuote(value: string) {
+ return `'${value.replaceAll("'", `'\\''`)}'`;
+}
+
+/** Reserve and release one loopback port for the compiled daemon test. */
+async function reserveFreePort() {
+ const listener = createServer(() => undefined);
+ await new Promise((resolveListen, reject) => {
+ listener.once("error", reject);
+ listener.listen(0, "127.0.0.1", resolveListen);
+ });
+ const address = listener.address();
+ const port = typeof address === "object" && address ? address.port : 0;
+ await new Promise((resolveClose) => listener.close(() => resolveClose()));
+ return port;
+}
+
+/** Wait until the compiled session daemon responds to its health endpoint. */
+async function waitForDaemon(port: number) {
+ const deadline = Date.now() + 5_000;
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(`http://127.0.0.1:${port}/health`);
+ if (response.ok) {
+ return;
+ }
+ } catch {
+ // The daemon may still be binding its loopback listener.
+ }
+ await Bun.sleep(50);
+ }
+ throw new Error("Timed out waiting for the compiled Hunk daemon.");
+}
+
+describe("compiled headless native-library loading", () => {
+ // Calibrate the platform temp path and filename matcher before trusting negative assertions.
+ compiledTest("detects extraction from an eager OpenTUI positive control", () => {
+ const { env, temp } = createTestEnvironment();
+ const proc = Bun.spawnSync([positiveControlExecutable!], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ expect(proc.exitCode).toBe(0);
+ expect(nativeArtifacts(temp).length).toBeGreaterThan(0);
+ });
+
+ compiledTest("does not extract OpenTUI for short-lived headless commands", () => {
+ const { env, temp } = createTestEnvironment();
+ const commands: Array<{ args: string[]; stdin?: string }> = [
+ { args: ["--help"] },
+ { args: ["--version"] },
+ { args: ["session", "--help"] },
+ { args: ["skill", "path"] },
+ { args: ["pager"], stdin: "plain pager text\n" },
+ {
+ args: ["pager"],
+ stdin: "diff --git a/a.txt b/a.txt\n@@ -1 +1 @@\n-old\n+new\n",
+ },
+ ];
+
+ for (const command of commands) {
+ const proc = Bun.spawnSync([executable!, ...command.args], {
+ env,
+ stdin: command.stdin === undefined ? "ignore" : Buffer.from(command.stdin),
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+ expect(proc.exitCode).toBe(0);
+ expect(nativeArtifacts(temp)).toEqual([]);
+ }
+ });
+
+ compiledLinuxTest("keeps captured-host static pager rendering OpenTUI-free", () => {
+ const { env, temp } = createTestEnvironment();
+ const patch =
+ "diff --git a/a.txt b/a.txt\nindex 7898192..6178079 100644\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-old\n+new\n";
+ const proc = Bun.spawnSync(
+ ["script", "-qec", `${shellQuote(executable!)} pager`, "/dev/null"],
+ {
+ env: {
+ ...env,
+ TERM: "dumb",
+ LAZYGIT_CONFIG_DIR: temp,
+ },
+ stdin: Buffer.from(patch),
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+ );
+
+ expect(proc.exitCode).toBe(0);
+ expect(Buffer.from(proc.stdout).toString("utf8")).toContain("a.txt");
+ expect(nativeArtifacts(temp)).toEqual([]);
+ });
+
+ compiledTest("keeps the daemon and session polling paths OpenTUI-free", async () => {
+ const port = await reserveFreePort();
+ const { env, temp } = createTestEnvironment(port);
+ const daemon = Bun.spawn([executable!, "daemon", "serve"], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ try {
+ await waitForDaemon(port);
+ const sessionList = Bun.spawnSync([executable!, "session", "list"], {
+ env,
+ stdin: "ignore",
+ stdout: "pipe",
+ stderr: "pipe",
+ });
+
+ expect(sessionList.exitCode).toBe(0);
+ expect(Buffer.from(sessionList.stdout).toString("utf8")).toContain(
+ "No active Hunk sessions.",
+ );
+ expect(nativeArtifacts(temp)).toEqual([]);
+ } finally {
+ daemon.kill("SIGTERM");
+ await daemon.exited;
+ }
+ });
+});
diff --git a/test/cli/fixtures/compiled-opentui-positive-control.ts b/test/cli/fixtures/compiled-opentui-positive-control.ts
new file mode 100644
index 00000000..761a1aa7
--- /dev/null
+++ b/test/cli/fixtures/compiled-opentui-positive-control.ts
@@ -0,0 +1,3 @@
+import { RGBA } from "@opentui/core";
+
+process.stdout.write(RGBA.fromHex("#ffffff").toString());