Skip to content

Extension API

The extension factory receives one API 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. This page indexes the whole object; larger registration calls are documented in depth on their own pages and summarized in place below.

The API generation this Hunk speaks (currently 25). Branch on it if you want one file to support several Hunk versions. Version 25 adds Promise-returning watch signatures and watch cancellation; version 24 adds review metadata to VCS patch results and short display revisions to commit descriptors; version 23 adds canonical unified-layout fields while preserving the previous event vocabulary; version 22 adds frame-derived pane preferred sizing, non-resizable dynamic panes, and commit-history paint tokens; version 21 adds optional inclusive history-range review planning and bounded comparison commit summaries; version 20 adds optional commit timestamps to review metadata, pane clipboard actions, and the theme.copyAction paint token; version 19 adds provider-owned history enumeration and review planning; version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated patch commands and projects it into pane availability and component props; version 16 adds pane-wide onActivate; version 15 added { side, line } to opted-in pane currentLine paint; version 14 added structured two-revision VCS diff endpoints; version 13 added saved-note parent identities and committed note-edit events; version 12 added responsive fractional pane sizing; version 11 added the dim line-highlight tone; version 10 added generic top-level CLI commands; version 9 added exact-filename and glob selectors to registerFileLanguage; version 8 added authoritative review snapshots to command handlers; version 7 added the current source line to command selection snapshots. Version 6 added session behavior, terminal-command observation, and live navigation/dialogs in lifecycle and bus handlers; version 5 added line highlighters and line-granular navigation (revealLine); version 4 added keyboard modes and docked panes, with API-v3 sidebar names remaining as deprecated aliases.

Register a generic top-level command tree. The extension owns every raw token below its lowercase-kebab top-level name:

hunk.registerCliCommand(
{ name: "greptile", summary: "Work with Greptile", usage: "<sync|review>" },
async (args, ctx) => {
if (args[0] === "sync") {
await ctx.stdout.write("Synced.\n");
return { kind: "exit" };
}
await ctx.stderr.write("Preparing review…\n");
return {
kind: "delegate",
argv: ["patch", "review.diff"],
review: {
kind: "change-request",
provider: "GitHub",
title: "Add structured review metadata",
url: "https://github.com/acme/project/pull/123",
id: "#123",
repository: "acme/project",
author: "octocat",
base: "main",
head: "review-metadata",
state: "open",
draft: false,
},
};
},
);

Handlers receive ctx.cwd, cooperative ctx.signal, streaming ctx.stdin, and leased, backpressure-aware stdout/stderr writers. They may access networks, services, processes, and files. Return a validated exit status or delegate once to a built-in Hunk command. A delegated patch command may also carry a provider-neutral review descriptor whose exact shape is change-request, commit, or comparison. Hunk bounds all strings and the 4 KiB payload, rejects control characters, unknown fields, and unsafe URLs, then copies and freezes it. provider and change-request id allow 256 bytes; repository, author, base, head, and revision allow 512; authoredAt allows 128; title and url allow 2 KiB. Change requests may also carry state (open, closed, or merged) and boolean draft; commits may carry a parseable authoredAt date-time. The descriptor remains app-bootstrap metadata rather than entering changeset transforms or ReviewDocumentV1; same-file refreshes preserve it, while unrelated reloads clear it. Commits opened from interactive hunk log receive the same metadata shape and retain it while refreshing the exact provider review request. Live-session list, context, and review JSON snapshots project the same optional bounded descriptor without granting provider or remote-reload capabilities. Exit results and non-patch delegation cannot carry one.

Delegation cannot follow stdout output or any stdin read, target another extension command, or change extension bootstrap flags. Built-ins and aliases cannot be shadowed; the first extension claim in discovery order wins.

Use a leading explicit path while developing:

Terminal window
hunk --extension ./greptile.ts greptile sync

Bare help remains static; hunk greptile --help passes --help to the handler.

summary and usage surface when a top-level token reaches extension discovery unclaimed. That failure lists every loaded command instead of only naming the bad token, so keep both to one short line:

