hunk-extensions

Contributors

GitHub-linked commit authors for this SKILL.md at the saved revision. Co-authors and history before file renames are not included.

File history ↗

Maps the `hunkdiff/extension` authoring surface for Hunk, the terminal diff viewer — hiding or reordering reviewed files, docked panes, alternate file views, commands and key bindings, dialogs, workspace writes, themes, syntax languages, VCS backends, lifecycle events. Use when writing, debugging, or installing a Hunk extension, or when a request asks Hunk itself to behave differently. Not for reviewing a diff in a live session — that is hunk-review.

packages/hunk/skills/hunk-extensions/SKILL.md

Download bundle ↓
main · ee556ac1 bundle fileScanned 2026-09-14

SKILL.md

5,365 tokens · o200k_base · 22,752 bytes

Building Hunk extensions

A Hunk extension is one TypeScript (or JSX/JS) file that default-exports a factory. Hunk imports it at startup and hands it an API object. No build step, no manifest 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"));
}

This skill is a map of the touchpoints, not a recipe. Decide what to build from the user's request; use the table below to find the call, then read the linked material before writing code.

Sources of truth — read before writing

SourceWhat it answers
docs/extensions.mdThe authoring guide. Every call, every rule. Start here.
packages/hunk/src/extension-api/types.tsThe contract — exact field names, optionality, doc comments.
examples/extensions/*Working extensions. Copy these patterns rather than invent.
docs/extension-architecture.mdHunk's internals. Needed only when changing the host.
docs/keybindings.md, docs/themes.mdChord grammar and theme token rules that extensions inherit.

Outside a Hunk checkout the guide is split across https://hunk.dev/docs/extend/extensions/ (discovery, trust, config) and its companion pages — extension-api, file-previews, vcs-adapters, custom-panes — and the contract ships as node_modules/hunkdiff/dist/npm/extension/index.d.ts.

The examples, by what they demonstrate:

  • review-triage/ — pane + commands + all three dialog shapes + lifecycle events + the extension event bus + a useSyncExternalStore bridge.
  • inline-edit/ — an interactive file-view mode driving ctx.workspace writes; its README explains the async lifetime rules better than anything else in tree.
  • rendered-markdown/ — a file view producing host-rendered rows from parsed Markdown, and a folder extension with an npm dependency.
  • code-document-file-view/ — API-v28 host-owned syntax paint over complete old/new code documents, including semantic gutters and partial UTF-16 ranges.
  • jsx-file-view/, jsx-file-view-gallery/ — the experimental fixed-height JSX row component contract.

Where extensions live

SourceTrust
--extension <path> (repeatable)runs immediately
[extensions] paths in user configruns immediately
~/.config/hunk/extensions/ (XDG-aware)runs immediately
.hunk/extensions/ or repo-config pathstrust prompt

Only the repo-local group is gated. Everything else — including --extension, even when its path points inside the repository under review — is read as explicit user intent and executes with full user permissions, no prompt. Never pass or suggest a path you have not read, including one copied from a repository's own README.

A directory matches *.ts/*.tsx/*.js/*.jsx/*.mjs at its top level, plus one level of folder extensions. A folder is an extension if it has a package.json with {"hunk": {"extensions": ["./index.ts"]}}, or an index.{ts,tsx,js,jsx,mjs}. Reach for a folder only when you need npm dependencies, helper modules, or a README; a single file keeps the install to one cp. A .hunk/extensions/ folder extension's node_modules has to exist on every machine that loads it — keep a repo-shared extension dependency-free.

Shared extensions install from git with hunk extension install <source> (owner/repo[@ref], git:host/path[@ref], a git URL, or a local path) into ~/.config/hunk/extensions/installed/<repo-name>/, where they load with global origin; list, update, and remove manage them. Declared dependencies are bun installed at install time. The manifest may state {"hunk": {"apiVersion": N}} — the minimum extension API version — and an older Hunk refuses the extension with a startup notice instead of failing mid-factory. To publish, push the folder-extension layout to a git repository's root with real name/version/description, tag releases for @ref pins, and add the hunk-extension GitHub topic so it appears at https://github.com/topics/hunk-extension.

The id is the file stem, or the folder name for a folder extension — unless its manifest declares several entries, in which case each entry is its own extension named by its own stem (numeric suffix on collision). The id is the namespace it owns: commands are <id>.<commandId>, panes and keyboard modes are <id>:<localId>, config [extension.<id>]. Ids match /^[A-Za-z0-9][A-Za-z0-9_-]*$/; hunk, git, jj, and sl are reserved. A bad or duplicate id is skipped with a startup notice.

Pick the touchpoint

To do thisCall
Keep demo/training view settings temporaryhunk.configureSession(options)
Add a selectable color themehunk.registerTheme(theme)
Highlight an extension, exact filename, or filename globhunk.registerFileLanguage(matcher, lang)
Support another VCS (git/jj/sl are reserved)hunk.registerVcsAdapter(adapter)
Add a navigation/list/status pane beside the reviewhunk.registerPane(pane)
Present a file as something other than a raw diffhunk.registerFileView(view) (experimental)
Mark character ranges inside diff lineshunk.registerLineHighlighter(highlighter)
Interpret review keys as a temporary global modehunk.registerKeyboardMode(mode)
Add a generic top-level CLI command treehunk.registerCliCommand(command, handler)
Bind a key / add an Extensions-menu entryhunk.registerCommand(command, handler)
Show persistent text on the bottom status rowctx.statusLine.set(item) in a handler
Ask for one line of text inline, less-stylectx.prompts.line(options) in a command
Hide, reorder, retitle files before reviewhunk.transformChangeset(fn)
React to loads, selection, view movement, notes, reloadshunk.on(event, handler)
Coordinate with another loaded extensionhunk.events.emit / hunk.events.on
Reload after an external agent changes reviewed inputsctx.review.requestReload() in an event
Read user-supplied settingshunk.config ([extension.<id>] table)
Snapshot stable files and every saved review notectx.review.snapshot() in a command
Branch on the API generation (currently 28)hunk.apiVersion

Registration is only valid while the factory runs — Hunk seals the API object afterwards.

Promise-returning VCS watchSignature hooks and watch cancellation require API version 25. Declare {"hunk": {"apiVersion": 25}} in the manifest, or branch on hunk.apiVersion and return signatures synchronously on older hosts. Use async I/O and honor ctx.signal on API 25; existing synchronous hooks remain supported.

Generic CLI handlers

Register one lowercase-kebab top-level token; the handler owns every raw token below it. Built-ins and aliases cannot be shadowed, and discovery order makes the first extension claim win. During development, place the explicit path before the extension command:

hunk --extension ./my-ext.ts my-command sync --help

The handler receives frozen args plus ctx.cwd, ctx.signal, streaming ctx.stdin, and leased ctx.stdout/ctx.stderr writers. summary and usage are listed when a token reaches discovery unclaimed, so write them as one short line each. Return { kind: "exit", code? } or { kind: "delegate", argv: ["diff", ...] }. Delegation is built-in-only and one-time: do not write stdout or read stdin before delegating; use stderr for progress. Reading stdin is an exit-only workflow. Respect cancellation promptly. Repo-local providers remain trust-gated; --no-extensions performs no discovery or import, while a leading explicit --extension path is immediate consent.

Use examples/extensions/github-pr/ as the reference for a complete CLI preprocessor: direct authenticated HTTP with cancellation, temporary artifacts with platform-accurate permission claims retained through delegated startup, cleanup on shutdown, and a one-time handoff to built-in patch without touching stdin or stdout.

What handlers receive

Every event, bus, command, and file-view mode handler — plus every changeset transform — gets ctx.cwd and ctx.notify(message, type?). A file view's matches and layout get no context at all. Beyond that:

  • Event and bus handlers also get ctx.panes (open/close/toggle/isOpen on any pane), live ctx.navigation, attributed ctx.dialogs, ctx.statusLine (set/clear this extension's status-row items), review reloads through ctx.review.requestReload(), and ctx.events.emit. ctx.sidebars is a deprecated alias for ctx.panes.
  • Command handlers get ctx.panes, ctx.fileViews (select/toggle/isActive/ refresh/enterMode/exitMode), ctx.highlights (refresh prepared line marks, whole or { fileId }-scoped), ctx.selection (a snapshot of file, hunk index, nullable current { side, line } source address, and files, the visible files in review order), ctx.navigation (live, guarded selectFile/selectHunk/revealLine, the last landing one exact (side, line) near the viewport top), ctx.commands (isEnabled/execute for public semantic hunk.* commands), ctx.keyboardModes (enter/exit/probe this extension's session modes), ctx.review (deeply immutable snapshots of stable files and complete saved store notes), ctx.dialogs (confirm/select/input, queued and attributed), ctx.statusLine (set/clear persistent status-row items), ctx.prompts (line: an inline status-row input resolving the text or null, queued and attributed like dialogs), and ctx.workspace (readDocument, canWriteDocument, writeDocument with consent).
  • Pane components get frozen files, selection, placement, exact dimensions, nullable immutable delegated-source review metadata, optional currentLine paint (with { side, line } when opted in), semantic theme, resolved keybindings, and guarded navigation/notification actions. Availability callbacks receive the same review value, so a pane can consume no geometry for ordinary reviews.
  • File-view layout gets file, width, signal, changes, and a lazy readDocument(side).
  • File-view mode handlers get ctx.file and ctx.fileViews. onKey, onEnter, and onExit must answer synchronouslyonKey's return value ("handled"/"pass"/"exit") is the routing decision, so kick off async work and report it later through notify or refresh. A passed key reaches any active session keyboard mode before ordinary Hunk routing. Escape is host-owned and never reaches onKey.
  • Session keyboard-mode handlers get only ctx.commands, ctx.highlights, ctx.statusLine, and activation-scoped ctx.keyboardModes beyond the standard context. A prompt-shaped interaction is a command plus ctx.prompts.line(), not a mode. Those controls become inert on exit, and lifecycle callbacks cannot change keyboard ownership. Keys are frozen snapshots; dialogs, focused inputs, and file-view modes outrank them. When the session mode owns input, Escape exits it; the status badge and Extensions menu are unconditional host-owned exits.

Event payloads, pane props, and a command's selection all hand you frozen ExtensionDiffFile / ExtensionDiffHunk views. A changeset transform is the exception: it receives the live changeset and is expected to return a new one. metadata is unfrozen either way — it is the renderer's parsed diff, so pass it through untouched.

Rules that bite

Most extension bugs are one of these:

  • Registering a surface does not show it. Panes need defaultOpen, replaces: "hunk:files", or a command that opens them. File views remain raw until selected from the View menu.
  • A rejected file-view layout silently becomes raw diff. hunkRows needs one in-bounds, inclusive entry per parsed hunk at the same array index, and sourceRanges may not overlap on a side; invalid, oversized, cancelled, and throwing layouts warn once and fall back.
  • Syntax paint stays declarative. File views declare complete codeDocuments and map exact symbolic spans with syntax; Hunk owns tokenization, theme colors, visible-window demand, resource limits, and plain fallback. Keep gutters and separators in non-syntax spans, and use sourceRanges separately for notes/navigation.
  • Never bundle or vendor React. Hunk serves its own react and @opentui/* to extension files; a second copy means a second hooks dispatcher and the component fails to render. Import them normally. OpenTUI intrinsics (box, text, scrollbox) need no import.
  • layout is a pure derivation of (file, width). A stateful view keeps painting its first answer until ctx.fileViews.refresh(viewId) — scope it with { fileId } when the state belongs to one file.
  • Handler state must live outside the component. Panes unmount when closed; bridge module-level state into React with useSyncExternalStore and immutable snapshots (review-triage/index.tsx is the working version).
  • Use ctx.review.snapshot() for complete saved-note state. note_created and note_edited are incremental UI events, not an authoritative collection. Snapshots include stale and orphaned saved notes, exclude drafts and static sidecar annotations, and should be re-read before irreversible async work; compare both generation and revision. review-note-navigator shows how to join stable note ids and file keys back to guarded navigation after awaiting a selector; file filters can still refuse hidden targets.
  • Retained review controls expire on reload. An old handler cannot control replacement content: pane/navigation calls become inert, dialogs cancel, and workspace reads or not-yet-started writes return null/unavailable. A consented write already in progress reports its real outcome, holds graceful exit until it settles, and reconciles the active review on success. shutdown runs after revocation, so use it only to release extension-owned resources.
  • A reload keeps your factory and renames the files. Factories re-run only after a trust grant or a cwd change, so module state survives — but a file's id encodes its position in the changeset, so a reload that adds or drops a file renumbers the rest. Key durable per-file state by path, or reconcile it on changeset_loaded. Pick one deliberately.
  • Transforms must preserve metadata (spreading a file does), keep ids unique, and return a real changeset — otherwise the transform is skipped with a warning and the previous changeset carries forward.
  • Chords are defaults. Users remap by command id in [keybindings]; built-ins win conflicts, refused one chord at a time. Bind the character shift produces ("!", not "shift+1").
  • Keyboard modes are grammar, not behavior. Keep pending sequences and numeric prefixes in the extension, then call one public ctx.commands.execute after resolving an action. vim-navigation demonstrates counts, Ctrl chords, and a : key passed to a registered command whose host input dialog temporarily outranks the still-active mode.
  • ctx.commands invokes Hunk, not other extensions. Probe with isEnabled("hunk.review.nextHunk"), then call execute(id, { count }) for an explicitly public built-in. Counts are positive whole numbers up to 10,000, applied atomically to movement; one-shot actions run once. Unknown, disabled, private, extension-owned, or stale commands return false.
  • Repo config can set [extension.<id>] for a globally installed extension. Treat hunk.config as untrusted for anything exec-adjacent (binary paths, shell commands, module loading).
  • ctx.workspace writes only apply to reloadable, unstaged working-tree reviews, by reviewed file id, inside the review root, with consent. Everything else returns { ok: false, reason } — check canWriteDocument first.
  • File-view note placement is all-or-raw per file: an unbound or range-less visible note makes Hunk render the complete raw diff instead of guessing.
  • Failures are contained, not sandboxed. A throwing factory is rolled back to zero registrations and a throwing handler is a warning naming the extension — containment against bugs, not against code that should not have been loaded.
  • The API touches nothing outside the review. No clipboard, no filesystem, no process surface beyond ctx.workspace — an extension is ordinary code, so shell out for the rest. Never write to stdout: the renderer owns it. For the same reason hunk.log is collected as diagnostics and printed nowhere; ctx.notify is how a user hears from you.
  • HunkExtensionUserError (detected structurally by name) buys the full treatment — message plus suggestions, no stack trace — only from a VCS adapter operation, which is where Hunk formats it for the CLI. From a command or event handler only the message survives, as a warning toast.

Verifying

Hunk's TUI needs a real terminal, and the review UI is the user's — do not launch hunk diff/hunk show to test, and do not reach for a pipe. No invocation applies extensions headlessly: hunk diff … | cat still starts the app and still takes the keyboard, so it hangs holding the user's terminal. Practical checks, in order of cost:

  1. Typecheck. In a checkout, bun run typecheck covers examples/extensions/** via the hunkdiff/extension path mapping. Standalone, add hunkdiff as a dev dependency and run tsc --noEmit; for a .tsx extension also add react, @types/react (React ships no declarations of its own), @opentui/core, and @opentui/react as dev dependencies and set "jsx": "react-jsx" with "jsxImportSource": "@opentui/react", or every <box> and <text> is an untyped intrinsic. Types only — shipping those packages is the second-React bug.
  2. Unit-test the logic. When parsing, matching, or formatting is worth testing, put it in helper modules with plain bun test coverage.
  3. PTY integration. In a checkout, test/pty/extensions-integration.test.ts launches Hunk over a PTY with --extension <path> and asserts on rendered snapshots; extend it via test/pty/harness.ts and run bun run test:integration.
  4. Hand it to the user to run: hunk diff --extension ./my-ext. --extension loads immediately with no trust prompt, so it is the iteration path. Ask them what the footer notices and toasts said.
  5. Triage with --no-extensions to confirm a symptom belongs to an extension (bundled VCS backends, the built-in files pane, and the / content search stay loaded either way).

If it does not load

  • No startup notice at all → a successful load is silent, so either it loaded and nothing opened it, or discovery never saw the file. Check the directory, the entry suffix, or the folder's package.json hunk.extensions paths.
  • Notice naming the extension → id rejected (reserved, malformed, or already claimed), import failure, missing default export, or a throwing factory.
  • Repo-local extension silently absent → the trust prompt was dismissed or denied; decisions are stored per repo root in ~/.config/hunk/state.json.
  • Pane closes with a toast → the component threw; a second React copy is the usual cause.
  • Pane or file view never appears → nothing opened it (no defaultOpen, no command), matches returned false, or the layout was rejected.
  • Command never fires → its chord lost to a built-in or an earlier extension (a warning says so); it is still reachable from the Extensions menu and bindable by <id>.<commandId>.

Changing Hunk itself

Only when the work is in the hunk repo rather than in a user extension:

  • Shipped VCS backends, the built-in files pane, and the / content search are bundled extensions in packages/hunk/src/extensions/default/, registering through the same public API. That dogfooding is deliberate — if the public contract cannot express something, that is a real gap, not a reason for a private path. default/vcs/ loads from VCS adapter resolution and must stay renderer-free. Bundled UI factories run once per process with no config; ui/lib/sessionRegistrations.ts composes their commands and line highlighters ahead of user extensions.
  • packages/hunk/src/extension-api/types.ts must stay import-free; declaration emission publishes whatever it reaches, and scripts/packaging/check-pack.ts fails the pack otherwise. Shapes shared with internal code are declared there and re-exported inward.
  • New API surface means updating docs/extensions.md (its examples are typechecked as consumer code), the matching hand-written page under website/src/content/docs/docs/extend/ (only cli.md and config.md are generated), docs/extension-architecture.md if ownership moves, and a changeset.
  • AGENTS.md and docs/extension-architecture.md own the rest of these rules.
Referenced from AGENTS.md

These references come from AGENTS.md at the skill snapshot.

Source excerpt starting at line 84.
  `--no-extensions`. See `docs/extension-architecture.md`, `docs/extensions.md`, and  `packages/hunk/skills/hunk-extensions/SKILL.md`.- Sidecar file order is intentional sidebar and review-stream order.
AGENTS.md · same revision ↗