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/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index c20aae747..34ace290a 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -27,6 +27,7 @@ const PRODUCTION_ENTRY_POINTS = [ "^src/main\\.tsx$", "^src/highlightWorkerEntry\\.ts$", "^src/opentui/index\\.ts$", + "^src/static/index\\.ts$", "^src/extension-api/index\\.ts$", "^src/hunk-review/skillDocument\\.ts$", ]; diff --git a/README.md b/README.md index d327d7a3f..cef62c912 100644 --- a/README.md +++ b/README.md @@ -282,6 +282,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 53d8182bb..4ec73b98b 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": { diff --git a/scripts/build-npm.ts b/scripts/build-npm.ts index 4ad4d95f5..04dc8c0b7 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 0edcf5d9c..d1ed3b300 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"; @@ -401,6 +402,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", @@ -416,6 +420,37 @@ 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."); + } + `, + ], + { + 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/changeset/loaders.ts b/src/core/changeset/loaders.ts index aece09bc4..9657bac71 100644 --- a/src/core/changeset/loaders.ts +++ b/src/core/changeset/loaders.ts @@ -256,7 +256,7 @@ async function loadVcsChangeset( } /** Build a changeset from patch text supplied by file or stdin. */ -async function loadPatchChangeset( +export async function loadPatchChangeset( input: PatchCommandInput, sidecar: SidecarContext | null, cwd = process.cwd(), diff --git a/src/opentui/model.ts b/src/opentui/model.ts index 533c1ad34..fe6d8030a 100644 --- a/src/opentui/model.ts +++ b/src/opentui/model.ts @@ -1,7 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs"; import { patchLooksBinary } from "../core/changeset/binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/changeset/diffPaths"; -import { countDiffStats } from "../core/changeset/diffFile"; +import { buildDiffFile, countDiffStats } from "../core/changeset/diffFile"; import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks"; import { sanitizePatch } from "../core/patch/sanitize"; import type { DiffFile } from "../core/changeset/model"; @@ -82,13 +82,25 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc ? { ...metadata, name: decodedPaths.path, prevName: decodedPaths.previousPath } : metadata; + const file = buildDiffFile( + normalizedMetadata, + findPatchChunk(metadata, chunks, index), + index, + sourceId, + null, + { pathsAreExact: Boolean(decodedPaths) }, + ); return buildHunkDiffFile( { - id: `${sourceId}:${index}:${normalizedMetadata.name}`, - metadata: normalizedMetadata, - patch: findPatchChunk(metadata, chunks, index), + id: file.id, + language: file.language, + metadata: file.metadata, + patch: file.patch, + path: file.path, + previousPath: file.previousPath, + stats: file.stats, }, - Boolean(decodedPaths), + true, ); }); } 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/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 9b4e00a24..b6b59a097 100644 --- a/src/ui/staticDiffPager.ts +++ b/src/ui/staticDiffPager.ts @@ -14,12 +14,13 @@ * 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/changeset/loaders"; import { reviewEmptyDiffReason, type ReviewEmptyDiffReason } from "../core/review/document"; +import { loadPatchChangeset } from "../core/changeset/loaders"; import { DEFAULT_TAB_WIDTH } from "../core/run/tabWidth"; import type { DiffFile } from "../core/changeset/model"; import type { CommonOptions } from "../core/run/commandInputs"; import type { NamedCustomThemeConfig } from "../extension-api/types"; +import type { StaticDiffOptions } from "../static/types.js"; import { buildSplitRows, buildStackRows, @@ -409,6 +410,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 commonOptions: CommonOptions = { + hunkHeaders: options.hunkHeaders, + lineNumbers: options.lineNumbers, + mode: options.layout, + tabWidth: options.tabWidth, + theme: options.theme, + transparentBackground: options.transparentBackground, + }; + const changeset = await loadPatchChangeset({ kind: "patch", text, options: commonOptions }, null); + 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, @@ -416,6 +452,7 @@ export async function renderStaticDiffPager( deps: StaticDiffPagerDeps = { stderr: process.stderr }, ) { try { + const { loadAppBootstrap } = await import("../core/changeset/loaders"); const bootstrap = await loadAppBootstrap({ kind: "patch", file: "-", @@ -430,16 +467,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/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"] +}