hunk: Unknown command: nosuchthing
Extension commands available here:
hunk gh <number|owner/repo#number|pull-request-url> [--repo <owner/repo>] — Review a GitHub pull request

For a complete implementation, see the dependency-free github-pr example. It fetches GitHub PR diffs directly, delegates a temporary patch with restrictive POSIX modes (and inherited temporary-directory ACLs on Windows) into Hunk, and cleans the patch up on shutdown:

Terminal window
hunk --extension ./examples/extensions/github-pr gh 123

Request host behavior for the review session loading the extension. Training, demo, and presentation extensions can make their view-setting changes temporary:

hunk.configureSession({ viewPreferences: "transient" });

If any loaded extension requests this, Hunk skips the save-view-preferences prompt on quit instead of offering to write practice state into user config. The default is "default".

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 are lowercase words separated by - or _ and cannot reuse a built-in id. Config-defined themes win over extension themes for the same id. Extension themes appear in the selector after config themes, in load order.

hunk.registerFileLanguage(matcher, language)

Section titled “hunk.registerFileLanguage(matcher, language)”

Map an extension, exact filename, or glob to an existing syntax-highlighting language. A string remains shorthand for a case-insensitive extension; object-form extension values receive the same trimming, leading-dot removal, and lowercasing:

hunk.registerFileLanguage(".zig", "zig");
hunk.registerFileLanguage({ kind: "filename", value: "BUILD" }, "python");
hunk.registerFileLanguage(
{ kind: "glob", value: "generated/**/*.proto", target: "path" },
"protobuf",
);

Filename and glob matching is case-sensitive. Filename selectors match at any directory depth; globs explicitly target the basename or review path exactly as decoded. / is the path separator, backslashes stay literal, and filename/glob whitespace is preserved. Globs reject NUL and skip NUL-bearing decoded patch paths; exact filenames can still match them. VCS review paths are normally repo-relative, while generic patches may carry absolute paths. Hunk's reserved .mts and .cts mappings run first and cannot be overridden. Otherwise, exact filenames take precedence over globs, then extensions. Later registrations win ties.

This selects a grammar already available to Pierre/Shiki; it does not load a new syntax grammar.

Contribute an additional version-control backend — the same call Hunk's own bundled Git, Jujutsu, and Sapling backends make. An adapter declares detect, its operations (working-tree-diff, revision-show, stash-show), and optionally detection priority, watch support, exact file sources, extra files, and rich user-fixable failures.

Full contract: VCS adapters.

Render a React component on the left, right, top, or bottom of the review. Panes receive their dimensions, review state, actions, keybindings, and optional current-line paint (including { side, line } when opted in). props.review, available(context).review, and preferredSize(context).review expose immutable metadata from a delegated patch command or interactive history selection, or null for ordinary reviews. preferredSize derives an automatic whole-cell target within the registered bounds; a manual divider drag takes precedence unless resizable: false suppresses that divider. registerSidebarView remains a deprecated alias.

Full contract: Custom panes.

Contribute an opt-in alternate presentation for matching files. A view receives the public file and hunk model, typed change ranges, terminal width, cancellation, and lazy exact-source reads. It returns deterministic symbolic rows, optional fixed-height React/OpenTUI row painters, source bindings for inline notes, and positional hunk extents.

Raw diff remains the default and fallback. Hunk continues to own review-stream geometry, scrolling, windowing, hunk navigation, selection, and note rendering.

Full contract and examples: File previews.

Mark character ranges inside Hunk's own diff rendering — search hits, diagnostics, secret scanning, coverage — without replacing the file's presentation. Syntax highlighting, word diff, and layout stay intact; the marked characters get a resolved background.

hunk.registerLineHighlighter({
id: "todos",
highlight({ file }) {
// Scan file.patch (or a readDocument result) and return marks.
return [{ side: "new", line: 12, range: [4, 8], tone: "warning" }];
},
});

