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
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 @@ -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
Expand Down Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions benchmarks/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const defaultScripts = [
"large-stream.ts",
"interaction-latency.ts",
"non-ascii-stream.ts",
"terminal-width.ts",
];

interface RunOptions {
Expand Down
72 changes: 72 additions & 0 deletions benchmarks/terminal-width.ts
Original file line number Diff line number Diff line change
@@ -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);
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
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 { 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 {
Expand Down Expand Up @@ -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(", ")}.`,
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 @@ -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,
Expand All @@ -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(
<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
Loading