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

Filter by extension

Filter by extension


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

Add a `hunkdiff/static` API for rendering unified patches as ANSI terminal output without starting an interactive review.
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,14 @@ jobs:
run: bun run test:tty-smoke

pack-npm:
name: Verify npm package
name: Verify npm package (Node ${{ matrix.node }})
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18.20.8, 20.20.0, 22]
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
Expand All @@ -123,7 +127,7 @@ jobs:
- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
node-version: ${{ matrix.node }}

- name: Install dependencies
run: bun install --frozen-lockfile
Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,38 @@ jobs:
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

static-node-compat:
name: Static renderer (Node ${{ matrix.node }})
needs: changes
if: needs.changes.outputs.code == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
node: [18.20.8, 20.20.0]
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

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

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ matrix.node }}

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

- name: Build npm runtime bundle
run: bun run build:npm

- name: Verify npm pack output
run: bun run check:pack

pr-validate:
name: Typecheck + Test + Smoke
needs: changes
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/ope

See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples.

### Static renderer

`hunkdiff/static` renders an existing unified patch as colored ANSI text without starting Hunk's interactive application. It is useful for terminal hosts that already have patch text and need stack or split presentation.

See [docs/static-renderer.md](docs/static-renderer.md) for the API and options.

## Examples

Ready-to-run demo diffs live in [`examples/`](examples/README.md).
Expand Down
4 changes: 2 additions & 2 deletions benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ bun run bench:competitors
- `highlight-prefetch.ts` — measures selected-file highlight startup and adjacent prefetch readiness.
- `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.
- `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 terminal-width calculation on content rather than chrome glyphs.
- `wrapped-cjk.ts` — reproduces issue #579 with 518 wrapped Japanese Markdown lines plus one pathological long logical line, includes renderer setup in first-frame latency, and measures immediate/coalesced frames from a real wheel burst.
- `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.
- `terminal-width.ts` — measures scalar-heavy CJK and emoji width calls plus the complex-cluster fallback, retaining width checksums so the measured work stays observable.
- `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
2 changes: 1 addition & 1 deletion benchmarks/large-stream-fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ interface LargeSplitStreamFixtureOptions {
linesPerFile?: number;
changedStartLine?: number;
changedEndLine?: number;
/** "non-ascii" embeds CJK/emoji/box-drawing chars in line content to exercise string-width. */
/** "non-ascii" embeds CJK/emoji/box-drawing chars to exercise terminal-width calculation. */
contentVariant?: ContentVariant;
}

Expand Down
2 changes: 1 addition & 1 deletion benchmarks/non-ascii-stream.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Benchmark first-frame and scroll-tick latency on a stream whose diff *content*
// contains CJK, emoji, and box-drawing characters. Non-ASCII content bypasses
// measureTextWidth's ASCII fast path, so this exercises the string-width cost on
// measureTextWidth's ASCII fast path, so this exercises complex terminal-width calculation on
// real line content rather than just chrome glyphs.
import { performance } from "node:perf_hooks";
import { testRender } from "@opentui/react/test-utils";
Expand Down
29 changes: 5 additions & 24 deletions benchmarks/terminal-width.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Benchmark Hunk's scalar fast path and complex-cluster fallback against string-width.
// Benchmark Hunk's scalar fast path and complex-cluster fallback.
import { performance } from "node:perf_hooks";
import stringWidth from "string-width";
import { measureTextWidth } from "../src/ui/lib/text";

const ITERATIONS = 2_000;
Expand Down Expand Up @@ -39,32 +38,14 @@ function measureWidthCalls(measure: WidthMeasure, corpus: WidthCorpus, iteration
return { elapsedMs: performance.now() - start, checksum };
}

