Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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+
Expand Down Expand Up @@ -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,
);

<HunkDiffView
diff={{
id: "example",
metadata,
language: "typescript",
path: "example.ts",
}}
layout="split"
width={88}
theme="midnight"
/>;
```

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:
Expand Down
20 changes: 20 additions & 0 deletions examples/7-opentui-component/README.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions examples/7-opentui-component/after.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
8 changes: 8 additions & 0 deletions examples/7-opentui-component/before.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface ReviewSummary {
title: string;
confidence: number;
}

export function summarizeReview(summary: ReviewSummary) {
return `${summary.title} (${summary.confidence})`;
}
17 changes: 17 additions & 0 deletions examples/7-opentui-component/change.patch
Original file line number Diff line number Diff line change
@@ -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}`;
}
33 changes: 33 additions & 0 deletions examples/7-opentui-component/from-files.tsx
Original file line number Diff line number Diff line change
@@ -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,
},
});
24 changes: 24 additions & 0 deletions examples/7-opentui-component/from-patch.tsx
Original file line number Diff line number Diff line change
@@ -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,
},
});
103 changes: 103 additions & 0 deletions examples/7-opentui-component/support.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<box
style={{
width: label.length + 2,
height: 1,
backgroundColor: active ? "#452650" : "#1f2430",
}}
onMouseUp={onPress}
>
<text fg={active ? "#fff0ff" : "#8f9bb3"}>{` ${label} `}</text>
</box>
);
}

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 (
<box
style={{
width: "100%",
height: "100%",
flexDirection: "column",
paddingLeft: 1,
paddingRight: 1,
paddingTop: 1,
paddingBottom: 1,
}}
>
<box style={{ width: "100%", height: 1 }}>
<text fg="#d8b4fe">{fitText(title, headerWidth)}</text>
</box>
<box style={{ width: "100%", height: 1 }}>
<text fg="#8f9bb3">{fitText(subtitle, headerWidth)}</text>
</box>
<box style={{ width: "100%", height: 1, flexDirection: "row" }}>
<text fg="#6b7280">layout</text>
<box style={{ width: 1, height: 1 }} />
<LayoutButton
active={activeLayout === "split"}
label="Split"
onPress={() => setActiveLayout("split")}
/>
<box style={{ width: 1, height: 1 }} />
<LayoutButton
active={activeLayout === "stack"}
label="Stack"
onPress={() => setActiveLayout("stack")}
/>
</box>
<box style={{ height: 1 }} />
<box style={{ flexGrow: 1 }}>
<HunkDiffView diff={diff} layout={activeLayout} width={diffWidth} theme="midnight" />
</box>
</box>
);
}

/** 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(<ExampleApp {...props} />);
}
4 changes: 3 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.
18 changes: 15 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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"
},
Expand Down
Loading
Loading