From 1443024f52ae279387a69954f314944f14d434eb Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Sat, 8 Aug 2026 22:34:11 +0900 Subject: [PATCH 1/4] feat: export static diff renderer --- .changeset/flat-rivers-diff.md | 5 +++ README.md | 6 ++++ docs/static-renderer.md | 46 +++++++++++++++++++++++++++ package.json | 4 +++ scripts/build-npm.ts | 26 +++++++++++++++ scripts/check-pack.ts | 17 ++++++++++ src/opentui/model.ts | 2 ++ src/static/index.ts | 8 +++++ src/static/types.ts | 17 ++++++++++ src/ui/staticDiffPager.test.ts | 11 +++++++ src/ui/staticDiffPager.ts | 58 ++++++++++++++++++++++------------ tsconfig.static.json | 12 +++++++ 12 files changed, 192 insertions(+), 20 deletions(-) create mode 100644 .changeset/flat-rivers-diff.md create mode 100644 docs/static-renderer.md create mode 100644 src/static/index.ts create mode 100644 src/static/types.ts create mode 100644 tsconfig.static.json 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/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/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 f03d0e69d..f0fe1207f 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,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": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 42a682a08..ce172fd1a 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,28 @@ for (const entry of readdirSync(opentuiTypesDir)) { } } +runBun([ + "build", + path.join(repoRoot, "src", "static", "index.ts"), + "--target", + "node", + "--format", + "esm", + "--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 +171,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 217ae74b9..eea36bf9e 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"; @@ -252,6 +253,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", @@ -263,6 +267,19 @@ for (const path of requiredPaths) { } } +const staticEntry = path.join(repoRoot, "dist", "npm", "static", "index.js"); +const staticRenderer = (await import(pathToFileURL(staticEntry).href)) as { + renderStaticDiff?: (patch: string, options?: { width?: number }) => Promise; +}; +const staticOutput = await staticRenderer.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 plainStaticOutput = staticOutput?.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); +if (!plainStaticOutput?.includes("a.ts modified +1 -1")) { + throw new Error("The published static renderer did not render a patch."); +} + const forbiddenPrefixes = [ ".github/", "src/", diff --git a/src/opentui/model.ts b/src/opentui/model.ts index 4ae4fa6e1..485955768 100644 --- a/src/opentui/model.ts +++ b/src/opentui/model.ts @@ -2,6 +2,7 @@ import { parsePatchFiles } from "@pierre/diffs"; import { patchLooksBinary } from "../core/binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/diffPaths"; import { countDiffStats } from "../core/diffFile"; +import { getFiletypeFromFileName } from "../core/fileLanguage"; import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks"; import { normalizePatch } from "../core/patch/normalize"; import type { DiffFile } from "../core/types"; @@ -85,6 +86,7 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc return buildHunkDiffFile( { id: `${sourceId}:${index}:${normalizedMetadata.name}`, + language: getFiletypeFromFileName(normalizedMetadata.name) ?? undefined, metadata: normalizedMetadata, patch: findPatchChunk(metadata, chunks, index), }, diff --git a/src/static/index.ts b/src/static/index.ts new file mode 100644 index 000000000..f8def0b6d --- /dev/null +++ b/src/static/index.ts @@ -0,0 +1,8 @@ +import { renderStaticDiff as renderStaticDiffInternal } from "../ui/staticDiffPager"; +import type { StaticDiffOptions } from "./types.js"; + +export type { StaticDiffOptions } from "./types.js"; + +/** Render a unified patch as ANSI text without starting Hunk's interactive application. */ +export const renderStaticDiff = (text: string, options: StaticDiffOptions = {}): Promise => + renderStaticDiffInternal(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/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index d0f924027..2257b7071 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { renderStaticDiff } from "../static"; import { renderStaticDiffPager } from "./staticDiffPager"; function stripAnsi(text: string) { @@ -31,6 +32,16 @@ function expectNoUnsafeTerminalControls(text: string) { } 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"; diff --git a/src/ui/staticDiffPager.ts b/src/ui/staticDiffPager.ts index 787df0c5a..0d18f92e1 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 { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { CommonOptions, DiffFile, NamedCustomThemeConfig } from "../core/types"; +import { createHunkDiffFilesFromPatch, toInternalDiffFile } from "../opentui/model"; +import type { StaticDiffOptions } from "../static/types.js"; import { buildSplitRows, buildStackRows, @@ -384,6 +385,41 @@ function warnFallback(deps: StaticDiffPagerDeps, reason: string) { ); } +/** Parse and render one patch through Hunk's static ANSI presentation pipeline. */ +async function renderStaticPatch( + text: string, + options: CommonOptions, + theme: AppTheme, + width: number, +) { + const files = createHunkDiffFilesFromPatch(text, "static").map(toInternalDiffFile); + 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 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 renderStaticPatch(text, 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,30 +427,12 @@ export async function renderStaticDiffPager( deps: StaticDiffPagerDeps = { stderr: process.stderr }, ) { try { - const bootstrap = await loadAppBootstrap({ - kind: "patch", - file: "-", - text, - options: { - ...options, - pager: true, - }, - }); const resolvedTheme = resolveTheme(options.theme, null, deps.customThemes); const theme = options.transparentBackground ? 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 renderStaticPatch(text, options, theme, width); } catch (error) { warnFallback(deps, fallbackMessage(error)); return sanitizeTerminalText(text); 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"] +} From 0ba9825331e5687167a37f1b5f14f0a9637f036b Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Tue, 11 Aug 2026 01:14:14 +0900 Subject: [PATCH 2/4] fix: preserve static renderer compatibility --- .github/workflows/ci.yml | 8 +- .github/workflows/pr-ci.yml | 32 ++++++ bun.lock | 10 +- package.json | 2 +- scripts/build-npm.ts | 1 + scripts/check-pack.ts | 36 +++++-- src/core/loaders.ts | 180 +-------------------------------- src/core/patch/changeset.ts | 173 +++++++++++++++++++++++++++++++ src/opentui/model.ts | 2 - src/static/index.ts | 42 +++++++- src/ui/staticDiffPager.test.ts | 67 ++++++++++++ src/ui/staticDiffPager.ts | 24 +++-- 12 files changed, 367 insertions(+), 210 deletions(-) create mode 100644 src/core/patch/changeset.ts 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/bun.lock b/bun.lock index f8dd0c36e..da06341a1 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "diff": "^8.0.3", "get-east-asian-width": "^1.5.0", "shell-quote": "1.9.0", - "string-width": "^8.2.1", + "string-width": "^7.2.0", "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/package.json b/package.json index 94d4a623c..ee0e652f5 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "diff": "^8.0.3", "get-east-asian-width": "^1.5.0", "shell-quote": "1.9.0", - "string-width": "^8.2.1", + "string-width": "^7.2.0", "zod": "^4.3.6" }, "devDependencies": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index ce172fd1a..0d9efb860 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -123,6 +123,7 @@ runBun([ "node", "--format", "esm", + "--splitting", "--external", "@pierre/diffs", "--outdir", diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index ffd5092dd..a0c1939c9 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -282,16 +282,34 @@ for (const path of requiredPaths) { } const staticEntry = path.join(repoRoot, "dist", "npm", "static", "index.js"); -const staticRenderer = (await import(pathToFileURL(staticEntry).href)) as { - renderStaticDiff?: (patch: string, options?: { width?: number }) => Promise; -}; -const staticOutput = await staticRenderer.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 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."); + } + `, + ], + { + cwd: repoRoot, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }, ); -const plainStaticOutput = staticOutput?.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); -if (!plainStaticOutput?.includes("a.ts modified +1 -1")) { - throw new Error("The published static renderer did not render a patch."); +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 = [ 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/opentui/model.ts b/src/opentui/model.ts index 485955768..4ae4fa6e1 100644 --- a/src/opentui/model.ts +++ b/src/opentui/model.ts @@ -2,7 +2,6 @@ import { parsePatchFiles } from "@pierre/diffs"; import { patchLooksBinary } from "../core/binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/diffPaths"; import { countDiffStats } from "../core/diffFile"; -import { getFiletypeFromFileName } from "../core/fileLanguage"; import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks"; import { normalizePatch } from "../core/patch/normalize"; import type { DiffFile } from "../core/types"; @@ -86,7 +85,6 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc return buildHunkDiffFile( { id: `${sourceId}:${index}:${normalizedMetadata.name}`, - language: getFiletypeFromFileName(normalizedMetadata.name) ?? undefined, metadata: normalizedMetadata, patch: findPatchChunk(metadata, chunks, index), }, diff --git a/src/static/index.ts b/src/static/index.ts index f8def0b6d..13b6b3fe8 100644 --- a/src/static/index.ts +++ b/src/static/index.ts @@ -1,8 +1,44 @@ -import { renderStaticDiff as renderStaticDiffInternal } from "../ui/staticDiffPager"; 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 const renderStaticDiff = (text: string, options: StaticDiffOptions = {}): Promise => - renderStaticDiffInternal(text, options); +export async function renderStaticDiff(text: string, options: StaticDiffOptions = {}) { + const { renderStaticDiff: render } = await loadRenderer(); + return render(text, options); +} diff --git a/src/ui/staticDiffPager.test.ts b/src/ui/staticDiffPager.test.ts index 2257b7071..2a33bb66f 100644 --- a/src/ui/staticDiffPager.test.ts +++ b/src/ui/staticDiffPager.test.ts @@ -1,6 +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, ""); @@ -31,6 +35,14 @@ 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 = @@ -58,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 0d18f92e1..7c9475eab 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -14,9 +14,9 @@ * here. If the static renderer cannot parse or render safely, callers fall back to the original patch * text so pager pipelines keep working. */ +import { normalizePatchChangeset } from "../core/patch/changeset"; import { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { CommonOptions, DiffFile, NamedCustomThemeConfig } from "../core/types"; -import { createHunkDiffFilesFromPatch, toInternalDiffFile } from "../opentui/model"; import type { StaticDiffOptions } from "../static/types.js"; import { buildSplitRows, @@ -385,14 +385,13 @@ function warnFallback(deps: StaticDiffPagerDeps, reason: string) { ); } -/** Parse and render one patch through Hunk's static ANSI presentation pipeline. */ -async function renderStaticPatch( - text: string, +/** Render normalized diff files through Hunk's static ANSI presentation pipeline. */ +async function renderStaticFiles( + files: DiffFile[], options: CommonOptions, theme: AppTheme, width: number, ) { - const files = createHunkDiffFilesFromPatch(text, "static").map(toInternalDiffFile); if (files.length === 0) { throw new Error("No diff files could be parsed."); } @@ -405,6 +404,7 @@ async function renderStaticPatch( /** 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, @@ -417,7 +417,7 @@ export async function renderStaticDiff(text: string, options: StaticDiffOptions ? withTransparentSurfaces(resolveTheme(commonOptions.theme, null)) : resolveTheme(commonOptions.theme, null); const width = resolveStaticWidth({ terminalColumns: options.width }); - return renderStaticPatch(text, commonOptions, theme, 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. */ @@ -427,12 +427,22 @@ export async function renderStaticDiffPager( deps: StaticDiffPagerDeps = { stderr: process.stderr }, ) { try { + const { loadAppBootstrap } = await import("../core/loaders"); + const bootstrap = await loadAppBootstrap({ + kind: "patch", + file: "-", + text, + options: { + ...options, + pager: true, + }, + }); const resolvedTheme = resolveTheme(options.theme, null, deps.customThemes); const theme = options.transparentBackground ? withTransparentSurfaces(resolvedTheme) : resolvedTheme; const width = resolveStaticWidth(deps); - return await renderStaticPatch(text, options, theme, width); + return await renderStaticFiles(bootstrap.changeset.files, options, theme, width); } catch (error) { warnFallback(deps, fallbackMessage(error)); return sanitizeTerminalText(text); From c80672da17c1374dc2567ff18bc37859a66296f5 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Tue, 11 Aug 2026 06:58:16 +0900 Subject: [PATCH 3/4] fix: preserve halfwidth terminal geometry --- bun.lock | 15 ++++++++++++-- package.json | 3 ++- scripts/build-npm.ts | 42 +++++++++++++++++++++++++-------------- scripts/check-pack.ts | 11 ++++++++++ src/ui/lib/text.ts | 10 ++++++++++ src/ui/lib/ui-lib.test.ts | 2 ++ 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/bun.lock b/bun.lock index da06341a1..fd0693b9b 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "diff": "^8.0.3", "get-east-asian-width": "^1.5.0", "shell-quote": "1.9.0", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "zod": "^4.3.6", }, "devDependencies": { @@ -34,6 +34,7 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", + "string-width-node18": "npm:string-width@^7.2.0", "tuistory": "^0.0.16", "typescript": "^5.9.3", }, @@ -466,7 +467,9 @@ "string-dedent": ["string-dedent@3.0.2", "", {}, "sha512-M4q+HpHCtGXlbyzYDOcOo7V185dlq6YXvGUPcWZqL4vttCX9gFYoWIOxcPd7v5CAYcTJsGLs3ZJCAH2TXONF/g=="], - "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=="], + "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + + "string-width-node18": ["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,6 +529,8 @@ "@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=="], @@ -540,8 +545,14 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "string-width-node18/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/package.json b/package.json index ee0e652f5..1c8b3331c 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,7 @@ "diff": "^8.0.3", "get-east-asian-width": "^1.5.0", "shell-quote": "1.9.0", - "string-width": "^7.2.0", + "string-width": "^8.2.1", "zod": "^4.3.6" }, "devDependencies": { @@ -142,6 +142,7 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", + "string-width-node18": "npm:string-width@^7.2.0", "tuistory": "^0.0.16", "typescript": "^5.9.3" }, diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 0d9efb860..d6c366131 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -41,6 +41,32 @@ function runBun(args: string[]) { } } +/** Build the Node static entry with a Node-18-compatible width engine. */ +async function buildStaticEntry() { + const result = await Bun.build({ + entrypoints: [path.join(repoRoot, "src", "static", "index.ts")], + target: "node", + format: "esm", + splitting: true, + external: ["@pierre/diffs"], + outdir: staticOutdir, + naming: { entry: "index.js" }, + plugins: [ + { + name: "static-node18-string-width", + setup(build) { + build.onResolve({ filter: /^string-width$/ }, () => ({ + path: Bun.resolveSync("string-width-node18", repoRoot), + })); + }, + }, + ], + }); + if (!result.success) { + throw new AggregateError(result.logs, "Static renderer build failed"); + } +} + rmSync(outdir, { recursive: true, force: true }); rmSync(typesOutdir, { recursive: true, force: true }); rmSync(extensionTypesOutdir, { recursive: true, force: true }); @@ -116,21 +142,7 @@ 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", -]); +await buildStaticEntry(); runBun(["x", "tsc", "-p", path.join(repoRoot, "tsconfig.static.json")]); for (const entry of readdirSync(staticTypesDir)) { diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index a0c1939c9..4310ab64f 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -297,6 +297,17 @@ const staticSmoke = Bun.spawnSync( 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-ガx\\n+ガy\\n", + { layout: "split", lineNumbers: false, width: 40 }, + ); + const wideLine = wideOutput + .replace(/\\x1b\\[[0-?]*[ -/]*[@-~]/g, "") + .split("\\n") + .find((line) => line.includes("ガx")); + if (!wideLine || wideLine.indexOf("▌", 1) !== 20) { + throw new Error("The static renderer misaligned halfwidth Katakana."); + } `, ], { diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 68d9a24b9..1c2999cb8 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -28,6 +28,7 @@ const zeroWidthScalarRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]$/u; const emojiModifierRegex = /^\p{Emoji_Modifier}$/u; const regionalIndicatorRegex = /^\p{Regional_Indicator}$/u; +const halfwidthKatakanaClusterRegex = /^[\uFF61-\uFF9F]+$/u; /** Return whether one scalar prepends itself to the following grapheme cluster. */ function isGraphemePrepend(codePoint: number) { @@ -120,6 +121,15 @@ export function measureClusterWidth(cluster: string): number { return zeroWidthScalarRegex.test(cluster) ? 0 : eastAsianWidth(codePoint); } + // Halfwidth Katakana combining marks form one grapheme but still occupy one cell per scalar. + if (halfwidthKatakanaClusterRegex.test(cluster)) { + let width = 0; + for (const scalar of cluster) { + width += eastAsianWidth(scalar.codePointAt(0)!); + } + return width; + } + return stringWidth(cluster); } diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 12abf40af..cca1159e7 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -249,6 +249,8 @@ describe("ui helpers", () => { { text: "\u0d4eക", width: 1, startsNewLine: false }, { text: "x", width: 1, startsNewLine: true }, ]); + expect(measureTextWidth("ガ")).toBe(2); + expect(sliceTextByWidth("ガ", 0, 2)).toEqual({ text: "ガ", width: 2 }); for (const cluster of ["กำ", "ກຳ", "ガ", "カ゚"]) { const width = stringWidth(cluster); expect(measureTextWidth(cluster)).toBe(width); From 7b4344956b0b7225b3322740ceb0c8b762e63cf8 Mon Sep 17 00:00:00 2001 From: Hiroki Osame Date: Tue, 11 Aug 2026 08:06:30 +0900 Subject: [PATCH 4/4] fix: unify terminal width behavior across runtimes --- benchmarks/README.md | 4 +- benchmarks/large-stream-fixture.ts | 2 +- benchmarks/non-ascii-stream.ts | 2 +- benchmarks/terminal-width.ts | 29 +----- bun.lock | 15 +-- package.json | 3 +- scripts/build-npm.ts | 42 +++----- scripts/check-pack.ts | 4 +- src/ui/lib/text.ts | 153 +++++++++++++++++++++++++---- src/ui/lib/ui-lib.test.ts | 83 +++++++++------- test/pty/layout.test.ts | 10 +- 11 files changed, 218 insertions(+), 129 deletions(-) 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 fd0693b9b..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": { @@ -34,7 +34,6 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", - "string-width-node18": "npm:string-width@^7.2.0", "tuistory": "^0.0.16", "typescript": "^5.9.3", }, @@ -467,9 +466,7 @@ "string-dedent": ["string-dedent@3.0.2", "", {}, "sha512-M4q+HpHCtGXlbyzYDOcOo7V185dlq6YXvGUPcWZqL4vttCX9gFYoWIOxcPd7v5CAYcTJsGLs3ZJCAH2TXONF/g=="], - "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], - - "string-width-node18": ["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=="], + "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=="], @@ -529,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=="], @@ -545,14 +540,8 @@ "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - "string-width-node18/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/package.json b/package.json index 1c8b3331c..b41eb75b3 100644 --- a/package.json +++ b/package.json @@ -118,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": { @@ -142,7 +142,6 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.13.1", - "string-width-node18": "npm:string-width@^7.2.0", "tuistory": "^0.0.16", "typescript": "^5.9.3" }, diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index d6c366131..0d9efb860 100644 --- a/scripts/build-npm.ts +++ b/scripts/build-npm.ts @@ -41,32 +41,6 @@ function runBun(args: string[]) { } } -/** Build the Node static entry with a Node-18-compatible width engine. */ -async function buildStaticEntry() { - const result = await Bun.build({ - entrypoints: [path.join(repoRoot, "src", "static", "index.ts")], - target: "node", - format: "esm", - splitting: true, - external: ["@pierre/diffs"], - outdir: staticOutdir, - naming: { entry: "index.js" }, - plugins: [ - { - name: "static-node18-string-width", - setup(build) { - build.onResolve({ filter: /^string-width$/ }, () => ({ - path: Bun.resolveSync("string-width-node18", repoRoot), - })); - }, - }, - ], - }); - if (!result.success) { - throw new AggregateError(result.logs, "Static renderer build failed"); - } -} - rmSync(outdir, { recursive: true, force: true }); rmSync(typesOutdir, { recursive: true, force: true }); rmSync(extensionTypesOutdir, { recursive: true, force: true }); @@ -142,7 +116,21 @@ for (const entry of readdirSync(opentuiTypesDir)) { } } -await buildStaticEntry(); +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)) { diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 4310ab64f..9b2db8a91 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -298,13 +298,13 @@ const staticSmoke = Bun.spawnSync( 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-ガx\\n+ガy\\n", + "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("ガx")); + .find((line) => line.includes("ガ")); if (!wideLine || wideLine.indexOf("▌", 1) !== 20) { throw new Error("The static renderer misaligned halfwidth Katakana."); } diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 1c2999cb8..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,13 +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 halfwidthKatakanaClusterRegex = /^[\uFF61-\uFF9F]+$/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) { @@ -103,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); @@ -121,16 +214,33 @@ export function measureClusterWidth(cluster: string): number { return zeroWidthScalarRegex.test(cluster) ? 0 : eastAsianWidth(codePoint); } - // Halfwidth Katakana combining marks form one grapheme but still occupy one cell per scalar. - if (halfwidthKatakanaClusterRegex.test(cluster)) { - let width = 0; - for (const scalar of 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; } - - return stringWidth(cluster); + return width; } /** @@ -178,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 cca1159e7..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, @@ -251,8 +250,12 @@ describe("ui helpers", () => { ]); expect(measureTextWidth("ガ")).toBe(2); expect(sliceTextByWidth("ガ", 0, 2)).toEqual({ text: "ガ", width: 2 }); - for (const cluster of ["กำ", "ກຳ", "ガ", "カ゚"]) { - const width = stringWidth(cluster); + 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([ @@ -288,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); } }); @@ -334,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/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(); }