Marks are addressed by source coordinates — side, a 1-based line, and a [start, end) range in UTF-16 code units of the raw line text — so they survive split vs unified layout, wrapping, horizontal scrolling, and collapsed-context expansion. A mark paints terminal columns, so a range covering only zero-width characters (bidi controls, ZWSP) paints nothing. Tones (match, current, info, warning, error, dim) rather than colors: Hunk resolves each tinted tone against the actual background of each marked line with a minimum-contrast guarantee stronger than its own word-diff emphasis, so a mark is never invisible on an added line's green; dim recedes text toward the background while preserving token hues for review progress or noise reduction; on a transparent cell the tint is resolved against the background Hunk assumes rather than the one behind the terminal. current renders as reverse video, the less/vim convention for the active hit.

highlight may be sync or async and is treated as a pure derivation of the file plus an invalidation epoch: results are cached until ctx.highlights.refresh("todos") (optionally { fileId }-scoped) re-derives them. Failures, oversized results, and invalid entries cost that file's marks and nothing else — highlights change colors, never text or geometry.

Full contract: authoring guide.

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 — panes and the review stream follow whatever you return.

Each file carries an opaque metadata field — the parsed diff the renderer draws from — so pass it through untouched; spreading a file preserves it. Returns are validated: a transform that throws or returns something the review UI cannot draw is skipped, and the previous changeset carries forward.

You never need metadata to know a file's hunks: the read-only views Hunk hands outward (event payloads, pane 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 at that boundary; a transform neither receives nor produces it.

Register a session-wide, deliberately activated keyboard interpretation. Modes receive frozen plain key snapshots after dialogs, menus, focused inputs, and interactive file views, but before Hunk's ordinary command table.

hunk.registerKeyboardMode({
id: "normal",
title: "Vim navigation",
onKey(key, ctx) {
if (key.sequence !== "j") return "pass";
ctx.commands.execute("hunk.review.stepDown");
return "handled";
},
});
hunk.registerCommand({ id: "vim", title: "Toggle Vim navigation", key: "ctrl+v" }, (ctx) => {
if (ctx.keyboardModes.isActive("normal")) ctx.keyboardModes.exitMode();
else ctx.keyboardModes.enterMode("normal");
});

onKey returns "handled", "pass", or "exit" synchronously. Optional onEnter/onExit callbacks reset extension-owned state such as counts and pending sequences; while either lifecycle callback runs, enterMode() and exitMode() return false. The context exposes only cwd, notify, public commands, activation-scoped keyboardModes controls, and highlights refresh controls. Those controls become inert on exit, so retained callbacks cannot replace a later mode. When the session mode is the highest-priority input owner, host-owned Escape exits it; the persistent status badge and Extensions-menu exit are clickable too.

One session mode runs at a time. Entering another runs the outgoing onExit first. Focused dialogs and file-view modes temporarily outrank, rather than destroy, a session mode. Content soft reloads preserve it; extension reload, registry closure, and App teardown retire it. See the complete authoring guide and vim-navigation example, which includes counts, Ctrl chords, and a focused : command line.

Register a named command, optionally bound to a key. Commands 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 join ctrl, alt/option, cmd/meta, and shift with + around a character or named key. For shifted symbols or digits, bind the resulting character ("!", not "shift+1"). ctrl+<letter> also matches an unnamed bare control byte; named keys stay distinct. key may be a list. Omit it for no binding. Existing bindings keep priority.

Declared keys are defaults: users remap commands by id in their [keybindings] table — yours is "<extensionId>.<commandId>". See docs/keybindings.md.

