OpenTUI component

June 5, 2026 ยท View on GitHub

hunkdiff/opentui exports reusable terminal diff components built from the same renderer as the Hunk CLI.

Use HunkDiffView when you want a batteries-included single-file diff, or compose the lower-level primitives when you want to build your own Hunk-like review UI without Hunk's sidebar, menus, global keyboard shortcuts, or session behavior.

Install

npm i hunkdiff @opentui/core@^0.1.88 @opentui/react@^0.1.88 react

hunkdiff declares OpenTUI and React as peer dependencies, so install them in your app.

Quick start

import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { HunkDiffView, createHunkDiffFile, 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,
);

const renderer = await createCliRenderer({
  useAlternateScreen: true,
  useMouse: true,
  exitOnCtrlC: true,
});
const root = createRoot(renderer);

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

In a real app, derive width from your layout or useTerminalDimensions().

Convenience vs primitives

HunkDiffView

HunkDiffView renders one file and can own an OpenTUI scrollbox:

<HunkDiffView diff={file} width={88} layout="split" scrollable />

Use it when you just want a drop-in diff viewer.

HunkDiffBody

HunkDiffBody renders only the diff body for one file. It does not create a scrollbox, file nav, keyboard shortcuts, menus, or session bridge behavior:

<scrollbox width="100%" height="100%" scrollY>
  <HunkDiffBody file={file} width={88} layout="stack" selectedHunkIndex={2} />
</scrollbox>

Use it when your app owns scrolling or surrounding layout.

HunkDiffFileHeader

HunkDiffFileHeader renders Hunk's compact file label/stats header:

<HunkDiffFileHeader file={file} width={88} onSelect={() => selectFile(file.id)} />

HunkReviewStream

HunkReviewStream renders a top-to-bottom multi-file review stream without Hunk's app shell, chrome, keybindings, or scroll owner:

<scrollbox width="100%" height="100%" scrollY>
  <HunkReviewStream
    files={files}
    width={terminal.width}
    layout="split"
    selection={{ fileId, hunkIndex }}
    onSelectionChange={({ fileId, hunkIndex }) => {
      setFileId(fileId);
      setHunkIndex(hunkIndex);
    }}
  />
</scrollbox>

Use it when you want Hunk's main review stream but your own navigation, chrome, scrolling, and keybindings.

HunkFileNav

HunkFileNav renders Hunk's file navigation list as an optional primitive. It does not render borders, outer padding, or a scrollbox; host apps own surrounding chrome and scrolling.

<scrollbox width={32} height="100%" scrollY>
  <HunkFileNav
    files={files}
    selectedFileId={fileId}
    width={32}
    onSelectFile={(nextFileId) => setFileId(nextFileId)}
  />
</scrollbox>

Building file inputs

The public file model is intentionally higher-level than Hunk's internal renderer rows. Row models are not exported.

type HunkDiffFileInput = {
  id: string;
  metadata: FileDiffMetadata;
  language?: string;
  path?: string;
  previousPath?: string;
  patch?: string;
  stats?: { additions: number; deletions: number };
  isBinary?: boolean;
  isTooLarge?: boolean;
  isUntracked?: boolean;
  statsTruncated?: boolean;
};

type HunkDiffFile = Omit<HunkDiffFileInput, "stats"> & {
  stats: { additions: number; deletions: number };
};

Components accept HunkDiffFileInput directly. Use createHunkDiffFile(...) when you want a normalized HunkDiffFile with paths and stats filled in once:

import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const file = createHunkDiffFile({
  id: "example",
  metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
  path: "example.ts",
  language: "typescript",
});

From before/after contents

Use parseDiffFromFile(...) when you already have the old and new file contents.

import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const file = createHunkDiffFile({
  id: "example",
  metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
});

From unified diff text

Use createHunkDiffFilesFromPatch(...) for a quick multi-file patch path:

import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui";

const files = createHunkDiffFilesFromPatch(patchText, "example:patch");

If you need direct access to Pierre's parser, parsePatchFiles(...) is still re-exported.

Common props

PropTypeDefaultNotes
layout"split" | "stack""split"Chooses side-by-side or stacked rendering.
widthnumberโ€”Required content width in terminal columns.
theme"graphite" | "midnight" | "paper" | "ember" | "catppuccin-latte" | "catppuccin-frappe" | "catppuccin-macchiato" | "catppuccin-mocha" | "zenburn""graphite"Matches Hunk's built-in themes.
showLineNumbersbooleantrueToggles line-number columns.
showHunkHeadersbooleantrueToggles @@ ... @@ hunk header rows.
showFileSeparatorsbooleantrueToggles separator rows between files in HunkReviewStream.
wrapLinesbooleanfalseWraps long lines instead of clipping horizontally.
horizontalOffsetnumber0Scroll offset for non-wrapped code rows.
highlightbooleantrueEnables syntax highlighting.
selectedHunkIndexnumber0Highlights one hunk as the active target for single-file components.
scrollablebooleantrueHunkDiffView only; primitives should be wrapped in OpenTUI scrollbox when needed.

Other exports

  • parseDiffFromFile
  • parsePatchFiles
  • FileDiffMetadata
  • createHunkDiffFile
  • createHunkDiffFilesFromPatch
  • countHunkDiffStats
  • HUNK_DIFF_THEME_NAMES
  • HunkDiffThemeName
  • HunkDiffLayout
  • HunkDiffFile
  • HunkDiffFileInput
  • HunkDiffStats
  • HunkDiffSelection
  • component prop types

parseDiffFromFile, parsePatchFiles, and FileDiffMetadata are re-exported from @pierre/diffs so you can build metadata without adding a second diff dependency.

Examples

The in-repo demos import from ../../src/opentui so they run from source. Published consumers should import from hunkdiff/opentui.