From 143329206e8e45daabfbdb6dabec6570d87c6ce5 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 7 Apr 2026 23:57:31 -0400 Subject: [PATCH 1/4] feat(opentui): publish reusable diff component Expose Hunk's single-file diff view as a typed npm entrypoint so other OpenTUI apps can render diffs with the same terminal-native UI. --- README.md | 59 ++++++++++++++ package.json | 16 ++++ scripts/build-npm.sh | 21 ++++- scripts/check-pack.ts | 10 ++- src/opentui/HunkDiffView.test.tsx | 66 ++++++++++++++++ src/opentui/HunkDiffView.tsx | 124 ++++++++++++++++++++++++++++++ src/opentui/index.ts | 4 + src/opentui/themes.ts | 3 + src/opentui/types.ts | 28 +++++++ src/ui/diff/useHighlightedDiff.ts | 16 ++++ tsconfig.opentui.json | 12 +++ 11 files changed, 357 insertions(+), 2 deletions(-) create mode 100644 src/opentui/HunkDiffView.test.tsx create mode 100644 src/opentui/HunkDiffView.tsx create mode 100644 src/opentui/index.ts create mode 100644 src/opentui/themes.ts create mode 100644 src/opentui/types.ts create mode 100644 tsconfig.opentui.json diff --git a/README.md b/README.md index 1062834..0b887a3 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,12 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built npm i -g hunkdiff ``` +To use the reusable OpenTUI component in your own app: + +```bash +npm i hunkdiff +``` + Requirements: - Node.js 18+ @@ -89,6 +95,59 @@ Hunk is optimized for reviewing a full changeset interactively. ## Advanced +### OpenTUI component + +Hunk also publishes a reusable OpenTUI React diff component powered by the same terminal renderer that the app uses. + +Import it from `hunkdiff` or `hunkdiff/opentui`: + +```tsx +import { HunkDiffView, parseDiffFromFile } from "hunkdiff"; + +const metadata = parseDiffFromFile( + { + cacheKey: "before", + contents: "export const value = 1;\n", + name: "example.ts", + }, + { + cacheKey: "after", + contents: "export const value = 2;\nexport const added = true;\n", + name: "example.ts", + }, + { context: 3 }, + true, +); + +; +``` + +Public API: + +- `diff?: { id, metadata, language?, path?, patch? }` +- `layout?: "split" | "stack"` +- `width: number` +- `theme?: "graphite" | "midnight" | "paper" | "ember"` +- `showLineNumbers?: boolean` +- `showHunkHeaders?: boolean` +- `wrapLines?: boolean` +- `horizontalOffset?: number` +- `highlight?: boolean` +- `scrollable?: boolean` +- `selectedHunkIndex?: number` + +The component re-exports `parseDiffFromFile`, `parsePatchFiles`, and `FileDiffMetadata` from `@pierre/diffs` so callers can build the `metadata` input without adding a second diff dependency. + ### Config You can persist preferences to a config file: diff --git a/package.json b/package.json index 04ce9b2..bab8b97 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,17 @@ "LICENSE" ], "type": "module", + "exports": { + ".": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./opentui": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./package.json": "./package.json" + }, "publishConfig": { "access": "public" }, @@ -81,6 +92,11 @@ "tuistory": "^0.0.16", "typescript": "^5.9.3" }, + "peerDependencies": { + "@opentui/core": "^0.1.88", + "@opentui/react": "^0.1.88", + "react": "^19.2.4" + }, "simple-git-hooks": { "pre-commit": "bunx lint-staged" }, diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index bb962b1..ea319c5 100644 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -5,7 +5,7 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" outdir="${repo_root}/dist/npm" rm -rf "${outdir}" -mkdir -p "${outdir}" +mkdir -p "${outdir}/opentui" BUN_TMPDIR="${repo_root}/.bun-tmp" \ BUN_INSTALL="${repo_root}/.bun-install" \ @@ -17,4 +17,23 @@ BUN_INSTALL="${repo_root}/.bun-install" \ chmod 0755 "${outdir}/main.js" +BUN_TMPDIR="${repo_root}/.bun-tmp" \ +BUN_INSTALL="${repo_root}/.bun-install" \ + bun build "${repo_root}/src/opentui/index.ts" \ + --target node \ + --format esm \ + --external react \ + --external react/jsx-runtime \ + --external react/jsx-dev-runtime \ + --external @opentui/core \ + --external @opentui/react \ + --external @opentui/react/jsx-runtime \ + --external @opentui/react/jsx-dev-runtime \ + --external @pierre/diffs \ + --outdir "${outdir}/opentui" \ + --entry-naming index.js + +bun x tsc -p "${repo_root}/tsconfig.opentui.json" + printf 'Built %s\n' "${outdir}/main.js" +printf 'Built %s\n' "${outdir}/opentui/index.js" diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 4818c81..15fe97b 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -43,7 +43,15 @@ if (!pack) { } const publishedPaths = new Set(pack.files.map((file) => file.path)); -const requiredPaths = ["bin/hunk.cjs", "dist/npm/main.js", "README.md", "LICENSE", "package.json"]; +const requiredPaths = [ + "bin/hunk.cjs", + "dist/npm/main.js", + "dist/npm/opentui/index.d.ts", + "dist/npm/opentui/index.js", + "README.md", + "LICENSE", + "package.json", +]; for (const path of requiredPaths) { if (!publishedPaths.has(path)) { diff --git a/src/opentui/HunkDiffView.test.tsx b/src/opentui/HunkDiffView.test.tsx new file mode 100644 index 0000000..e525168 --- /dev/null +++ b/src/opentui/HunkDiffView.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import type { ReactNode } from "react"; +import { HUNK_DIFF_THEME_NAMES, HunkDiffView, parseDiffFromFile } from "./index"; + +async function captureFrame(node: ReactNode, width = 120, height = 24) { + const setup = await testRender(node, { width, height }); + + try { + await act(async () => { + await setup.renderOnce(); + }); + + return setup.captureCharFrame(); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } +} + +describe("HunkDiffView", () => { + test("renders a diff through the public OpenTUI entrypoint", async () => { + const metadata = parseDiffFromFile( + { + cacheKey: "before", + contents: "export const value = 1;\n", + name: "example.ts", + }, + { + cacheKey: "after", + contents: "export const value = 2;\nexport const added = true;\n", + name: "example.ts", + }, + { context: 3 }, + true, + ); + + const frame = await captureFrame( + , + 92, + 12, + ); + + expect(frame).toContain("@@ -1,1 +1,2 @@"); + expect(frame).toContain("1 - export const value = 1;"); + expect(frame).toContain("1 + export const value = 2;"); + expect(frame).toContain("2 + export const added = true;"); + }); + + test("exports the documented built-in theme names", () => { + expect(HUNK_DIFF_THEME_NAMES).toEqual(["graphite", "midnight", "paper", "ember"]); + }); +}); diff --git a/src/opentui/HunkDiffView.tsx b/src/opentui/HunkDiffView.tsx new file mode 100644 index 0000000..2099481 --- /dev/null +++ b/src/opentui/HunkDiffView.tsx @@ -0,0 +1,124 @@ +import { useMemo } from "react"; +import type { DiffFile } from "../core/types"; +import { findMaxLineNumber } from "../ui/diff/codeColumns"; +import { buildSplitRows, buildStackRows } from "../ui/diff/pierre"; +import { diffMessage, DiffRowView, fitText } from "../ui/diff/renderRows"; +import { useHighlightedDiff } from "../ui/diff/useHighlightedDiff"; +import { resolveTheme } from "../ui/themes"; +import type { HunkDiffFile, HunkDiffViewProps } from "./types"; + +/** Count visible additions and deletions from Pierre metadata for the internal file adapter. */ +function countDiffStats(metadata: HunkDiffFile["metadata"]) { + let additions = 0; + let deletions = 0; + + for (const hunk of metadata.hunks) { + for (const content of hunk.hunkContent) { + if (content.type === "change") { + additions += content.additions; + deletions += content.deletions; + } + } + } + + return { additions, deletions }; +} + +/** Adapt the public diff shape into Hunk's internal file model without exposing app-only fields. */ +function toInternalDiffFile(diff: HunkDiffFile): DiffFile { + return { + agent: null, + id: diff.id, + language: diff.language, + metadata: diff.metadata, + patch: diff.patch ?? "", + path: diff.path ?? diff.metadata.name, + previousPath: diff.metadata.prevName, + stats: countDiffStats(diff.metadata), + }; +} + +/** Render one diff file body with Hunk's terminal-native OpenTUI renderer. */ +export function HunkDiffView({ + diff, + layout = "split", + width, + theme = "graphite", + showLineNumbers = true, + showHunkHeaders = true, + wrapLines = false, + horizontalOffset = 0, + highlight = true, + scrollable = true, + selectedHunkIndex = 0, +}: HunkDiffViewProps) { + const resolvedTheme = resolveTheme(theme, null); + const internalDiff = useMemo(() => (diff ? toInternalDiffFile(diff) : undefined), [diff]); + const resolvedHighlighted = useHighlightedDiff({ + file: internalDiff, + appearance: resolvedTheme.appearance, + shouldLoadHighlight: highlight, + }); + const rows = useMemo( + () => + internalDiff + ? layout === "split" + ? buildSplitRows(internalDiff, resolvedHighlighted, resolvedTheme) + : buildStackRows(internalDiff, resolvedHighlighted, resolvedTheme) + : [], + [internalDiff, layout, resolvedHighlighted, resolvedTheme], + ); + const lineNumberDigits = useMemo( + () => String(internalDiff ? findMaxLineNumber(internalDiff) : 1).length, + [internalDiff], + ); + + if (!internalDiff) { + return ( + + {fitText("No file selected.", Math.max(1, width - 2))} + + ); + } + + if (internalDiff.metadata.hunks.length === 0) { + return ( + + + {fitText(diffMessage(internalDiff), Math.max(1, width - 2))} + + + ); + } + + const content = ( + + {rows.map((row) => ( + + + + ))} + + ); + + if (!scrollable) { + return content; + } + + return ( + + {content} + + ); +} diff --git a/src/opentui/index.ts b/src/opentui/index.ts new file mode 100644 index 0000000..b33cdac --- /dev/null +++ b/src/opentui/index.ts @@ -0,0 +1,4 @@ +export { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; +export { HUNK_DIFF_THEME_NAMES, type HunkDiffThemeName } from "./themes"; +export { HunkDiffView } from "./HunkDiffView"; +export type { HunkDiffFile, HunkDiffLayout, HunkDiffViewProps } from "./types"; diff --git a/src/opentui/themes.ts b/src/opentui/themes.ts new file mode 100644 index 0000000..07b9599 --- /dev/null +++ b/src/opentui/themes.ts @@ -0,0 +1,3 @@ +export const HUNK_DIFF_THEME_NAMES = ["graphite", "midnight", "paper", "ember"] as const; + +export type HunkDiffThemeName = (typeof HUNK_DIFF_THEME_NAMES)[number]; diff --git a/src/opentui/types.ts b/src/opentui/types.ts new file mode 100644 index 0000000..f0af376 --- /dev/null +++ b/src/opentui/types.ts @@ -0,0 +1,28 @@ +import type { FileDiffMetadata } from "@pierre/diffs"; +import type { HunkDiffThemeName } from "./themes"; + +export type HunkDiffLayout = "split" | "stack"; + +/** One diff file body that the exported OpenTUI component can render. */ +export interface HunkDiffFile { + id: string; + metadata: FileDiffMetadata; + language?: string; + path?: string; + patch?: string; +} + +/** Public props for the reusable OpenTUI diff component. */ +export interface HunkDiffViewProps { + diff?: HunkDiffFile; + layout?: HunkDiffLayout; + width: number; + theme?: HunkDiffThemeName; + showLineNumbers?: boolean; + showHunkHeaders?: boolean; + wrapLines?: boolean; + horizontalOffset?: number; + highlight?: boolean; + scrollable?: boolean; + selectedHunkIndex?: number; +} diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts index e82fcc1..d0d1d31 100644 --- a/src/ui/diff/useHighlightedDiff.ts +++ b/src/ui/diff/useHighlightedDiff.ts @@ -19,10 +19,26 @@ function enforceCacheLimit() { } } +/** Build a fallback fingerprint from parsed metadata when raw patch text is unavailable. */ +function metadataFingerprint(file: DiffFile) { + return JSON.stringify({ + additionLines: file.metadata.additionLines, + deletionLines: file.metadata.deletionLines, + hunks: file.metadata.hunks, + name: file.metadata.name, + prevName: file.metadata.prevName, + type: file.metadata.type, + }); +} + /** Content fingerprint from the diff patch. Changes whenever the underlying diff * changes, allowing per-file cache invalidation without a global flush. */ function patchFingerprint(file: DiffFile) { const { patch } = file; + if (patch.length === 0) { + return metadataFingerprint(file); + } + const mid = Math.floor(patch.length / 2); return `${patch.length}:${patch.slice(0, 64)}:${patch.slice(mid, mid + 64)}:${patch.slice(-64)}`; } diff --git a/tsconfig.opentui.json b/tsconfig.opentui.json new file mode 100644 index 0000000..986ca42 --- /dev/null +++ b/tsconfig.opentui.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/npm", + "rootDir": "./src" + }, + "include": [], + "files": ["src/opentui/index.ts"] +} From 8236d42e5a83ce3f86e1f9826a4581ada37fe545 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 8 Apr 2026 00:09:29 -0400 Subject: [PATCH 2/4] docs(opentui): add runnable diff component examples Show how to embed HunkDiffView from before/after contents and from raw patch text so OpenTUI consumers have concrete starting points. --- README.md | 2 + examples/7-opentui-component/README.md | 19 +++++++ examples/7-opentui-component/after.ts | 10 ++++ examples/7-opentui-component/before.ts | 8 +++ examples/7-opentui-component/change.patch | 17 ++++++ examples/7-opentui-component/from-files.tsx | 33 ++++++++++++ examples/7-opentui-component/from-patch.tsx | 24 +++++++++ examples/7-opentui-component/support.tsx | 57 +++++++++++++++++++++ examples/README.md | 4 +- tsconfig.examples.json | 9 ++++ 10 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 examples/7-opentui-component/README.md create mode 100644 examples/7-opentui-component/after.ts create mode 100644 examples/7-opentui-component/before.ts create mode 100644 examples/7-opentui-component/change.patch create mode 100644 examples/7-opentui-component/from-files.tsx create mode 100644 examples/7-opentui-component/from-patch.tsx create mode 100644 examples/7-opentui-component/support.tsx create mode 100644 tsconfig.examples.json diff --git a/README.md b/README.md index 0b887a3..95d91ea 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,8 @@ Hunk is optimized for reviewing a full changeset interactively. Hunk also publishes a reusable OpenTUI React diff component powered by the same terminal renderer that the app uses. +Runnable source examples live in [`examples/7-opentui-component`](examples/7-opentui-component/README.md). + Import it from `hunkdiff` or `hunkdiff/opentui`: ```tsx diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md new file mode 100644 index 0000000..24c1d84 --- /dev/null +++ b/examples/7-opentui-component/README.md @@ -0,0 +1,19 @@ +# 7-opentui-component + +Two minimal OpenTUI apps that embed `HunkDiffView` directly. + +## Run + +```bash +bun run examples/7-opentui-component/from-files.tsx +bun run examples/7-opentui-component/from-patch.tsx +``` + +## What it shows + +- embedding `HunkDiffView` inside a normal OpenTUI app shell +- building `diff.metadata` with `parseDiffFromFile` +- parsing raw unified diff text with `parsePatchFiles` +- a scrollable terminal diff component that other OpenTUI apps can reuse + +The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff` or `hunkdiff/opentui` instead. diff --git a/examples/7-opentui-component/after.ts b/examples/7-opentui-component/after.ts new file mode 100644 index 0000000..a621a67 --- /dev/null +++ b/examples/7-opentui-component/after.ts @@ -0,0 +1,10 @@ +export interface ReviewSummary { + title: string; + confidence: number; + tags: string[]; +} + +export function formatReviewSummary(summary: ReviewSummary) { + const tagSuffix = summary.tags.length > 0 ? ` [${summary.tags.join(", ")}]` : ""; + return `${summary.title} (${summary.confidence})${tagSuffix}`; +} diff --git a/examples/7-opentui-component/before.ts b/examples/7-opentui-component/before.ts new file mode 100644 index 0000000..6acaa83 --- /dev/null +++ b/examples/7-opentui-component/before.ts @@ -0,0 +1,8 @@ +export interface ReviewSummary { + title: string; + confidence: number; +} + +export function summarizeReview(summary: ReviewSummary) { + return `${summary.title} (${summary.confidence})`; +} diff --git a/examples/7-opentui-component/change.patch b/examples/7-opentui-component/change.patch new file mode 100644 index 0000000..443264e --- /dev/null +++ b/examples/7-opentui-component/change.patch @@ -0,0 +1,17 @@ +diff --git a/src/reviewSummary.ts b/src/reviewSummary.ts +index 1111111..2222222 100644 +--- a/src/reviewSummary.ts ++++ b/src/reviewSummary.ts +@@ -1,8 +1,10 @@ + export interface ReviewSummary { + title: string; + confidence: number; ++ tags: string[]; + } + +-export function summarizeReview(summary: ReviewSummary) { +- return `${summary.title} (${summary.confidence})`; ++export function formatReviewSummary(summary: ReviewSummary) { ++ const tagSuffix = summary.tags.length > 0 ? ` [${summary.tags.join(", ")}]` : ""; ++ return `${summary.title} (${summary.confidence})${tagSuffix}`; + } diff --git a/examples/7-opentui-component/from-files.tsx b/examples/7-opentui-component/from-files.tsx new file mode 100644 index 0000000..98e2fb6 --- /dev/null +++ b/examples/7-opentui-component/from-files.tsx @@ -0,0 +1,33 @@ +#!/usr/bin/env bun + +import { parseDiffFromFile } from "../../src/opentui"; +import { readExampleFile, runExample } from "./support"; + +const path = "src/reviewSummary.ts"; +const before = readExampleFile("before.ts"); +const after = readExampleFile("after.ts"); +const metadata = parseDiffFromFile( + { + cacheKey: "example:before", + contents: before, + name: path, + }, + { + cacheKey: "example:after", + contents: after, + name: path, + }, + { context: 3 }, + true, +); + +await runExample({ + title: "HunkDiffView from file contents", + subtitle: "Built with parseDiffFromFile. Press Ctrl-C to exit.", + diff: { + id: "example:files", + metadata, + language: "typescript", + path, + }, +}); diff --git a/examples/7-opentui-component/from-patch.tsx b/examples/7-opentui-component/from-patch.tsx new file mode 100644 index 0000000..56445c9 --- /dev/null +++ b/examples/7-opentui-component/from-patch.tsx @@ -0,0 +1,24 @@ +#!/usr/bin/env bun + +import { parsePatchFiles } from "../../src/opentui"; +import { readExampleFile, runExample } from "./support"; + +const patch = readExampleFile("change.patch"); +const parsedPatches = parsePatchFiles(patch, "example:patch", true); +const metadata = parsedPatches.flatMap((entry) => entry.files)[0]; + +if (!metadata) { + throw new Error("Expected one diff file in examples/7-opentui-component/change.patch."); +} + +await runExample({ + title: "HunkDiffView from patch text", + subtitle: "Built with parsePatchFiles. Press Ctrl-C to exit.", + diff: { + id: "example:patch", + metadata, + language: "typescript", + path: metadata.name, + patch, + }, +}); diff --git a/examples/7-opentui-component/support.tsx b/examples/7-opentui-component/support.tsx new file mode 100644 index 0000000..89b19aa --- /dev/null +++ b/examples/7-opentui-component/support.tsx @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { createCliRenderer } from "@opentui/core"; +import { createRoot, useTerminalDimensions } from "@opentui/react"; +import type { HunkDiffFile, HunkDiffLayout } from "../../src/opentui"; +import { HunkDiffView } from "../../src/opentui"; + +interface ExampleProps { + title: string; + subtitle: string; + diff: HunkDiffFile; + layout?: HunkDiffLayout; +} + +/** Read one checked-in example file relative to this folder. */ +export function readExampleFile(name: string) { + return readFileSync(path.join(import.meta.dir, name), "utf8"); +} + +function ExampleApp({ title, subtitle, diff, layout = "split" }: ExampleProps) { + const terminal = useTerminalDimensions(); + const diffWidth = Math.max(24, terminal.width - 2); + + return ( + + {title} + {subtitle} + + + + + + ); +} + +/** Launch a tiny OpenTUI app that embeds the exported Hunk diff component. */ +export async function runExample(props: ExampleProps) { + const renderer = await createCliRenderer({ + useAlternateScreen: true, + useMouse: true, + exitOnCtrlC: true, + openConsoleOnError: true, + }); + const root = createRoot(renderer); + + root.render(); +} diff --git a/examples/README.md b/examples/README.md index eafc1bc..fdc4c46 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,6 +1,6 @@ # Examples -Ready-to-run demo diffs for Hunk. +Ready-to-run demos for Hunk and the exported OpenTUI diff component. Each folder tells a small review story and includes the exact command to run from the repository root. @@ -14,9 +14,11 @@ Each folder tells a small review story and includes the exact command to run fro | `4-ui-polish` | screenshot-friendly TSX diff | `hunk diff examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx` | | `5-pager-tour` | line scrolling, paging, and hunk jumps | `hunk diff --pager examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts` | | `6-readme-screenshot` | README screenshot with agent notes | `hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight` | +| `7-opentui-component` | embedding `HunkDiffView` in OpenTUI | `bun run examples/7-opentui-component/from-files.tsx` | ## Notes - The patch-based examples include checked-in `change.patch` files, so you can open them without creating a temporary repo. - The agent demo also includes an `agent-context.json` sidecar to show inline review notes beside the diff. - The pager tour is intentionally taller than a typical terminal viewport so you can try `↑`, `↓`, `PageUp`, `PageDown`, `Home`, `End`, and `[` / `]` right away. +- The OpenTUI component example folder also includes `from-patch.tsx` if you want the same demo driven by raw unified diff text instead of `before` / `after` contents. diff --git a/tsconfig.examples.json b/tsconfig.examples.json new file mode 100644 index 0000000..9ffc4df --- /dev/null +++ b/tsconfig.examples.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "include": [], + "files": [ + "examples/7-opentui-component/support.tsx", + "examples/7-opentui-component/from-files.tsx", + "examples/7-opentui-component/from-patch.tsx" + ] +} From 3d4decc7e8806e401217b2afb215b0ad0dc2110b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 8 Apr 2026 08:45:48 -0400 Subject: [PATCH 3/4] feat(opentui): make diff example interactive Let OpenTUI consumers switch between split and stack layouts directly in the runnable example, and fix the header rows so the controls render cleanly in narrow terminals. --- examples/7-opentui-component/README.md | 1 + examples/7-opentui-component/support.tsx | 52 ++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md index 24c1d84..873bdb4 100644 --- a/examples/7-opentui-component/README.md +++ b/examples/7-opentui-component/README.md @@ -14,6 +14,7 @@ bun run examples/7-opentui-component/from-patch.tsx - embedding `HunkDiffView` inside a normal OpenTUI app shell - building `diff.metadata` with `parseDiffFromFile` - parsing raw unified diff text with `parsePatchFiles` +- switching between split and stacked layouts with example shell controls - a scrollable terminal diff component that other OpenTUI apps can reuse The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff` or `hunkdiff/opentui` instead. diff --git a/examples/7-opentui-component/support.tsx b/examples/7-opentui-component/support.tsx index 89b19aa..c01946d 100644 --- a/examples/7-opentui-component/support.tsx +++ b/examples/7-opentui-component/support.tsx @@ -2,8 +2,10 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { createCliRenderer } from "@opentui/core"; import { createRoot, useTerminalDimensions } from "@opentui/react"; +import { useState } from "react"; import type { HunkDiffFile, HunkDiffLayout } from "../../src/opentui"; import { HunkDiffView } from "../../src/opentui"; +import { fitText } from "../../src/ui/lib/text"; interface ExampleProps { title: string; @@ -17,8 +19,33 @@ export function readExampleFile(name: string) { return readFileSync(path.join(import.meta.dir, name), "utf8"); } +function LayoutButton({ + active, + label, + onPress, +}: { + active: boolean; + label: string; + onPress: () => void; +}) { + return ( + + {` ${label} `} + + ); +} + function ExampleApp({ title, subtitle, diff, layout = "split" }: ExampleProps) { + const [activeLayout, setActiveLayout] = useState(layout); const terminal = useTerminalDimensions(); + const headerWidth = Math.max(1, terminal.width - 2); const diffWidth = Math.max(24, terminal.width - 2); return ( @@ -33,11 +60,30 @@ function ExampleApp({ title, subtitle, diff, layout = "split" }: ExampleProps) { paddingBottom: 1, }} > - {title} - {subtitle} + + {fitText(title, headerWidth)} + + + {fitText(subtitle, headerWidth)} + + + layout + + setActiveLayout("split")} + /> + + setActiveLayout("stack")} + /> + - + ); From de9194dd99a6d1267a823e6613fe040edb813f30 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Wed, 8 Apr 2026 08:50:41 -0400 Subject: [PATCH 4/4] fix(opentui): narrow published package surface Avoid duplicate React/OpenTUI installs for consumers, keep the public import path explicit, replace the expensive patch-less highlight fingerprint, and publish only the OpenTUI declaration files. --- README.md | 4 +-- examples/7-opentui-component/README.md | 2 +- package.json | 10 ++---- scripts/build-npm.sh | 5 +++ scripts/check-pack.ts | 10 +++++- src/ui/diff/useHighlightedDiff.ts | 43 +++++++++++++++++++++----- tsconfig.opentui.json | 2 +- 7 files changed, 56 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 95d91ea..0647d20 100644 --- a/README.md +++ b/README.md @@ -101,10 +101,10 @@ Hunk also publishes a reusable OpenTUI React diff component powered by the same Runnable source examples live in [`examples/7-opentui-component`](examples/7-opentui-component/README.md). -Import it from `hunkdiff` or `hunkdiff/opentui`: +Import it from `hunkdiff/opentui`: ```tsx -import { HunkDiffView, parseDiffFromFile } from "hunkdiff"; +import { HunkDiffView, parseDiffFromFile } from "hunkdiff/opentui"; const metadata = parseDiffFromFile( { diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md index 873bdb4..3b15599 100644 --- a/examples/7-opentui-component/README.md +++ b/examples/7-opentui-component/README.md @@ -17,4 +17,4 @@ bun run examples/7-opentui-component/from-patch.tsx - switching between split and stacked layouts with example shell controls - a scrollable terminal diff component that other OpenTUI apps can reuse -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff` or `hunkdiff/opentui` instead. +The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/package.json b/package.json index bab8b97..1512f15 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,6 @@ ], "type": "module", "exports": { - ".": { - "types": "./dist/npm/opentui/index.d.ts", - "import": "./dist/npm/opentui/index.js" - }, "./opentui": { "types": "./dist/npm/opentui/index.d.ts", "import": "./dist/npm/opentui/index.js" @@ -73,21 +69,21 @@ "bench:large-stream-profile": "bun run benchmarks/large-stream-profile.ts" }, "dependencies": { - "@opentui/core": "^0.1.88", - "@opentui/react": "^0.1.88", "@pierre/diffs": "^1.1.0", "bun": "^1.3.10", "commander": "^14.0.3", "diff": "^8.0.3", - "react": "^19.2.4", "zod": "^4.3.6" }, "devDependencies": { + "@opentui/core": "^0.1.88", + "@opentui/react": "^0.1.88", "@types/bun": "latest", "@types/react": "^19.2.14", "lint-staged": "^16.4.0", "oxfmt": "^0.41.0", "oxlint": "^1.56.0", + "react": "^19.2.4", "simple-git-hooks": "^2.13.1", "tuistory": "^0.0.16", "typescript": "^5.9.3" diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index ea319c5..31697a8 100644 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -3,8 +3,10 @@ set -Eeuo pipefail repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" outdir="${repo_root}/dist/npm" +types_outdir="${repo_root}/dist/npm-types" rm -rf "${outdir}" +rm -rf "${types_outdir}" mkdir -p "${outdir}/opentui" BUN_TMPDIR="${repo_root}/.bun-tmp" \ @@ -35,5 +37,8 @@ BUN_INSTALL="${repo_root}/.bun-install" \ bun x tsc -p "${repo_root}/tsconfig.opentui.json" +cp "${types_outdir}/opentui/"*.d.ts "${outdir}/opentui/" +rm -rf "${types_outdir}" + printf 'Built %s\n' "${outdir}/main.js" printf 'Built %s\n' "${outdir}/opentui/index.js" diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 15fe97b..934846a 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -59,7 +59,15 @@ for (const path of requiredPaths) { } } -const forbiddenPrefixes = [".github/", "src/", "test/", "scripts/", "tmp/"]; +const forbiddenPrefixes = [ + ".github/", + "src/", + "test/", + "scripts/", + "tmp/", + "dist/npm/core/", + "dist/npm/ui/", +]; const forbiddenPaths = ["AGENTS.md", "bun.lock"]; for (const file of pack.files) { diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts index d0d1d31..9cac013 100644 --- a/src/ui/diff/useHighlightedDiff.ts +++ b/src/ui/diff/useHighlightedDiff.ts @@ -19,16 +19,43 @@ function enforceCacheLimit() { } } +/** Summarize rendered diff lines without serializing whole arrays into the cache key. */ +function lineSetFingerprint(lines: string[] | undefined) { + let totalChars = 0; + let hash = 2166136261; + + for (const line of lines ?? []) { + totalChars += line.length; + + for (let index = 0; index < line.length; index += 1) { + hash ^= line.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + + hash ^= 10; + hash = Math.imul(hash, 16777619); + } + + return `${lines?.length ?? 0}:${totalChars}:${(hash >>> 0).toString(36)}`; +} + /** Build a fallback fingerprint from parsed metadata when raw patch text is unavailable. */ function metadataFingerprint(file: DiffFile) { - return JSON.stringify({ - additionLines: file.metadata.additionLines, - deletionLines: file.metadata.deletionLines, - hunks: file.metadata.hunks, - name: file.metadata.name, - prevName: file.metadata.prevName, - type: file.metadata.type, - }); + const hunkSummary = file.metadata.hunks + .map( + (hunk) => + `${hunk.hunkSpecs ?? ""}:${hunk.deletionStart}:${hunk.deletionCount}:${hunk.additionStart}:${hunk.additionCount}:${hunk.hunkContent.length}`, + ) + .join("|"); + + return [ + file.metadata.name, + file.metadata.prevName ?? "", + file.metadata.type, + lineSetFingerprint(file.metadata.deletionLines), + lineSetFingerprint(file.metadata.additionLines), + hunkSummary, + ].join(":"); } /** Content fingerprint from the diff patch. Changes whenever the underlying diff diff --git a/tsconfig.opentui.json b/tsconfig.opentui.json index 986ca42..e1b363a 100644 --- a/tsconfig.opentui.json +++ b/tsconfig.opentui.json @@ -4,7 +4,7 @@ "noEmit": false, "declaration": true, "emitDeclarationOnly": true, - "outDir": "./dist/npm", + "outDir": "./dist/npm-types", "rootDir": "./src" }, "include": [],