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());