diff --git a/README.md b/README.md index 10628341..0647d207 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,61 @@ 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. + +Runnable source examples live in [`examples/7-opentui-component`](examples/7-opentui-component/README.md). + +Import it from `hunkdiff/opentui`: + +```tsx +import { HunkDiffView, parseDiffFromFile } from "hunkdiff/opentui"; + +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/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md new file mode 100644 index 00000000..3b15599c --- /dev/null +++ b/examples/7-opentui-component/README.md @@ -0,0 +1,20 @@ +# 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` +- 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/opentui` instead. diff --git a/examples/7-opentui-component/after.ts b/examples/7-opentui-component/after.ts new file mode 100644 index 00000000..a621a676 --- /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 00000000..6acaa833 --- /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 00000000..443264ef --- /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 00000000..98e2fb63 --- /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 00000000..56445c9b --- /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 00000000..c01946da --- /dev/null +++ b/examples/7-opentui-component/support.tsx @@ -0,0 +1,103 @@ +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; + 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 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 ( + + + {fitText(title, headerWidth)} + + + {fitText(subtitle, headerWidth)} + + + layout + + setActiveLayout("split")} + /> + + setActiveLayout("stack")} + /> + + + + + + + ); +} + +/** 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 eafc1bcf..fdc4c466 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/package.json b/package.json index 04ce9b2c..1512f155 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,13 @@ "LICENSE" ], "type": "module", + "exports": { + "./opentui": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./package.json": "./package.json" + }, "publishConfig": { "access": "public" }, @@ -62,25 +69,30 @@ "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" }, + "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 bb962b11..31697a8a 100644 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -3,9 +3,11 @@ 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}" -mkdir -p "${outdir}" +rm -rf "${types_outdir}" +mkdir -p "${outdir}/opentui" BUN_TMPDIR="${repo_root}/.bun-tmp" \ BUN_INSTALL="${repo_root}/.bun-install" \ @@ -17,4 +19,26 @@ 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" + +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 4818c815..934846ab 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)) { @@ -51,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/opentui/HunkDiffView.test.tsx b/src/opentui/HunkDiffView.test.tsx new file mode 100644 index 00000000..e5251684 --- /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 00000000..2099481e --- /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 00000000..b33cdacb --- /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 00000000..07b9599a --- /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 00000000..f0af3767 --- /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 e82fcc16..9cac013b 100644 --- a/src/ui/diff/useHighlightedDiff.ts +++ b/src/ui/diff/useHighlightedDiff.ts @@ -19,10 +19,53 @@ 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) { + 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 * 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.examples.json b/tsconfig.examples.json new file mode 100644 index 00000000..9ffc4df4 --- /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" + ] +} diff --git a/tsconfig.opentui.json b/tsconfig.opentui.json new file mode 100644 index 00000000..e1b363a3 --- /dev/null +++ b/tsconfig.opentui.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "./dist/npm-types", + "rootDir": "./src" + }, + "include": [], + "files": ["src/opentui/index.ts"] +}