Registered commands are also listed in the menu bar's Extensions menu under their title, showing whichever key they currently answer to — a command with no binding 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). It receives the standard context plus:

  • ctx.commands.isEnabled(commandId) / execute(commandId, { count? }) — probes or invokes an explicitly public built-in hunk.* command through the same live table as keyboard and menu actions. Relative movement applies counts atomically; extension-owned and cross-extension commands return false.
  • ctx.keyboardModes.enterMode(id) / exitMode() / isActive(id?) — controls only keyboard modes registered by this command's owning extension.
  • ctx.panes.open(paneId) / close(paneId) / toggle(paneId) / isOpen(paneId) — controls your panes by bare id or any pane by its fully qualified "<extensionId>:<paneId>" key, including "hunk:files". ctx.sidebars is deprecated.
  • ctx.fileViews.select(viewId) / toggle(viewId) / isActive(viewId) — controls a matching file preview for the current file; select(null) restores raw diff.
  • ctx.fileViews.refresh(viewId, options?) — marks that view's prepared layouts stale so a stateful view re-derives; every file presenting it re-lays out, keeping its current rows visible until the replacement resolves. Pass { fileId } to scope the invalidation to one reviewed file's presentation of the view.
  • ctx.fileViews.enterMode(viewId) / exitMode() / isModeActive(viewId) — starts, stops, or checks an interactive preview. Entering selects the view and returns whether its mode started.
  • ctx.review.snapshot() — captures stable file identities and every saved note from the authoritative shared ReviewStore.
  • ctx.selection — where the review was pointing when the command fired.
  • ctx.navigation — moves the review stream.
  • ctx.dialogs — asks the user, below.
  • ctx.workspace — reads reviewed files, and writes one back to the working tree with the user's consent, below.
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 view, identical to a pane's files entries; it is null when filtering hides the selected file or when no files are visible. selection.hunkIndex is null whenever file is, or when the file has no hunks. selection.currentLine is the one-based { side, line } source address carrying the current-line marker, or null when that marker is off or the review has not settled on a rendered line. It belongs to this file and hunk, uses Hunk's canonical new-side address for context rows, and can be passed directly to navigation.revealLine. The values are captured when the command fires, so an async handler keeps the selection it started from.

ctx.review.snapshot() returns a deeply immutable value, or null after the command's review generation expires. It contains the opaque producer generation, the shared store's stateRevision, every file in authoritative review/sidebar order, and every saved live or reviewer note. File records expose stable fileKey, transient navigation runtimeId, content identity, status, and paths. Note records preserve their complete old/new anchor and active, stale, or orphaned reconciliation status.

Drafts are not saved and are excluded. Static sidecar annotations that never entered ReviewStore remain on changeset file views rather than in the snapshot. Saved notes are ordered by live arrival and then reviewer creation, including orphaned notes an exporter may need to move into a summary.

For irreversible asynchronous work, capture once, prepare the request, then call snapshot() again and compare both generation and stateRevision. Revisions compare only within one generation. The review-snapshot-export example demonstrates the complete JSON export and stale-work check. The review-note-navigator example combines the complete inventory and authoritative anchors with a selector dialog and guarded navigation to currently visible files.

ctx.navigation.selectFile(fileId), selectHunk(fileId, hunkIndex), and revealLine(fileId, side, line) route through the same guarded review controller as a pane's actions — the stream scrolls, selection updates, selection_changed fires. Unlike selection it is live: a handler that awaits a dialog and then navigates still works.

revealLine is the finest target: a hunk hundreds of lines tall has one anchor, so selectHunk can leave the line you meant pages below the viewport. line is 1-based on side as the patch numbers it, so a context line answers to either side's number. The revealed line lands a little below the viewport top — where every other Hunk reveal lands — and becomes the current line, pairing with a mark from registerLineHighlighter. A line no rendered row carries (inside a collapsed gap, absent from a partial patch, or with the current-line marker off) falls back to the hunk containing it; a line no hunk covers, a side outside "old"/"new", and a line number that is not a positive whole number are refused with a warning naming the extension.

All built-ins listed in the keybindings reference are public to command handlers. This includes the unbound hunk.review.alignCurrentLineTop, hunk.review.alignCurrentLineCenter, and hunk.review.alignCurrentLineBottom commands. count defaults to 1, is capped at 10,000, and scales relative row, viewport, horizontal, file, hunk, and annotated navigation in one host transition. Absolute and one-shot commands run once. Unknown, disabled, non-public, extension-owned, or stale commands return false. isEnabled also returns false for a malformed id; malformed execute ids, options, and counts throw into normal extension failure containment.