/** Verify and time one deterministic terminal-text shape. */
/** 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)}`);
const measurement = measureWidthCalls(measureTextWidth, corpus, ITERATIONS);
console.log(`METRIC ${name}_text_width_ms=${measurement.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`);
console.log(`METRIC ${name}_width_checksum=${measurement.checksum}`);
}

measureScenario("cjk_scalar", CJK_SCALAR_LINES);
Expand Down
10 changes: 2 additions & 8 deletions bun.lock

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

46 changes: 46 additions & 0 deletions docs/static-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# Static renderer

`hunkdiff/static` turns a unified patch into Hunk's non-interactive ANSI output. Use it when your application already has patch text and needs a terminal-rendered diff without creating an OpenTUI application.

## Install

```bash
npm i hunkdiff
```

## Usage

```ts
import { renderStaticDiff } from "hunkdiff/static";

const patch = [
"diff --git a/greeting.ts b/greeting.ts",
"--- a/greeting.ts",
"+++ b/greeting.ts",
"@@ -1 +1 @@",
"-export const greeting = 'hello';",
"+export const greeting = 'hello, world';",
"",
].join("\n");

const output = await renderStaticDiff(patch, {
layout: "stack",
width: process.stdout.columns,
});

process.stdout.write(output);
```

The renderer sanitizes patch text before writing terminal output. It returns ANSI text and does not create an alternate screen, read input, or start Hunk's interactive review UI.

## Options

| Option | Description |
| ----------------------- | ----------------------------------------------------------------------------- |
| `layout` | `"stack"` (default) or `"split"` rendering. |
| `theme` | Built-in Hunk theme id. Unknown ids use the default theme. |
| `lineNumbers` | Show old and new line-number gutters. Defaults to `true`. |
| `hunkHeaders` | Show `@@` hunk headers. Defaults to `true`. |
| `tabWidth` | Source-code tab stop width from 1 through 16. Defaults to `4`. |
| `transparentBackground` | Leave neutral surfaces transparent while preserving changed-line backgrounds. |
| `width` | Available terminal columns. Defaults to stdout columns or 120. |
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@
"types": "./dist/npm/opentui/index.d.ts",
"import": "./dist/npm/opentui/index.js"
},
"./static": {
"types": "./dist/npm/static/index.d.ts",
"import": "./dist/npm/static/index.js"
},
"./package.json": "./package.json"
},
"publishConfig": {
Expand Down Expand Up @@ -114,9 +118,9 @@
"chokidar": "^4.0.3",
"commander": "^14.0.3",
"diff": "^8.0.3",
"emoji-regex": "^10.6.0",
"get-east-asian-width": "^1.5.0",
"shell-quote": "1.9.0",
"string-width": "^8.2.1",
"zod": "^4.3.6"
},
"devDependencies": {
Expand Down
27 changes: 27 additions & 0 deletions scripts/build-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const outdir = path.join(repoRoot, "dist", "npm");
const typesOutdir = path.join(repoRoot, "dist", "npm-types");
const opentuiOutdir = path.join(outdir, "opentui");
const opentuiTypesDir = path.join(typesOutdir, "opentui");
const staticOutdir = path.join(outdir, "static");
const staticTypesDir = path.join(typesOutdir, "static");
const extensionOutdir = path.join(outdir, "extension");
const extensionTypesOutdir = path.join(repoRoot, "dist", "npm-extension-types");

Expand Down Expand Up @@ -43,6 +45,7 @@ rmSync(outdir, { recursive: true, force: true });
rmSync(typesOutdir, { recursive: true, force: true });
rmSync(extensionTypesOutdir, { recursive: true, force: true });
mkdirSync(opentuiOutdir, { recursive: true });
mkdirSync(staticOutdir, { recursive: true });
mkdirSync(extensionOutdir, { recursive: true });

const opentuiNativePackages = [
Expand Down Expand Up @@ -113,6 +116,29 @@ for (const entry of readdirSync(opentuiTypesDir)) {
}
}

runBun([
"build",
path.join(repoRoot, "src", "static", "index.ts"),
"--target",
"node",
"--format",
"esm",
"--splitting",
"--external",
"@pierre/diffs",
"--outdir",
staticOutdir,
"--entry-naming",
"index.js",
]);

runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.static.json")]);
for (const entry of readdirSync(staticTypesDir)) {
if (entry.endsWith(".d.ts")) {
copyFileSync(path.join(staticTypesDir, entry), path.join(staticOutdir, entry));
}
}

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

runBun([
Expand Down Expand Up @@ -146,4 +172,5 @@ rmSync(extensionTypesOutdir, { recursive: true, force: true });

console.log(`Built ${mainJs}`);
console.log(`Built ${path.join(opentuiOutdir, "index.js")}`);
console.log(`Built ${path.join(staticOutdir, "index.js")}`);
console.log(`Built ${path.join(extensionOutdir, "index.js")}`);
46 changes: 46 additions & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { checkExtensionConsumerTypes } from "./extension-consumer-check";
import { buildDocExamples } from "./extension-doc-examples";
import { npmCommand } from "./script-helpers";
Expand Down Expand Up @@ -262,6 +263,9 @@ const requiredPaths = [
"dist/npm/extension/index.js",
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"dist/npm/static/index.d.ts",
"dist/npm/static/index.js",
"dist/npm/static/types.d.ts",
"README.md",
"LICENSE",
"package.json",
Expand All @@ -277,6 +281,48 @@ for (const path of requiredPaths) {
}
}

const staticEntry = path.join(repoRoot, "dist", "npm", "static", "index.js");
const staticSmoke = Bun.spawnSync(
[
"node",
"--input-type=module",
"--eval",
`
const { renderStaticDiff } = await import(${JSON.stringify(pathToFileURL(staticEntry).href)});
const output = await renderStaticDiff(
"diff --git a/a.ts b/a.ts\\n--- a/a.ts\\n+++ b/a.ts\\n@@ -1 +1 @@\\n-const value = 1;\\n+const value = 2;\\n",
{ width: 80 },
);
const plain = output.replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, "");
if (!plain.includes("a.ts modified +1 -1")) {
throw new Error("The published static renderer did not render a patch.");
}
const wideOutput = await renderStaticDiff(
"diff --git a/a.txt b/a.txt\\n--- a/a.txt\\n+++ b/a.txt\\n@@ -1 +1 @@\\n-ガ\\tx\\n+ガ\\ty\\n",
{ layout: "split", lineNumbers: false, width: 40 },
);
const wideLine = wideOutput
.replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, "")
.split("\\n")
.find((line) => line.includes("ガ"));
if (!wideLine || wideLine.indexOf("▌", 1) !== 20) {
throw new Error("The static renderer misaligned halfwidth Katakana.");
}
`,
],
{
cwd: repoRoot,
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
env: process.env,
},
);
if (staticSmoke.exitCode !== 0) {
const output = Buffer.from(staticSmoke.stderr).toString("utf8").trim();
throw new Error(`The published static renderer failed under Node.\n${output}`);
}

const forbiddenPrefixes = [
".github/",
"src/",
Expand Down
Loading
Loading