diff --git a/.changeset/flat-rivers-diff.md b/.changeset/flat-rivers-diff.md new file mode 100644 index 000000000..9754aa481 --- /dev/null +++ b/.changeset/flat-rivers-diff.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add a `hunkdiff/static` API for rendering unified patches as ANSI terminal output without starting an interactive review. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f8d014a6f..c0c63bed8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 51f953d0d..16e2132ff 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -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 diff --git a/README.md b/README.md index ddaf6c024..b4671c009 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/benchmarks/README.md b/benchmarks/README.md index 69201ed33..939a0ab42 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -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. diff --git a/benchmarks/large-stream-fixture.ts b/benchmarks/large-stream-fixture.ts index 82d70a667..f3043303e 100644 --- a/benchmarks/large-stream-fixture.ts +++ b/benchmarks/large-stream-fixture.ts @@ -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; } diff --git a/benchmarks/non-ascii-stream.ts b/benchmarks/non-ascii-stream.ts index 9ce73db95..3717e4037 100644 --- a/benchmarks/non-ascii-stream.ts +++ b/benchmarks/non-ascii-stream.ts @@ -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"; diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts index b231db680..b9d070112 100644 --- a/benchmarks/terminal-width.ts +++ b/benchmarks/terminal-width.ts @@ -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; @@ -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); diff --git a/bun.lock b/bun.lock index f8dd0c36e..045791263 100644 --- a/bun.lock +++ b/bun.lock @@ -10,9 +10,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": { @@ -466,7 +466,7 @@ "string-dedent": ["string-dedent@3.0.2", "", {}, "sha512-M4q+HpHCtGXlbyzYDOcOo7V185dlq6YXvGUPcWZqL4vttCX9gFYoWIOxcPd7v5CAYcTJsGLs3ZJCAH2TXONF/g=="], - "string-width": ["string-width@8.2.1", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], @@ -526,8 +526,6 @@ "@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - "@opentui/core/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "cli-truncate/string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "ghostty-opentui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -542,12 +540,8 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "wrap-ansi/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "@opentui/core/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], } } diff --git a/docs/static-renderer.md b/docs/static-renderer.md new file mode 100644 index 000000000..1f696cab1 --- /dev/null +++ b/docs/static-renderer.md @@ -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. | diff --git a/package.json b/package.json index 5d0e23b5b..b41eb75b3 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 42a682a08..0d9efb860 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -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"); @@ -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 = [ @@ -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([ @@ -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")}`); diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index cdd0ba637..9b2db8a91 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -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"; @@ -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", @@ -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/", diff --git a/src/core/loaders.ts b/src/core/loaders.ts index e630939bc..20cdb3fb1 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -1,17 +1,11 @@ -import { - parseDiffFromFile, - parsePatchFiles, - type FileContents, - type FileDiffMetadata, -} from "@pierre/diffs"; +import { parseDiffFromFile, type FileContents, type FileDiffMetadata } from "@pierre/diffs"; import { createTwoFilesPatch } from "diff"; import { resolve as resolvePath } from "node:path"; import { findAgentFileContext, loadAgentContext } from "./agent"; import { createSkippedBinaryMetadata, isProbablyBinaryFile } from "./binary"; import { buildDiffFile, type BuildDiffFileOptions, type DiffFileSourceContext } from "./diffFile"; import { createFileSourceFetcher, type FileSourceSpec } from "./fileSource"; -import { splitPatchIntoFileChunks, findPatchChunk } from "./patch/chunks"; -import { normalizePatch, stripTerminalControl } from "./patch/normalize"; +import { normalizePatchChangeset } from "./patch/changeset"; import { DEFAULT_TAB_WIDTH } from "./tabWidth"; import { getConfiguredVcsAdapter, loadVcsReview, operationFromInput } from "./vcs"; import type { VcsAdapter } from "./vcs/types"; @@ -24,8 +18,6 @@ import type { CliInput, NamedCustomThemeConfig, DiffFile, - DiffLineMoveKind, - DiffLineMoveKinds, DiffToolCommandInput, FileCommandInput, PatchCommandInput, @@ -67,113 +59,6 @@ function createSourceFetcherBuilder( }; } -/** Return SGR parameter strings that Git emitted before one diff line marker. */ -function leadingSgrParameters(rawLine: string, expectedSign: "+" | "-") { - const parameters: string[] = []; - let index = 0; - - while (index < rawLine.length) { - if (rawLine[index] === "\x1b") { - const csi = rawLine.slice(index).match(/^\x1b\[([0-?]*)([ -/]*)([@-~])/); - if (csi) { - if (csi[3] === "m") { - parameters.push(csi[1] ?? ""); - } - index += csi[0].length; - continue; - } - } - - return rawLine[index] === expectedSign ? parameters : []; - } - - return []; -} - -/** Return whether one SGR parameter list contains the Git color Hunk reserves for moved lines. */ -function sgrContainsColor(parameters: string[], colorCode: "35" | "36") { - return parameters.some((parameter) => parameter.split(";").includes(colorCode)); -} - -/** Classify one ANSI-colored Git diff line as moved when it carries Hunk's reserved color. */ -function movedLineKindFromAnsi( - rawLine: string, - side: "addition" | "deletion", -): DiffLineMoveKind | undefined { - const colorCode = side === "addition" ? "36" : "35"; - const sign = side === "addition" ? "+" : "-"; - return sgrContainsColor(leadingSgrParameters(rawLine, sign), colorCode) ? "moved" : undefined; -} - -/** Capture Git's color-moved ANSI classes before the normal patch parser strips colors. */ -function collectLineMoveKinds(patchText: string): DiffLineMoveKinds[] { - const files: DiffLineMoveKinds[] = []; - let current: DiffLineMoveKinds | null = null; - let inHunk = false; - let additionLineIndex = 0; - let deletionLineIndex = 0; - - const createFileMoveKinds = () => { - const moveKinds: DiffLineMoveKinds = { additionLines: [], deletionLines: [] }; - files.push(moveKinds); - inHunk = false; - additionLineIndex = 0; - deletionLineIndex = 0; - return moveKinds; - }; - - for (const rawLine of patchText.replaceAll("\r\n", "\n").split("\n")) { - const plainLine = stripTerminalControl(rawLine); - - if (plainLine.startsWith("diff --git ")) { - current = createFileMoveKinds(); - continue; - } - - if (!current && (plainLine.startsWith("--- ") || plainLine.startsWith("@@ "))) { - current = createFileMoveKinds(); - } - - const activeMoveKinds = current; - if (!activeMoveKinds) { - continue; - } - - if (plainLine.startsWith("@@ ")) { - inHunk = true; - continue; - } - - if (!inHunk) { - continue; - } - - if (plainLine.startsWith("+") && !plainLine.startsWith("+++")) { - activeMoveKinds.additionLines[additionLineIndex] = movedLineKindFromAnsi(rawLine, "addition"); - additionLineIndex += 1; - continue; - } - - if (plainLine.startsWith("-") && !plainLine.startsWith("---")) { - activeMoveKinds.deletionLines[deletionLineIndex] = movedLineKindFromAnsi(rawLine, "deletion"); - deletionLineIndex += 1; - continue; - } - - if (plainLine.startsWith(" ")) { - additionLineIndex += 1; - deletionLineIndex += 1; - } - } - - return files; -} - -/** Return whether one file has any captured moved-line classifications. */ -function hasLineMoveKinds(moveKinds: DiffLineMoveKinds | undefined) { - return Boolean(moveKinds?.additionLines.some(Boolean) || moveKinds?.deletionLines.some(Boolean)); -} - /** Reorder files to follow agent-context narrative order when a sidecar provides one. */ export function orderDiffFiles(files: DiffFile[], agentContext: AgentContext | null) { if (!agentContext || agentContext.files.length === 0) { @@ -211,67 +96,6 @@ export function orderDiffFiles(files: DiffFile[], agentContext: AgentContext | n .map((entry) => entry.file); } -/** Parse raw patch text into the shared changeset model used by the app. */ -function normalizePatchChangeset( - patchText: string, - title: string, - sourceLabel: string, - agentContext: AgentContext | null, - perFileOptions?: Pick, -): Changeset { - const lineMoveKinds = collectLineMoveKinds(patchText); - const normalizedPatch = normalizePatch(patchText); - const normalizedPatchText = normalizedPatch.text; - - let parsedPatches: ReturnType; - try { - parsedPatches = parsePatchFiles(normalizedPatchText, "patch", true); - } catch { - return { - id: `changeset:${Date.now()}`, - sourceLabel, - title, - summary: normalizedPatchText.trim() || undefined, - agentSummary: agentContext?.summary, - files: [], - }; - } - - const metadataFiles = parsedPatches.flatMap((entry) => entry.files); - const chunks = splitPatchIntoFileChunks(normalizedPatchText); - - return { - id: `changeset:${Date.now()}`, - sourceLabel, - title, - summary: - parsedPatches - .map((entry) => entry.patchMetadata) - .filter(Boolean) - .join("\n\n") || undefined, - agentSummary: agentContext?.summary, - files: metadataFiles.map((metadata, index) => { - const decodedPaths = normalizedPatch.filePaths[index]; - const normalizedMetadata = decodedPaths - ? { ...metadata, name: decodedPaths.path, prevName: decodedPaths.previousPath } - : metadata; - - return buildDiffFile( - normalizedMetadata, - findPatchChunk(metadata, chunks, index), - index, - sourceLabel, - agentContext, - { - ...perFileOptions, - pathsAreExact: Boolean(decodedPaths), - lineMoveKinds: hasLineMoveKinds(lineMoveKinds[index]) ? lineMoveKinds[index] : undefined, - }, - ); - }), - }; -} - /** Return the change type to show when direct file comparison skips binary contents. */ function resolveBinaryComparisonType( leftPath: string, diff --git a/src/core/patch/changeset.ts b/src/core/patch/changeset.ts new file mode 100644 index 000000000..cae70f21d --- /dev/null +++ b/src/core/patch/changeset.ts @@ -0,0 +1,173 @@ +import { parsePatchFiles } from "@pierre/diffs"; +import { buildDiffFile, type BuildDiffFileOptions } from "../diffFile"; +import type { AgentContext, Changeset, DiffLineMoveKind, DiffLineMoveKinds } from "../types"; +import { splitPatchIntoFileChunks, findPatchChunk } from "./chunks"; +import { normalizePatch, stripTerminalControl } from "./normalize"; + +/** Return SGR parameter strings that Git emitted before one diff line marker. */ +function leadingSgrParameters(rawLine: string, expectedSign: "+" | "-") { + const parameters: string[] = []; + let index = 0; + + while (index < rawLine.length) { + if (rawLine[index] === "\x1b") { + const csi = rawLine.slice(index).match(/^\x1b\[([0-?]*)([ -/]*)([@-~])/); + if (csi) { + if (csi[3] === "m") { + parameters.push(csi[1] ?? ""); + } + index += csi[0].length; + continue; + } + } + + return rawLine[index] === expectedSign ? parameters : []; + } + + return []; +} + +/** Return whether one SGR parameter list contains the Git color Hunk reserves for moved lines. */ +function sgrContainsColor(parameters: string[], colorCode: "35" | "36") { + return parameters.some((parameter) => parameter.split(";").includes(colorCode)); +} + +/** Classify one ANSI-colored Git diff line as moved when it carries Hunk's reserved color. */ +function movedLineKindFromAnsi( + rawLine: string, + side: "addition" | "deletion", +): DiffLineMoveKind | undefined { + const colorCode = side === "addition" ? "36" : "35"; + const sign = side === "addition" ? "+" : "-"; + return sgrContainsColor(leadingSgrParameters(rawLine, sign), colorCode) ? "moved" : undefined; +} + +/** Capture Git's color-moved ANSI classes before the normal patch parser strips colors. */ +function collectLineMoveKinds(patchText: string): DiffLineMoveKinds[] { + const files: DiffLineMoveKinds[] = []; + let current: DiffLineMoveKinds | null = null; + let inHunk = false; + let additionLineIndex = 0; + let deletionLineIndex = 0; + + const createFileMoveKinds = () => { + const moveKinds: DiffLineMoveKinds = { additionLines: [], deletionLines: [] }; + files.push(moveKinds); + inHunk = false; + additionLineIndex = 0; + deletionLineIndex = 0; + return moveKinds; + }; + + for (const rawLine of patchText.replaceAll("\r\n", "\n").split("\n")) { + const plainLine = stripTerminalControl(rawLine); + + if (plainLine.startsWith("diff --git ")) { + current = createFileMoveKinds(); + continue; + } + + if (!current && (plainLine.startsWith("--- ") || plainLine.startsWith("@@ "))) { + current = createFileMoveKinds(); + } + + const activeMoveKinds = current; + if (!activeMoveKinds) { + continue; + } + + if (plainLine.startsWith("@@ ")) { + inHunk = true; + continue; + } + + if (!inHunk) { + continue; + } + + if (plainLine.startsWith("+") && !plainLine.startsWith("+++")) { + activeMoveKinds.additionLines[additionLineIndex] = movedLineKindFromAnsi(rawLine, "addition"); + additionLineIndex += 1; + continue; + } + + if (plainLine.startsWith("-") && !plainLine.startsWith("---")) { + activeMoveKinds.deletionLines[deletionLineIndex] = movedLineKindFromAnsi(rawLine, "deletion"); + deletionLineIndex += 1; + continue; + } + + if (plainLine.startsWith(" ")) { + additionLineIndex += 1; + deletionLineIndex += 1; + } + } + + return files; +} + +/** Return whether one file has any captured moved-line classifications. */ +function hasLineMoveKinds(moveKinds: DiffLineMoveKinds | undefined) { + return Boolean(moveKinds?.additionLines.some(Boolean) || moveKinds?.deletionLines.some(Boolean)); +} + +/** Parse raw patch text into the shared changeset model used by every Hunk host. */ +export function normalizePatchChangeset( + patchText: string, + title: string, + sourceLabel: string, + agentContext: AgentContext | null, + perFileOptions?: Pick, +): Changeset { + const lineMoveKinds = collectLineMoveKinds(patchText); + const normalizedPatch = normalizePatch(patchText); + const normalizedPatchText = normalizedPatch.text; + + let parsedPatches: ReturnType; + try { + parsedPatches = parsePatchFiles(normalizedPatchText, "patch", true); + } catch { + return { + id: `changeset:${Date.now()}`, + sourceLabel, + title, + summary: normalizedPatchText.trim() || undefined, + agentSummary: agentContext?.summary, + files: [], + }; + } + + const metadataFiles = parsedPatches.flatMap((entry) => entry.files); + const chunks = splitPatchIntoFileChunks(normalizedPatchText); + + return { + id: `changeset:${Date.now()}`, + sourceLabel, + title, + summary: + parsedPatches + .map((entry) => entry.patchMetadata) + .filter(Boolean) + .join("\n\n") || undefined, + agentSummary: agentContext?.summary, + files: metadataFiles.map((metadata, index) => { + const decodedPaths = normalizedPatch.filePaths[index]; + const normalizedMetadata = decodedPaths + ? { ...metadata, name: decodedPaths.path, prevName: decodedPaths.previousPath } + : metadata; + + return buildDiffFile( + normalizedMetadata, + findPatchChunk(metadata, chunks, index), + index, + sourceLabel, + agentContext, + { + ...perFileOptions, + pathsAreExact: Boolean(decodedPaths), + lineMoveKinds: hasLineMoveKinds(lineMoveKinds[index]) ? lineMoveKinds[index] : undefined, + }, + ); + }), + }; +} diff --git a/src/static/index.ts b/src/static/index.ts new file mode 100644 index 000000000..13b6b3fe8 --- /dev/null +++ b/src/static/index.ts @@ -0,0 +1,44 @@ +import type { StaticDiffOptions } from "./types.js"; + +export type { StaticDiffOptions } from "./types.js"; + +type StaticRenderer = typeof import("../ui/staticDiffPager"); + +let rendererPromise: Promise | undefined; + +/** Load Pierre-backed rendering after providing the browser metadata its root entry expects. */ +function loadRenderer() { + rendererPromise ??= (async () => { + const runtime = globalThis as typeof globalThis & { + navigator?: Pick; + }; + const navigatorDescriptor = Object.getOwnPropertyDescriptor(runtime, "navigator"); + if (runtime.navigator === undefined) { + Object.defineProperty(runtime, "navigator", { + configurable: true, + value: { + maxTouchPoints: 0, + platform: "", + userAgent: "", + }, + }); + } + + try { + return await import("../ui/staticDiffPager"); + } finally { + if (navigatorDescriptor) { + Object.defineProperty(runtime, "navigator", navigatorDescriptor); + } else { + Reflect.deleteProperty(runtime, "navigator"); + } + } + })(); + return rendererPromise; +} + +/** Render a unified patch as ANSI text without starting Hunk's interactive application. */ +export async function renderStaticDiff(text: string, options: StaticDiffOptions = {}) { + const { renderStaticDiff: render } = await loadRenderer(); + return render(text, options); +} diff --git a/src/static/types.ts b/src/static/types.ts new file mode 100644 index 000000000..df9be58c4 --- /dev/null +++ b/src/static/types.ts @@ -0,0 +1,17 @@ +/** Options for rendering a unified patch as a non-interactive terminal diff. */ +export interface StaticDiffOptions { + /** Stack changed lines vertically or place deletion/addition lines side by side. Defaults to stack. */ + layout?: "stack" | "split"; + /** Built-in Hunk theme id. Unknown ids fall back to the default theme. */ + theme?: string; + /** Show old and new line-number gutters. Defaults to true. */ + lineNumbers?: boolean; + /** Show unified hunk headers. Defaults to true. */ + hunkHeaders?: boolean; + /** Source-code tab stop width from 1 through 16. Defaults to 4. */ + tabWidth?: number; + /** Keep neutral surfaces transparent while preserving changed-line backgrounds. */ + transparentBackground?: boolean; + /** Available terminal columns. Defaults to stdout columns or 120 when unavailable. */ + width?: number; +} diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 68d9a24b9..52d036e06 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -1,5 +1,5 @@ import { eastAsianWidth } from "get-east-asian-width"; -import stringWidth from "string-width"; +import emojiRegex from "emoji-regex"; import { sanitizeTerminalLine } from "../../lib/terminalText"; const printableAsciiRegex = /^[\u0020-\u007E]*$/; @@ -22,12 +22,107 @@ export function textClusters(text: string) { return Array.from(graphemeSegmenter.segment(text), (segment) => segment.segment); } -// 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. +// Hunk's terminal profile follows string-width 8.2.2 without its Node 20-only Unicode Sets +// syntax: https://github.com/sindresorhus/string-width/blob/64dc20cddd374df0ff43ba3469491ae98cf0cdfc/index.js const zeroWidthScalarRegex = - /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]$/u; + /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]$/u; +const zeroWidthClusterRegex = + /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Nonspacing_Mark}|\p{Enclosing_Mark}|\p{Surrogate})+$/u; +const leadingNonPrintingRegex = + /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Nonspacing_Mark}\p{Enclosing_Mark}\p{Surrogate}]+/u; +const spacingMarkRegex = /\p{Spacing_Mark}/u; const emojiModifierRegex = /^\p{Emoji_Modifier}$/u; const regionalIndicatorRegex = /^\p{Regional_Indicator}$/u; +const extendedPictographicRegex = /\p{Extended_Pictographic}/gu; +const unqualifiedKeycapRegex = /^[\d#*]\u20E3$/u; +const emojiSequenceRegex = emojiRegex(); + +/** Return whether a multi-scalar cluster follows Hunk's double-width emoji policy. */ +function isDoubleWidthEmojiCluster(cluster: string) { + emojiSequenceRegex.lastIndex = 0; + const emojiMatch = emojiSequenceRegex.exec(cluster); + if (emojiMatch?.index === 0 && emojiMatch[0].length === cluster.length) { + return true; + } + + if (unqualifiedKeycapRegex.test(cluster)) { + return true; + } + + // Minimally-qualified ZWJ sequences still render as one emoji in the terminals Hunk targets. + if (!cluster.includes("\u200D") || cluster.length > 50) { + return false; + } + + const pictographics = cluster.match(extendedPictographicRegex); + return pictographics !== null && pictographics.length >= 2; +} + +function isHangulLeadingJamo(codePoint: number) { + return ( + (codePoint >= 0x1100 && codePoint <= 0x115f) || (codePoint >= 0xa960 && codePoint <= 0xa97c) + ); +} + +function isHangulVowelJamo(codePoint: number | undefined) { + return ( + codePoint !== undefined && + ((codePoint >= 0x1160 && codePoint <= 0x11a7) || (codePoint >= 0xd7b0 && codePoint <= 0xd7c6)) + ); +} + +function isHangulTrailingJamo(codePoint: number | undefined) { + return ( + codePoint !== undefined && + ((codePoint >= 0x11a8 && codePoint <= 0x11ff) || (codePoint >= 0xd7cb && codePoint <= 0xd7fb)) + ); +} + +function isHangulJamo(codePoint: number) { + return ( + isHangulLeadingJamo(codePoint) || + isHangulVowelJamo(codePoint) || + isHangulTrailingJamo(codePoint) + ); +} + +/** Measure a Hangul Jamo cluster, or return null when ordinary cluster rules should handle it. */ +function measureHangulClusterWidth(cluster: string): number | null { + const codePoints: number[] = []; + for (const scalar of cluster) { + if (!zeroWidthScalarRegex.test(scalar)) { + codePoints.push(scalar.codePointAt(0)!); + } + } + + if (codePoints.length === 0) { + return null; + } + + let width = 0; + for (let index = 0; index < codePoints.length; index += 1) { + const codePoint = codePoints[index]!; + if (!isHangulJamo(codePoint)) { + if (width === 0) { + return null; + } + + for (let remaining = index; remaining < codePoints.length; remaining += 1) { + width += eastAsianWidth(codePoints[remaining]!); + } + return width; + } + + if (isHangulLeadingJamo(codePoint) && isHangulVowelJamo(codePoints[index + 1])) { + width += 2; + index += isHangulTrailingJamo(codePoints[index + 2]) ? 2 : 1; + continue; + } + + width += eastAsianWidth(codePoint); + } + return width; +} /** Return whether one scalar prepends itself to the following grapheme cluster. */ function isGraphemePrepend(codePoint: number) { @@ -102,11 +197,10 @@ export function measureSimpleSanitizedTextWidth(text: string) { } /** - * Measure one grapheme cluster in terminal cells, matching string-width on every input. + * Measure one grapheme cluster in terminal cells. * - * 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. + * Single-scalar emoji are East Asian Wide. Multi-scalar clusters additionally account for emoji + * sequences, composed Hangul, spacing marks, and Halfwidth/Fullwidth Forms. */ export function measureClusterWidth(cluster: string): number { const codePoint = cluster.codePointAt(0); @@ -120,7 +214,33 @@ export function measureClusterWidth(cluster: string): number { return zeroWidthScalarRegex.test(cluster) ? 0 : eastAsianWidth(codePoint); } - return stringWidth(cluster); + if (zeroWidthClusterRegex.test(cluster)) { + return 0; + } + + if (isDoubleWidthEmojiCluster(cluster)) { + return 2; + } + + const visibleCluster = cluster.replace(leadingNonPrintingRegex, ""); + const hangulWidth = measureHangulClusterWidth(visibleCluster); + if (hangulWidth !== null) { + return hangulWidth; + } + + let width = eastAsianWidth(visibleCluster.codePointAt(0)!); + let isFirstScalar = true; + for (const scalar of visibleCluster) { + if (isFirstScalar) { + isFirstScalar = false; + continue; + } + + if (spacingMarkRegex.test(scalar) || (scalar >= "\uFF00" && scalar <= "\uFFEF")) { + width += eastAsianWidth(scalar.codePointAt(0)!); + } + } + return width; } /** @@ -168,7 +288,16 @@ export function measureSanitizedTextWidth(text: string) { // Most source text is a sequence of independent scalars. Scan code points directly instead of // allocating Intl.Segmenter records; composition-sensitive text keeps the whole-string fallback. - return measureSimpleSanitizedTextWidth(text) ?? stringWidth(text); + const simpleWidth = measureSimpleSanitizedTextWidth(text); + if (simpleWidth !== null) { + return simpleWidth; + } + + let width = 0; + for (const cluster of textClusters(text)) { + width += measureClusterWidth(cluster); + } + return width; } /** Measure text in terminal cells, treating CJK and emoji clusters as wide. */ diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 12abf40af..1c3ec96c4 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -1,7 +1,6 @@ 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, @@ -249,8 +248,14 @@ describe("ui helpers", () => { { text: "\u0d4eക", width: 1, startsNewLine: false }, { text: "x", width: 1, startsNewLine: true }, ]); - for (const cluster of ["กำ", "ກຳ", "ガ", "カ゚"]) { - const width = stringWidth(cluster); + expect(measureTextWidth("ガ")).toBe(2); + expect(sliceTextByWidth("ガ", 0, 2)).toEqual({ text: "ガ", width: 2 }); + for (const [cluster, width] of [ + ["กำ", 1], + ["ກຳ", 1], + ["ガ", 2], + ["カ゚", 2], + ] as const) { expect(measureTextWidth(cluster)).toBe(width); expect(sliceTextByWidth(cluster, 0, width)).toEqual({ text: cluster, width }); expect(wrapTextByWidth(cluster, width)).toEqual([ @@ -286,39 +291,51 @@ describe("ui helpers", () => { expect(cellRangeToCharRange("\u200bab", 1, 1)).toEqual({ startIndex: 2, endIndex: 3 }); }); - test("cluster width measurement matches string-width across terminal text shapes", () => { + test("cluster width measurement follows Hunk's terminal profile", () => { const clusters = [ - "", - "\0", - "\u200b", - "\u0301", - "\ud800", - "─", - "·", - "日", - "👍", - "e\u0301", - "1\u20e3", - "🧑‍💻", - "\u1100\u1161\u11a8", - ]; - - for (const cluster of clusters) { - expect(measureClusterWidth(cluster)).toBe(stringWidth(cluster)); + ["", 0], + ["\0", 0], + ["\u200b", 0], + ["\u0301", 0], + ["\ud800", 0], + ["─", 1], + ["·", 1], + ["日", 2], + ["👍", 2], + ["e\u0301", 1], + ["1\u20e3", 2], + ["🧑‍💻", 2], + ["\u1100\u1161\u11a8", 2], + ["का", 2], + ["কি", 2], + ["ᄀ가", 4], + ["؀日", 2], + ["A゙", 2], + ["\u05b0", 0], + ["\u093e", 1], + ["⚠︎", 1], + ["⚠️", 2], + ["🖐🏻", 2], + ["🇦🇦", 1], + ["\u{1F02C}\uFE0F", 1], + ] as const; + + for (const [cluster, width] of clusters) { + expect(measureClusterWidth(cluster)).toBe(width); } const complexLines = [ - "日本語 scalar text 👍 🚀", - "🧑‍💻 👩‍🔬 terminal tools", - "👍🏽 emoji modifier", - "1️⃣ keycap sequence", - "♥️ variation selector", - "🇯🇵 regional indicators", - "e\u0301 a\u0308 combining text", - "\u1100\u1161\u11a8 Hangul Jamo", - ]; - for (const line of complexLines) { - expect(measureTextWidth(line)).toBe(stringWidth(line)); + ["日本語 scalar text 👍 🚀", 24], + ["🧑‍💻 👩‍🔬 terminal tools", 20], + ["👍🏽 emoji modifier", 17], + ["1️⃣ keycap sequence", 18], + ["♥️ variation selector", 21], + ["🇯🇵 regional indicators", 22], + ["e\u0301 a\u0308 combining text", 18], + ["\u1100\u1161\u11a8 Hangul Jamo", 14], + ] as const; + for (const [line, width] of complexLines) { + expect(measureTextWidth(line)).toBe(width); } }); @@ -332,14 +349,14 @@ describe("ui helpers", () => { expect(measureTextWidth("好".repeat(120))).toBe(240); expect(fitText("好".repeat(4), 6)).toBe("好好."); - // Surrogate-pair runs (emoji) skip the fast path and stay correct via string-width. + // Surrogate-pair runs (emoji) skip the repeated-code-unit fast path and stay correct. expect(measureTextWidth("👍".repeat(3))).toBe(6); // Zero-width and composition-sensitive repeated scalars defer to whole grapheme measurement. expect(measureTextWidth("\u0301".repeat(4))).toBe(0); expect(measureTextWidth("e\u0301")).toBe(1); for (const scalar of ["\u0d4e", "ำ", "ຳ"]) { - expect(measureTextWidth(scalar.repeat(2))).toBe(stringWidth(scalar.repeat(2))); + expect(measureTextWidth(scalar.repeat(2))).toBe(1); } }); diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index d0f924027..2a33bb66f 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { renderStaticDiff } from "../static"; import { renderStaticDiffPager } from "./staticDiffPager"; +import { resolveTheme } from "./themes"; function stripAnsi(text: string) { return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); @@ -30,7 +35,25 @@ function expectNoUnsafeTerminalControls(text: string) { expect(text).not.toContain("\x1b"); } +function ansiBackground(hex: string) { + const value = hex.replace(/^#/, ""); + return `\x1b[48;2;${Number.parseInt(value.slice(0, 2), 16)};${Number.parseInt( + value.slice(2, 4), + 16, + )};${Number.parseInt(value.slice(4, 6), 16)}m`; +} + describe("static diff pager", () => { + test("renders a patch through the public static API", async () => { + const patchText = + "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"; + + const output = await renderStaticDiff(patchText, { layout: "stack", width: 80 }); + + expect(stripAnsi(output)).toContain("a.ts modified +1 -1"); + expect(output).toContain("\x1b[38;2;"); + }); + test("renders diff-like stdin as non-interactive ANSI output", async () => { const patchText = "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"; @@ -47,6 +70,61 @@ describe("static diff pager", () => { expect(output).not.toContain("\x1b[?1049h"); }); + test("preserves Git moved-line colors in pager output", async () => { + const patchText = [ + "diff --git a/a.ts b/a.ts", + "--- a/a.ts", + "+++ b/a.ts", + "@@ -1 +1 @@", + "\x1b[35m-const value = 1;\x1b[m", + "\x1b[36m+const value = 2;\x1b[m", + "", + ].join("\n"); + + const output = await renderStaticDiffPager(patchText); + const theme = resolveTheme(undefined, null); + + expect(output).toContain(ansiBackground(theme.movedRemovedBg)); + expect(output).toContain(ansiBackground(theme.movedAddedBg)); + }); + + test("preserves agent-sidecar file order in pager output", async () => { + const directory = mkdtempSync(join(tmpdir(), "hunk-static-agent-order-")); + const sidecar = join(directory, "agent.json"); + writeFileSync( + sidecar, + JSON.stringify({ + version: 1, + files: [ + { path: "beta.ts", annotations: [] }, + { path: "alpha.ts", annotations: [] }, + ], + }), + ); + const patchText = [ + "diff --git a/alpha.ts b/alpha.ts", + "--- a/alpha.ts", + "+++ b/alpha.ts", + "@@ -1 +1 @@", + "-export const alpha = 1;", + "+export const alpha = 2;", + "diff --git a/beta.ts b/beta.ts", + "--- a/beta.ts", + "+++ b/beta.ts", + "@@ -1 +1 @@", + "-export const beta = 1;", + "+export const beta = 2;", + "", + ].join("\n"); + + try { + const output = stripAnsi(await renderStaticDiffPager(patchText, { agentContext: sidecar })); + expect(output.indexOf("beta.ts modified")).toBeLessThan(output.indexOf("alpha.ts modified")); + } finally { + rmSync(directory, { recursive: true, force: true }); + } + }); + test("honors configured hidden line numbers and hunk headers", async () => { const patchText = "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"; diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts index 787df0c5a..7c9475eab 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -14,9 +14,10 @@ * here. If the static renderer cannot parse or render safely, callers fall back to the original patch * text so pager pipelines keep working. */ -import { loadAppBootstrap } from "../core/loaders"; +import { normalizePatchChangeset } from "../core/patch/changeset"; import { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { CommonOptions, DiffFile, NamedCustomThemeConfig } from "../core/types"; +import type { StaticDiffOptions } from "../static/types.js"; import { buildSplitRows, buildStackRows, @@ -384,6 +385,41 @@ function warnFallback(deps: StaticDiffPagerDeps, reason: string) { ); } +/** Render normalized diff files through Hunk's static ANSI presentation pipeline. */ +async function renderStaticFiles( + files: DiffFile[], + options: CommonOptions, + theme: AppTheme, + width: number, +) { + if (files.length === 0) { + throw new Error("No diff files could be parsed."); + } + + const rendered = await Promise.all( + files.map((file) => renderStaticFile(file, theme, options, width)), + ); + return `${rendered.join("\n\n")}\n`; +} + +/** Render a unified patch as ANSI text without starting Hunk's interactive application. */ +export async function renderStaticDiff(text: string, options: StaticDiffOptions = {}) { + const changeset = normalizePatchChangeset(text, "Static diff", "static", null); + const commonOptions: CommonOptions = { + hunkHeaders: options.hunkHeaders, + lineNumbers: options.lineNumbers, + mode: options.layout, + tabWidth: options.tabWidth, + theme: options.theme, + transparentBackground: options.transparentBackground, + }; + const theme = commonOptions.transparentBackground + ? withTransparentSurfaces(resolveTheme(commonOptions.theme, null)) + : resolveTheme(commonOptions.theme, null); + const width = resolveStaticWidth({ terminalColumns: options.width }); + return renderStaticFiles(changeset.files, commonOptions, theme, width); +} + /** Render diff-like pager stdin as colored static output, falling back to the original patch on failure. */ export async function renderStaticDiffPager( text: string, @@ -391,6 +427,7 @@ export async function renderStaticDiffPager( deps: StaticDiffPagerDeps = { stderr: process.stderr }, ) { try { + const { loadAppBootstrap } = await import("../core/loaders"); const bootstrap = await loadAppBootstrap({ kind: "patch", file: "-", @@ -405,16 +442,7 @@ export async function renderStaticDiffPager( ? withTransparentSurfaces(resolvedTheme) : resolvedTheme; const width = resolveStaticWidth(deps); - const rendered = await Promise.all( - bootstrap.changeset.files.map((file) => renderStaticFile(file, theme, options, width)), - ); - - if (rendered.length === 0) { - warnFallback(deps, "no files rendered"); - return sanitizeTerminalText(text); - } - - return `${rendered.join("\n\n")}\n`; + return await renderStaticFiles(bootstrap.changeset.files, options, theme, width); } catch (error) { warnFallback(deps, fallbackMessage(error)); return sanitizeTerminalText(text); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 48e43c97d..b8b6926ae 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import stringWidth from "string-width"; import { createPtyHarness, dragMouse, rightmostColumnOf } from "./harness"; const harness = createPtyHarness(); @@ -70,14 +69,13 @@ describe("PTY layout", () => { throw new Error(`Expected wide and plain split rows in snapshot:\n${snapshot}`); } - const wideSeparatorIndex = wideLine.indexOf("▌", 1); - const plainSeparatorIndex = plainLine.indexOf("▌", 1); + const wideSeparatorIndex = wideLine.indexOf("▌", wideLine.indexOf("▌") + 1); + const plainSeparatorIndex = plainLine.indexOf("▌", plainLine.indexOf("▌") + 1); expect(wideSeparatorIndex).toBeGreaterThan(0); expect(plainSeparatorIndex).toBeGreaterThan(0); - expect(stringWidth(wideLine.slice(0, wideSeparatorIndex))).toBe( - stringWidth(plainLine.slice(0, plainSeparatorIndex)), - ); + // The Japanese prefix has three two-cell scalars; every other prefix character is ASCII. + expect(wideSeparatorIndex + 3).toBe(plainSeparatorIndex); } finally { session.close(); } diff --git a/tsconfig.static.json b/tsconfig.static.json new file mode 100644 index 000000000..b3f3d2da7 --- /dev/null +++ b/tsconfig.static.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/npm-types", + "rootDir": "./src" + }, + "include": [], + "files": ["src/static/index.ts"] +}