A handler may be async; a failure becomes a warning naming your extension.

ctx.dialogs puts a question on screen and waits for the answer. Three methods, all return promises:

  • confirm({ title, body?, confirmLabel?, cancelLabel? })true or false
  • select({ title, options }) → the chosen string, or null
  • input({ title, placeholder?, initial? }) → the typed string, or null
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 fits acting on part of the selection — asking which hunk 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; your text fills the title, body, and choices. Dialogs from installed extensions carry an ext <your-id> attribution line — the same marker notify toasts use — so a third-party prompt cannot present itself as Hunk asking. Hunk's own bundled extensions omit that redundant marker.

One dialog shows at a time; concurrent requests queue in call order, across extensions. Escape cancels (false or null), Enter accepts; confirm dialogs also answer to y/n, select dialogs to /, and everything is clickable. A session reload cancels open and queued dialogs, and a dialog pending at shutdown resolves its cancel value.

ctx.workspace reads full documents from the current review and writes eligible working-tree files.

MethodResult
readDocument(fileId, "old" | "new")Reviewed source text or null
canWriteDocument(fileId)Whether review policy allows a write
writeDocument({ fileId, text }){ ok: true } or { ok: false, reason, detail }
const file = ctx.selection.file;
if (file && ctx.workspace.canWriteDocument(file.id)) {
const text = await ctx.workspace.readDocument(file.id, "new");
if (text !== null) {
await ctx.workspace.writeDocument({ fileId: file.id, text: transform(text) });
}
}

Reads return the source represented by the review, including historical content in revision and stash reviews. Missing, unreadable, or oversized sources return null; reads never prompt.

Writes require a reloadable, unstaged working-tree review and a writable reviewed-file id. Hunk verifies the target, asks for attributed consent, verifies it again, writes it, and reloads the review. Other review kinds and deleted, binary, oversized, missing, symlinked, or root-escaping targets return unavailable. Cancellation returns cancelled; an attempted write failure returns failed with a displayable detail.

canWriteDocument does not inspect the filesystem, so writeDocument can still refuse a changed target. See the full workspace guide for lifecycle and error details.

Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Every handler receives ctx.panes, live ctx.navigation, attributed ctx.dialogs, and ctx.review.requestReload() alongside cwd and notify, so a startup handler can present one focused welcome dialog and navigate to its first example without a keypress. ctx.sidebars is deprecated. Controls retained across a review or extension-registry replacement expire instead of controlling the replacement UI; workspace reads and writes that have not started return null/unavailable. Once a consented filesystem write starts, it reports its actual outcome, graceful shutdown waits for it, and success reconciles the review then active.

