A Hunk extension entry is one TypeScript (or JavaScript) file that
default-exports a function. Hunk imports it at startup and hands it an API
object. An entry may stand alone or be declared by a folder's optional
package.json manifest; no build step is required.
// ~/.config/hunk/extensions/hello.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.on("startup", (_event, ctx) => {
ctx.notify("Hello from my extension");
});
}The extension API is experimental. Everything below works today, but the
hunkdiff/extensionsurface may change in breaking ways between minor releases while it stabilizes against real third-party extensions. Breaking changes will be called out in release notes, andhunk.apiVersionidentifies the surface an extension was written against.
Discovery runs group by group, alphabetically by resolved path within each group — a folder extension's entries sort together, at the folder's own path. The first occurrence of a resolved path wins, so a path you pass explicitly keeps its origin even if the same file is also discovered somewhere else.
| Group | Source | Trust |
|---|---|---|
| 1 | --extension <path> (repeatable) |
runs immediately |
| 2 | [extensions] paths in your user config |
runs immediately |
| 3 | ~/.config/hunk/extensions/ |
runs immediately |
| 4 | .hunk/extensions/ in the repo under review |
prompts for trust |
| 4 | [extensions] paths in the repo .hunk/config.toml |
prompts for trust |
The two repo-local sources share a group number because they are one group: both are repo-controlled, so they share a trust decision and their paths are sorted together rather than one source being loaded ahead of the other.
A directory source matches *.ts, *.tsx, *.js, *.jsx, *.mjs directly
inside it, plus one level of folder extensions, so a folder extension can keep
helper modules beside its entry file.
A folder is an extension if it declares its entry files in a package.json, or
failing that if it has an index.{ts,tsx,js,jsx,mjs} (in that preference
order, so a folder shipping both a source and a built entry resolves the same
everywhere). The manifest field is hunk:
~/.config/hunk/extensions/my-ext/
package.json # {"hunk": {"extensions": ["./src/index.ts"]}}
node_modules/ # bun install / npm install, right here
src/
index.ts # the declared entry
helper.ts
The manifest wins over the index.* fallback, and its paths resolve against the
folder. It may list more than one entry, in which case each entry loads as its
own extension in the order the manifest gives. Each one is identified by its
file stem; when stems collide, later entries receive a numeric suffix while
avoiding ids already claimed by other entries in the manifest.
Because the manifest is a real package.json, a folder extension may depend on
npm packages: declare them, install them into the folder's own node_modules,
and imports resolve from the entry file the way they do in any other package.
Pointing --extension or [extensions] paths straight at a directory works
either way: a directory that is itself a folder extension loads as that one
extension, so its helper modules stay helpers. A directory that is not is
treated as a directory of extensions and scanned with the patterns above.
An extension's id is its file stem, or its folder name for
<name>/index.ts. A manifest that declares a single entry also keeps the
folder's name, whatever the entry file is called. The id is what
[extension.<id>] config tables key off, so moving a single-file extension into
a folder of the same name — or later giving that folder a manifest — keeps its
config working.
The id is also the namespace your extension owns: its commands are
<id>.<commandId> and its sidebar views <id>:<viewId>. So the id has to be
spelled like a name — starting with a letter or digit, then letters, digits,
-, or _. A dot or a colon would make those composed ids ambiguous, and
hunk, git, jj, and sl are reserved for what Hunk ships. An extension
whose id breaks a rule is skipped with a startup notice naming the file; rename
it and it loads. If two discovery sources offer the same id, the first in
source order loads and the other is skipped the
same way, since one id cannot own two config tables.
--no-extensions disables user extensions for one run — nothing on disk is
read, let alone executed. Use it when triaging a bug.
--extension is explicit user intent: the file loads immediately, with no
trust prompt, even when the path points inside the repository under review.
Never pass a path you have not read — including one copy-pasted from a
repository's own README.
Every VCS backend Hunk ships — Git, Jujutsu, and Sapling — is an extension,
and so is the built-in file-navigation sidebar. They live in
src/extensions/default/, are compiled into the binary, and register through
the same hunk.registerVcsAdapter and hunk.registerSidebarView this guide
documents. There is no core-registered backend left, no private sidebar, and no
private path into the review pipeline.
Git in particular is the reason: it is the backend that exercises every integration point there is — exact file sources, skipped-too-large placeholders, untracked files, watch plans, rich failures — so running it through the published API is what keeps that API honest. Anything Git can do, your adapter can do, because Git does it the same way you would.
Bundled extensions differ from yours in three ways, all of them consequences of being Hunk's own code:
- They are statically imported, so they load synchronously, before config resolution picks the session's VCS.
- They are implicitly trusted: no discovery, no trust prompt, and no
[extension.<id>]config table. - They stay loaded under
--no-extensionsand[extensions] enabled = false. Those switches exist to triage extensions you installed; losing VCS support from a debugging flag would break every workflow there is.
Failure isolation still applies to them. The ids git, jj, and sl are
reserved as a result — see registerVcsAdapter below — and so is hunk, the
id the bundled sidebar and every built-in command are named under.
Extensions run with your user permissions, exactly like a shell dotfile. That is fine for extensions you installed yourself, and not fine for extensions that came with a repository you are about to review — pointing a diff tool at unfamiliar code is a normal thing to do, and it must never execute that code.
So repo-local sources are gated. The first time Hunk finds extensions in a
repository's .hunk/extensions (or repo-config paths), it skips them and asks:
Run this repository's extensions?
This repository contains extensions in .hunk/extensions.
Extensions run with your user permissions.
enter/t trust · esc not now · n never
- Trust records the decision and reloads the session so the repo's extensions take effect immediately.
- Not now (also
Esc) dismisses without recording anything; you will be asked again next time. - Never records a denial so Hunk stops offering.
Decisions are stored per repository root in ~/.config/hunk/state.json. The
prompt is a normal dialog over the review stream, not a gate in front of it:
you can dismiss it and keep reviewing.
Trust is keyed by the repo root's path, not by the repository's identity —
the same model VS Code workspace trust uses. If you delete a trusted checkout
and a different repository later occupies that path, it inherits the decision.
Clear the entry from state.json if that matters for a path you reuse.
A broken extension should not break review. An extension that fails to import, has no default export, or throws from its factory is skipped, its partial registrations are rolled back, and it becomes a startup notice in the footer. A handler or transform that throws later is reported as a warning naming the extension, and everything else keeps running. Event handlers receive frozen copies of the changeset, so accidental mutation throws inside the handler instead of corrupting the review.
This is crash containment, not a sandbox. Per-file metadata inside event
payloads is shared with the renderer for performance and is not frozen, and an
extension runs with your full user permissions — it can do anything your shell
can. The containment protects you from bugs, not from code you should not have
loaded in the first place.
The factory receives one object. Registration calls are only valid while the factory is running; Hunk seals the object afterwards so a deferred callback cannot mutate the registry mid-session.
The API generation this Hunk speaks (currently 2). Branch on it if you want
one file to support several Hunk versions.
Contribute one selectable theme. The object is the same shape as a
[themes.<id>] config table:
hunk.registerTheme({
id: "midnight-review",
label: "Midnight Review",
base: "catppuccin-mocha",
accent: "#7fd1ff",
syntaxScopes: { "keyword.operator": "#7fd1ff" },
});Theme ids must be lowercase words separated by - or _ and cannot reuse a
built-in id. Config-defined themes always win over extension themes for the same
id; the loser is reported as a startup notice. Extension themes appear in the
selector after config themes, in load order.
Map a file extension to a syntax-highlighting language. The extension may be written with or without a leading dot and is lowercased.
hunk.registerFileLanguage(".zig", "zig");
hunk.registerFileLanguage("bzl", "python");Later registrations win over earlier ones. Hunk's own .mts and .cts
mappings cannot be overridden; attempts are skipped with a notice.
Contribute an additional VCS backend. This is the same call Hunk's own bundled Git, Jujutsu, and Sapling backends make.
hunk.registerVcsAdapter({
id: "hg",
name: "Mercurial",
detect: (cwd) => (existsSync(join(cwd, ".hg")) ? { id: "hg", repoRoot: cwd } : null),
operations: {
"working-tree-diff": {
async load(input, ctx) {
return {
repoRoot: ctx.cwd,
sourceLabel: ctx.cwd,
title: "Mercurial working copy",
patchText: await runHgDiff(ctx.cwd),
untrackedPaths: await listHgUnknownFiles(ctx.cwd),
};
},
},
},
});The ids Hunk ships with — git, jj, and sl — are reserved. An adapter that
reuses one is skipped with a notice.
operations is optional and may implement any of working-tree-diff,
revision-show, and stash-show; an operation you leave out — or leaving the
map off entirely — produces a clear "not supported" error for that command
instead of a crash.
A load result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing:
| Field | What it adds |
|---|---|
untrackedPaths |
files your VCS calls unknown, synthesized into added-file diffs |
readFileSource |
exact whole-file contents, for context expansion and highlighting |
extraFiles |
files reviewed outside the patch, including skipped placeholders |
untrackedPaths is the shorthand: list the repo-root-relative paths your VCS
reports as unknown and Hunk synthesizes the added-file diffs for you, skipping
binaries and files too large to render. Honor input.options.excludeUntracked
when you do, so --exclude-untracked still means what it says. The other two
are covered below.
Detection prefers the nearest checkout: a Git repository nested inside a jj
workspace is reviewed as Git, whatever the priorities say. The same rule covers
your adapter — a Mercurial checkout inside a Git repository is reviewed as
Mercurial. detectionPriority only decides which backend wins when several
recognize the same directory — the colocated case, where one working copy
carries two sets of markers.
| Adapter | Priority |
|---|---|
bundled jj |
200 |
bundled sl |
100 |
bundled git |
0 (HUNK_CORE_VCS_DETECTION_PRIORITY) |
| your adapter, by default | -100 (HUNK_DEFAULT_VCS_DETECTION_PRIORITY) |
Higher is consulted first; equal priorities fall back to registration order.
jj and Sapling sit above Git because a colocated jj repository — or a Sapling
repository created with sl init --git — also carries Git metadata, and the
Git view is the wrong one.
The default puts your adapter below Git, so installing an extension never
silently changes how an existing repository is reviewed. Set
detectionPriority explicitly to outrank a shipped backend; it is your machine.
import { HUNK_CORE_VCS_DETECTION_PRIORITY } from "hunkdiff/extension";
hunk.registerVcsAdapter({
id: "hg",
name: "Mercurial",
detectionPriority: HUNK_CORE_VCS_DETECTION_PRIORITY + 10,
detect,
});Detection runs the same way for every adapter, whichever tier registered it:
the nearest checkout wins, detectionPriority breaks ties between adapters
that recognize the same root, and equal priorities fall back to registration
order. Config resolves the session's VCS before your extension has been
imported, so detection runs again once extensions are loaded — with the full
adapter list — and that second answer is the one the session uses.
What detection never overrides is an explicit choice: a vcs = "<id>" in Hunk
config naming a backend this session loaded is honored as-is, however near a
checkout some other adapter finds.
--watch works through extension adapters. Each operation may add:
watchSignature(input, ctx)— a cheap fingerprint of the reviewed state. Hunk polls it and reloads when it changes.watchPlan(input, ctx)— the filesystem targets that cover that state, so Hunk reacts to events instead of polling on a timer.
watchPlan: (input, ctx) => ({
coverage: "hybrid",
targets: [
{
kind: "directory-tree",
directory: ctx.cwd,
ignoredRoots: [join(ctx.cwd, ".hg")],
sources: ["worktree"],
},
],
}),coverage: "hybrid" promises the targets cover the reviewed state. Leaving
watchPlan out is equivalent to poll-only and still works — it just costs a
subprocess per tick.
A patch carries the changed lines and a little context, and nothing else. If
your VCS can produce a file's whole contents on each side, say so with
readFileSource and Hunk will expand context past the hunk, highlight against
the real file, and word-diff accurately.
async load(input, ctx) {
// Pin the revisions while the operation loads, then close over them: by the
// time Hunk asks for a file, nothing can have moved underneath it.
const [oldRev, newRev] = await resolveHgRevisions(input, ctx.cwd);
return {
repoRoot: ctx.cwd,
sourceLabel: ctx.cwd,
title: "Mercurial working copy",
patchText: await runHgDiff(ctx.cwd),
readFileSource: async ({ path, previousPath, changeType, side }) => {
if (side === "old") {
return changeType === "new" ? null : hgCat(oldRev, previousPath ?? path);
}
return changeType === "deleted" ? null : hgCat(newRev, path);
},
};
}Return null for a side that has no content — the old side of an added file, a
path the revision never contained — rather than throwing. Hunk calls the reader
at most once per file and side and caches what it resolves, so you do not
need your own cache, and it never calls it for a file the diff reports as
binary. Leaving readFileSource off is fine: Hunk falls back to the content the
patch itself carries, which renders the same diff with less context available.
extraFiles lists files to review that your patchText does not contain, in
the order they should appear. Each entry is one of two kinds, and Hunk builds
the diff model for both — you describe files, you never assemble them.
A patch entry is a file with its own one-file diff. Reach for it when your VCS produces better text for a file than Hunk reading the working copy would — its own binary detection, its own path quoting:
extraFiles: [
{
kind: "patch",
path: "notes.md",
patchText: await hgDiffOneFile("notes.md"),
isUntracked: true,
},
];A skipped entry is a file Hunk should list but not render. Reviewing a multi-hundred-megabyte generated file costs more than it is worth, so report the file and why instead of producing a diff nothing will read:
extraFiles: [
{
kind: "skipped",
path: "dist/bundle.js",
reason: "too-large",
changeType: "change",
stats: { additions: 100_001, deletions: 0 },
statsTruncated: true,
},
];readFileSource covers the patch entries too; a skipped entry has no content to
read, so it never gets a source reader.
untrackedPaths remains the shorthand for the common case: list the paths your
VCS calls unknown and Hunk synthesizes the added-file diffs from the working
copy, skipping binaries and files too large to render. Use extraFiles instead
only when your VCS renders those files better than a plain read would.
input.options.colorMoved is true when the user asked for move detection.
Hunk reads move classes back out of the patch itself, so emit ANSI-colored diff
text painting moved additions cyan and moved deletions magenta — what
git diff --color-moved produces — and those lines render as moved. This is
ordinary post-processing over whatever patch text an adapter returns, not a Git
special case. A backend with no notion of moved lines can ignore the option.
Throw a HunkExtensionUserError when the problem is how Hunk was invoked rather
than a bug — no repository here, an unresolvable revision, a missing binary.
Hunk prints the message without a stack trace and lists the suggestions beneath
it. Anything else is reported as an unexpected error.
import { HunkExtensionUserError } from "hunkdiff/extension";
throw new HunkExtensionUserError("`hunk stash show` is not supported by Mercurial.", {
suggestions: ["Use `hunk show <rev>` to review a commit instead."],
});Hunk detects this structurally — an object whose name is
"HunkExtensionUserError" with an optional suggestions array of strings — so a
plain-JavaScript extension, or one bundling its own copy of the class, is
treated the same way. HUNK_EXTENSION_USER_ERROR_NAME is exported if you would
rather not hard-code the string. Hunk's own bundled Git, Jujutsu, and Sapling
backends raise their failures exactly this way.
Contribute a sidebar view — your own React component, rendered inside Hunk's
OpenTUI tree. Registration is additive: your view exists beside the built-in
file navigation, on either side of the review stream, and any number of views
can be open at once. Pair it with registerCommand so a key opens it:
// ~/.config/hunk/extensions/flat-sidebar.tsx
import { useMemo } from "react";
import type { ExtensionSidebarViewProps, HunkExtensionAPI } from "hunkdiff/extension";
function FlatSidebar({ files, selectedFileId, theme, actions }: ExtensionSidebarViewProps) {
const ordered = useMemo(() => [...files].sort((a, b) => a.path.localeCompare(b.path)), [files]);
return (
<scrollbox scrollY={true} width="100%" height="100%">
{ordered.map((file) => (
<text
key={file.id}
content={` ${file.path} +${file.stats.additions} -${file.stats.deletions}`}
style={{
fg: file.id === selectedFileId ? theme.accent : theme.text,
bg: theme.panel,
}}
onMouseDown={() => actions.selectFile(file.id)}
/>
))}
</scrollbox>
);
}
export default function (hunk: HunkExtensionAPI) {
hunk.registerSidebarView({
id: "flat",
title: "Flat files",
placement: "right",
component: FlatSidebar,
});
hunk.registerCommand(
{ id: "toggle-flat", title: "Toggle flat sidebar", key: "ctrl+f" },
(ctx) => {
ctx.sidebars.toggle("flat");
},
);
}Beyond id and component, a view may declare a title (for diagnostics and
future menu listings), a placement of "left" (default) or "right",
defaultOpen: true to start open, or replacesDefault: true to start open
in place of the built-in file navigation — which stays available, just
closed, so a command can reopen it.
Import react normally — Hunk serves its own React instance to extension files
at import time, so hooks, context, and JSX all run on the reconciler drawing the
rest of the app. Never bundle or vendor a copy of React into an extension: a
second React means a second hooks dispatcher, and the component will fail to
render. OpenTUI elements (box, text, scrollbox, ...) are plain intrinsic
elements and need no import.
The component receives fresh props as the app changes:
| Prop | What it is |
|---|---|
files |
the visible reviewed files, review-stream order, filtered, frozen views (each carries changeType, statsTruncated, and hunks summaries beside the usual file fields) |
selectedFileId |
the selected file, or null |
selectedHunkIndex |
the selected hunk within that file, or null |
width |
terminal columns the sidebar pane occupies |
theme |
hex color tokens from the active theme, updated on theme switch |
keybindings |
the current command bindings, resolved from defaults and the user's [keybindings] table |
actions |
navigation the sidebar may trigger |
actions.selectFile(fileId) and actions.selectHunk(fileId, hunkIndex) route
through the same review controller as the built-in sidebar and the keyboard
shortcuts, so the review stream scrolls, selection updates, and the
selection_changed event fires exactly as if the user had clicked a built-in
row. actions.notify(message, type?) shows a toast attributed to your
extension. An action given a file id that is not currently visible is refused
with a warning rather than corrupting the selection.
The three hunk surfaces line up by design: each file's hunks lists public
ExtensionDiffHunk summaries (index, the @@ header, inclusive old/new
line spans) in render order, selectedHunkIndex reports the same index, and
actions.selectHunk(fileId, hunkIndex) accepts it. That is everything a hunk
checklist, a per-hunk progress view, or an agent-annotation navigator needs —
match an annotation's oldRange/newRange against the summaries' spans to
find its hunk — without touching the opaque metadata.
A component that owns a key event should ask the injected keybindings
manager about a command id, rather than hard-coding the command's default
chord. Like Pi's injected KeybindingsManager, this keeps local component
behavior synchronized with the user's remaps and unbindings:
import type { ExtensionKeyEvent, ExtensionSidebarViewProps } from "hunkdiff/extension";
export function handleSidebarKey(props: ExtensionSidebarViewProps, key: ExtensionKeyEvent) {
const nextFile = props.files[1];
if (nextFile && props.keybindings.matches(key, "hunk.review.nextFile")) {
// The user may have remapped this from `.` to another chord.
props.actions.selectFile(nextFile.id);
}
}keybindings.getKeys(commandId) returns the current chord list for a label or
hint; unknown and unbound commands return an empty list. matches(key, commandId) returns false for those commands too. The manager includes both
Hunk commands and extension commands under their documented ids, and its key
event argument is structural — OpenTUI's KeyEvent works directly.
matchesKey, parseKeyChord, and matchesKeyChord remain exported for
extension-local keys that intentionally are not commands. Prefer a named
command whenever a shortcut should be user-remappable.
Hunk keeps owning pane arrangement — widths, resize dividers, responsive show/hide, and dropping panes that no longer fit a narrow terminal — and your component fills the pane it is given. A component that throws while rendering costs you the pane, not the user the session: the failure is reported as a toast naming your extension, the pane closes, and the built-in file navigation reopens if nothing else is showing.
Props carry the pane's width but not its height: the pane is a flex cell, so
give your root element height="100%" and let layout size it. Everything else
about scrolling — pane viewport height, scroll position, keeping a row visible
— goes through the <scrollbox> itself, via a plain React ref. Hunk serves
its own @opentui/core to extension files, so the renderable a ref hands you
is the very instance the host renders with.
The one behavior a list sidebar always ends up needing is following the
selection. Give your rows stable id props, hold a ref to the scrollbox, and
scroll the selected row into view from an effect:
import { useEffect, useRef } from "react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ExtensionSidebarViewProps } from "hunkdiff/extension";
function HunkList({
files,
selectedFileId,
selectedHunkIndex,
theme,
actions,
}: ExtensionSidebarViewProps) {
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
// Follow policy is deliberately yours: the host never scrolls a pane it
// cannot see into, so decide here when (and whether) to follow.
useEffect(() => {
if (selectedFileId !== null) {
scrollRef.current?.scrollChildIntoView(`row-${selectedFileId}-${selectedHunkIndex ?? 0}`);
}
}, [selectedFileId, selectedHunkIndex]);
return (
<scrollbox ref={scrollRef} width="100%" height="100%" scrollY={true} focused={false}>
{files.flatMap((file) =>
(file.hunks ?? []).map((hunk) => {
const selected = file.id === selectedFileId && hunk.index === selectedHunkIndex;
return (
<box
key={`${file.id}:${hunk.index}`}
id={`row-${file.id}-${hunk.index}`}
style={{ width: "100%", height: 1 }}
onMouseUp={() => actions.selectHunk(file.id, hunk.index)}
>
<text
content={` ${file.path} ${hunk.header}`}
style={{ fg: selected ? theme.accent : theme.text }}
/>
</box>
);
}),
)}
</scrollbox>
);
}The ref surface this recipe stands on is the exact one the built-in sidebar runs on:
scrollChildIntoView(id)scrolls the descendant with thatidprop into view.scrollTopandviewport.heightread the current scroll offset and the pane's viewport rows — the pane-height number the props do not carry. A read before the first layout pass reports0, so viewport-dependent code belongs behind the events below rather than a bare mount effect.verticalScrollBar.on("change", handler),viewport.on("layout-changed", handler), andviewport.on("resized", handler)report scrolling and pane resizes; unsubscribe with the matching.offin your effect's cleanup.
That is enough to window a long list yourself: the built-in sidebar renders
only the rows near the viewport, plus spacer boxes sized from those same
reads (its render-window helper is host code, but nothing it computes needs
anything beyond this surface — useTerminalDimensions from @opentui/react
serves as its pre-first-layout viewport estimate).
One honest caveat: this contract rides on OpenTUI's renderable API, served at
whatever version Hunk pins — a wider surface than hunkdiff/extension itself.
The built-in sidebar exercising the exact same calls is the compatibility
guarantee: a change that breaks your scroll code breaks Hunk's own sidebar
first. Still, keep scroll handling small and behind your own helpers.
The built-in sidebar is itself a bundled extension
(src/extensions/default/ui/sidebar/): it registers through this exact call,
its component consumes exactly the props documented above, and its windowing
and selection follow run on exactly the ref contract above — so it doubles as
the reference implementation for everything a third-party sidebar can build,
from grouping and stat badges down to scroll behavior.
Lifecycle handlers run outside React, but a sidebar component only rerenders
when React sees a change. The recipe that connects them is a module-local store
read through useSyncExternalStore: the event handler updates the store, and
any mounted component subscribed to it rerenders — while the store keeps
accumulating even when the pane is closed.
import { useSyncExternalStore } from "react";
import type { HunkExtensionAPI } from "hunkdiff/extension";
let viewedPaths: ReadonlySet<string> = new Set();
const listeners = new Set<() => void>();
function markViewed(path: string) {
if (viewedPaths.has(path)) return;
viewedPaths = new Set(viewedPaths).add(path); // new reference, so React sees the change
for (const listener of listeners) listener();
}
function useViewedPaths() {
return useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => viewedPaths,
);
}
function ViewedCount() {
const viewed = useViewedPaths();
return <text content={`${viewed.size} files viewed`} />;
}
export default function (hunk: HunkExtensionAPI) {
hunk.on("file_viewed", ({ file }) => markViewed(file.path));
hunk.registerSidebarView({ id: "progress", component: ViewedCount });
}Snapshots must be immutable — replace the set instead of mutating it, so
useSyncExternalStore can compare references. Storing state in a hook inside
the component instead would lose it every time the pane closes and unmounts.
A file view is an alternate host-rendered presentation of one file in the same top-to-bottom review stream. It is not a whole-file React component: Hunk owns row measurement, scrolling/windowing, hunk navigation, and fallback to Pierre's raw diff. A constrained, experimental fixed-height JSX row POC lets individual validated rows paint OpenTUI content without taking over that geometry. Raw is always the default; users select a matching view from View for the selected file. Rows may bind themselves to exact old/new source ranges so Hunk can insert its own inline review-note cards without giving the extension note contents or geometry.
The installable
examples/extensions/rendered-markdown/
uses this contract for a parsed Markdown preview. It is intentionally not bundled
or loaded by default; copy the folder into ~/.config/hunk/extensions/, install
its dependency there, and its View entry and F8 command become available.
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.registerFileView({
id: "plain-markdown",
title: "Plain Markdown",
matches: (file) => file.path.endsWith(".md"),
async layout(input) {
const document = await input.readDocument("new");
if (!document || document.length > 100_000) return null;
const sourceLines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n");
const rows = sourceLines.map((text, index) => ({
id: `line:${index + 1}`,
spans: [{ text: text || " " }],
sourceRanges: [{ side: "new" as const, range: [index + 1, index + 1] as const }],
}));
if (rows.length === 0) return null;
return {
rows,
hunkRows: (input.file.hunks ?? []).map((hunk) => ({
startRow: Math.max(0, (hunk.newRange?.[0] ?? 1) - 1),
endRow: Math.min(rows.length - 1, (hunk.newRange?.[1] ?? 1) - 1),
})),
};
},
});
}layout receives one readonly input containing file, width, signal,
changes, and readDocument. input.file is the same frozen public
ExtensionDiffFile sidebars receive. input.changes exposes typed added and
removed ranges without Pierre metadata;
complete old/new hunk ranges remain available through input.file.hunks.
readDocument("old" | "new") is lazy and cached by Hunk; it resolves exact
text or null when that side is absent, unavailable, too large, or fails to
load. Never treat null as an exception: return null from layout to keep
raw diff active.
Layouts use an omitted tone for ordinary text and generic symbolic tones
(muted, accent, accent-muted, syntax, added, removed) plus optional
terminal attributes (bold, italic, underline, strikethrough). Hunk
resolves those primitives only while painting, so the host does not learn the
extension's content format and measurement remains theme-independent. Every
parsed hunk needs one in-bounds, inclusive hunkRows entry at the same array
position as input.file.hunks.
A row's optional sourceRanges contains inclusive, one-based exact-source
bindings such as { side: "new", range: [12, 18] }. Hunk reads only the bound
source sides, verifies every range is in bounds, rejects overlapping ranges on
the same side across rows, and requires each bound row to belong to exactly one
hunkRows extent. One source line and one bound row therefore resolve to one
presentation/hunk target. Inline notes anchor by their existing preferred-side start
line and are inserted before the bound row. Placement is all-or-raw per
file: if any visible note is range-less or unbound, Hunk temporarily renders
the complete raw diff rather than guessing or dropping review data. The stored
presentation selection returns when the note layer is hidden or the mapping
becomes resolvable. Draft note editing remains raw-only.
Invalid, oversized, cancelled, or throwing layouts are isolated with one
warning per concrete extension registration and fall back to raw diff. Rapid width changes are coalesced, and Hunk never paints
geometry measured for a stale width. An experimental custom row keeps symbolic
fallback spans and declares its fixed painter
atomically as component: { height, render }. Painter props include the same
curated semantic theme palette as custom sidebars. It updates live at paint
time without entering layout or changing deterministic geometry. If painting
fails, the fallback spans are clipped to that same declared height rather than
changing stream geometry. Custom rows are non-focusable
paint surfaces: registered commands are their supported keyboard path. A
cooperatively delivered, handled left-button mouse-up may act and stop
propagation, while wheel, drag, and unhandled input remain host-owned. Hunk
makes no portal, renderer, focus, or input-delivery guarantee; see the linked
JSX POC for state lifetime, clipping, and error boundaries. The opt-in
jsx-file-view-gallery runs
fixed JSX rows against checked-in TypeScript, CSS, and package dependency diffs.
A command handler can control the selected file's view through
ctx.fileViews.select("view-id"), toggle("view-id"), and
isActive("view-id"); pass null to select to restore raw rendering.
Bare ids address the calling extension; use "other-extension:view-id" to
address another registered view. The public command API remains current-file
only. When the current file already uses an alternate presentation, View →
Apply “…” to all matching files applies it to every file in the complete
changeset that passes that view's matches function, including files hidden by
the current filter. Nonmatches retain their existing choices, and host
constraints such as an active draft may temporarily keep a selected file raw.
Register a named command, optionally bound to a key. Commands are not a sidebar one-off: they are the same mechanism Hunk's own shortcuts dispatch through — one table, one loop, built-ins first.
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.registerCommand({ id: "hello", title: "Say hello", key: "ctrl+g" }, (ctx) => {
ctx.notify("hello from a command");
});
}Key chords are ctrl, alt/option, cmd/meta, and shift joined with
+ around a base key — a character ("y", "["), an uppercase letter for its
shifted form ("G"), or a named key ("f2", "pageup", "left"). shift
applies to letters and named keys only: for a shifted symbol or digit, bind the
character the shift produces ("!", not "shift+1"), since terminals report
the character rather than the combination. An
unparsable chord fails the registration; a chord already owned by a built-in
shortcut — or by an earlier-loaded extension — leaves that chord unbound, with a
warning toast naming both sides. Omit key to register a command with no
binding.
key also takes a list, binding the command to every chord in it:
hunk.registerCommand({ id: "hello", title: "Say hello", key: ["ctrl+g", "f9"] }, (ctx) => {
ctx.notify("hello from a command");
});Chords are refused one at a time: if ctrl+g were already taken, the command
would still answer to f9.
Whatever an extension declares is a default. Users remap commands by id in the
[keybindings] table of their own config, extension commands included — yours
is named "<extensionId>.<commandId>", while Hunk's own are "hunk.app.quit"
and friends. See docs/keybindings.md for the rules; the
practical consequence is that a chord you declare may not be the chord your
command ends up on.
Every registered command is also listed in the menu bar's Extensions menu,
under its title, showing whichever key it currently answers to. The menu
appears only when something registered a command, entries are grouped by
extension in load order, and running one from the menu is the same dispatch the
key would have done — so a command with no key, or one whose chord was
refused, is still reachable with the mouse.
The handler fires when the key is pressed outside modal UI — dialogs, menus,
and focused text inputs own their keys first. It receives the standard context
plus ctx.sidebars, the controls for opening sidebar views:
ctx.sidebars.open(viewId)/close(viewId)/toggle(viewId)— a bare id names your own extension's view,"files"names the built-in file navigation, and"<extensionId>:<viewId>"addresses any registered view. Opening a view also reveals the sidebar area when the user has hidden it withs, so the open is never silent.ctx.sidebars.isOpen(viewId)reports current state.
ctx.selection is where the review was pointing when the command fired — the
same selection a sidebar component sees in its props, so a command never has to
track selection_changed itself to know what the user is looking at:
hunk.registerCommand(
{ id: "show-selection", title: "Show the selected file", key: "ctrl+y" },
(ctx) => {
const { file, hunkIndex } = ctx.selection;
if (!file) {
ctx.notify("No file selected");
return;
}
ctx.notify(hunkIndex === null ? file.path : `${file.path} — hunk ${hunkIndex + 1}`);
},
);selection.file is a frozen read-only view, identical to the entries in a
sidebar's files prop. Hunk keeps the selection inside the visible files, so
it is null only when nothing is visible at all — a filter that matches no
files. selection.hunkIndex is that file's
selected hunk, and null whenever file is — or when the file has no hunks to
select. The values are captured when the command fires: a handler that awaits
still sees the selection it was run from, not wherever the user navigated to
meanwhile.
ctx.navigation moves the review stream: selectFile(fileId) and
selectHunk(fileId, hunkIndex), the same guarded navigation a sidebar's
actions carry, routed through the same review controller — the stream
scrolls, selection updates, and selection_changed fires exactly as if the
user had clicked a sidebar row. Unlike selection it is live, not a snapshot:
a call acts on the review as it is at that moment, so a handler that awaits a
dialog and then navigates still works. A file id the stream cannot currently
show is refused with a warning rather than corrupting the selection, and a
hunk index is clamped into the file's real range.
A handler may be async; a failure (sync or rejected) becomes a warning naming your extension.
ctx.dialogs puts a question on screen and waits for the answer. Three shapes,
all promise-returning:
confirm({ title, body?, confirmLabel?, cancelLabel? })→trueorfalseselect({ title, options })→ the chosen string, ornullinput({ title, placeholder?, initial? })→ the typed string, ornull
hunk.registerCommand(
{ id: "reformat", title: "Reformat the selected file", key: "ctrl+r" },
async (ctx) => {
const file = ctx.selection.file;
if (!file) {
return;
}
const proceed = await ctx.dialogs.confirm({
title: `Reformat ${file.path}?`,
body: "The file is rewritten in place.",
confirmLabel: "reformat",
});
ctx.notify(proceed ? `Reformatting ${file.path}` : "Left it alone");
},
);select is the natural fit for acting on part of the selection — here, asking
which hunk of the selected file to jump to, then navigating there:
hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, async (ctx) => {
const file = ctx.selection.file;
const hunks = file?.hunks ?? [];
if (!file || hunks.length === 0) {
ctx.notify("Nothing to pick from", "warning");
return;
}
const labels = hunks.map((hunk) => hunk.header || `hunk ${hunk.index + 1}`);
const picked = await ctx.dialogs.select({ title: "Which hunk?", options: labels });
// `navigation` is live, so the jump is valid even after awaiting the dialog.
if (picked !== null) {
ctx.navigation.selectHunk(file.id, labels.indexOf(picked));
}
});Hunk draws the dialog, not you: your text fills the title, body, and choices,
and the frame carries an ext <your-id> attribution line — the same marker
notify toasts use — so a prompt can never present itself as Hunk asking.
One dialog is on screen at a time. Concurrent requests queue in call order,
across extensions too, so a second question waits its turn instead of replacing
the first. While a dialog is up it owns the keyboard: Escape cancels (false,
or null), Enter accepts — the confirm action, the highlighted option, or the
typed text — and review shortcuts stay suppressed underneath. Confirm dialogs
also answer to y/n, select dialogs to ↑/↓, and every dialog's actions
and rows are clickable.
Two things resolve a dialog without the user: the session moving on, and bad
arguments. A session reload — the refresh key, a watch-triggered reload, an
agent command — cancels open and queued dialogs, since the review they asked
about is being replaced; a dialog pending at shutdown resolves its cancel value
the same way, and a request made after that point cancels immediately. A blank
title, or a select with no options, is a bug in the extension rather than an
answer from the user, so the promise rejects; like any other handler
failure, that surfaces as a warning naming your extension.
Rewrite the loaded changeset before it reaches the review UI. Transforms run in registration order, each seeing the previous one's output, on first load and on every reload.
hunk.transformChangeset((changeset) => ({
...changeset,
files: changeset.files.filter((file) => !file.path.endsWith(".lock")),
}));The function may be async. Filtering and reordering files is fully supported —
the sidebar and the review stream both follow whatever you return.
Each file carries an opaque metadata field: it is the parsed diff the renderer
draws from, so pass it through untouched (spreading a file preserves it). What
you return is validated before it is reviewed. A transform that throws, or
returns something the review UI could not draw — not a changeset with a files
array, a file missing metadata.hunks or stats, two files sharing an id —
is skipped: the previous changeset carries forward and you get a warning naming
your extension and the problem.
You never need to reach into metadata to know what a file's hunks are: the
read-only views Hunk hands outward (event payloads, sidebar props, a command's
selection) carry a hunks list of public summaries — index, the @@ header,
and the inclusive old/new line spans, in render order. Like changeType, it is
derived from the metadata at that boundary, so a transform neither receives nor
produces it, and a stale value spread through a transform is replaced with what
the metadata actually parses to.
Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks
the UI waiting for one. Alongside cwd and notify, every handler receives
ctx.sidebars, the same open/close/toggle controls command handlers receive.
That means a changeset_loaded handler can reveal its extension's sidebar when
it finds something worth showing — no keypress required.
| Event | Payload | When |
|---|---|---|
startup |
{ cwd } |
once, after the app mounts with its first changeset |
changeset_loaded |
{ changeset } |
first load and every reload |
selection_changed |
{ fileId, hunkIndex } |
when the review selection settles (debounced ~150ms) |
file_viewed |
{ file, hunkIndex } |
when selection settles on a file or a reload replaces it |
filter_changed |
{ filter } |
whenever the file-filter query changes |
theme_changed |
{ themeId } |
when the user commits a new theme |
layout_changed |
{ mode, layout } |
mode or responsive split/stack layout changes |
watch_reload_pending |
{} |
watcher observed a change before its reload check |
note_created |
{ note } |
a user saves an inline review note |
note_edited |
{ note } |
an in-progress inline note's body changes |
session_reload |
{ changeset, reason } |
on every session reload |
shutdown |
{} |
on exit, best-effort within a short timeout |
selection_changed is trailing-debounced on purpose: holding [/] retargets
the selection many times a second, and handlers only care where the user landed.
fileId and hunkIndex are null when nothing is selected.
session_reload's reason is "watch" (the watcher saw the source change),
"daemon" (an agent command through the session broker), or "manual" (the
refresh key, or the reload after granting extension trust).
note_created and note_edited cover notes authored in Hunk's own UI, in this
session. Review notes are session-local state, so there is no backlog to replay
on startup — but comments added through agent session commands do not emit
these events, and a session_reload may remap or drop notes without one
either. A list accumulated from these events is therefore "notes the user saved
here this session", not a complete review record; present it as such.
shutdown handlers get a short window (250ms) to finish before Hunk exits
anyway, so treat it as best-effort flushing rather than guaranteed cleanup.
hunk.events is a small bus shared by every loaded extension. Use it to
coordinate extensions without coupling them through a command or global state.
Names are open-ended, so namespace them with your extension id. Listeners get
the same ctx.sidebars controls as lifecycle handlers; delivery is fire-and-forget
and one listener's failure is reported without stopping the others. Events an
extension emits while factories are loading are queued until every extension
has had a chance to subscribe.
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.events.on<{ fileCount: number }>("summary:ready", (payload, ctx) => {
if (payload.fileCount > 100) ctx.sidebars.open("summary");
});
hunk.on("changeset_loaded", ({ changeset }, ctx) => {
hunk.events.emit("summary:ready", { fileCount: changeset.files.length });
ctx.sidebars.open("summary");
});
}Bus payloads are shallow-frozen copies when they are objects. Keep nested data immutable if multiple extensions will read it.
Your extension's own [extension.<id>] config table, as a plain object. Hunk
does not interpret the keys — unknown keys pass straight through — and repo
config overrides user config key by key.
Treat these values as untrusted. Tables merge by extension id with no notion of where the extension was installed from, so a repository under review can set or override configuration for an extension you installed globally. That is deliberate — repo-level tuning of a shared extension is a normal team workflow, and Hunk shows a startup notice listing the extension ids a repo configures — but it means
hunk.configmust never be trusted for exec-adjacent decisions such as binary paths, shell commands, or module loading. Validate those against something the user controls.
# ~/.config/hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "dist/**"]const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];Every handler and transform receives a context object with cwd and notify.
Event and bus handlers additionally receive sidebars and events.emit; command
handlers receive sidebars, selection, navigation, and dialogs. notify
shows a single unobtrusive line at the bottom of the app that clears
itself after a few seconds; queued messages appear in turn. type is "info"
(default), "warning", or "error", which selects the color. Notifications
raised before the UI has mounted are buffered and flushed once it does, so a
startup handler can notify safely.
Record a diagnostic line. Logs are collected per extension rather than written to the terminal, because the TUI owns the screen.
The examples directory contains two user-installable folder extensions:
examples/extensions/review-triage/is a session-local hunk triage board combining a sidebar, commands, dialogs, lifecycle listeners, and the extension event bus. Its API evaluation and follow-up opportunities are recorded in Extension API field notes.examples/extensions/rendered-markdown/parses Markdown into generic host-owned file-view rows. Its README shows how to run it from the checkout or copy it into the global extensions directory.
Collapse lockfiles and generated output out of every review, and say how many files were hidden.
// ~/.config/hunk/extensions/collapse-generated.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
/** Match one path against a `*`-only glob, anchored at both ends. */
function matchesPattern(path: string, pattern: string) {
const source = pattern
.split("*")
.map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${source}$`).test(path);
}
export default function (hunk: HunkExtensionAPI) {
const patterns = (hunk.config.patterns as string[] | undefined) ?? [
"*.lock",
"*-lock.json",
"dist/*",
];
hunk.transformChangeset((changeset, ctx) => {
const kept = changeset.files.filter(
(file) => !patterns.some((pattern) => matchesPattern(file.path, pattern)),
);
const hidden = changeset.files.length - kept.length;
if (hidden > 0) {
ctx.notify(`Collapsed ${hidden} generated ${hidden === 1 ? "file" : "files"}`);
}
return { ...changeset, files: kept };
});
}Configure it without touching the code:
# .hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "bun.lockb", "generated/*"]Try it against the working tree without installing it:
hunk diff --extension ./collapse-generated.tshunk diff --extension ./path/to/entry.ts # load one entry file (repeatable)
hunk diff --extension ./my-ext # a folder extension: loads ./my-ext/index.ts
hunk diff --no-extensions # disable user extensions for this run# ~/.config/hunk/config.toml or .hunk/config.toml
[extensions]
enabled = true # false disables loading for this layer
paths = ["~/dev/hunk-ext/index.ts"] # extra entry files or directories
[extension.my-extension] # opaque payload handed to that extension
some_key = "some value"[extensions] enabled layers like every other option: a repo .hunk/config.toml
overrides your user config. --no-extensions is a hard off switch that no config
layer can re-enable. Both govern user extensions only — Hunk's bundled
Git, Jujutsu, and Sapling backends load either way. [extensions] paths from a repo
config is trust-gated the same way .hunk/extensions is, because it is
repo-controlled either way.
Menu entries, standalone keybindings (a chord contributed without a command —
commands registered through registerCommand are already user-remappable
via [keybindings]), custom note renderers, session commands, and CLI
subcommands are not contributable yet. Commands and their default key bindings
landed with registerCommand — the named-command registry the rest build on;
see
docs/extension-system-exploration.md for the
design and phasing.