EventPayloadWhen
startup{ cwd }once, after the app mounts with its first changeset
changeset_loaded{ changeset }first load and every reload
command_executed{ commandId, canonicalCommandId? }after a named command dispatches in this terminal host
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
hunk_viewed{ file, hunkIndex }when selection settles on a different hunk
filter_changed{ filter }whenever the file-filter query changes
theme_changed{ themeId }when the user commits a new theme
layout_changed{ mode, layout, canonicalMode?, canonicalLayout? }mode or responsive split/unified 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
note_changed{ kind, note }a saved ReviewStore note is created, updated, or removed
session_reload{ changeset, reason }on every session reload
shutdown{}on exit, best-effort within a short timeout
  • A newly mounted instance receives startup before its first changeset_loaded; reloads deliver changeset_loaded before session_reload after the matching review commits.
  • Starting with extension API v23, layout_changed adds canonicalMode and canonicalLayout, which use "unified". The original mode and layout fields remain available for compatibility and continue to report "stack" where their canonical counterparts report "unified". To preserve exhaustive existing source, ExtensionLayoutMode and ExtensionResolvedLayout retain their pre-v23 shapes; new extensions use ExtensionCanonicalLayoutMode and ExtensionCanonicalResolvedLayout. Hunk will keep "stack" and the legacy fields until a separately announced major API revision.
  • selection_changed is trailing-debounced: holding [/] retargets many times a second, and handlers only care where the user landed. fileId and hunkIndex are null when nothing is selected.
  • hunk_viewed fires when the settled (file, hunk) pair changes, including [/] inside one file. Current-line movement within a hunk does not emit it. file_viewed still fires only when the selected file object changes.
  • command_executed reports stable command ids after terminal dispatch from a key, menu, or ctx.commands.execute. For a renamed command, commandId preserves the deprecated identity and canonicalCommandId names its replacement. Detached async extension work may still be running; the event observes the accepted action rather than promise settlement. It follows remapped keys; browser/session review intents and widget-owned Escape, Enter, note-editor Ctrl-S, and F10 menu navigation are not terminal commands.
  • session_reload's reason is "watch", "daemon" (an agent command through the session broker), "extension" (an in-process extension request), or "manual".
  • note_created and note_edited cover notes authored in Hunk's own UI this session. Agent session comments do not emit them, and a reload may remap or drop notes. Use them for incremental reactions.
  • note_changed is store-backed: kind is "created", "updated", or "removed", and note matches the snapshot shape command handlers read through ctx.review.snapshot(). It includes agent session comments and user deletes; drafts never appear. Reloads that remap notes do not emit it — use session_reload to invalidate extension-owned state and read the complete current record from a later command snapshot.
  • shutdown handlers get 250ms before Hunk exits anyway; treat it as best-effort flushing. UI authority has already been revoked, so shutdown is for releasing extension-owned resources rather than navigation or dialogs.

A small bus shared by every loaded extension, for coordinating without coupling through global state. Namespace event names with your extension id. Delivery is fire-and-forget; events emitted while factories are still loading are queued until every extension has subscribed.

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.panes.open("summary");
});
hunk.on("changeset_loaded", ({ changeset }, ctx) => {
hunk.events.emit("summary:ready", { fileCount: changeset.files.length });
ctx.panes.open("summary");
});
}

Bus payloads are shallow-frozen copies when they are objects. Keep nested data immutable if multiple extensions will read it.

ctx.review.requestReload() in event handlers

Section titled “ctx.review.requestReload() in event handlers”

Request a soft reload after an external agent, service, or process changes the current review's inputs. Hunk preserves mounted UI state and selection where possible, serializes the request with every other reload, and coalesces concurrent extension requests. This does not require --watch.

import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.events.on("agent:files-changed", async (_payload, ctx) => {
const result = await ctx.review.requestReload();
if (!result.ok) ctx.notify(result.detail, "warning");
});
}

Success resolves { ok: true } after the replacement commits and emits session_reload with reason "extension". Non-reloadable inputs and expired controls resolve unavailable; started reloads that fail resolve failed with a displayable detail. Factory-time bus events run before live controls mount, so they cannot request a reload.

Requests made from the successor's lifecycle handlers can schedule a trailing reload. Check session_reload.reason before requesting from that event so an extension does not create its own reload loop.

Your extension's own [extension.<id>] config table, as a plain object. Hunk does not interpret the keys, and repo config overrides user config key by key.

Treat these values as untrusted. A repository under review can set or override the table for an extension you installed globally (deliberate — team-level tuning of a shared extension), so never use hunk.config 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 with cwd and notify; event and bus handlers add panes, review.requestReload, and events.emit, command handlers add commands, panes, fileViews, selection, navigation, and dialogs. notify shows one transient line at the bottom of the app; type is "info" (default), "warning", or "error". Messages raised before the UI mounts are buffered, 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.

Menu entries, standalone keybindings (chords without a command — registerCommand commands are already user-remappable), custom note renderers, and session commands. Generic CLI trees are available through registerCliCommand. See the extension architecture for the current host design. The original exploration records historical rationale and phasing.