hunk Documentation

Review-first terminal diff viewer for agentic coders

9,639 stars TypeScript TypeScript 60 files · ~134,911 tokens #agents#cli#code-review#diff#git#jj#jujutsu#sapling
RAW Doc

Technical Documentation: modem-dev/hunk

ℹ️ Provenance: Multi-Tier Ingestion · CodeWiki Reference

Overview (`README.md`)

hunk

Hunk is an interactive terminal diff viewer for reviewing code changes, built on OpenTUI and Pierre diffs.

hunk.dev · Documentation




  • multi-file review stream with sidebar navigation
  • inline AI and agent annotations beside the code
  • split, unified, and responsive auto layouts
  • watch mode for auto-reloading file and Git-backed reviews
  • keyboard, mouse, pager, and Git difftool support
Split view with sidebar and inline AI notes Unified view and mouse-selectable menus

Install

On macOS and Linux, install the standalone binary:

bash
curl -fsSL https://hunk.dev/install.sh | sh

Windows users can install with npm or mise. Other installation methods are also available:

bash
npm i hunkdiff                    # macOS, Linux, or Windows; requires Node.js 22+
brew install hunk                    # macOS or Linux
mise use -g hunk                     # macOS, Linux, or Windows

[!NOTE]
If you previously installed hunk via modem-dev/tap, be sure to uninstall it first with brew uninstall modem-dev/tap/hunk.

Windows requires mise 2026.8.6 or newer. Nix users can use the default package exported in flake.nix; see the Nix guide for details. Hunk also ships as a default tool in Omarchy, installed through mise.

Requirements:

  • macOS (arm64, x64), Linux (arm64, x64; glibc), or Windows (x64); prebuilt binaries are not published for Windows on arm64 or musl-based Linux such as Alpine
  • On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor
  • Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js
  • Git recommended for most workflows

Update Hunk

For npm, Homebrew, and default install-script installs (Hunk 0.20+):

bash
hunk update          # install the newest release
hunk update --check  # check without installing
hunk update 0.20.0   # select an exact npm or default install-script release

On an older release, update once with the installer or package manager that installed Hunk, then use hunk update going forward. Custom HUNK_INSTALL_DIR installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead.

Quick start

bash
hunk           # show help
hunk --version # print the installed version

Working with Git

Use familiar Git commands to open changes in Hunk:

bash
hunk diff                      # review current repo changes, including untracked files
hunk diff --watch              # auto-reload as the working tree changes
hunk show                      # review the latest commit
hunk show HEAD~1               # review an earlier commit
hunk log                       # browse history on a terminal; print when redirected
hunk log --static              # force static output, paging when needed
hunk status                    # view workspace status and sibling worktrees (Git)
hunk status --static           # print one read-only workspace snapshot
hunk status --json             # emit one versioned workspace snapshot

hunk status is read-only: it does not fetch or change the repository. See workspace status for details.

hunk log opens an interactive history browser in a terminal and prints text when piped or redirected. Use --oneline for compact output. In the browser, enable Graph view from the View menu to see commit branches, or press v to select a commit range. Quit a commit review to return to history.

Working with GitHub

Hunk bundles hunk gh, so pull requests, commits, and comparisons open directly without the
GitHub CLI or a separate extension:

bash
hunk gh pr                         # infer the open PR for the current branch
hunk gh pr 123                     # infer the repository from origin
hunk gh pr 'modem-dev/hunk#123'    # quote this form for shells that treat # as a comment
hunk gh commit a1b2c3d --repo modem-dev/hunk
hunk gh compare main...feature --repo modem-dev/hunk

Public repositories work anonymously. Set GH_TOKEN, or GITHUB_TOKEN when it is absent, for
private repositories and higher API limits. GitHub Enterprise is not currently supported.

Working with Jujutsu and Sapling

Hunk auto-detects Jujutsu and Sapling checkouts, so hunk diff [revset] and hunk show [revset] use native revsets inside jj or Sapling workspaces. hunk log --vcs jj also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set vcs = "git" or vcs = "jj" or vcs = "sl" in config.

Viewing complete files and browsing directories

bash
hunk open                 # browse the current directory
hunk open README.md       # view a complete, syntax-highlighted document
hunk open path/to/project # browse another directory without requiring a repository

Use arrows and Enter to navigate, Tab to switch focus, i to show hidden/Git-ignored entries, and e to edit the displayed file in $EDITOR. See the open workflow for details and file limits.

Working with raw files and patches

bash
hunk diff --files before.ts after.ts        # compare two files directly
hunk diff --files before.ts after.ts --watch # auto-reload when either file changes
git diff --no-color | hunk patch -          # review a patch from stdin

Working with agents

  1. Install the review skill once: npx skills add modem-dev/hunk -g --skill hunk-review.
  2. Open Hunk in another terminal with hunk diff or hunk show.
  3. Ask your agent to use the hunk-review skill against the live Hunk session.
  4. Press a in Hunk to show agent notes; they start hidden.

If your agent has no skill system, ask it to run hunk skill show and follow the output.

See Review with an agent for live sessions, --agent-context, and experimental rich notes.

Feature comparison

Capability hunk lumen difftastic delta diff-so-fancy diff
Review-first interactive UI ✅ ✅ ❌ ❌ ❌ ❌
Multi-file review stream + sidebar ✅ ✅ ❌ ❌ ❌ ❌
Inline agent / AI annotations ✅ ❌ ❌ ❌ ❌ ❌
Responsive auto split/unified layout ✅ ❌ ❌ ❌ ❌ ❌
Mouse support inside the viewer ✅ ✅ ❌ ❌ ❌ ❌
Runtime view toggles ✅ ✅ ❌ ❌ ❌ ❌
Syntax highlighting ✅ ✅ ✅ ✅ ❌ ❌
Structural diffing ❌ ❌ ✅ ❌ ❌ ❌
Pager-compatible mode ✅ ❌ ✅ ✅ ✅ ✅

Advanced

Config

You can persist preferences to a config file:

  • $XDG_CONFIG_HOME/hunk/config.toml (default ~/.config/hunk/config.toml)
  • .hunk/config.toml in a repository

Persistent state uses $XDG_STATE_HOME/hunk/state.json when the variable is
absolute, or ~/.local/state/hunk/state.json otherwise. Hunk preserves existing
state by copying the old config-directory file on first use, keeping the original
and falling back to it if migration fails. Global extension paths are unchanged.

For example:

toml
theme = "github-dark-default"
mode = "auto"               # auto, split, unified
auto_split_min_width = 160  # minimum available diff columns for split in auto mode
line_numbers = true
wrap_lines = false

See the configuration guide for command overrides and examples, the full reference for every option, or the theme guide to customize colors.

Keybindings

Remap shortcuts with a [keybindings] table in your user config. See the keybinding guide for available commands, default keys, and examples.

Git integration

Set Hunk as your Git pager so git diff and git show open in Hunk automatically:

[!NOTE]
Untracked files are auto-included only for Hunk's own hunk diff working-tree loader. If you open git diff through hunk pager, Git still decides the patch contents, so untracked files will not appear there.

bash
git config --global core.pager "hunk pager"

Or in your Git config:

ini
[core]
    pager = hunk pager

If you want to keep Git's default pager and add opt-in aliases instead:

bash
git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff"
git config --global alias.hshow "-c core.pager=\"hunk pager\" show"

Jujutsu pager integration

To use Hunk as jj's pager, run jj config edit --user and update:

toml
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"

Sapling pager integration

To use Hunk as Sapling's pager, run sl config -u and update:

ini
[pager]
pager = hunk pager

Extensions (experimental)

The extension API is experimental and may change in breaking ways between
minor releases while it stabilizes; breaking changes are called out in
release notes.

Hunk loads plain TypeScript extensions from ~/.config/hunk/extensions/, from a
repository's .hunk/extensions/ (after you explicitly trust that repository),
and from --extension for development. --no-extensions turns those off
for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded.

Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review:

ts
// ~/.config/hunk/extensions/collapse-lockfiles.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.transformChangeset((changeset, ctx) => {
    const files = changeset.files.filter((file) => !file.path.endsWith(".lock"));
    ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`);
    return { ...changeset, files };
  });
}

Extensions shared as git repositories install straight from their host, and a
hunk-extension GitHub topic marks community ones:

bash
hunk extension install acme/[email protected]   # or git:host/path, a URL, a local path
hunk extension list                                 # then update [name] / remove <name>

Browse community extensions at
github.com/topics/hunk-extension;
publish yours by pushing the extension to a repository root and adding that
topic.

See the extension guide for the API, configuration, and publishing instructions.
Installable examples include
review triage,
review snapshot export, an optional
rendered Markdown file view, and a
Vim navigation mode built from public semantic commands.

OpenTUI component

Hunk also publishes HunkDiffView and lower-level primitives from hunkdiff/opentui for embedding the same diff renderer in your own OpenTUI app.

See the OpenTUI component guide for install, API, and runnable examples.

Examples

Ready-to-run demo diffs live in the examples directory.

Each example includes the exact command to run from the repository root.

Contributing

💬 Chat with users/contributors on the Modem Discord server

See CONTRIBUTING.md for development setup and contribution guidelines.

Sponsored by Modem.

License

MIT


Overview (`.changeset/README.md`)

Changesets

Hunk uses Changesets for release-note fragments and npm version preparation.

For user-visible changes, add a changeset instead of editing CHANGELOG.md directly:

bash
bun run changeset

Select hunkdiff and choose the semver bump that matches the shipped CLI/package change:

  • patch for fixes and small behavior changes
  • minor for new user-facing features
  • major for breaking changes

The private root workspace lists packages/*, and Changesets discovers the published hunkdiff
package at packages/hunk/package.json. Private implementation workspaces remain ignored and are
never published.

For maintenance-only PRs that should not appear in release notes, create an empty changeset:

bash
bun run changeset -- --empty

Release prep runs:

bash
bun run release:version

CHANGELOG.md at the repository root is the canonical changelog consumed by the website and
release tooling. The version wrapper temporarily stages that history beside the package for
Changesets, then copies the generated release entry back to the root and removes the temporary
package changelog. It also bumps packages/hunk/package.json and refreshes bun.lock so the
release commit records the new workspace version.

After the tag release publishes npm packages and GitHub release assets, verify Homebrew through Homebrew/homebrew-core. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated hunk <version> PR, confirm it merges, then verify brew install hunk resolves to the released version. Use brew bump-formula-pr hunk --version <version> only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly.


Overview (`benchmarks/README.md`)

Benchmarks

Benchmark scripts, shared fixtures, and local result artifacts live here. These benchmarks are local diagnostics for Hunk's core promise: fast loading, fast first render, fast navigation, and predictable memory use on large diffs.

Running locally

Run the default local benchmark suite with one JSON result file:

bash
bun run bench -- --samples 3 --out benchmarks/results/local.json

Run optional competitor comparisons when the tools are installed:

bash
bun run bench -- --samples 3 --include-competitors --out benchmarks/results/local-with-competitors.json

Include the opt-in huge fixture tier (~1k files / 300k+ diff lines plus one ~50k-line file). A single huge sample can take minutes on the unoptimized hot path, so it is excluded from the default suite; enable it with the flag or HUNK_BENCH_INCLUDE_HUGE=1:

bash
bun run bench -- --samples 1 --include-huge --out benchmarks/results/local-with-huge.json

Generate the committed release benchmark snapshot during release prep:

bash
bun run bench:release
bun run bench:release:compare

Run focused scripts while iterating:

bash
bun run bench:startup-first-frame
bun run bench:bootstrap-load
bun run bench:working-tree-load
bun run bench:changeset-parse
bun run bench:render-layout
bun run bench:highlight-prefetch
bun run bench:highlight-worker-cache
bun run bench:highlight-cache-layers
bun run bench:large-stream
bun run bench:interaction-latency
bun run bench:non-ascii-stream
bun run bench:wrapped-cjk
bun run bench:terminal-width
bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
bun run bench:geometry-memory
bun run bench:navigation-memory
bun run bench:resize-memory
bun run bench:competitors

Scripts

  • startup-first-frame.ts — launches hunk diff in a real PTY on a small committed repo with a dirty working tree and measures time from process spawn to the first painted review frame. It is the only metric that includes Bun boot, CLI and config resolution, git bootstrap, and the OpenTUI import with its native-library load; in-process first-frame metrics start after the renderer is already imported. It answers the terminal background probe like a real terminal would. The same launch reports startup_first_color_ms (spawn to the first keyword-colored cell) and startup_post_paint_key_max_ms (the slowest answer among page-down keys sent from first paint through the next 400ms, which is where main-thread highlighting shows up as blocked input). Set HUNK_BENCHMARK_EXECUTABLE to measure a compiled binary instead of the source entrypoint.
  • bootstrap-load.ts — measures bootstrap and git-loader cost on a synthetic large repo, including file-pair bootstrap.
  • working-tree-load.ts — measures git working-tree loads across small, medium, large, many-untracked, and few-large-untracked repos.
  • changeset-parse.ts — measures patch normalization, Pierre parsing, patch chunking, and normalized DiffFile construction for many-small-files, balanced, and large-single-file patches.
  • render-layout.ts — measures pure split/unified row building, section geometry, and review-plan construction for many-small-files, balanced, and large-single-file streams.
  • highlight-prefetch.ts — measures selected-file highlight startup and adjacent prefetch readiness.
  • worker-highlight-cache.ts — measures a cold worker highlight against an immediate compact-result cache hit.
  • highlight-cache-layers.ts — measures a resident terminal-cache hit against a worker-cache revisit after the terminal cache evicts the diff.
  • large-stream.ts — measures large split-stream first-frame and scroll cost.
  • interaction-latency.ts — measures per-press ] hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of memory.ts). Interaction timing starts after render-driven syntax highlighting reaches idle so Bun scheduler changes cannot move startup work into the first input sample.
  • non-ascii-stream.ts — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs. Its scroll timing uses the same highlight-idle boundary.
  • wrapped-cjk.ts — reproduces issue #579 with 518 wrapped Japanese Markdown lines plus one pathological long logical line, isolates each mount with a full GC while including renderer setup in first-frame latency, and measures immediate/coalesced frames from a real wheel burst.
  • terminal-width.ts — measures scalar-heavy CJK and emoji width calls plus cached complex-cluster measurements against equivalent string-width reference paths, verifying identical width checksums.
  • huge-stream.ts — opt-in huge tier (--include-huge or HUNK_BENCH_INCLUDE_HUGE=1): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
  • large-stream-profile.ts — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
  • memory.ts — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
  • geometry-memory.ts — optional local retained-memory profiler for all-files section geometry, including JSC-native heap metrics, navigation projection, and giant-file first-copy latency. Copy-content heap growth is measured from the post-navigation sample; ordinary navigation should not materialize lazy copy-selection rows.
  • navigation-memory.ts — optional local retained-memory profiler for repeated hunk navigation through a mounted review stream.
  • resize-memory.ts — optional local retained-memory profiler for repeated terminal-width changes through a mounted review stream; this targets geometry-cache retention across resize variants.
  • competitors.ts — optional local informational comparisons against git diff --no-ext-diff, delta, difftastic, and diff-so-fancy when installed.
  • large-stream-fixture.ts and lib/fixtures.ts — shared deterministic synthetic fixtures.

Output format

Each script prints METRIC name=value lines. benchmarks/run.ts repeats scripts, aggregates samples, prints a readable summary, and can write JSON:

json
{
  "version": 1,
  "samplesPerBenchmark": 3,
  "results": [
    {
      "name": "large-stream/cold_first_frame_ms",
      "unit": "ms",
      "samples": [61.2, 60.8, 62.1],
      "median": 61.2,
      "p75": 62.1,
      "p95": 62.1,
      "threshold": {
        "maxRegressionRatio": 1.15,
        "minAbsoluteRegression": 5
      },
      "comparable": true
    }
  ]
}

Notes

  • These benchmarks are intentionally local-only for now. They are useful diagnostics, but CI proved too noisy and slow for PR gating.
  • The default local suite excludes optional memory profiling, resize profiling, pure-planning profiling, the huge fixture tier, and competitor comparisons. Run those focused scripts when deeper diagnostics are needed.
  • Fixture tiers: the moderate tier (180 files × 120 lines) backs large-stream.ts and interaction-latency.ts; the huge tier (1,000 files × 300 lines + one 50,000-line file) backs huge-stream.ts and is opt-in because one sample can take minutes before hot-path fixes land.
  • Competitor comparisons are informational because installed tool versions and feature parity vary by environment.
  • Use --samples 5 locally when validating borderline changes.
  • Set HUNK_BENCHMARK_TRACE=1 on an interaction benchmark to print the raw per-input latency distribution behind each median and p95.
  • Use benchmarks/results/ for local benchmark output; result files in that directory are ignored by default.

Overview (`examples/README.md`)

Examples

Ready-to-run demos for Hunk and the exported OpenTUI diff component.

Each folder tells a small review story and includes the exact command to run from the repository root.

Quick menu

Example Best for Command
1-hello-diff fastest first run hunk diff --files examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts
2-mini-app-refactor realistic multi-file review hunk patch examples/2-mini-app-refactor/change.patch
3-agent-review-demo inline agent rationale hunk patch examples/3-agent-review-demo/change.patch --agent-context examples/3-agent-review-demo/agent-context.json
4-ui-polish screenshot-friendly TSX diff hunk diff --files examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx
5-pager-tour line scrolling, paging, and hunk jumps hunk diff --files examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts --pager
6-readme-screenshot README screenshot with agent notes hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight
7-opentui-component embedding HunkDiffView in OpenTUI bun run examples/7-opentui-component/from-files.tsx
8-opentui-primitives composing Hunk's OpenTUI primitives bun run examples/8-opentui-primitives/primitives-demo.tsx
9-agent-markup-notes STML markup rendered inside notes hunk patch examples/9-agent-markup-notes/change.patch --agent-context examples/9-agent-markup-notes/agent-context.json

Installable extension examples

Extension examples are not bundled with Hunk. Each README shows how to try or install its folder explicitly.

Notes

  • The patch-based examples include checked-in change.patch files, so you can open them without creating a temporary repo.
  • The agent demo also includes an agent-context.json sidecar to show inline review notes beside the diff.
  • The pager tour is intentionally taller than a typical terminal viewport so you can try ↑, ↓, PageUp, PageDown, Home, End, and [ / ] right away.
  • The OpenTUI component example folder also includes from-patch.tsx if you want the same demo driven by raw unified diff text instead of before / after contents.
  • The OpenTUI primitives example shows how to assemble a custom review UI from Hunk's exported building blocks.

Overview (`nix/README.md`)

Installing using Nix

Nix users can install Hunk from source instead of using npm.

Install from a flake

  1. Add Hunk to your nix flake inputs like such:
nix
{
    inputs = {
        hunk = {
          url = "github:modem-dev/hunk";
          inputs.nixpkgs.follows = "nixpkgs";
        };
    }
}
  1. Use in NixOS environment.systemPackages:
nix
{
  environment.systemPackages = [
    inputs.hunk.packages.${pkgs.stdenv.hostPlatform.system}.hunk
  ];
}

Or in Home Manager home.packages:

nix
{
  home.packages = [
    inputs.hunk.packages.${pkgs.stdenv.hostPlatform.system}.hunk
  ];
}

Home Manager

Hunk provides a Home Manager module to manage both the package and its configuration.

  1. Add the module to your Home Manager configuration:
nix
{
  imports = [
    inputs.hunk.homeManagerModules.default
  ];

  programs.hunk = {
    enable = true;
    enableGitIntegration = true; # Optional: set hunk as default git pager
    settings = {
      theme = "graphite";
      mode = "split";
      line_numbers = true;
      tab_width = 4;
    };
  };
}

enableGitIntegration writes to Home Manager's Git configuration, so it requires Home Manager's Git module to be enabled with programs.git.enable = true;.

Running from a flake

Run Hunk directly with Nix:

bash
nix run github:modem-dev/hunk -- --help

Updating Hunk

Flake users update Hunk by updating their own pinned flake.lock input:

bash
nix flake lock --update-input hunk

Supported systems

The flake builds for x86_64-linux, aarch64-linux, and aarch64-darwin.

Intel macOS (x86_64-darwin) is not exposed, because Nixpkgs 26.11 dropped support for it and merely declaring a dropped system breaks flake evaluation for everyone consuming Hunk. Intel macOS users should install Hunk via npm or Homebrew instead.

If you pin an older Nixpkgs that still supports a system Hunk does not list, point Hunk's systems input at a wider list:

nix
{
  inputs.hunk = {
    url = "github:modem-dev/hunk";
    inputs.nixpkgs.follows = "nixpkgs";
    inputs.systems.url = "github:nix-systems/default"; # adds x86_64-darwin
  };
}

The same input also drives bun2nix, so overriding it once widens the whole build. If your flake already has its own systems input, inputs.systems.follows = "systems" works too.

Building using Nix

Run nix build to build the default package for the current system. The resulting Hunk binary will be ./result/bin/hunk.

You can also build the named package explicitly:

bash
nix build .#hunk

Maintainer dependency updates

When JavaScript or Bun dependencies change, regenerate the Nix dependency lockfile and commit it with the dependency change:

bash
bun run nix:update-lock

This script requires Nix and runs the flake-pinned bun2nix version from flake.lock.


Overview (`scripts/README.md`)

Repository scripts

Scripts are grouped by the workflow they support. Prefer the stable bun run aliases in the root
package.json when one exists.

  • benchmarks/ — release and daemon benchmark tooling
  • build/ — source builds and local binary installation
  • dev/ — manual development probes and fixtures
  • generate/ — checked-in documentation, skill, changelog, and theme generation
  • launch-video/ — launch-video capture and composition
  • packaging/ — package assembly, validation, publication, and install checks
  • quality/ — repository architecture, package-contract, and example conformance tests
  • release/ — Changesets and release-channel validation
  • test/ — default test-suite and compatibility runners
  • website/ — website catalogs, helpers, and link checks

Keep tests next to the script or policy they cover.


Overview (`test/README.md`)

Test layout

Most unit tests are colocated with the source they cover. The top-level test/ tree holds tests
that cross module, process, repository, runtime, or terminal boundaries.

Structure

text
test/
  helpers/                 shared test-only builders, fixtures, and colocated helper tests
  fixtures/                runtime-neutral cross-process fixtures
  cli/                     black-box CLI contracts
  session/                 daemon, broker, and session CLI flows
  review-conformance/      shared semantic fixtures and consumer projections
  session-broker-node/     real Node adapter conformance
  session-broker-runtime/  shared Bun/Node connection fixtures
  pty/                     live PTY-driven UI integration
  smoke/                   opt-in terminal transcript checks

Placement

  • Keep a test beside one module or helper when it can exercise that owner directly.
  • Put shared unit-test builders in test/helpers/ and name them explicitly as test-only helpers.
  • Use test/cli/ for spawned entrypoint behavior such as help, version, pager fallback, and errors.
  • Use test/session/ for cross-process daemon, broker, and session CLI flows.
  • Use test/review-conformance/ for hand-authored review semantics and every registered consumer's
    real projection.
  • Use test/session-broker-node/, test/session-broker-runtime/, and test/fixtures/ for portable
    broker adapter behavior and shared cross-runtime fixtures.
  • Use test/pty/ for resize, navigation, mouse, layout, scrolling, and note visibility in a live UI.
  • Use test/smoke/ for transcript-level rendering checks on a real TTY.

Commands and coverage

Command Coverage
bun run test packages/, scripts/, examples/, test/cli/, test/helpers/, and test/session/, as defined by scripts/test/run-test-suite.ts
bun run test:windows Windows platform, process, VCS, CLI, and packaging coverage; Linux owns the terminal UI semantic suite
bun test ./test/review-conformance Shared review fixtures and registered consumer projections
bun run test:session-broker-node Real Node listener/adapter conformance using the checked-in cross-runtime fixtures
bun run test:integration PTY-backed tests under test/pty/
bun run test:tty-smoke Opt-in real-TTY smoke tests under test/smoke/
bun run test:install-vm Opt-in Firecracker install compatibility scenarios under test/cli/install-vm/
bun run vm:shell [-- --with-hunk] Disposable Ubuntu Firecracker shell, optionally with a fresh local Hunk build

npm extension installation tests require npm/Node.js and use a disposable loopback registry
with real tarballs, not public npm. To exercise both the source CLI and a compiled binary:

sh
bun run build:bin
HUNK_TEST_BINARY="$PWD/dist/hunk" bun test test/cli/npm-extension-install.test.ts

HUNK_TEST_BINARY is optional; the source CLI case runs in the default suite. On Windows,
set that environment variable to the absolute path of dist/hunk.exe before running Bun.

The dedicated trees are not selected directly by bun run test, though package tests import some
shared runtime fixtures. Run the commands that match the changed behavior; omission from the default
suite does not mean another command covers it.

Shell completion compatibility

test/pty/shell-completion-integration.test.ts presses Tab in real Bash Readline and Zsh ZLE
sessions. It covers standalone Bash, Bash with an installed bash-completion helper, and sourced
and autoloaded Zsh scripts. The fixture's hunk shell function only records the resulting
arguments; it never launches the application. These tests are Unix-only and skip missing shells.

Run them directly with:

bash
bun test test/pty/shell-completion-integration.test.ts

Set HUNK_TEST_BASH_COMPLETION to test a helper installed outside the detected system/Homebrew
locations. To verify standalone compatibility with a particular Bash executable, including
macOS's Bash 3.2, use:

bash
HUNK_TEST_BASH_EXECUTABLE=/bin/bash bun test \
  packages/hunk/src/app/shellCompletion.test.ts \
  test/pty/shell-completion-integration.test.ts \
  --test-name-pattern 'shell completion scripts|bash standalone'

Replace /bin/bash with the desired executable. Run the helper-backed case with the Bash version
supported by that installed helper; modern bash-completion versions may themselves require
Bash 4 or newer.

The Windows suite omits the platform-neutral terminal UI semantic tree, which the Linux suite covers,
then runs the focused UI tests for worker startup, editor commands, and workspace path safety. Add new
UI tests to the windows-ui group when their behavior depends on Windows platform boundaries.


Overview (`benchmarks/release/README.md`)

Release benchmark snapshots

Committed files in this directory are the performance baselines used by the release workflow. They are intentionally versioned so a release can be audited after publishing.

Release prep

Before pushing a release tag, run the benchmark suite for the version in package.json:

bash
bun run bench:release

This writes:

text
benchmarks/release/bench-x.y.z.json

Then compare it against the latest lower stable release snapshot:

bash
bun run bench:release:compare

Commit the new bench-x.y.z.json file with the release-prep change. The tag release workflow validates that this file exists and fails before publishing npm packages if the comparison finds a material regression.

Regression policy

The gate compares benchmark medians and only fails on regressions that exceed both the relative and absolute thresholds embedded in the benchmark result metadata:

  • timing metrics: default +15% and at least +5ms
  • memory metrics: default +20% and at least +8MiB

New metrics are informational until a later release has a baseline. Missing previously comparable metrics fail, because that means the gate can no longer protect that measurement.

If a release intentionally accepts a measured tradeoff, record the metric names and rationale in the new snapshot's acceptedRegressions array. The comparison report will mark those rows as accepted instead of failing, while preserving the audit trail in the committed release artifact.

Backfilling

When adding this gate or restoring a missing baseline, check out the release tag and generate the snapshot with the same Bun version and runner class used for current release prep. Commit backfilled snapshots before relying on the release gate.


Overview (`examples/1-hello-diff/README.md`)

1-hello-diff

A tiny first-run demo with one clean TypeScript diff.

Run

bash
hunk diff --files examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts

What to look for

  • a renamed type and function parameter
  • a small helper extraction
  • obvious intra-line changes in strings and copy

Overview (`examples/2-mini-app-refactor/README.md`)

2-mini-app-refactor

A realistic multi-file demo where a tiny morning-summary app gets reorganized into grouped output.

Run

bash
hunk patch examples/2-mini-app-refactor/change.patch

What to look for

  • a multi-file review stream in sidebar order
  • a new helper module added mid-change
  • the entrypoint switching from flat output to grouped sections
  • a matching validation-file update at the end of the review

Overview (`examples/3-agent-review-demo/README.md`)

3-agent-review-demo

A flagship Hunk demo: a small command-palette refactor with inline agent rationale attached to the interesting hunks.

Run

bash
hunk patch examples/3-agent-review-demo/change.patch \
  --agent-context examples/3-agent-review-demo/agent-context.json

What to look for

  • query normalization extracted into its own helper
  • ranking logic that prefers strong matches over loose substring hits
  • inline notes beside the changed hunks, not in a separate panel or PR description

Overview (`examples/4-ui-polish/README.md`)

4-ui-polish

A screenshot-friendly TSX diff with copy edits, prop renames, and a small layout cleanup.

Run

bash
hunk diff --files examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx

What to look for

  • renamed props and extracted button-label helper
  • nice intra-line emphasis in strings and labels
  • a compact UI-focused diff that looks good in split and unified layouts

Overview (`examples/5-pager-tour/README.md`)

5-pager-tour

A tall single-file diff made to show line scrolling, paging, and hunk jumps.

Run

bash
hunk diff --files examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts --pager

What to look for

  • enough changed content to exceed a normal terminal viewport
  • ↑ and ↓ for line-by-line movement
  • PageUp, PageDown, Home, and End for larger jumps
  • multiple hunks so [ and ] are worth trying too

Overview (`examples/6-readme-screenshot/README.md`)

6-readme-screenshot

A screenshot-optimized demo for the main README: a multi-file UI refactor with inline agent rationale.

Run

bash
hunk patch examples/6-readme-screenshot/change.patch \
  --agent-context examples/6-readme-screenshot/agent-context.json \
  --mode split \
  --theme github-dark-default

Screenshot setup

  • use a wide terminal so the sidebar and split diff are both visible
  • keep the first file selected: src/components/ReviewSummaryCard.tsx
  • make sure agent notes are visible
  • capture the first annotated hunk with the note popover open

What it shows well

  • inline agent rationale beside the changed code
  • a clear mix of removed and added lines in one hunk
  • a visible multi-file sidebar
  • TSX prop renames, copy edits, and helper extraction with strong syntax color

Overview (`examples/7-opentui-component/README.md`)

7-opentui-component

Two minimal OpenTUI apps that embed HunkDiffView directly.

For package install and API details, see OpenTUI component docs.

Run

bash
bun run examples/7-opentui-component/from-files.tsx
bun run examples/7-opentui-component/from-patch.tsx

What it shows

  • embedding HunkDiffView inside a normal OpenTUI app shell
  • building diff.metadata with parseDiffFromFile
  • parsing raw unified diff text with parsePatchFiles
  • switching between split and unified layouts with example shell controls
  • a scrollable terminal diff component that other OpenTUI apps can reuse

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


Overview (`examples/8-opentui-primitives/README.md`)

8-opentui-primitives

A small custom OpenTUI app assembled from Hunk's lower-level primitives instead of the full Hunk CLI app shell.

For package install and API details, see OpenTUI component docs.

Run

bash
bun run examples/8-opentui-primitives/primitives-demo.tsx

What it shows

  • createHunkDiffFilesFromPatch for turning unified diff text into public Hunk file models
  • HunkFileNav for a standalone file list
  • HunkReviewStream for a multi-file review stream without Hunk's menu bar or global shortcuts
  • HunkDiffFileHeader and HunkDiffBody for a single-file view assembled by the host app
  • Host-owned window borders/chrome around each primitive so you can inspect component boundaries
  • Host-owned state for selected file and split/unified layout

The in-repo demo imports from ../../packages/hunk/src/opentui so it runs from source. Published consumers should import from hunkdiff/opentui instead.


Overview (`examples/9-agent-markup-notes/README.md`)

9 — agent markup notes (STML)

Shows agent notes that carry STML markup — a small HTML-like markup that
Hunk renders as real terminal UI inside the inline note card: bordered boxes,
rows of shapes, lists, badges, and code blocks instead of plain text.

Run from the repository root:

sh
hunk patch examples/9-agent-markup-notes/change.patch \
  --agent-context examples/9-agent-markup-notes/agent-context.json

Press a to reveal the agent notes for the selected hunk.

The same markup works for live comments from an agent driving a session:

sh
hunk session comment add --repo . --file src/retry.ts --new-line 3 \
  --summary "Retry flow" \
  --markup '<box border border-color="accent">shapes in a note</box>' \
  --focus

Learn and iterate from the CLI:

sh
hunk markup guide                                  # authoring guide with copy-paste patterns
echo '<badge color="success">OK</badge> ready' | \
  hunk markup render - --width 56                  # preview before publishing

Tags: block (box, card, row, text, h1–h3, list/item, hr,
spacer, code) and inline (b, i, u, s, dim, color, kbd,
badge, a, br). Colors accept semantic tokens (accent, success,
warning, danger, info, muted), ANSI-style names, or hex.


Overview (`packages/hunk-git/README.md`)

@hunk/git

Private, statically bundled Git provider for Hunk. This 0.0.0 workspace is not a public or
versioned SDK.

The package exports its provider from src/index.ts. It supports working-tree, revision, and stash
reviews; history; exact old/new source reads; staged changes; untracked and large-file handling;
and filesystem-assisted watch plans.

Provider code may import local modules, platform built-ins, the public hunkdiff/extension
contract, and explicit @hunk/vcs/* leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in packages/hunk/src/extensions/default/vcs/index.ts and composes it
into the provider-neutral catalog in packages/hunk/src/app/vcsCatalog.ts. Keep tests beside the
provider source. Run bun test packages/hunk-git and bun run deps:check after changes.


Overview (`packages/hunk-jj/README.md`)

@hunk/jj

Private, statically bundled Jujutsu provider for Hunk. This 0.0.0 workspace is not a public or
versioned SDK.

The package exports its provider from src/index.ts. It supports working-copy and revision reviews,
history, exact source reads where the revision shape permits them, and signature-based refresh.
Staged and stash reviews are not supported.

Provider code may import local modules, platform built-ins, the public hunkdiff/extension
contract, and explicit @hunk/vcs/* leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in packages/hunk/src/extensions/default/vcs/index.ts and composes it
into the provider-neutral catalog in packages/hunk/src/app/vcsCatalog.ts. Keep tests beside the
provider source. Run bun test packages/hunk-jj and bun run deps:check after changes.


Overview (`packages/hunk-sapling/README.md`)

@hunk/sapling

Private, statically bundled Sapling provider for Hunk. This 0.0.0 workspace is not a public or
versioned SDK.

The package exports its provider from src/index.ts. It supports working-copy and revision reviews,
untracked-file discovery, and signature-based refresh. History, staged and stash reviews, exact
source reads, and filesystem watch plans are not supported.

Provider code may import local modules, platform built-ins, the public hunkdiff/extension
contract, and explicit @hunk/vcs/* leaves. It must not import Hunk core, app, session, or UI
internals.

Hunk registers the provider in packages/hunk/src/extensions/default/vcs/index.ts and composes it
into the provider-neutral catalog in packages/hunk/src/app/vcsCatalog.ts. Keep tests beside the
provider source. Run bun test packages/hunk-sapling and bun run deps:check after changes.


Overview (`packages/hunk-vcs/README.md`)

@hunk/vcs

Private implementation helpers shared by Hunk's bundled VCS providers. This 0.0.0 workspace is
not a public or versioned SDK.

The package has no root export. Providers import only the leaf they need:

  • @hunk/vcs/async-process
  • @hunk/vcs/diff-target
  • @hunk/vcs/large-file
  • @hunk/vcs/path
  • @hunk/vcs/source

@hunk/vcs sits at the bottom of the workspace graph and does not import other workspace packages.
Provider implementations combine these helpers with the public hunkdiff/extension contract; Hunk
registers them in packages/hunk/src/extensions/default/vcs/index.ts and composes the catalog in
packages/hunk/src/app/vcsCatalog.ts.

Keep unit tests beside the source. Run bun test packages/hunk-vcs and bun run deps:check after
changing this package.


Overview (`packages/hunk/README.md`)

hunk

Hunk is an interactive terminal diff viewer for reviewing code changes, built on OpenTUI and Pierre diffs.

hunk.dev · Documentation




  • multi-file review stream with sidebar navigation
  • inline AI and agent annotations beside the code
  • split, unified, and responsive auto layouts
  • watch mode for auto-reloading file and Git-backed reviews
  • keyboard, mouse, pager, and Git difftool support
Split view with sidebar and inline AI notes Unified view and mouse-selectable menus

Install

On macOS and Linux, install the standalone binary:

bash
curl -fsSL https://hunk.dev/install.sh | sh

Windows users can install with npm or mise. Other installation methods are also available:

bash
npm i hunkdiff                    # macOS, Linux, or Windows; requires Node.js 22+
brew install hunk                    # macOS or Linux
mise use -g hunk                     # macOS, Linux, or Windows

[!NOTE]
If you previously installed hunk via modem-dev/tap, be sure to uninstall it first with brew uninstall modem-dev/tap/hunk.

Windows requires mise 2026.8.6 or newer. Nix users can use the default package exported in flake.nix; see the Nix guide for details. Hunk also ships as a default tool in Omarchy, installed through mise.

Requirements:

  • macOS (arm64, x64), Linux (arm64, x64; glibc), or Windows (x64); prebuilt binaries are not published for Windows on arm64 or musl-based Linux such as Alpine
  • On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor
  • Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js
  • Git recommended for most workflows

Update Hunk

For npm, Homebrew, and default install-script installs (Hunk 0.20+):

bash
hunk update          # install the newest release
hunk update --check  # check without installing
hunk update 0.20.0   # select an exact npm or default install-script release

On an older release, update once with the installer or package manager that installed Hunk, then use hunk update going forward. Custom HUNK_INSTALL_DIR installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead.

Quick start

bash
hunk           # show help
hunk --version # print the installed version

Working with Git

Use familiar Git commands to open changes in Hunk:

bash
hunk diff                      # review current repo changes, including untracked files
hunk diff --watch              # auto-reload as the working tree changes
hunk show                      # review the latest commit
hunk show HEAD~1               # review an earlier commit
hunk log                       # browse history on a terminal; print when redirected
hunk log --static              # force static output, paging when needed
hunk status                    # view workspace status and sibling worktrees (Git)
hunk status --static           # print one read-only workspace snapshot
hunk status --json             # emit one versioned workspace snapshot

hunk status is read-only: it does not fetch or change the repository. See workspace status for details.

hunk log opens an interactive history browser in a terminal and prints text when piped or redirected. Use --oneline for compact output. In the browser, enable Graph view from the View menu to see commit branches, or press v to select a commit range. Quit a commit review to return to history.

Working with GitHub

Hunk bundles hunk gh, so pull requests, commits, and comparisons open directly without the
GitHub CLI or a separate extension:

bash
hunk gh pr                         # infer the open PR for the current branch
hunk gh pr 123                     # infer the repository from origin
hunk gh pr 'modem-dev/hunk#123'    # quote this form for shells that treat # as a comment
hunk gh commit a1b2c3d --repo modem-dev/hunk
hunk gh compare main...feature --repo modem-dev/hunk

Public repositories work anonymously. Set GH_TOKEN, or GITHUB_TOKEN when it is absent, for
private repositories and higher API limits. GitHub Enterprise is not currently supported.

Working with Jujutsu and Sapling

Hunk auto-detects Jujutsu and Sapling checkouts, so hunk diff [revset] and hunk show [revset] use native revsets inside jj or Sapling workspaces. hunk log --vcs jj also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set vcs = "git" or vcs = "jj" or vcs = "sl" in config.

Viewing complete files and browsing directories

bash
hunk open                 # browse the current directory
hunk open README.md       # view a complete, syntax-highlighted document
hunk open path/to/project # browse another directory without requiring a repository

Use arrows and Enter to navigate, Tab to switch focus, i to show hidden/Git-ignored entries, and e to edit the displayed file in $EDITOR. See the open workflow for details and file limits.

Working with raw files and patches

bash
hunk diff --files before.ts after.ts        # compare two files directly
hunk diff --files before.ts after.ts --watch # auto-reload when either file changes
git diff --no-color | hunk patch -          # review a patch from stdin

Working with agents

  1. Install the review skill once: npx skills add modem-dev/hunk -g --skill hunk-review.
  2. Open Hunk in another terminal with hunk diff or hunk show.
  3. Ask your agent to use the hunk-review skill against the live Hunk session.
  4. Press a in Hunk to show agent notes; they start hidden.

If your agent has no skill system, ask it to run hunk skill show and follow the output.

See Review with an agent for live sessions, --agent-context, and experimental rich notes.

Feature comparison

Capability hunk lumen difftastic delta diff-so-fancy diff
Review-first interactive UI ✅ ✅ ❌ ❌ ❌ ❌
Multi-file review stream + sidebar ✅ ✅ ❌ ❌ ❌ ❌
Inline agent / AI annotations ✅ ❌ ❌ ❌ ❌ ❌
Responsive auto split/unified layout ✅ ❌ ❌ ❌ ❌ ❌
Mouse support inside the viewer ✅ ✅ ❌ ❌ ❌ ❌
Runtime view toggles ✅ ✅ ❌ ❌ ❌ ❌
Syntax highlighting ✅ ✅ ✅ ✅ ❌ ❌
Structural diffing ❌ ❌ ✅ ❌ ❌ ❌
Pager-compatible mode ✅ ❌ ✅ ✅ ✅ ✅

Advanced

Config

You can persist preferences to a config file:

  • $XDG_CONFIG_HOME/hunk/config.toml (default ~/.config/hunk/config.toml)
  • .hunk/config.toml in a repository

Persistent state uses $XDG_STATE_HOME/hunk/state.json when the variable is
absolute, or ~/.local/state/hunk/state.json otherwise. Hunk preserves existing
state by copying the old config-directory file on first use, keeping the original
and falling back to it if migration fails. Global extension paths are unchanged.

For example:

toml
theme = "github-dark-default"
mode = "auto"               # auto, split, unified
auto_split_min_width = 160  # minimum available diff columns for split in auto mode
line_numbers = true
wrap_lines = false

See the configuration guide for command overrides and examples, the full reference for every option, or the theme guide to customize colors.

Keybindings

Remap shortcuts with a [keybindings] table in your user config. See the keybinding guide for available commands, default keys, and examples.

Git integration

Set Hunk as your Git pager so git diff and git show open in Hunk automatically:

[!NOTE]
Untracked files are auto-included only for Hunk's own hunk diff working-tree loader. If you open git diff through hunk pager, Git still decides the patch contents, so untracked files will not appear there.

bash
git config --global core.pager "hunk pager"

Or in your Git config:

ini
[core]
    pager = hunk pager

If you want to keep Git's default pager and add opt-in aliases instead:

bash
git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff"
git config --global alias.hshow "-c core.pager=\"hunk pager\" show"

Jujutsu pager integration

To use Hunk as jj's pager, run jj config edit --user and update:

toml
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"

Sapling pager integration

To use Hunk as Sapling's pager, run sl config -u and update:

ini
[pager]
pager = hunk pager

Extensions (experimental)

The extension API is experimental and may change in breaking ways between
minor releases while it stabilizes; breaking changes are called out in
release notes.

Hunk loads plain TypeScript extensions from ~/.config/hunk/extensions/, from a
repository's .hunk/extensions/ (after you explicitly trust that repository),
and from --extension for development. --no-extensions turns those off
for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded.

Extensions can add commands, themes, VCS backends, custom sidebars, and more. For example, filter lockfiles out of a review:

ts
// ~/.config/hunk/extensions/collapse-lockfiles.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.transformChangeset((changeset, ctx) => {
    const files = changeset.files.filter((file) => !file.path.endsWith(".lock"));
    ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`);
    return { ...changeset, files };
  });
}

Extensions shared as git repositories install straight from their host, and a
hunk-extension GitHub topic marks community ones:

bash
hunk extension install acme/[email protected]   # or git:host/path, a URL, a local path
hunk extension list                                 # then update [name] / remove <name>

Browse community extensions at
github.com/topics/hunk-extension;
publish yours by pushing the extension to a repository root and adding that
topic.

See the extension guide for the API, configuration, and publishing instructions.
Installable examples include
review triage,
review snapshot export, an optional
rendered Markdown file view, and a
Vim navigation mode built from public semantic commands.

OpenTUI component

Hunk also publishes HunkDiffView and lower-level primitives from hunkdiff/opentui for embedding the same diff renderer in your own OpenTUI app.

See the OpenTUI component guide for install, API, and runnable examples.

Examples

Ready-to-run demo diffs live in the examples directory.

Each example includes the exact command to run from the repository root.

Contributing

💬 Chat with users/contributors on the Modem Discord server

See CONTRIBUTING.md for development setup and contribution guidelines.

Sponsored by Modem.

License

MIT


Overview (`packages/session-broker-bun/README.md`)

@hunk/session-broker-bun

Private Bun HTTP and websocket adapter for @hunk/session-broker.

Use this workspace to serve a runtime-neutral SessionBrokerDaemon through Bun.serve(...).
It is not a published, versioned SDK package. See the main broker README for current internal API
construction and the session broker SDK contract for the future
public package.

What it does

  • binds a broker daemon to a Bun HTTP server
  • upgrades websocket requests on the daemon socket path
  • forwards websocket messages and close events into the daemon
  • exposes a stopped promise compatible with Hunk's daemon lifecycle
  • lets callers override or add custom HTTP routes before the daemon's built-in routes

Usage

Create the daemon with steps 1–2 in
@hunk/session-broker, then bind it to Bun:

ts
import type { SessionBrokerDaemon } from "@hunk/session-broker";
import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun";

declare const daemon: SessionBrokerDaemon;

const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});

Custom routes

You can override or extend request handling with handleRequest.

ts
const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
  handleRequest: async (request) => {
    const url = new URL(request.url);
    if (url.pathname === "/health") {
      return Response.json({ ok: true, overridden: true });
    }

    return undefined;
  },
});

Return undefined to fall through to the daemon's built-in routes. The raw /broker HTTP API is available only when the daemon was created with exposeHttpApi: true.

License

MIT


Overview (`packages/session-broker-core/README.md`)

@hunk/session-broker-core

Low-level shared primitives for the session broker packages.

This package is an internal foundation layer, not the main entrypoint you should build against in most cases.

Use this package when

  • you are working on broker internals
  • you need the shared envelope types and parsers directly
  • you are implementing a higher-level broker package on top of it

Prefer these packages for normal use

  • @hunk/session-broker — main runtime-neutral broker API
  • @hunk/session-broker-bun — Bun runtime adapter
  • @hunk/session-broker-node — Node runtime adapter

What this package includes

  • shared session envelope types
  • registration and snapshot wire parsing helpers
  • low-level in-memory SessionBrokerState
  • selector helpers for sessionId, sessionPath, and repoRoot
  • generic terminal metadata capture

What this package does not include

  • daemon behavior
  • session-side websocket lifecycle helpers
  • Bun or Node listener setup
  • app-specific command semantics or projections

Those higher-level concerns live in the packages above.

Package boundary

The intended split is:

  • @hunk/session-broker-core — low-level primitives
  • @hunk/session-broker — main broker API
  • runtime adapters — Bun and Node listener bindings

Quick example

ts
import {
  brokerWireParsers,
  parseSessionRegistrationEnvelope,
  parseSessionSnapshotEnvelope,
  SessionBrokerState,
} from "@hunk/session-broker-core";

If you find yourself reaching for this package directly in app code, double-check whether @hunk/session-broker would be the better fit.

License

MIT


Overview (`packages/session-broker-node/README.md`)

@hunk/session-broker-node

Private Node HTTP and websocket adapter for @hunk/session-broker.

Use this workspace to serve the broker daemon under Node instead of Bun. It is not a published,
versioned SDK package. See the main broker README for current internal API construction and the
session broker SDK contract for the future public package.

What it does

  • serves a runtime-neutral SessionBrokerDaemon through Node HTTP
  • upgrades websocket requests with ws
  • forwards websocket messages and close events into the daemon
  • exposes async startup and shutdown helpers
  • keeps the runtime-specific listener code out of @hunk/session-broker

Usage

Create the daemon with steps 1–2 in
@hunk/session-broker, then bind it to Node:

ts
import type { SessionBrokerDaemon } from "@hunk/session-broker";
import { serveSessionBrokerDaemon } from "@hunk/session-broker-node";

declare const daemon: SessionBrokerDaemon;

const server = await serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});

Why this package exists

This package runs the shared broker API against Node listener and websocket primitives.

If the Node adapter needs an abstraction the shared package does not provide, the fix should happen in @hunk/session-broker, not as Node-only glue.

License

MIT


Overview (`packages/session-broker/README.md`)

@hunk/session-broker

Runtime-neutral session broker daemon and connection helpers.

The session broker SDK contract
describes the future supported public package. These private 0.0.0 source workspaces predate that
contract and do not yet satisfy all of its security, compatibility, supervision, packaging, or
release gates.

The current package provides signed producer/caller hello, short-lived caller sessions, replay
admission, and default-deny raw HTTP authorization primitives. Hunk discovers app-scoped
credentials, starts session producers, and authenticates callers in its own runtime adapters. The
legacy Hunk WebSocket and custom session routes remain internal-only; no bearer fallback is
available. Browser-review capabilities are separate from broker caller credentials.

This is the main broker package in the workspace. It owns the reusable broker behavior without committing to Bun or Node server APIs.

Use this package when you want to:

  • track live sessions
  • register and update session snapshots
  • route commands to one live session
  • expose broker health and optional raw list/get/dispatch APIs
  • manage session-side websocket connection state

Current workspace roles

The source tree is currently split into four private workspaces:

  • @hunk/session-broker-core — low-level shared primitives and envelope parsing
  • @hunk/session-broker — main runtime-neutral broker API
  • @hunk/session-broker-bun — Bun HTTP/websocket adapter
  • @hunk/session-broker-node — Node HTTP/websocket adapter

The public SDK contract consolidates these into one @hunk/session-broker package whose root
selects Node or Bun automatically. Until that migration lands, these names and examples describe
internal workspace usage only.

What this package owns

  • SessionBroker raw session registry
  • SessionBrokerDaemon runtime-neutral daemon engine
  • SessionBrokerConnection runtime-neutral session-side websocket helper
  • raw broker HTTP request types
  • health handling and optional capabilities API handling
  • stale-session pruning and idle shutdown

What this package does not own

  • Bun Bun.serve(...)
  • Node http / ws listener setup
  • app-specific command semantics
  • app-specific projections like Hunk review exports, comments, or selected hunks
  • daemon process launch policy

Current internal quick start

1. Create a broker

ts
import {
  SessionBroker,
  brokerWireParsers,
  createSessionBrokerProtocolParsers,
  parseSessionRegistrationEnvelope,
  parseSessionSnapshotEnvelope,
} from "@hunk/session-broker";

interface SessionInfo {
  title: string;
}

interface SessionState {
  selectedIndex: number;
}

function parseInfo(value: unknown): SessionInfo | null {
  const record = brokerWireParsers.asRecord(value);
  if (!record) {
    return null;
  }

  const title = brokerWireParsers.parseRequiredString(record.title);
  return title === null ? null : { title };
}

function parseState(value: unknown): SessionState | null {
  const record = brokerWireParsers.asRecord(value);
  if (!record) {
    return null;
  }

  const selectedIndex = brokerWireParsers.parseNonNegativeInt(record.selectedIndex);
  return selectedIndex === null ? null : { selectedIndex };
}

const protocolParsers = createSessionBrokerProtocolParsers({
  appRevision: 1,
  features: [],
  parseRegistration: (value) => parseSessionRegistrationEnvelope(value, parseInfo),
  parseSnapshot: (value) => parseSessionSnapshotEnvelope(value, parseState),
  commands: [
    {
      command: "select",
      version: 1,
      parseInput: (value) => (brokerWireParsers.parseNonNegativeInt(value) === null ? null : value),
      parseResult: (value) => (value === true ? true : null),
    },
  ],
});

const broker = new SessionBroker({ protocolParsers });

2. Create a daemon engine

ts
import { createSessionBrokerDaemon } from "@hunk/session-broker";

const daemon = createSessionBrokerDaemon({
  broker,
  capabilities: {
    version: 1,
    name: "example-broker",
  },
});

At this point the daemon can:

  • handle health requests
  • process websocket register/snapshot/heartbeat/result messages
  • prune stale sessions and request idle shutdown

The raw HTTP broker API is opt-in and fails closed: exposeHttpApi: true exposes no control route
unless an explicit immutable appId, a singleton appRevision, a callerAuthenticator, and an app
authorizer are supplied. The included
SessionBrokerAuthenticator implements Ed25519 challenge/proof and signed requests; applications
inject app-scoped grants, public verifiers, daemon signing identity, revocation policy, and their
own default-deny authorization hook. It performs no filesystem, environment, coordinator, or Hunk
credential discovery.

3. Serve it through a runtime adapter

Bun

ts
import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun";

const server = serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});

Node

ts
import { serveSessionBrokerDaemon } from "@hunk/session-broker-node";

const server = await serveSessionBrokerDaemon({
  daemon,
  hostname: "127.0.0.1",
  port: 47657,
});

Session-side connection helper

Use SessionBrokerConnection when an app window or live process needs to stay registered with the broker.

ts
import {
  createNativeSessionBrokerLifecycleClock,
  createSessionBrokerConnection,
  type SessionBrokerSocketLike,
} from "@hunk/session-broker";

const lifecycleClock = createNativeSessionBrokerLifecycleClock();
const connection = createSessionBrokerConnection({
  url: "ws://127.0.0.1:47657/session",
  createSocket: (url) => new WebSocket(url) as unknown as SessionBrokerSocketLike,
  registration,
  snapshot,
  protocolParsers,
  lifecycleClock,
  bridge: {
    dispatchCommand: async (message) => {
      if (message.command !== "select") throw new Error("Unsupported command.");
      selectFile(message.input);
      return true;
    },
  },
});

connection.start();

lifecycleClock may be supplied in the connection options when an application needs to share
lifecycle timing with its launcher or make producer scheduling deterministic. The ordinary
SessionBrokerLifecycleClock contract provides current time, one-shot scheduling, delayed-first
fixed-rate interval scheduling, and awaitable delay. Scheduled callbacks return idempotent
disposers. createNativeSessionBrokerLifecycleClock() uses unref'd native timers so pending
handshake, heartbeat, reconnect, and polling work does not retain the process.

createSocket must return a fresh socket object for every generation because the helper installs
property callbacks that cannot distinguish queued events after object reuse. Reconnect and close
callbacks receive a frozen opaque generation token; after foreign work settles, use
connection.isGenerationCurrent(token) before committing app-owned state. These checks fence
commits only: they do not cancel foreign promises, cryptography, probes, or other work already in
progress.

Unexpected defects in connection-owned native callbacks, scheduled work, or fire-and-forget work
terminally retire that connection. Applications may supply onDefect to observe the event. The
callback receives only SESSION_BROKER_LIFECYCLE_DEFECT_MESSAGE; thrown values and lifecycle data
are never forwarded, the callback runs at most once, and callback failures are contained. This
runtime-neutral package does not write defect reports to process output.

The helper owns:

  • initial register
  • later snapshot updates
  • heartbeats
  • command-result replies
  • queued broker commands until the bridge is ready
  • reconnect scheduling

Raw broker API

The daemon always serves GET /health. Its raw capability/control API is intentionally small and
disabled by default. When exposeHttpApi: true is set together with an explicit appId, singleton
appRevision, caller authenticator, and authorizer, it additionally serves:

  • GET /broker/capabilities
  • POST /broker

Request body shapes:

ts
{ action: "list" }
{ action: "get", selector: { sessionId: "..." } }
{ action: "dispatch", selector: { sessionId: "..." }, command: "...", commandVersion: 1, input: {...} }

An omitted commandVersion is validated and deliberately defaults to revision 1 for current
internal callers. Authentication covers the exact bounded HTTP body bytes before strict UTF-8 and
JSON decoding. Authenticated responses use { body, authentication }; the signed authentication
record binds daemon generation, broker revision, target application contract when applicable,
request ID, HTTP status, and the canonical structured-body digest.

Hunk-specific layering

Hunk uses this package for generic broker lifecycle and keeps product behavior in packages/hunk:

  • credential discovery: packages/hunk/src/session/broker/credentials.ts
  • daemon and Hunk-specific routes: packages/hunk/src/session/broker/brokerServer.ts
  • authenticated agent caller: packages/hunk/src/session/agent/cliClient.ts
  • session commands and review projections: packages/hunk/src/session/

The broker owns transport and lifecycle; Hunk owns session meaning and policy.

License

MIT


Overview (`packages/term-video/README.md`)

@hunk/term-video

Keyframe-based terminal product videos. Instead of screen-recording in real
time, a script drives the real TUI over a PTY and snaps discrete styled
keyframes; a storyboard then declares how long each state holds and which
caption accompanies it; Chromium composites each frame onto a stage (window
chrome, captions, title cards); ffmpeg encodes the result. Pacing is
deterministic, a minute of video is ~300 unique frames, and every frame is an
inspectable PNG before anything is encoded.

Entry points

  • @hunk/term-video/capture (Bun) — PTY driving and keyframe snapping:
    createKeyframer, launchApp, launchShell, createCommandWrapper,
    typeCommand, ensureKeyboardIsLive, makeSceneFilter.

  • @hunk/term-video/plan (Bun or Node) — pure storyboard→frame-plan
    expansion: planFrames, requiredKeyframes. Unit-tested; owns caption
    carry-forward plus camera-pan, zoom, and source-aligned highlight animation.

  • @hunk/term-video/compose (Node ≥ 22) — composeStoryboard renders the
    plan in headless Chromium against src/stage.html (or a custom stage) and
    writes PNG frames plus an ffmpeg concat list. playwright-core is resolved
    from the caller's work directory so its version can match the driven
    Chromium build.

  • @hunk/term-video/timeline (Node ≥ 22) — renderTimeline steps a free-form
    motion page frame by frame instead of planning stage states. The page owns
    all of its motion and exposes timelineReady, TIMELINE_DURATION, and a
    pure renderAt(t), so any frame renders on its own for review stills.
    frameTimes and encodeArgs are the unit-tested sampling and encoding
    pieces.

Consumers

scripts/launch-video/ holds Hunk's current storyboard (scenes, captions,
cards) on top of this package, and scripts/launch-video/promo/ holds Hunk's
release-promo style kit rendered through timeline; skills/hunk-launch-video/SKILL.md documents the
end-to-end workflow and recipes for single-feature, custom, and full-release
videos, environment gotchas included.


Overview (`scripts/launch-video/README.md`)

Hunk video pipeline

Generates Hunk product videos from real Hunk sessions — no screen recording.
The same pipeline supports single-feature demos, workflow explainers, launch
videos, and full-release roundups. This directory holds only the current Hunk
storyboard: the capture scenes (capture.ts) and the shot list, cards, and
captions (compose.mjs). The generic machinery — PTY driving, keyframe
rendering, storyboard planning, Chromium compositing, and the stage template —
is the @hunk/term-video package in packages/term-video/.

The full procedure — regeneration steps, environment gotchas, scene authoring,
storyboard model, and content-accuracy rules — lives in
skills/hunk-launch-video/SKILL.md.

promo/ is the house style for release videos: engine.js and
stage.html are the reusable style kit, storyboards/<version>.mjs holds one
release's content, contributors.mjs computes the credits from a tag range,
and render.mjs renders the cut through @hunk/term-video/timeline. The
full-release recipe in the skill walks through making the next one.


Overview (`workers/release-proxy/README.md`)

Hunk release proxy

This Cloudflare Worker serves GET /v1/curl/latest. Clients reach it as
https://hunk.dev/api/release/latest: the repository-level vercel.json rewrites that path to the
Worker's updates.hunk.dev custom domain so the installer and hunk update only ever contact the
apex domain. macOS 26.4+ runs browser-pasted Terminal commands in an XProtect sandbox and blocks
the paste when the command reaches a host Apple's website reputation service distrusts, which the
Worker hostname tripped. It normalizes GitHub's latest stable Hunk release to:

json
{ "version": "0.20.1" }

A cron trigger refreshes that version in Workers KV every minute. Requests read KV instead of
GitHub, so release-check traffic cannot consume GitHub's API quota. Failed refreshes preserve the
last valid version, while metadata older than six hours returns an endpoint error so Hunk can use
its direct-GitHub fallback. Unchanged metadata writes a fresh heartbeat at most once per hour.

The Worker writes one structured release_check log containing only allowlisted source and
currentVersion values. Scheduled attempts write a bounded release_refresh result. Client
responses use Cache-Control: no-store so Cloudflare's outer cache cannot bypass the Worker and its
per-request log. It does not use cookies, request bodies, or installation identifiers. Cloudflare's
infrastructure may provide its own request metadata subject to the account's log and retention
configuration.

Development

sh
npm install
bun test
npm run typecheck
npm run dev

Deployment

The committed RELEASE_METADATA binding points at the production KV namespace. Before the first
deployment, add a read-only GitHub credential as a Worker-scoped secret:

sh
npx wrangler secret put GITHUB_TOKEN

wrangler deploy publishes the Worker and its every-minute cron trigger to the configured
updates.hunk.dev custom domain. The release-proxy.yml workflow checks pull requests and main
without receiving production credentials; deployment stays a manual operation from a trusted
maintainer machine:

sh
npm ci
npm test
npm run typecheck
npm run deploy
curl -fsS https://updates.hunk.dev/v1/curl/latest
curl -fsSL https://hunk.dev/api/release/latest

The second check covers the Vercel rewrite. Keep the updates.hunk.dev custom domain while
releases that call it directly remain installed; they fall back to GitHub when it is unavailable.

The client and installer fall back directly to GitHub, so their rollout does not depend on
deployment ordering. Verify the endpoint and its bounded structured logs after each deployment.
Keep the GitHub credential in the Worker secret, never in Hunk or Wrangler configuration.


Overview (`examples/extensions/cli-tools/README.md`)

CLI tools extension

Demonstrates a generic top-level command tree. The handler owns every token below cli-tools, can stream headless output, receives cooperative cancellation, and can hand terminal ownership to one built-in Hunk command.

Run it directly from this checkout:

bash
bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status
bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review

status writes to stdout and exits. review performs signal-aware asynchronous preprocessing, writes progress to stderr, then delegates to hunk diff. A delegating handler must not write stdout or read stdin because the built-in command or TUI takes ownership of both.

The example parses its raw argument tokens directly. Extensions may use their own parser, make network requests, spawn processes, or access the filesystem under Hunk's ordinary extension trust model.


Overview (`examples/extensions/code-document-file-view/README.md`)

Code-document file view

This API-v28 example presents one old/new code pair per diff hunk. It declares the complete source documents so Hunk can tokenize with multiline lexical context, while each displayed span references either a complete old-side line or a partial new-side UTF-16 slice. Gutters and the unhighlighted prefix stay ordinary symbolic spans.

Hunk owns the language, active syntax theme, workers, caching, terminal safety, and plain fallback. The extension never supplies token colors.

Run it from this checkout against two JavaScript or TypeScript files:

bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/code-document-file-view \
  path/to/before.ts path/to/after.ts

Choose View → File presentation → Code documents: old / new. The example intentionally omits sourceRanges: syntax references paint code but do not claim note/navigation ownership. A visible note therefore makes Hunk use the raw diff, as required by the all-or-raw note policy.


Overview (`examples/extensions/inline-edit/README.md`)

Inline edit extension

A miniature line editor for the file under review. Press Ctrl-E, type into the diff, press Ctrl-S, and Hunk writes the file back to your working tree after asking you first.

This example is not bundled or loaded by Hunk. Install it explicitly if you want it.

It exists to demonstrate that Hunk's interactive extension surfaces compose, so it uses them all at once:

Capability Where this extension uses it
ctx.workspace canWriteDocument gates the affordance, readDocument("new") fills the buffer, writeDocument performs the consented write and reloads the review.
mode on a registered file view onKey claims arrows, editable characters, Backspace, Enter, and Ctrl-S, while ], ?, and q pass through so navigation, help, and quit keep working. Ctrl-S is recognized with matchesKey, so the bare control byte terminals send for it matches too.
fileViews.enterMode(viewId) One call makes the view the file's presentation and gives its mode the keyboard, so Ctrl-E opens the editor in a single press.
fileViews.refresh(viewId, { fileId }) Every buffer or caret change re-derives the layout. A view's layout is a pure function of (file, width), so this is the only way a stateful presentation redraws — and the buffer belongs to one file, so the refresh is scoped to it and no other file re-lays out.

Try it from this checkout

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inline-edit

Install it globally

Copy the whole folder — it has no dependencies:

bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/inline-edit ~/.config/hunk/extensions/

Hunk discovers the folder automatically on later launches. Open View and choose File presentation: Inline edit, or press Ctrl-E. The command is named inline-edit.edit for [keybindings] customization.

Keys

Key Does
Ctrl-E Starts editing the selected file, showing the view if it was not already.
↑ ↓ ← → Move the caret. ←/→ wrap across line ends.
any other printable character Types at the caret — including characters bound to Hunk commands, like z.
Backspace Deletes back one character, joining with the previous line at column 0.
Enter Splits the line at the caret.
Ctrl-S Asks Hunk to write the buffer. Hunk confirms, writes, and reloads the review.
Esc Leaves the editor and discards everything typed since the last write.
everything else Still Hunk's: ] moves to the next hunk, ? opens help, q quits.

The header row reads EDITING — Esc exits · ctrl+s writes, plus a MODIFIED marker whenever the buffer differs from the text on disk.

How the pieces fit

Ctrl-E is one press. The command gates on canWriteDocument, starts reading the document, and calls fileViews.enterMode before awaiting that read. Entry therefore uses the same selected file the command captured; a selection change cannot attach a late buffer to another file. enterMode makes the view that file's presentation and gives its mode the keyboard together, then the completed read builds the buffer and refreshes the view. When entry refuses (no mode, a file Hunk keeps on raw diff, a view that does not match), it warns by name and returns false without installing a buffer.

The editor slot is claimed synchronously, before that command's first await. Reading the document suspends the handler, so a guard that only checked "is a session live?" would let a second Ctrl-E through the window in between, and one of the two handlers would then be parked forever on a session nothing could end. The claim is released on every way out — an unwritable review, an unreadable document, a refused enterMode — and once a session is live it is the live session that answers the next press.

onKey has to answer synchronously — its return value is the routing decision — and the mode context carries only file and fileViews. So a keystroke can never write anything itself. What Ctrl-S does instead is post a request into the edit session, and the command handler that entered the mode is still awaiting that session: ctx.workspace is valid for the whole life of a command handler's promise, so the handler is the mode's async runtime.

That loop ends the same way from every direction, because onExit runs on every exit path:

  • Saved. A successful writeDocument reloads the session, the reload exits the mode, onExit ends the loop.
  • Escaped. Escape is host-owned, so it exits the mode without ever reaching onKey, and the loop ends the same way.
  • Cancelled write. { ok: false, reason: "cancelled" } is the user answering, not a failure. The editor keeps running.
  • Failed write. The detail sentence is shown as a warning and the editor keeps running, so nothing typed is lost to a full disk.

Limitations

This is a demonstration, not an editor:

  • No wrapping. Lines are truncated at the pane width with …. A file-view layout must be deterministic for (file, width), and wrapping would be a second layout problem on top of the one this example is about.
  • Whole-line truncation. The caret can move past the visible edge; the row does not scroll horizontally to follow it.
  • One buffer, no undo. Escape discards everything since the last write, with no confirmation beyond the notice it leaves behind.
  • Writes are working-tree only, which is a property of ctx.workspace: hunk show, hunk patch, a staged diff, and a file-pair diff have no working-tree document to replace, so the command refuses instead of opening an editor that could not save.
  • Agent notes describe the document as it was loaded. They stay on the lines they were written about, but nothing re-reads the changeset while you type.

Source bindings and provenance

Rows carry sourceRanges so Hunk can place its own inline notes inside this presentation, and a row may only bind a line it honestly still is. Row position cannot answer that once you split or join a line, so the edit session keeps provenance: for each buffer line, the document line it came from, or nothing at all.

Edit Provenance
typing in a line kept — an edited line is still the line it came from
Enter (split) the first line keeps it; the new tail gets none
Backspace (join) the merged line presents both source lines so either note stays attached

So a line you inserted binds nothing, the lines below a split keep the numbers they had, and a joined row keeps every source line it now presents. The hunk extents in hunkRows are derived from the same provenance, so the hunk highlight follows the rows still holding a hunk's lines instead of drifting down by however many lines you added above them. A join across two distinct hunks is refused because one bound row cannot belong to two hunk extents.

One thing this shows that is easy to get wrong: Hunk accepts a binding only on a row exactly one hunkRows extent owns, and rejects the whole layout otherwise — so a last pass drops the bindings no extent owns, and context lines outside every hunk are presented without one. The rendered Markdown example ends its layout with the same pass, for the same reason.

Where this is documented


Three opt-in presentations exercise the constrained React/OpenTUI row contract against checked-in, realistic file pairs. Run one command from the repository root, then press F8 or choose Extensions → Toggle JSX demo for current file. Press F8 again to restore Pierre's raw diff.

1. Change atlas

Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone.

bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \
  examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/after.ts

2. CSS palette delta

The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles.

bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \
  examples/extensions/jsx-file-view-gallery/fixtures/css-palette/after.css

3. Dependency delta

A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff.

bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/jsx-file-view-gallery \
  --mode unified \
  examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \
  examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/after/package.json

Mixed five-file review

To see all three preview types retained together between ordinary raw diffs—and enough content to exercise stream scrolling—run:

bash
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts

Follow the short activation sequence in mixed-review/README.md.

Contract illustrated

  • Every painter stays inside a declared fixed-height row; geometry, scrolling, windowing, and hunk navigation remain host-owned.
  • Every row keeps useful symbolic spans for row-local error fallback, clipped to the same fixed rectangle.
  • Semantic data is captured in closures during layout; painters receive only bounded paint props and use Hunk's live paint-only semantic theme palette.
  • The demos intentionally have no pointer handlers. Registered commands are the supported interaction path.
  • Layouts return null when exact source is unavailable or no supported semantic row can be attributed. Mixed diffs may retain neutral summary rows for non-semantic hunks so navigation stays positional.
  • Rows bind conservatively attributed exact-source ranges. Hunk renders a note inside the alternate view only when its preferred-side anchor resolves uniquely; otherwise the complete file falls back to raw diff.

This gallery is experimental and is not loaded unless you explicitly pass or install its folder. See docs/file-view-jsx-poc.md for the full contract.


Overview (`examples/extensions/jsx-file-view/README.md`)

JSX file-view POC extension

An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file presentations. It appears only for files with at least two parsed hunks and creates two custom rows per hunk, with stable row IDs and explicit hunk bounds.

Run it from this checkout against a multi-hunk working-tree change:

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view

Choose Extensions → Toggle JSX hunk cards (POC). The row component uses a React state hook and OpenTUI box/text elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The spans on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it.

This example is deliberately opt-in and experimental. See docs/file-view-jsx-poc.md for constraints.


Overview (`examples/extensions/pane-layout/README.md`)

Pane layout extension

Registers a resizable right pane and fixed two-row top and bottom panes.

Run it from this checkout:

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/pane-layout

Press ctrl+p or use the Extensions menu. Drag the right divider to resize.

Copy this directory to your Hunk extensions directory to install it.


Overview (`examples/extensions/rendered-markdown/README.md`)

Rendered Markdown extension

An optional Markdown preview for Hunk's experimental file-view API. It parses Markdown with Marked and presents headings, inline formatting, links, lists, quotes, tables, and fenced code as host-owned symbolic rows.

This example is not bundled or loaded by Hunk. Install it explicitly if you want it.

Try it from this checkout

The repository's root install supplies the example's development dependency:

bash
bun run packages/hunk/src/main.tsx -- diff \
  --extension ./examples/extensions/rendered-markdown \
  before.md after.md

Install it globally

Copy the whole folder, then install its local dependency:

bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/
cd ~/.config/hunk/extensions/rendered-markdown
bun install

Hunk discovers the folder automatically on later launches. Open View and choose File presentation: Rendered Markdown, or press F8. The command is named rendered-markdown.toggle-rendered-markdown for [keybindings] customization.

Raw Pierre diff remains the default and fallback. The preview reads exact text through input.readDocument, returns hunk row bounds in source-hunk order, binds rendered blocks to exact new-side ranges, and retains hunk navigation and selection highlighting. Hunk inserts inline note cards at uniquely bound rows; if any visible note cannot be resolved, the complete file temporarily returns to raw rendering so review data is never hidden.

Why it returns rows instead of an OpenTUI Markdown component

OpenTUI includes a capable MarkdownRenderable, but mounting it would make the extension own opaque layout geometry. Hunk needs exact rows before mount for review-stream measurement, windowing, scrolling, hunk navigation, and fallback. This example therefore uses the same Marked parser OpenTUI uses, then translates its tokens into the file-view contract's generic tones and text attributes.


Overview (`examples/extensions/review-note-navigator/README.md`)

Review note navigator extension

Lists every note currently saved in Hunk's shared ReviewStore, then navigates to a selected visible note's authoritative source anchor. The example shows how ctx.review.snapshot() complements dialogs and live navigation: selection and note events alone cannot recover notes that already existed before the extension began listening.

Run it directly from this checkout:

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator

Save one or more review notes, then run Extensions → Navigate saved review note… (F8). Each choice includes its reconciliation status, file, preferred line, side, and summary.

  • Active notes reveal their current source line.
  • Stale notes reveal the last authoritative anchor Hunk retained.
  • Orphaned notes remain visible in the inventory but produce a warning because they have no current review location.
  • A note whose file is hidden by Hunk's current file filter remains in the inventory, but guarded navigation refuses the hidden target with a warning; clear the filter and retry.
  • Drafts and static sidecar annotations that never entered ReviewStore are intentionally absent.

The command captures the complete note inventory before opening its selector. After the user chooses, it reads the authoritative snapshot again and resolves the selected note by stable note id, so an edit made by another review surface cannot make it navigate using an obsolete anchor. A reload cancels the dialog and retires the command's review controls.


Overview (`examples/extensions/review-snapshot-export/README.md`)

Review snapshot export extension

Exports Hunk's authoritative saved review state as JSON. The example shows why ctx.review.snapshot() is more useful than accumulating note_created events: one command receives every note currently retained by the shared ReviewStore, including stale and orphaned notes an exporter must handle explicitly.

Run it directly from this checkout:

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export

Add one or more review notes, then run Extensions → Export review snapshot… (F9) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file.

The JSON includes:

  • the opaque producer generation and ReviewStore revision
  • every reviewed file's stable fileKey, runtime navigation id, content identity, status, and path
  • all saved live and reviewer notes with their resolved old/new anchors and reconciliation status

Draft notes and static sidecar annotations that never entered ReviewStore are intentionally absent. Saved notes appear in live-arrival order followed by reviewer-creation order.

The command captures a snapshot before opening its path dialog, then reads the controls again before writing. If either the generation or revision changed, it refuses the stale export and asks the user to rerun the command. Publishers can use the same check before an irreversible network request.

Trust

This example uses node:fs directly to write the user-selected export path. Hunk extensions run with the user's full permissions; this is distinct from ctx.workspace, which mediates writes to reviewed files and asks for consent. Install and run only extensions you trust.


Overview (`examples/extensions/review-triage/README.md`)

Review triage extension

A session-local hunk review board for Hunk. It records which hunks you have visited and lets you mark the selected hunk approved, investigate, or blocked with an optional rationale.

Run it directly from this checkout:

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triage

Or copy the directory to your Hunk extensions directory and keep its package.json; its manifest makes the folder a single review-triage extension.

Use

Open Extensions → Toggle review triage (Y). The right pane lists each visible file's hunks; click a hunk to navigate the review stream. Use Extensions → Mark selected hunk… (x) to choose a status and enter an optional rationale. Center current review line, Set review focus…, and Clear triage decisions are menu-only commands.

The board intentionally keeps state only for the running Hunk session. Reloading reconciles decisions against the newly parsed hunks and drops entries that no longer match, rather than silently transferring a decision to changed code.

API surface exercised

  • registerPane renders public file/hunk summaries and navigates with pane actions.
  • registerCommand supplies the Extensions-menu items and user-remappable defaults; ctx.commands.execute delegates the dedicated centering action to Hunk's public semantic command.
  • dialogs.select, dialogs.input, and dialogs.confirm implement the review decision and clear flows.
  • Lifecycle handlers track changeset loads, reloads, selection, viewed hunks, Hunk notes, filters, and pending watch reloads through a useSyncExternalStore bridge.
  • hunk.events publishes decisions and listens for review-triage:open, so another extension can reveal the board without importing its state.

Overview (`examples/extensions/vim-navigation/README.md`)

Vim navigation extension

A small Vim-style normal mode for Hunk's whole review stream. It demonstrates session keyboard modes and public semantic command execution without accessing scroll boxes, renderer objects, or viewport coordinates.

This example is not bundled or loaded by Hunk. Install it explicitly if you want it.

Try it from this checkout

bash
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/vim-navigation

Press F6 or choose Extensions → Toggle Vim navigation. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press Esc to leave.

Install it globally

bash
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/

Keys

Key Action
j / k Move the current review line down/up
[ / ] Move to the previous/next hunk
gg / G Jump to the start/end of the review
zt / zz / zb Align the current line at the top/center/bottom
Ctrl-D / Ctrl-U Move down/up by half pages
positive digits Prefix the next relative motion, for example 5j or 3]
: Open the host-rendered Vim command line
Esc Exit the mode (host-owned; the extension never receives it)
everything else Pass through to normal Hunk routing

Counts are parsed by the extension and capped at 10,000. Once a normal-mode sequence resolves, the extension calls ctx.commands.execute(id, { count }) exactly once, so Hunk applies movement atomically. A bare 0 passes to Hunk's normal layout shortcut; 0 can extend a count that already began with 1–9.

Pressing : passes the key to the example's registered command, which opens ctx.prompts.line() on the status row with a : prefix. That focused host input captures typed keys ahead of the still-active session mode until Enter submits, or Escape clears the buffer and a second Escape cancels. The deliberately small Ex-style command set is:

Command Action
:top Jump to the start of the review
:bottom Jump to the end of the review

Unsupported commands produce an attributed warning. Absolute source-line commands such as Vim's :100 are intentionally absent because Hunk does not expose source-line targeting as a public semantic command; relative counted movement such as 100j remains available in normal mode.

The example enables Hunk's host-owned current-line marker on entry so the z* alignment commands have a target. It resets all pending prefix/count state on entry and exit. Invalid continuations clear pending state and pass the current key back to Hunk.


Overview (`test/cli/install-vm/README.md`)

Optional Firecracker install compatibility suite

This suite tests Hunk's Linux x64 npm and pnpm installs/upgrades, authenticated daemon upgrades, legacy Bun fallback, offline execution, and curl install/upgrade behavior in fresh Firecracker microVMs. It is completely opt-in: bun install, normal tests, typechecking, builds, and packaging do not check for Docker/KVM or download VM assets.

Run

Requirements:

  • Linux x86_64 with at least 6 GiB free;
  • Docker daemon access without sudo;
  • readable/writable /dev/kvm and /dev/net/tun.
sh
bun run test:install-vm -- --list
bun run test:install-vm -- --scenario pnpm-global-upgrade
bun run test:install-vm -- --scenario authenticated-daemon-upgrade
bun run test:install-vm

For manual investigation, open a root shell in a clean Ubuntu 24.04 guest. Every shell includes curated examples and deterministic benchmark-shaped patches under /root/fixtures. Add --with-hunk to build the current checkout and install its Linux x64 baseline binary and bundled skills before the shell opens:

sh
bun run vm:shell
bun run vm:shell -- --with-hunk

The shell starts in /root, so hunk patch fixtures/examples/2-mini-app-refactor/change.patch opens a small multi-file example and hunk patch fixtures/benchmarks/balanced-changeset.patch opens a larger synthetic review. See /root/fixtures/README.md for the complete command list. The default command stays pristine for install and upgrade testing: it does not build package fixtures, stage Hunk, or mount the checkout. Both modes require an interactive terminal and reuse the pinned kernel, Firecracker binary, and prepared Node rootfs cache. The guest has 2 vCPU, 2 GiB RAM, public-internet egress, and Node under /opt/node; controller-enforced filtering rejects spoofed guest sources and private, link-local, metadata, and reserved destinations. Both modes mount one owner-only staging directory containing only allowlisted example inputs and generated benchmark patches; --with-hunk adds the freshly compiled binary and generated hunkdiff/skills tree to that snapshot. The controller copies the snapshot into the disposable guest and the host removes the staging directory after the session. Exit the SSH shell to stop Firecracker and delete the writable disk, ephemeral SSH key, socket, configuration, TAP device, and firewall rules. The immutable base cache remains for later sessions. The shell and install suite share a runtime lock and cannot run concurrently.

Use --reuse-fixtures to reuse package fixtures only when their checkout identity and every tarball checksum still match; stale or altered fixtures are rebuilt. Automation that intentionally permits unsupported hosts may pass --allow-skip; a requested local run otherwise fails with an actionable preflight report. In GitHub Actions, an allowed skip emits a workflow warning and a prominent step summary in addition to a structured skipped result—it is not VM success.

The first run lazily builds the controller image and downloads checksum-pinned Firecracker, kernel, rootfs, and Node inputs. They live under tmp/install-vm/cache; generated package fixtures and structured runs live under tmp/install-vm/fixtures and tmp/install-vm/runs. Remove only those harness-owned artifacts with:

sh
bun run test:install-vm:clean

The runner deliberately does not reclaim a stale tmp/install-vm/.lock, because deleting a lock
owned by a racing process is unsafe. After an interrupted host dies, confirm no suite is running and
remove that lock directory manually before retrying.

Every scenario gets a sparse/reflink clone of the verified immutable base image, an ephemeral run-only SSH public key injected into that clone, and isolated HOME, PATH, npm prefix, pnpm global directory, and pnpm store. Hunk's generated fixture packages are checksum-pinned and published to the local registry. Fixture preparation also compiles two full Hunk binaries from isolated copies of the exact checkout and reflink-capable dependency snapshots: the current authenticated daemon revision and the immediately preceding incompatible revision. The checkout sourceIdentity remains source-only; a separate daemonUpgradeBuildInputIdentity frames and hashes the ignored node_modules snapshot bytes and contained symlink targets plus the Bun executable bytes/version used by the builder. External dependency symlinks are rejected, and an isolated PATH entry forces nested build commands to use that attested Bun executable. The fixture manifest binds both compiled binary SHA-256 digests, which the guest compares with the live A/B daemon executables. Temporary source, dependency, and package staging trees are removed before fixtures become visible. The daemon-upgrade scenario therefore adds two compilation passes and about one production idle timeout to a targeted run. Verdaccio currently proxies uncached transitive dependencies, so first-run package installation still depends on npm availability; the historical corruption oracle also deliberately uses the live npm registry while consuming the validated exact Hunk, Bun, and pnpm pins from pins.json. Results include result.json, junit.xml, structured commands and observations, guest command logs, assertions, Firecracker console output, and the fixture source identity. Scenario-specific required-evidence contracts bind required command IDs to exact expectation semantics and make missing daemon lifecycle proof fail aggregation and release validation. The daemon scenario stops one exact test-owned upgraded client across incumbent retirement, waits for the other original client to register on the successor, then resumes and verifies the delayed PID/start-token registers without restart; it never signals a daemon. Release validation rejects symlinked or escaping evidence and reads the referenced health, metadata, executable, fixture-manifest, warning, command-log, and session-list artifacts from the run directory instead of trusting result labels alone. It also requires the locally reusable fixture set to pass checkout, build-input, and package checksum verification, then extracts and hashes the actual A/B package binaries as an independent digest trust input; missing or stale local fixtures fail validation. Writable disks, SSH keys, sockets, cache identities, locks, and registry credentials are excluded from result artifacts. Release evidence can be checked against the current checkout and complete scenario manifest with bun run ./test/cli/install-vm/validate-release-result.ts <result.json>. A targeted development run can be checked with --scenario ; that explicit subset check is not complete release evidence.

Security boundary

The controller container receives only /dev/kvm, /dev/net/tun, NET_ADMIN, CHOWN, and DAC_OVERRIDE. The last two let it traverse the owner-only validated cache/result binds while running, then return their ownership to the invoking user; the directories are never made world-writable. The container drops all other capabilities, enables no-new-privileges, uses a read-only container root, and never mounts the repository or Docker socket. TAP and NAT changes stay in its Docker network namespace and are removed on exit. Third-party package lifecycle scripts run as root only inside disposable guests with no repository, host credentials, or host-writable package cache.

This is development/test isolation, not a production Firecracker jail. Run repository-controlled KVM jobs only on trusted disposable hosts. The interactive shell has filtered public-internet egress and should not receive host secrets; its controller mounts only the harness cache and the validated shell-input snapshot. The dedicated workflow is manual and never runs for pull requests.

Coverage boundaries

Firecracker runs Linux guests on the host CPU. It cannot validate macOS or Windows, emulate Apple Silicon, or reproduce the final native macOS ARM64 exit behavior from issue #866. Native Apple Silicon coverage remains tracked by TODO-1994d3d9.

The Node-resolvable npm Bun fallback remains a best-effort legacy path. A standalone bun on PATH is deliberately different and is not used by the launcher. The suite observes an older fallback package but does not declare a supported minimum Bun version.


Mixed preview review

This launches one real five-file Git working-tree review that is intentionally taller than a terminal viewport:

  • README.md — raw Markdown diff;
  • package.json — dependency version-segment highlights;
  • scripts/deploy.py — raw Python diff;
  • src/invoice.ts — responsive change-atlas cards;
  • styles/theme.css — exact-source color swatches.

The launcher creates a temporary repository, commits the checked-in before fixtures, copies the after fixtures into its working tree, and starts Hunk from this checkout. The repository is removed when Hunk exits.

From the Hunk repository root:

bash
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.ts

Raw diff is deliberately the default. To build the mixed stream:

  1. Click package.json in the sidebar and press F8.
  2. Click src/invoice.ts and press F8.
  3. Click styles/theme.css and press F8.
  4. Click README.md to return to the top, then scroll through the main pane.

Selecting files only jumps the main review stream; it does not collapse other files. The three preview selections therefore remain active together, interleaved with the two raw Pierre diffs. Use [ and ] while scrolling to verify that hunk navigation crosses raw and custom sections using the same host-owned geometry.


Website Src Content Docs Changelog Index (`website/src/content/docs/changelog/index.md`)


title: Changelog
editUrl: false
tableOfContents: false
head:


RSS · CHANGELOG.md

[Hunk 0.24](/changelog/0.24/)

Latest · October 13, 2026 – October 19, 2026 · 3 releases · 119 changes

Hunk 0.24 adds complete-file and directory browsing, a live workspace status view, npm-installed extensions, shell completions, and pane-aware automatic diff layout, while moving syntax highlighting off the review's main thread by default.

[Hunk 0.23](/changelog/0.23/)

September 30, 2026 · 1 release · 18 changes

Hunk 0.23 brings diff search, bundled GitHub review, an inline status line, and a default theme that follows your terminal, while making daemon version mismatches easier to resolve.

[Hunk 0.22](/changelog/0.22/)

September 8, 2026 – September 10, 2026 · 3 releases · 81 changes

Hunk 0.22 turns repository history into a responsive review workspace with commit-range browsing, persistent multiline selections, keyboard-driven threaded notes, and richer extension context.

[Hunk 0.21](/changelog/0.21/)

August 30, 2026 – September 4, 2026 · 4 releases · 77 changes

Hunk 0.21 turns reviews into a richer agent-aware workspace with authenticated live sessions, editable threaded notes, two-revision and two-file comparisons, more responsive navigation, and a broader extension surface.

[Hunk 0.20](/changelog/0.20/)

August 25, 2026 – August 29, 2026 · 2 releases · 14 changes

Hunk 0.20 adds a one-line installer with conditional checksum verification and self-updates, launches a directory of 12 community extensions, and makes line-level reviewing faster and more precise.

[Hunk 0.19](/changelog/0.19/)

August 16, 2026 – August 24, 2026 · 2 releases · 39 changes

Hunk 0.19.0 expands the extension platform, makes live agent guidance more precise, and keeps large reviews responsive.

[Hunk 0.18](/changelog/0.18/)

August 1, 2026 – August 14, 2026 · 4 releases · 90 changes

Hunk 0.18.0 makes reviews more precise, customizable, and extensible—while improving performance and reliability across large repositories and diverse terminals.

[Hunk 0.17](/changelog/0.17/)

July 7, 2026 – July 28, 2026 · 8 releases · 26 changes

Hunk 0.17 makes the review chrome configurable, adds an Agent menu for setting up the review skill, and smooths out scrolling, theming, and session reliability across platforms.

[Hunk 0.16](/changelog/0.16/)

June 19, 2026 · 1 release · 8 changes

Hunk 0.16 rebuilds the built-in theme system around a single theme setting with a live selector, and lets custom themes inherit from any built-in theme.

[Hunk 0.15](/changelog/0.15/)

June 8, 2026 – June 13, 2026 · 4 releases · 23 changes

Hunk 0.15 adds release benchmark gating, Sapling support, and a run of rendering performance work that keeps large reviews responsive on Windows and Unix alike.

[Hunk 0.14](/changelog/0.14/)

May 26, 2026 – June 1, 2026 · 2 releases · 20 changes

Hunk 0.14 adds mouse-drag selection with clipboard copy, inline expansion of collapsed unchanged context, and the Catppuccin Latte and Mocha themes.

[Hunk 0.13](/changelog/0.13/)

May 18, 2026 – May 19, 2026 · 2 releases · 12 changes

Hunk 0.13 adds human-authored inline notes saved with c, an e shortcut to open the selected file in $EDITOR, and g/G jump navigation.

[Hunk 0.12](/changelog/0.12/)

May 12, 2026 – May 14, 2026 · 2 releases · 15 changes

Hunk 0.12 brings native Windows support, Homebrew release automation, terminal-aware light and dark theme detection, and row windowing for very large single-file reviews.

[Hunk 0.11](/changelog/0.11/)

May 9, 2026 – May 10, 2026 · 2 releases · 13 changes

Hunk 0.11 adds Jujutsu support through vcs = "jj", a pager-mode sidebar file tree, and steadier cross-file hunk navigation.

[Hunk 0.10](/changelog/0.10/)

April 21, 2026 · 1 release · 7 changes

Hunk 0.10 introduces hunk daemon serve as the standard session entrypoint, publishes reusable session-broker packages, and surfaces agent comment counts in the sidebar.

[Hunk 0.9](/changelog/0.9/)

April 8, 2026 – April 21, 2026 · 6 releases · 28 changes

[Hunk 0.8](/changelog/0.8/)

March 29, 2026 – March 30, 2026 · 2 releases · 11 changes

[Hunk 0.7](/changelog/0.7/)

March 25, 2026 · 1 release · 8 changes

[Hunk 0.6](/changelog/0.6/)

March 23, 2026 – March 24, 2026 · 2 releases · 11 changes

[Hunk 0.5](/changelog/0.5/)

March 22, 2026 – March 23, 2026 · 2 releases · 10 changes

[Hunk 0.4](/changelog/0.4/)

March 22, 2026 · 1 release · 2 changes

[Hunk 0.3](/changelog/0.3/)

March 22, 2026 · 1 release · 8 changes

[Hunk 0.2](/changelog/0.2/)

March 20, 2026 · 1 release · 1 change

[Hunk 0.1](/changelog/0.1/)

March 20, 2026 · 1 release · 7 changes


Website Src Content Docs Docs Index (`website/src/content/docs/docs/index.mdx`)


title: Hunk documentation

import { CardGrid, LinkCard } from "@astrojs/starlight/components";

Hunk is a terminal diff viewer for reviewing complete changesets and keeping agent rationale beside the code it explains. Use this page to jump directly to the guide or reference you need.

New to Hunk?

  1. Install Hunk.
  2. Open your working tree with hunk diff.
  3. Follow the quick start to learn the review stream and navigation model.

Browse the docs

Common tasks


Agent Workflows (`docs/agent-workflows.md`)

Agent workflows

Use Hunk with agents in two ways:

  • Recommended: steer a live Hunk window from another terminal with hunk session ...
  • Alternative: load prewritten agent notes from a file with --agent-context
  1. Open Hunk in one terminal with a normal review command such as hunk diff or hunk show.
  2. Load the Hunk review skill: packages/hunk/skills/hunk-review/SKILL.md.
  3. Ask the agent to use the skill and review the current session.

A good generic prompt is:

text
Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path.

That skill teaches the agent how to inspect a live Hunk session, navigate it, reload it, and leave inline comments.

How live session control works

When a Hunk TUI starts, it registers with a local loopback daemon. hunk session ... talks to that daemon to find the right live window and control it.

Most users only need hunk session .... Use hunk mcp serve only for manual startup or debugging of the local daemon.

If hunk session list reports no sessions while Hunk is visibly running, the agent sandbox may be blocking loopback access. Rerun hunk session list --json with the agent's network/sandbox escalation. Do not probe /session-api with raw curl: session controls require an automatically discovered, owner-private caller credential and signed responses, and Hunk intentionally exposes no credential flags.

The commands you will use most

Inspect the current review

Start here before navigating or commenting:

bash
hunk session list
hunk session get --repo .
hunk session review --repo . --json
  • list shows the active Hunk windows
  • get --repo . confirms which live session matches the current repo
  • review --json returns the loaded file and hunk structure without dumping the full raw patch

When a CLI extension delegated the review, JSON list, context, and review outputs may also include a
bounded review descriptor with provider, title, URL, and kind-specific identity. It is descriptive
context only and does not add remote provider or reload capabilities.

Only add --include-patch when an agent truly needs raw unified diff text:

bash
hunk session review --repo . --include-patch --json

Move the live window to the right place

Use navigate to jump to the file or hunk you want the user to see:

bash
hunk session navigate --repo . --file src/App.tsx --hunk 2
hunk session navigate --repo . --next-comment

To jump to an exact comment returned by the JSON list, copy its commentId into --comment:

bash
hunk session comment list --repo . --json
hunk session navigate --repo . --comment <comment-id> --json

Use reload when you want the already-open Hunk window to show a different diff or commit:

bash
hunk session reload --repo . -- diff
hunk session reload --repo . -- show HEAD~1 -- README.md

Notes:

  • always include -- before the nested Hunk command in reload
  • --hunk is 1-based
  • --next-comment and --prev-comment are handy when an agent is walking the user through existing notes

Add comments

For one note, use comment add:

bash
hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording"

Reply to an existing note by id; the reply inherits the parent's file and code anchor:

bash
hunk session comment add --repo . --reply-to user:123 --summary "Addressed in the latest revision"

For multiple notes or replies, use one stdin batch with comment apply:

bash
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"},{"replyTo":"user:123","summary":"Addressed"}]}' \
  | hunk session comment apply --repo . --stdin

Each comment apply item requires summary and either:

  • replyTo by itself to inherit an existing note's anchor, or
  • filePath with exactly one target such as hunk, hunkNumber, oldLine, or newLine

If you want the UI to jump to the new note, add --focus to comment add or comment apply.

For comment cleanup and inspection, use:

bash
hunk session comment list --repo .
hunk session comment rm --repo . <comment-id>
hunk session comment clear --repo . --file README.md --yes
hunk session comment clear --repo . --all --yes # also clears human `c` notes

Agents can remove or bulk-clear human notes for cleanup, but cannot create or edit them through the session CLI.

Session targeting

Most commands can target the live session in a few ways:

  • --repo : most common; matches the live session by its current repo root
  • <session-id>: useful when multiple Hunk windows are open for the same repo
  • if only one session exists, Hunk can auto-resolve it

reload also supports some advanced selectors:

  • --session-path targets the live Hunk window by its current working directory
  • --source changes where the replacement diff or show command runs

For normal worktree use, prefer --repo /path/to/worktree. Reach for --session-path and --source only when you need to repoint an already-open window to another checkout or path.

Alternative workflow: load agent comments from a file

Use --agent-context when you already have agent-written rationale or notes in a JSON sidecar file and want to render them beside the diff.

bash
hunk diff --agent-context notes.json
hunk patch change.patch --agent-context notes.json

For a compact real example, see examples/3-agent-review-demo/agent-context.json.

Opt into experimental rich notes

STML note bodies are experimental and disabled by default. Start a new review with --experimental to render sidecar markup fields and accept live comments that carry markup:

bash
hunk --experimental diff --agent-context notes.json
# Equivalent: hunk diff --experimental --agent-context notes.json

Normal reviews keep using each annotation's required plain-text summary fallback. Opted-in live sessions list stml in hunk session context --json under experimentalFeatures; reload commands cannot change the launch opt-in.

Practical defaults

  • start with hunk session review --repo . --json
  • only add --include-patch when the raw patch is actually needed
  • use comment add for one-off notes and comment apply for batches
  • prefer --repo over --session-path unless you have a specific advanced reload case

Brand (`docs/brand.md`)

Hunk brand guide

Where Hunk's logo files live, which version to use where, and the colors and type the website is built on. Source of truth for the site look is website/src/styles/brand.css; this page records the intent.

Voice and feel

  • Terminal-first, "modern desktop diff tool in a terminal". Monospace everywhere, hard edges, paper-and-ink contrast with one green accent.
  • Plain, concrete copy. Tagline: Made for review. Eyebrow: Terminal diffs. Domain: hunk.dev.
  • The wordmark is always lowercase and monospace: hunk. That applies to the logo and to the header text next to the mark. Running prose, docs, and release notes keep their existing style (Hunk as the product name); this rule is about the logo, not copy.

Public downloads

The public brand page at hunk.dev/brand/ offers all four logos in
ink and paper colors as SVG, transparent 2048px-wide PNG, and vector PDF, plus a ZIP bundle.
The website footer links to it.

The ink SVGs below remain the canonical sources. Regenerate the committed exports in
website/public/brand/downloads/ and website/public/brand/hunk-brand.zip after editing them:

bash
# Requires rsvg-convert (librsvg) and zip on PATH.
cd website
bun run generate:brand

Every logo is plain filled paths with no masks or strokes, so the PDF exports stay pure vector
(SVG masks export as embedded bitmaps). The generator recolors the ink fill and pins PDF dates,
so unchanged logos regenerate byte-for-byte. Downloads are static files;
the website build does not require the export tools.

Logo versions

All files are in website/public/brand/ and served at https://hunk.dev/brand/<file>. They are filled with the ink color #16140f; recolor by editing the fill, or inline the SVG and use currentColor.

File What it is Use it for
logo-mark.svg Flat "h" mark: a framed square with a stem and top-right bar. No outline. Header, favicon, app icons, avatars, anything under ~40px.
logo-mark-3d.svg The "h" block with its offset outline shadow. Large placements only (about 40px and up): hero art, slides, social cards, stickers.
logo-wordmark.svg The lowercase hunk letters on their own. Next to the mark when the lockup is too heavy; text-only contexts.
logo-lockup.svg 3D mark plus wordmark. Open Graph images, README banner, talk title slides.

Other places the mark appears:

Path Purpose
website/public/favicon.svg Browser tab icon. Flat mark in #65d291 on a #111a15 rounded square.
website/public/icon.png 512px PNG of the favicon (fallback for browsers that skip SVG icons).
website/public/apple-icon.png 180px apple-touch icon.
website/src/components/BrandHeader.astro Header logo: inline flat mark (24px) plus hunk text.
website/public/og.png 1200×630 social card. Not yet on the new logo.
website/public/docs/favicon.svg Docs favicon. Not yet updated.

Regenerate the PNG icons after changing favicon.svg:

bash
rsvg-convert -w 512 -h 512 website/public/favicon.svg -o website/public/icon.png
rsvg-convert -w 180 -h 180 website/public/favicon.svg -o website/public/apple-icon.png

Rules

Clear space and minimum sizes are proposed guidance, not something the site enforces yet.

  • Small means flat. Below ~40px the 3D outline turns into a smudge; use logo-mark.svg.
  • Clear space: keep at least the width of the stem (about 8% of the mark's width) free on every side. In the lockup, the gap between mark and wordmark is the mark's top-right bar height.
  • Minimum size: 16px for the flat mark, 40px for the 3D mark, 48px wide for the wordmark.
  • Don't stretch, rotate, outline, add shadows or gradients, recolor outside the palette below, or retype the wordmark in another font. The wordmark is outlined paths, not live text.
  • Don't put the mark inside a circle. A rounded square (radius about 19%) is the only container, as in the favicon.

Color

Light is the default. Dark mode is toggled with data-theme="dark". Tokens live in website/src/styles/brand.css.

Role Light Dark Token
Background (paper) #f4f1ea #0c0e10 --hunk-bg
Surface #fffdf8 #14181b --hunk-paper
Panel #e9e4d8 #1b1f24 --hunk-panel
Ink #16140f #f2f4f6 --hunk-ink
Body text #3a352b #c3c9cf --hunk-body
Muted text #6b6557 #9aa4af --hunk-muted
Rule #dcd6c8 #262d34 --hunk-rule
Accent green #1f7a35 #5dd06a --hunk-accent
Accent soft #bfe9c6 #2c6b34 --hunk-accent-soft
Accent ink #0d3a18 #b9f2c0 --hunk-accent-ink
  • Use the accent green sparingly: the current nav item, the highlighted word in the hero, primary controls.
  • The diff colors in screenshots come from the terminal theme, not the brand palette.
  • The favicon uses a darker green pair (#111a15 / #65d291) that is not a site token. Align it with the table above or add it as a token if it stays.
  • The logo exploration files (C51/C59) were drawn on a saturated blue (#1F3DFF). That blue is not part of the site palette; decide before using it anywhere.

Typography

  • JetBrains Mono (variable) for everything: headings, body, nav, code, docs. Loaded from @fontsource-variable/jetbrains-mono and exposed as --hunk-font.
  • Fallback stack: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace.
  • Header logo text is 18px, weight 700, -0.02em letter-spacing. Check marketing.css and starlight.css for the other weights in use.
  • Eyebrows (like TERMINAL DIFFS) are 13px, uppercase, 0.15em letter-spacing.
  • Use the font via var(--hunk-font); never hard-code another family.

UI details that make it look like Hunk

  • Hard 1px ink borders with a solid offset shadow (--hunk-shadow) on cards and the install panel, not blur shadows.
  • Minimal chrome: a thin top bar and a single rule under it, no redundant headers.
  • Terminal screenshots sit in a window frame with three hollow dots.
  • Modem ("Built by") uses website/public/modem-light.svg in the footer. Keep it at its own aspect ratio and don't merge it with the Hunk mark.
  • Product name in prose: Hunk. Wordmark and logo text: hunk, lowercase. npm package: hunkdiff. Command: hunk. Repo: modem-dev/hunk.

Updating this guide

When the palette, font, or logo files change, update the tables above in the same change. The site media rituals (OG image, screenshots) are in website/MEDIA.md.


Browser Review Rebuild (`docs/browser-review-rebuild.md`)

Browser review rebuild plan

Current status (2026-09-06; after Phase 4). Shared review semantics, producer publication, the broker
mirror, bounded resources, capability-authenticated HTTP actions, and SSE have landed. No browser
client, served review page, browser assets, or browser CLI entrypoint exists yet. Phases 5 and 6
remain planned. Semantic deep-link grammar is deferred until its first browser/opener consumer.

The synchronized browser-review feature (originally prototyped in one large branch) lands as a
stack of small, independently reviewable PRs. Its Hunk-owned capability, resource, and semantic
protocol remains separate from the generic per-application daemon contract in
session-broker-sdk.md. Each phase has a hard gate and stands on the
previous one. The seam contract — shared primitives stay renderer-free and platform-neutral —
is enforced by scripts/quality/source-boundaries.test.ts, whose debt lists may only shrink.

Each phase lists the audit findings it repays (browser-review-seam-audit.md, ids A1–G5). A
finding whose duplicate sites span phases is checked off when its last site converts; until
then it stays open with the converted sites noted. Phases 0–5 change no user-reachable
behavior (the browser client has no entry point until Phase 6) and ship empty changesets;
Phase 6 carries the minor changeset announcing the feature.

Phase 0 — seam contract and guardrails (this doc)

  • Boundary gates for packages/hunk/src/core/review/ (the shared review model), packages/hunk/src/session/reviewProtocol.ts
    (the wire schema), and packages/hunk/src/web/ (the browser client). The gates tolerate absent trees, so
    they land ahead of the code they constrain.
  • A shrink-only debt map for the Node-only primitives the prototype's model files still carry;
    each entry must be repaid with a platform-neutral implementation before a browser bundle may
    import that file.
  • The existing architecture boundaries stay at full strength. The prototype relocated bundled
    VCS providers into packages/hunk/src/core/vcs/ and weakened this suite to compensate; that relocation must
    not ride along with any rebuild phase — extraction PRs land against the restored gates.

Phase 1 — review model + terminal adoption (three PRs)

  1. Review store: state / actions / reducer / store / intents / selectors in
    packages/hunk/src/core/review/, with useReviewController / App / AppHost refactored onto it in the
    same PR. Behavior-neutral; existing PTY integration tests must pass untouched.
  2. Review document projection + diff geometry: document / identity / sourceIdentity / anchors / contentManifest / notes / expansion / reconcile / jsonStream plus the geometry
    primitives, adopted by the terminal — including switching the terminal's collapsed-gap math
    to reviewGapAddress (A1) and note-anchor ranges to full per-side extents (A3). The
    conformance harness (test/review-conformance/) and its first adversarial fixtures land
    here; add the directory to the CLAUDE.md test-directory list in the same PR.
  3. Navigation intents + command catalog: selection/move / selection/select-file and
    the shared navigation/reveal/note selectors; the agent runtime's navigateSession deleted
    in favor of the shared walk; the command catalog split with semantic commands lowered to
    intents. A semantic address grammar landed here and was later removed because it had no
    consumer; recreate it beside the first Phase 5 or 6 consumer.

Repays: A1–A10 (PR 2); B1–B9, B11, F1–F3 (PR 3); D2 core and terminal sites. G3's first grammar was later removed as unused.
B-findings with browser sites stay open until Phase 5 consumes the selectors.
Gate: ladder rungs 1–4 — tombstones appended for every deleted copy, terminal planner
registered in the conformance harness, adversarial fixtures landed per repaid finding, and the
existing PTY suite passing untouched.

Phase 2 — producer runtime

packages/hunk/src/app/review/producer.ts owns generations, snapshot serving, and resource
materialization. packages/hunk/src/app/session/reviewRuntime.ts mounts the producer and broker
client for an interactive session. This phase serves the existing hunk session surface only.
Resource read failures map to distinct error codes; integrity failures are never collapsed into
unknown-resource.

Repays: D1 and D4 producer/snapshot sites (helpers land in core beside the model; remaining
sites convert in Phase 3); D5 producer sites.
Gate: producer registered in the conformance harness; existing test/session/ suite passing
untouched (rung 4).

Phase 3 — wire protocol + broker mirror

reviewProtocol.ts, broker wire.ts validation, broker review mirror, reviewResourceCache
(bounded in-flight budget). Patch reconstruction for hunk session review --include-patch uses
bounded-parallel loads from day one. Valuable without any web UI: agents get chunked,
digest-verified, memory-bounded resource access. Optional extension-delegated review identity rides
in bounded registration metadata and projects into list, context, and review snapshots; it never
enters ReviewDocumentV1 or creates a remote reload/provider capability. The wire vocabulary is derived from
ReviewIntent (B12) and carries expandedLineProof (B10) and actor identity (G2) from its
first version so the browser never needs a schema break.

Repays: B10, B12, G2 wire fields; C1/C2 producer and broker sites (their browser sites close
in Phase 5); D1/D3/D5 broker sites.
Gate: broker suites join the conformance harness (wire round-trip + mirror against the shared
fixtures, including the C1 ordering fixtures); reviewResources.integration.test.ts with the
parallel-load test; vocabulary derivation checks active (rung 5).

Phase 4 — HTTP surface, no client (landed)

browserReviewServer + capability auth + SSE, loopback-only, tested with plain fetch.
Four routes per live session, mounted inside the existing daemon rather than on a port per
terminal: the current publication (position plus resource catalog), bounded digest-verified
resource reads through the existing mirror and cache, an SSE stream, and action submission
parsed by reviewProtocol.ts and forwarded to the existing apply_review_action path —
which this gives its first production caller. Range handling covers zero-length resources.
The SSE event contract lives in a shared reviewEventProtocol module from day one (C4), and
the user-facing error catalog is created beside the stabilized error codes (G4).

Authorization is a capability the session mints, publishing only its SHA-256 to the
daemon; it rides in the URL fragment and is presented in a request header, so it reaches no
log, path, or query string, and a cross-origin page cannot attach it the way it could a
cookie — which is why there is no CSRF machinery and no CORS header anywhere on the surface.
That replaces the prototype's cookie exchange and, with it, the renewal problem: a
capability lives as long as the session, so a review outlives any TTL by construction. The
cost is that EventSource cannot carry a header, so the Phase 5 client reads the stream
with fetch — which is also what makes C5's single reconnect scheduler reachable.

Repays: C4 server side; G4 catalog creation.
Gate: HTTP-contract tests against the shared event-protocol module; security review focused on
this PR alone.

Phase 5 — browser client (two PRs)

  1. Read-only mirror: apiClient / mirror / pierreDocument / review stream rendering a
    snapshot with Pierre — built on the shared geometry/selector/ordering primitives from day
    one (no sideRange, no local acceptance rules, no bare split("\n")). No actions, no note
    editing.
  2. Interactivity: action dispatch through the broker, selection sync (G2 policy decided
    before this PR), note editing, watch/reload generation swaps, browser key bindings and the
    command palette rendered from the shared catalog.

Repays: A11, C3, C5, E1, G1; the browser sites of A/B/C/D findings left open in earlier
phases; F browser bindings; G3/G4 browser adoption. E2 and the G2 selection policy must be
decided (not necessarily built) before PR 2.
Gate: browser projection joins the conformance harness — the same fixtures every other
consumer runs, closing the renderer-parity loop (note placement, gap addressing, reveal
targets, default note targets); web boundary gates and the browser-closure node-free gate
active; command-parity check (both clients render command surfaces from the shared catalog).

Phase 6 — entry points and packaging

  • --web / --no-open / hunk session open / --tailscale CLI wiring. The review URL is
    always recoverable from the terminal, and the opener preserves URL fragments on every
    platform (no rundll32). Deep-link fragments use the shared address grammar (G3), never
    ad-hoc strings.
  • Offline browser assets are generated at build/release time or diff-checked by a script gate,
    not hand-maintained compiled output.

Repays: G3 fragment adoption (closes G3).
Gate: CLI contract tests, offline-asset check, real terminal + browser smoke run, and a final
audit-doc sweep — every A–F finding checked off or explicitly re-scoped, G5 remaining as a
placement rule.

Seam inventory (prototype audit)

A file-level audit of the prototype found roughly thirty duplicated derivations across the
terminal, browser, agent runtime, and broker — the seam is wider than the review model and wire
schema. Each rebuild phase lands the primitives below and deletes the per-consumer copies it
replaces. This section is the summary; the per-finding work-list with sites and observed
divergences is browser-review-seam-audit.md, and extraction PRs check findings off there:

  • Diff geometry (core/review, Phase 1 PR 2): per-side hunk ranges (reviewHunkRange —
    the prototype has four implementations, one wrong), gap addressing (reviewGapAddress — the
    terminal's own copy is off by one against core's), trailing-gap existence, expansion side,
    hunk rebasing for isolated rendering, normalized source-line splitting (browser skips the
    CRLF normalization core digests depend on), per-file split/unified line totals carried on
    ReviewFileV1 instead of recomputed, empty-diff reason, default hunk note target, and STML
    tag roles so both renderers share one tag vocabulary.
  • Navigation semantics (core/review intents/selectors, Phase 1 PR 3): relative hunk/file
    navigation with an explicit wrap policy (selection/move — the prototype implements the walk
    three times: terminal, agent runtime, and the browser was set to become a third), file-jump
    (selection/select-file), selection normalization and fallback, reveal-target resolution,
    active-note selection, notes-by-hunk grouping (owner-index based, not range containment), one
    filter matcher shared by stream and tree, one note-visibility predicate, and one
    viewport-anchor policy. The wire vocabulary is derived from ReviewIntent instead of
    hand-restated, and gains the expandedLineProof field the browser needs for expanded-line
    selection parity.
  • Ordering and transfer (core/review + protocol, Phases 3–4): one generation/state-
    revision acceptance state machine (the prototype has five with differing rules), one chunk
    assembly/verification helper (four copies, two inside one file), one epoch/supersede queue,
    one reconnect scheduler, and an SSE event-contract module so server and client derive frame
    names, envelopes, and byte bounds from the same constants.
  • Validation single-sourcing (reviewProtocol + core/review, Phases 2–4): note byte
    bounds measured one way everywhere (the prototype's per-field vs whole-note split lets an
    oversized note poison the entire snapshot), one empty-body policy, one note-anchor ownership
    calculator (producer, broker, and browser each re-derive it, the broker's copy dropping the
    fallback branch), an order-independent canonical-file/manifest consistency check, shared
    digest validators and exact-key helpers, and no re-declared wire constants.
  • Presentation helpers (Phase 5): file stat badges and change-kind decorations, plus the
    browser importing shared language registration for side effects.

Deliberately renderer-specific — do not unify: terminal row building, cell measurement, and row
windowing; browser IntersectionObserver windowing and DOM reveal mechanics; STML layout; platform
hashing (node:crypto vs Web Crypto); theme palettes, pending a product decision on whether the
browser mirrors the terminal theme.

Commands and keyboard shortcuts in the browser

The terminal command system (packages/hunk/src/ui/lib/appCommands.ts) fuses three separable things per
command: identity (id, title, chords), binding (terminal KeyEvent matching), and effect
(closures over live App state). Making commands work in the browser means splitting them, not
transporting them:

  • Catalog as shared data: id, title, category, default chords, and a declared resolution
    locus move to a renderer-neutral catalog. Menus (appMenus.ts), the help dialog, and a
    browser command palette all render from the same catalog, and user [keybindings] config
    applies to the catalog rather than to one renderer.
  • Three resolution loci, declared per command:
    • Semantic — lowers to a ReviewIntent and resolves at the producer, with changes
      broadcast to every attached client (hunk/file navigation, annotated navigation, start
      note, toggle gap expansion, toggle agent notes, filter). The browser fires these through
      the existing apply-action wire path — no new wire surface, and the agent runtime's
      hunk session commands become a third consumer of the same lowering.
    • Client-local — view state that is deliberately per-client (scrolling, paging, line
      alignment, layout mode, wrap, line numbers, sidebar, menu bar, theme selector, help).
      Each client implements its own handler; identity and chords stay shared so help and
      palettes agree.
    • Host-only — quit, source refresh, edit-in-$EDITOR, agent-skill helpers, and all
      extension commands. Not invocable from the browser by default: extension commands execute
      host-side with dialog access, so browser invocation is remote code execution and needs an
      explicit per-command allowlist in the registration capability list. Deferred beyond the
      initial rebuild.
  • Keymap: chords keep the shared string vocabulary (keymap.ts); each client maps chords
    to its own event type and masks what its platform reserves (e.g. browser Cmd+W).

Phasing: the catalog split and semantic lowering land in Phase 1 PR 3, alongside the
navigation intents the semantic commands lower to. Browser key bindings and the command
palette land with Phase 5, gated by the shared catalog so the two clients cannot drift on what
a command means. Brokered host-command invocation is explicitly out of scope until the
allowlist design exists.

Per-phase seam verification

Import gates prove code may use a primitive, not that it does — a consumer can silently
re-derive. Every phase therefore passes the same five-rung ladder, and each rung is mechanical:

  1. Boundary gates (every phase, exists today): scripts/quality/source-boundaries.test.ts —
    import containment, shrink-only debt lists, and the extracted-duplicate tombstone list.
    Repaying an audit finding means deleting the duplicate copies and appending their paths to
    the tombstone list in the same PR
    ; a resurrected path fails CI forever after.
  2. Conformance harness (built in Phase 1 PR 2, grows every phase — not deferred to Phase 5):
    one golden fixture corpus under test/review-conformance/, with every consumer registering
    a suite against the same fixtures as it lands — terminal render planning (Phase 1),
    producer projection (Phase 2), broker mirror and wire round-trip (Phase 3), HTTP surface
    (Phase 4), browser projection (Phase 5). A phase's gate is that all previously registered
    suites still pass plus its own joins.
  3. Adversarial fixtures from the audit: every audit finding that documented a divergence
    contributes the fixture its old copy got wrong — pure-insertion hunks and zero-count sides
    (A1/A2), hunks with leading context (A3), CRLF and no-trailing-newline sources (A4),
    fallback-owner and expanded-gap notes (D3/B8), non-contiguous state revisions and replayed
    events (C1), oversized note bodies at the per-field/whole-note boundary (D1). Expected
    values in these fixtures are written by hand from the semantics, not generated by
    calling the primitive — a generated expectation would follow a bug instead of catching it.
    Repaying a finding requires landing its fixture; the finding's checkbox in the audit doc
    points at the fixture file.
  4. Behavior invariance: adoption phases must not change what users see — Phase 1 passes
    the existing PTY integration suite untouched, Phases 2–3 the existing test/session/
    suite. A needed test edit in these phases is a red flag reviewed as a behavior change, not
    a test update.
  5. Vocabulary derivation checks (Phase 3 on): a test asserts the wire action vocabulary
    equals the intent vocabulary minus a named exclusion list (so a new intent that should be
    wire-reachable cannot be silently forgotten — the B12 failure mode), and that coupled
    constants are imports, not re-declared literals.

Residual risk — a consumer re-implementing a primitive identically passes fixtures until the
copies drift. Where that risk is highest (geometry, ordering rules), spot-check with a seam
probe: temporarily patch the core primitive to return a sentinel in a scratch build and confirm
each consumer's conformance suite fails. Not CI machinery — a release-prep check for Phases 1,
3, and 5.

Process rule tying it together: every extraction PR names the audit finding ids it repays, and
"repaid" is defined mechanically as (a) duplicate copies deleted, (b) tombstones appended,
(c) the finding's adversarial fixture landed, (d) the consumer registered in the conformance
harness. A finding without all four stays open in the audit doc.


Browser Review Seam Audit (`docs/browser-review-seam-audit.md`)

Browser review seam audit — prototype duplication findings

Current status (2026-09-06; after Phase 4). Core review semantics, terminal adoption, producer and broker
projections, wire protocol, bounded resources, HTTP actions, and SSE have landed. There is no
browser client or served review page. Historical prototype locators below may name deleted files;
each finding's latest status annotation is authoritative.

Companion to browser-review-rebuild.md. This is the per-finding work-list behind the plan's
seam inventory: every duplicated derivation found in the prototype, the sites on each side, the
observed or reachable divergence, and the shared primitive that replaces it. File/line
references are against the prototype branch (feat/browser-review at merge commit 9bb0ad0)
and will drift as that branch changes; treat them as locators, not anchors. Each extraction PR
should delete the copies its primitive replaces and check off the finding here.

Run boundary — after Phase 4

Open work and its planned phase:

  • C3, C5 — the epoch/supersede queue and one reconnect scheduler, both browser-client
    work (Phase 5). C4 is repaid on the server side; its client half closes with the reader
    that imports the same module.
  • The event stream needs a fetch reader, not EventSource. The capability is
    presented in a header, which EventSource cannot set, so Phase 5's client consumes the
    stream with fetch and a streaming reader. That is also what makes C5's "one reconnect
    scheduler" reachable — there is no built-in reconnect to interleave with.
  • No replay buffer. The surface answers any Last-Event-ID with a fresh publication
    rather than retained frames. The stream carries no deltas, so a publication is a
    complete resynchronization and a history would be an optimization; if Phase 5 measures a
    need for one, it is additive.
  • Browser sites of A, B, C, D — every finding whose latest annotation still requires a
    browser consumer remains open: A3–A11; B3–B8 and B10's client; C1 and C2's client; D1's
    composer, D2 and D5's browser sites, D3's pierreNoteAnchor, and D4's
    parseCanonicalReviewFile. B5 still needs its browser source policy. B7 and B8 also retain
    terminal adoption/behavior work.
  • E1, E2 — the shared stat-badge formatter, and the product decision about whether the
    browser mirrors the terminal theme. Both Phase 5; E2 must be decided before its second PR.
  • F browser bindings — F1–F3's browser halves (palette, keymap resolution onto DOM
    events). F4 stays a scope boundary, not work.
  • G1, G2 policy — view-option classification and persistence, and the multi-client
    selection and authorship policy. G2's wire fields are done (see G2); what a receiver
    should do with an actor tag is the decision, due before Phase 5 PR 2.
  • G3 adoption — no address grammar exists on current main. It was removed as dead code and
    returns beside the first browser deep-link or opener-fragment consumer (Phases 5–6).
  • G5 — a placement rule for undo, if undo is ever built. Not work.

Remote draft composition now reaches notes/update-draft through core and the wire protocol, but
has no browser caller until Phase 5. HTTP publications contain only a position and resource catalog;
resource reads provide canonical files, patches, and sources, while successful actions acknowledge a
new position. Phase 5 must define bounded snapshot or delta transport for live selection, filter,
and note state before a browser can render those changes.

A. Diff geometry

  • A1. Collapsed-gap line ranges — 3 implementations, terminal off by one. Terminal
    packages/hunk/src/ui/diff/pierre.ts (leadingCollapsedRanges/trailingCollapsedRanges, ~:456-480) vs
    core packages/hunk/src/core/review/expansion.ts (reviewGapAddress); web already consumes core's gap
    objects. For a pure-insertion hunk the terminal's leading range is off by one against core's,
    and the trailing math diverges for zero-count sides; a note created on a terminal-expanded
    line can be rejected on reload because intents.ts validates against reviewGapAddress.
    Fix: terminal calls reviewGapAddress; delete its local math.
    Repaid (Phase 1 PR 2): reviewLeadingGap/reviewGapAddress in core/review/expansion.ts;
    pierre.ts copies deleted; fixtures pure-insertion-hunk and pure-deletion-hunk in
    test/review-conformance/geometryFixtures.ts; core and terminal render planning both registered.
    Residual repaid (Pierre 1.3.5): the parser now reports the complete leading gap when the
    anchor side has zero rows, so pure-deletion-hunk pins lines 1–5 instead of preserving the
    former one-line undercount. A2's separate trailing-gap residual remains.
  • A2. Trailing-context existence — 3 formulations. pierre.ts trailingCollapsedLines,
    producer packages/hunk/src/app/session/registration.ts (~:131-139, boolean hasTrailingContext), core
    expansion.ts. The browser can offer a "Trailing context" button whose expansion core then
    rejects (gap-not-found). Fix: one reviewTrailingGap(file) in core.
    Repaid (Phase 1 PR 2): reviewTrailingGap in core/review/expansion.ts;
    trailingCollapsedLines/trailingCollapsedRanges deleted from pierre.ts. Residual, recorded
    in that function: a last hunk with a zero-count side leaves the two line-array tails one apart,
    so no trailing gap is offered even though the file has one. Every consumer now agrees on hiding
    it; correcting the count changes terminal output and is staged separately.
  • A3. Per-side hunk range — 4 implementations, one wrong. core/liveComments.ts
    hunkLineRange, core/review/anchors.ts reviewHunkRange (correct), web
    pierreDocument.ts sideRange (uses changed-line counts deletionLines/additionLines as
    span lengths — wrong for hunks with leading context, misplacing notes in the browser), and
    ReviewStream.tsx re-filtering against manifest ranges. Fix: reviewHunkRange everywhere;
    collapse hunkLineRange into it.
    Repaid (Phase 1 PR 2): reviewHunkRange/reviewHunkRanges in core/review/geometry.ts;
    hunkLineRange deleted and all six terminal/session sites converted; fixture
    hunk-with-leading-context.
  • A4. Source-line splitting for expanded context — 3 implementations, browser skips
    normalization.
    Terminal expandCollapsedRows.ts sliceLines and core anchors.ts
    normalizedReviewSourceLines agree (CRLF-normalize, strip one trailing newline); web
    ReviewStream.tsx does a bare split("\n") — \r glyphs, phantom EOF line, and context
    digests that can mismatch on reload. (sourceBackedHighlight.ts splitSourceLines is a
    legitimate fourth variant — Pierre's highlighter needs retained newlines; comment it.) Fix:
    export and adopt normalizedReviewSourceLines.
    Repaid (Phase 1 PR 2): normalizedReviewSourceLines in core/review/geometry.ts;
    expandCollapsedRows.ts sliceLines deleted; splitSourceLines carries the comment saying why
    it is legitimately different; fixtures crlf-source and source-without-trailing-newline.
  • A5. Expansion side policy deleted ? "old" : "new" — 3 copies. Core intents.ts
    (authoritative), terminal diffSectionRowPlan.ts (recomputed instead of reading gap.side),
    web pierreDocument.ts fallback ordering. Fix: thread gap.side; export
    reviewExpansionSide(file).
    Repaid (Phase 1 PR 2): reviewExpansionSide in core/review/expansion.ts; both terminal
    recomputations (diffSectionRowPlan.ts, useReviewController.ts) deleted.
  • A6. Hunk content-index rebasing — 2 copies, opposite isPartial conclusions. Web
    pierreDocument.ts isolatePierreHunk vs terminal sourceBackedHighlight.ts (~:108-199).
    Fix: one rebaseReviewHunk(hunk, origins) in core.
    Repaid (Phase 1 PR 2, terminal site): rebaseReviewHunk in core/review/geometry.ts, adopted
    by sourceBackedHighlight.ts. It returns the per-side end indices so a caller can slice or
    validate without re-walking; the browser's isolate-one-hunk use lands on it in Phase 5.
  • A7. File split/unified line totals — web guesses. pierreDocument.ts reconstructs
    splitLineCount/unifiedLineCount by reducing over hunks; terminal reads Pierre's
    authoritative values. Mis-sizes browser virtualization when the parser counts rows outside
    hunk spans. Fix: carry both on ReviewFileV1.
    Repaid (Phase 1 PR 2, model side): splitLineCount/unifiedLineCount carried on
    ReviewFileV1 by core/review/document.ts; the browser consumes them in Phase 5.
  • A8. Empty-diff explanation — 3 variants with different precedence. Terminal
    plannedRowText.ts diffMessage (rename-pure first), web ReviewStream.tsx (binary first),
    staticDiffPager.ts (extra cases). Same file can explain itself differently per client.
    Fix: reviewEmptyDiffReason(file) in core.
    Repaid (Phase 1 PR 2): reviewEmptyDiffReason in core/review/document.ts with one canonical
    precedence — what the change is outranks how it is stored (rename-only -> binary ->
    too-large -> new-file -> deleted-file -> no-hunks), the review stream's existing order.
    plannedRowText.ts and staticDiffPager.ts keep their own wording and share the reason; fixture
    binary-rename-with-no-rows. The static pager's own order put storage first, so a renamed
    binary or oversized rename now reports as a rename there too.
  • A9. STML tag vocabulary — parse shared, tag semantics forked. Terminal
    ui/lib/stml/layout.ts handles the full vocabulary; web ReviewNote.tsx handles a subset
    (everything else flattens to ``) and accepts a <tag> alias the terminal lacks. Fix:
    stmlTagRole(tag) in core/review/stml.ts; both renderers switch on roles.
    Repaid (Phase 1 PR 2, terminal site): core/review/stml.ts owns the tag-to-role table;
    ui/lib/stml/layout.ts switches on roles and parse.ts derives its void/raw-text tags from
    them. The parser itself stays in ui/lib/stml for now — it sanitizes through
    lib/terminalText, which core may not import — so relocating it is Phase 5 work, before the
    browser can parse notes.
  • A10. Default line target for a hunk note — 2 divergent rules. Terminal
    core/liveComments.ts firstCommentTargetForHunk (first added line, else first deleted);
    web App.tsx (hunk's first line — usually context). Same action anchors to different lines
    per client. Fix: reviewDefaultHunkLineTarget(hunk) in core, used by both (and by reveal).
    Repaid (Phase 1 PR 2): reviewDefaultHunkLineTarget in core/review/geometry.ts;
    firstCommentTargetForHunk deleted and both terminal callers converted; every geometry fixture
    pins the target, pure-deletion-hunk and hunk-with-leading-context adversarially.
  • A11. Language registration side effect missing in browser. core/changeset/fileLanguage.ts
    registers .mts/.cts; the web bundle never imports it, so Pierre's own inference runs
    unregistered for files without an explicit language. Fix: side-effect import in
    packages/hunk/src/web/main.tsx (or fold registration into the shared model).

Renderer-specific, do not unify: terminal row construction and measured-cell windowing
(rowWindowing.ts, diffSectionGeometry.ts) vs browser IntersectionObserver windowing and
height estimation; move-kind painting (browser lacks the Pierre input — a feature gap, not a
duplication); hunk header text (browser delegates to Pierre separators); platform hashing.

B. Navigation and selection

  • B1. Hunk navigation across the stream — 3 implementations. Terminal
    useReviewController.ts + ui/lib/hunks.ts (cursors, nearest-cursor, multi-step carry);
    agent runtime reviewSessionRuntime.ts navigateSession (separate walk whose terminal
    parity is asserted only by a comment, and which lacks the terminal's multi-step carry rule);
    browser (none yet — would be the third). Annotated sets are also built from different inputs
    (terminal DiffFile model vs intersectingHunkIndices). Fix: selection/move intent
    (scope: hunk|file|annotated-hunk|annotated-file, delta, wrap policy) planned over shared
    selectors; delete ui/lib/hunks.ts and the runtime walk.
    Repaid (Phase 1 PR 3, core and terminal sites): planReviewSelectionMove in
    core/review/navigation.ts, reached through the selection/move intent. ui/lib/hunks.ts is
    deleted and tombstoned, and the session runtime's separate walk is gone — comment navigation
    (--next-comment / --prev-comment) now plans the same intent the keyboard does, so the
    multi-step carry rule it lacked applies there too. Fixture annotated-hunk-multi-step-carry
    in test/review-conformance/navigationFixtures.ts; the planner is registered as a navigation
    consumer. Residual: which hunks count as annotated is still derived from the terminal's merged
    diff-file model (buildReviewAnnotationIndex) and handed to the planner as a caller-owned
    fact, because the semantic document does not carry notes yet — one derivation now, consumed by
    both the keyboard and the session.
  • B2. Wrap vs clamp policy split. moveToFile clamps; moveToAnnotatedFile wraps
    (ui/lib/reviewState.ts). Encode per-scope wrap policy in the selection/move intent.
    Repaid (Phase 1 PR 3): REVIEW_SELECTION_WRAP_POLICY names the policy per scope —
    hunk/file/annotated-hunk clamp, annotated-file wraps — and findNextAnnotatedFile is deleted.
    The asymmetry is today's terminal behavior kept deliberately, including two quirks now stated
    rather than implied: a clamping hunk move at an edge re-selects and re-reveals the same hunk
    while a file move at an edge publishes nothing at all, and annotated-file navigation from a
    file with no notes enters the ring at its start (so the first forward step lands on the ring's
    second entry). Fixture scope-wrap-and-clamp.
  • B3. File-jump semantics — hard-coded identically in both clients. "Hunk 0 + file-top
    reveal" in terminal App.tsx/useReviewController.ts and web App.tsx; the terminal-only
    forward-cross-file alignment rule has no web counterpart. Fix: selection/select-file
    intent owning the rule.
    Repaid (Phase 1 PR 3, core and terminal sites): selection/select-file owns "first hunk"
    (REVIEW_FILE_JUMP_HUNK_INDEX) with REVIEW_FILE_JUMP_REVEAL as its default reveal, and the
    forward-cross-file alignment rule moved into the hunk-move planner, where the crossing is
    known. The terminal's selectFile lowers to the intent and no longer takes a hunk index.
    Fixture scope-wrap-and-clamp pins both reveals. The browser's copy lands in Phase 5.
  • B4. Selection fallback after reload/filter — 2 divergent answers. Terminal
    resolveSelectedFile returns undefined (renders "no file"); web validSelection and
    treeSource.reset silently fall back to files[0]. Core permits fileKey: null. Fix:
    selectNormalizedSelection/selectFallbackFileKey selectors; delete both client fallbacks.
    Repaid (Phase 1 PR 3, core and terminal sites): both selectors live in
    core/review/selectors.ts, and resolveSelectedFile is deleted. Recorded difference from this
    finding's description: the terminal in this repo did not render "no file" for a selection the
    filter hides — it kept rendering the selected file, and fell back only when the file was gone
    from the document. That behavior is authoritative and is what the selector now states, so
    fileKey: null is reached exactly when nothing is visible at all. Fixtures
    selection-outliving-its-file and selection-with-nothing-visible. The browser's
    validSelection/treeSource.reset fallbacks close in Phase 5.
  • B5. Filter matching — 3 matchers. Core reviewFileMatchesFilter (path, previousPath,
    agentSummary), terminal filterReviewFiles (normalized paths), web tree search
    (canonicalPath only) — browser sidebar and stream can disagree on the same query. Also
    live-per-keystroke (terminal) vs apply-on-Enter (web, which clobbers in-flight typing on
    snapshot). Fix: one matcher; one committed-vs-live decision.
    Repaid (Phase 1 PR 3, core and terminal sites): reviewFileMatchesFilter in
    core/review/selectors.ts is the only matcher, and filterReviewFiles is deleted. The
    terminal's behavior won on both points of difference: paths are normalized before matching
    (core's matcher did not), and the three fields are joined before the substring test, so a query
    may span the boundary between them. Residual: the committed-vs-live decision is still open —
    the terminal matches live per keystroke, and planning reads the immediate filter while
    rendering reads a one-render-deferred copy of it. The browser's tree search closes in Phase 5.
  • B6. Reveal-target derivation — web re-derives, wrongly. Web App.tsx recomputes the
    hunk target line (newRange ? "new" : "old"), duplicating core canonicalLineForHunk which
    prefers by side counts and requires a backed line — pure-deletion hunks scroll the wrong side
    in the browser. Fix: selectRevealTarget(state) selector; clients only resolve DOM/rows.
    Repaid (Phase 1 PR 3, core site): reviewCanonicalHunkLine in core/review/geometry.ts
    (preferred side first, backed sides only), behind selectRevealTarget. Fixture
    pure-deletion-reveal-target pins the case the prototype browser got wrong, and pins that a
    hunk's position is its first row while a note about the whole hunk hangs from its first change.
    The terminal's reveal is row geometry it measures itself and stays renderer-local; the browser
    consumes the selector in Phase 5.
  • B7. "Jump to note" target — terminal geometry decides, web ignores. The
    active-note choice lives in DiffPane.tsx row scanning; web never reads
    reveal.scrollToNote. Fix: selectActiveRevealNoteId(state) in core.
    Repaid (Phase 1 PR 3, core site): selectActiveRevealNoteId names the policy — an active
    draft in the selected hunk first, else the note anchored earliest in it, arrival order breaking
    ties — which is what DiffPane's row scan resolves geometrically today. The terminal site
    stays open: it looks the answer up by measured row bounds, and swapping that for the selector
    is a rendering change rather than a semantic one. Browser adoption is Phase 5.
  • B8. Notes-by-hunk grouping — web re-filters by range containment. ReviewStream.tsx
    drops annotations whose anchor came from core's fallback path or expanded context even after
    pierreDocument accepted them — notes silently disappear in the browser. Fix: group by
    ownerHunkIndex via a shared selectNotesByHunk.
    Repaid (Phase 1 PR 3, core site): selectNotesByHunk groups by reviewNoteOwnerHunkIndex,
    reading the ownership resolveReviewNoteAnchor decided instead of re-testing containment. The
    terminal site stays open deliberately: DiffPane groups by range overlap and therefore renders
    a note under every hunk it overlaps, so converting it changes what a reviewer sees and
    belongs in a behavior-changing PR, not this one.
  • B9. Note-visibility policy — two core predicates for one rule.
    selectors.ts reviewNoteVisibleByPolicy (web path) vs notes.ts alwaysShowReviewNote
    (terminal path). Collapse to one predicate over {source}.
    Repaid (Phase 1 PR 3): one reviewNoteVisibleByPolicy over {source} in
    core/review/state.ts, beside the other stored-note policies; alwaysShowReviewNote is
    deleted, and DiffPane calls the predicate over the normalized source.
  • B10. Selected-line semantics — browser structurally weaker. Terminal maps rendered rows
    to semantic side/line with expandedLineProof and separates "anchor" from "reveal"; web
    sends raw lines with reveal forced on every click, and the wire selection/set-line /
    notes/create-user actions have no expandedLineProof field at all — browser clicks inside
    expanded regions are rejected or mis-sided. Fix: add the proof to the wire schema; make
    reveal an explicit caller decision in both clients.
    Repaid (Phase 3, wire and producer sites): ReviewExpandedLineClaim in
    core/review/expansion.ts states what a caller claims — the gap, the side, the line, and the
    identity of the source it expanded — and resolveReviewExpandedLine is the one thing that
    decides whether the claim holds. The wire carries it as expandedLineProof on the two
    actions that can name a line (notes/start-draft's target, and notes/create-user's
    precondition on the draft it is saving), refusing evidence that accompanies no line, and
    packages/hunk/src/app/session/reviewCommands.ts checks it before planning. Where the resulting note hangs
    is deliberately not decided there: it goes through reviewLineAnchor's fallback owner
    exactly as a terminal note does, which reviewCommands.test.ts pins by asserting an empty
    intersection set and the declared owner. Fixtures start-draft-on-an-expanded-line,
    create-user-note-at-an-expanded-line, and start-draft-with-a-proof-about-nothing in
    test/review-conformance/wireFixtures.ts. Reveal is already an explicit caller decision on
    every intent that carries one; the browser half of both closes in Phase 5.
  • B11. Viewport-driven selection — opposite policies. Terminal publishes
    nearest-hunk-to-center into shared state; web keeps IntersectionObserver results local. With
    both clients attached, terminal scrolling rewrites shared selection under the browser. Fix:
    one core policy (e.g. selection/anchor intent that never bumps reveal tokens).
    Repaid (Phase 1 PR 3, core and terminal sites): REVIEW_VIEWPORT_ANCHOR_REVEAL in
    core/review/state.ts, and the selection/anchor intent that carries it. The terminal's
    viewport-centered hunk selection and its line-cursor anchoring both publish through it, and the
    local preserveViewport selection option is deleted. The policy is stated, but the
    multi-client question it exists for — whether an anchor should be shared at all — is G2,
    decided before Phase 5 PR 2.
  • B12. Wire vocabulary hand-restates ReviewIntent — 3 places. HunkReviewActionV1
    union, capability list in registration.ts, validation list in wire.ts, remapped
    field-by-field in reviewSessionRuntime.ts; forgotten fields/actions are silently
    unreachable from the browser. Fix: derive the wire type and both lists from one
    HUNK_REVIEW_ACTION_TYPES source; validate-and-narrow rather than restate.
    Repaid (Phase 3): REVIEW_INTENT_TYPES in core/review/intents.ts is the vocabulary,
    made total in both directions by type assertions — a member added to ReviewIntent and not
    listed fails to typecheck, and a listed name that is not an intent fails too.
    HUNK_REVIEW_ACTION_TYPES is that list, and nothing is withheld: a semantic intent
    resolves at the producer and is broadcast to every attached surface, so every one of them
    belongs to every surface. Withholding one would mean subtracting it by name, with the reason
    it is not shareable. The wire type is derived
    the same way — HunkReviewActionV1 is ReviewIntent with the two wire-only fields added to
    the members that need them — and toReviewIntent strips them again, so an action is
    validated and narrowed rather than restated. The action-type-to-parser table is keyed by the
    vocabulary, so a wire-reachable intent without a parser does not compile.
    scripts/quality/review-vocabulary.test.ts is the ladder's rung 5: it asserts the equality, that
    every exclusion names a real intent once, and that every type in the vocabulary really
    reaches a parser. Round-trip fixtures for every action live in
    test/review-conformance/wireFixtures.ts, registered as the review wire protocol consumer.
    Repaid (note editing and replies): notes/update-draft now carries remote composer text,
    while notes/start-edit, notes/start-reply, and notes/update-user share saved-note
    mutation and reply semantics across terminal and remote surfaces. Their strict parsers and
    hand-authored wire fixtures keep the expanded vocabulary total.

Renderer-specific, do not unify: terminal line-cursor measurement/reconciliation; browser
requestAnimationFrame/MutationObserver reveal mechanics; tree presentation details (duplicate
path suffixes, expansion retention, git-status badges).

C. Transport and lifecycle

  • C1. Generation/state-revision acceptance — 5 implementations, 3 policies. Web
    mirror.ts (retired-generation memory; requires contiguous +1 revisions — a contract the
    server does not guarantee, since initial snapshots and Last-Event-ID replay can skip),
    broker state.ts (accepts equal revisions for width-only refreshes), producer
    reviewSessionRuntime.ts (equality required except selection actions), wire.ts parse-time
    triple-equality, plus a fifth copy in web App.tsx compensating for the first being
    stricter than the broker. Fix: core/review/generationOrder.ts exporting the invariant and
    classifySnapshot/classifyState → accepted|stale|gap, consumed by all five sites.
    Repaid (Phase 2, producer site): core/review/generationOrder.ts states the invariant
    once — a generation carries a producer id and a sequence that advances by exactly one, and
    revisions strictly increase but need not be contiguous — and classifyReviewPublication
    answers accepted | stale | gap. assertReviewPublicationAdvance is the producer's own
    side of it, checked before any generation is swapped in. Two decisions worth stating:
    revision skips are legal (the prototype client's contiguous +1 rule is the bug, not the
    contract), and a republication carrying no new semantic position — a renderer width changed
    and nothing about the review did — classifies as a replay, so the broker's "accept equal
    revisions for width-only refreshes" case becomes a publication-key decision at the producer
    rather than a looser comparison. Fixtures live in
    test/review-conformance/orderingFixtures.ts and cover both the classification and the
    transitions a real producer emits. Broker and browser sites close in Phases 3 and 5.
    Repaid (Phase 3, broker site): the daemon's ReviewMirror
    (packages/hunk/src/session/broker/reviewMirror.ts) holds one publication per session and orders every
    arriving one with a single classifyReviewPublication call — accepted advances the
    revision, gap replaces the generation and retires everything derived from the old one,
    stale is ignored. It has no comparison of its own, so the prototype's "accept equal
    revisions for width-only refreshes" case does not exist here: a republication carrying no
    new position is a replay, exactly as the contract says. The one non-ordering rule it does
    apply is stated as such — a later generation is adoptable only together with the catalog
    describing it, because a mirror holding a position whose resources it cannot name would
    advertise reads nobody can serve. packages/hunk/src/app/session/reviewCommands.ts makes the same one
    call for an action's expectedStateRevision, so "has the review moved past what this
    caller decided from" is the same question as "is this publication ahead". The mirror is
    registered against the Phase 2 fixtures as the broker review mirror ordering consumer,
    which is what proves it has no rules of its own. The browser site closes in Phase 5.
  • C2. Chunk assembly + verification — 4 copies, 2 in one file. Web apiClient.ts range
    loop; broker state.ts materializing and pre-sized loops (which already disagree on
    progress/eof rules); SSE reassembly in mirror.ts. Three in-flight dedupe key formats;
    digest case normalized on a different operand in each. Fix:
    core/review/resourceAssembly.ts (ChunkAssembler with expected size/digest, bounded
    progress, verified finalize) plus one keyed single-flight helper; HTTP Range/abort, broker
    reservations, and concurrency tuning stay at the edges.
    Repaid (Phase 2, producer site): core/review/resources.ts owns resource addressing, the
    chunk bound both ends validate against, and the failure vocabulary;
    packages/hunk/src/app/review/resourceStore.ts produces and serves the bytes. Single flight is
    structural rather than a cache bolted on — a read reaches the underlying reader only
    through the in-flight map — and bulk loads run under an explicit concurrency limit instead
    of an unbounded Promise.all, which is the pair of defects the original review found.
    Digest comparison normalizes both operands (reviewDigestsEqual). The reader half — chunk
    reassembly against an expected size and digest — lands with the broker in Phase 3 and
    consumes the same module.
    Repaid (Phase 3, broker site): core/review/resourceAssembly.ts is the reader half —
    one ReviewChunkAssembler that holds a stream to the size and digest it declares, requires
    each chunk to start where the last ended, treats a chunk that neither advances nor ends as
    a failure, accepts a zero-length resource as one empty end-of-stream chunk, and verifies
    the assembled bytes against the digest with a ReviewDigestFn the caller injects. The
    broker's load loop (packages/hunk/src/session/broker/state.ts) does nothing but ask for the next window
    and decode it; there is one loop, where the prototype had two near-verbatim copies inside
    this file that already disagreed about progress and end-of-stream. Bounding is
    reviewResourceCache.ts: an LRU with a daemon-wide byte budget plus a reservation taken
    before any bytes are requested, so assemblies in flight are bounded too — and an unmeasured
    resource reserves one chunk and is resized to what the writer declares rather than
    reserving its kind's ceiling, which is what let a handful of ordinary patches serialize the
    parallel loads. Single flight is one map keyed by session, generation, and resource id;
    concurrent callers await the same assembly. packages/hunk/src/session/broker/reviewResources.integration.test.ts
    drives the whole path with only the socket replaced. The browser's apiClient range loop
    closes in Phase 5.
  • C3. Epoch/supersede/trailing-retry — 2 parallel machines. Runtime reload queue
    (reloadEpochSequence/supersededReloads) vs web snapshot recovery
    (recoveryEpoch/trailing while-loop), plus three unrelated anti-spin timing constants. Fix:
    one epoch-queue primitive; keep DOM/React wiring local.
  • C4. SSE event contract defined on both ends. Frame names (${type}-begin/-chunk/-end),
    begin/end envelopes, and the event-id grammar are built in browserReviewServer.ts and
    re-declared/regex-parsed in mirror.ts/apiClient.ts; client bounds (12 MiB / 1024 chunks)
    are unlinked from server bounds and only coincidentally compatible. Fix:
    packages/hunk/src/session/reviewEventProtocol.ts owning names, envelopes, id grammar, and bounds derived
    from MAX_BROWSER_REVIEW_SNAPSHOT_BYTES.
    Repaid (Phase 4, server side): packages/hunk/src/session/reviewEventProtocol.ts owns the event
    vocabulary, the frame names and their phases, the begin/chunk/end envelopes and their
    parsers, the event-id grammar, and every bound — MAX_REVIEW_EVENT_PAYLOAD_BYTES is the
    protocol's envelope bound, REVIEW_EVENT_CHUNK_BYTES is the shared resource chunk size,
    and MAX_REVIEW_EVENT_CHUNKS is the quotient, so a sender asking for smaller windows is
    clamped to the ceiling a reader is allowed to hold rather than emitting frames the reader
    will refuse. browserReviewServer.ts imports all of it and declares none of it; the
    browser client imports the same module unchanged in Phase 5, which
    scripts/quality/source-boundaries.test.ts keeps possible by gating the module's transitive
    closure platform-free. Two decisions differ from the prototype deliberately: a chunked
    payload is framed and verified as the byte stream it is, so reading it is the shared
    ReviewChunkAssembler rather than a fourth reassembly loop (C2's rule applied here); and
    only the frame that completes an event carries an id, so a Last-Event-ID can never
    name a position inside a half-delivered payload. Fixtures
    publication-exactly-one-window and publication-one-byte-over-a-window in
    test/review-conformance/eventFixtures.ts pin the boundary the two ends must agree on,
    and both the protocol and the real HTTP surface are registered as event consumers. The
    browser reader joins in Phase 5, which is when this finding closes.
  • C5. Reconnect/backoff — 4 schedulers, 1 verbatim duplicate. apiClient.ts (exp/4 s),
    web App.tsx (exp/4 s + anti-spin), brokerClient.ts (fixed 3 s — re-implementing the
    scheduler of the connection it already configures), session-broker/connection.ts. Fix: one
    createReconnectScheduler in @hunk/session-broker-core; EventSource's built-in reconnect
    interplay stays client-side.

D. Notes and validation

  • D1. Note byte bounds measured in two units. Wire checks body and markup separately
    against MAX_REVIEW_NOTE_BYTES; broker/producer check whole-note JSON — so a note that
    passes action validation can poison the entire snapshot with a capacity error. Neither
    client pre-checks size, and the server's action-body cap is smaller than the largest
    "valid" note. Fix: one reviewNoteWithinSizeLimit used by wire, broker, producer, and both
    composers.
    Repaid (Phase 2, core and producer sites): core/review/noteSize.ts measures the whole
    note in the unit a transport pays — its serialized bytes, through the platform-free
    utf8ByteLength — and MAX_REVIEW_NOTE_BYTES sits beside it. Fixtures
    test/review-conformance/noteSize.ts pin the boundary the two prototype rules disagreed
    at, including a note whose summary, rationale, and markup each fit while the note itself is
    three times the bound. Wire and composer sites adopt it in Phases 3 and 5.
    Repaid (Phase 3, wire site): isTransportableReviewNote in packages/hunk/src/session/reviewProtocol.ts
    is reviewNoteWithinSizeLimit and nothing else — the wire has no per-field check any more, and
    declares no second bound. The protocol module is registered as a consumer of the note-size
    corpus, so every-field-fits-but-the-note-does-not — the note whose summary, rationale, and
    markup each pass a per-field check while the note is triple the bound — is now refused at the
    wire rather than admitted and then failing at the publisher. Both composer sites are Phase 5.
  • D2. Empty-body policy — 5 declarations. Core intents (throws), terminal (cancels
    draft — a deliberate UX difference worth keeping explicit), web in three places. Fix: one
    isBlankReviewNoteBody predicate.
    Repaid (Phase 1 PR 2, core and terminal sites): the predicate lives in
    core/review/intents.ts, and test/review-conformance/noteBodies.ts pins the boundary cases
    against both the predicate and the draft-persistence plan they drive.
  • D3. Note-anchor ownership — 4 implementations. Core resolveReviewNoteAnchor
    (authoritative, with fallback-owner support), notes.ts sibling over Pierre geometry,
    broker wire.ts re-derivation that omits the fallback branch (a legal expanded-gap note can
    get a whole registration rejected), web pierreNoteAnchor without the count clamp. Fix: one
    owner/intersection calculator parameterized over a range accessor.
    Repaid (Phase 3, broker and wire sites): by deletion rather than by extraction — the
    daemon has no anchor calculator at all. The broker mirrors a position and a resource
    catalog, forwards an action, and never sees a note's ranges; ownership is decided once, by
    resolveReviewNoteAnchor at the producer, reached through the intent. That is what makes
    the prototype's failure impossible rather than merely fixed: its broker copy re-derived
    intersections, omitted the fallback branch, and rejected a legal expanded-gap note — and
    with it the whole registration. The case is pinned from the wire end in
    packages/hunk/src/app/session/reviewCommands.test.ts: a note created remotely on an expanded-gap line
    ends up with an empty intersection set and the fallback owner the caller declared, which is
    exactly the shape the dropped branch produced. Web pierreNoteAnchor closes in Phase 5.
  • D4. Canonical-file ↔ manifest consistency — 3 checks, 3 field lists. Producer
    registration.ts (authoritative ~17 fields), web parseCanonicalReviewFile (12, and
    compares flags/sourceResourceIds via key-order-sensitive JSON.stringify — lazily
    inserted source ids can spuriously error a file), broker legacy check (10). Nobody compares
    canonical hunk content to manifest hunks. Fix: one order-independent
    assertCanonicalFileMatchesManifest in core; producer self-check reuses it.
    (contentManifest.ts is legitimately different — a parity-test snapshot, not a validator.)
    Repaid (Phase 2, core and producer sites): core/review/canonicalFile.ts carries no field
    list of its own — it projects the candidate file into a content manifest entry and compares
    that by value at every level, so the list cannot drift from what the model says a file is,
    key order never participates, and hunk content is checked alongside the geometry derived
    from it. The manifest gained the content that requires (patch text, hunk blocks, source
    identity) and stays a snapshot rather than becoming the validator. The producer self-checks
    with it before serving any canonical file, and the conformance harness runs that check over
    every fixture. The browser and broker copies go when those tiers land.
  • D5. Validator/constant hygiene. isReviewSha256Digest exists but is bypassed by five
    inline regexes with case-sensitivity drift; raw createHash("sha256") at seven sites
    instead of reviewDigest; hasExactKeys private while the pattern is inlined ~10×;
    action-envelope parsing duplicated between reviewProtocol.ts and browserReviewServer.ts;
    wire constants re-declared as literals (client range size vs server response cap, snapshot
    bounds, filter length cap) and re-typed in tests. Fix: export the validators/helpers from
    reviewProtocol/brokerWireParsers; import constants everywhere, and derive coupled bounds
    from each other. Also name the intentionally stricter resolution === "active" filter
    (isActiveStoredReviewNote) beside isRenderableStoredReviewNote, and comment why
    parseReviewState notes intentionally skip manifest-geometry matching.
    Repaid (Phase 2, core and producer sites): core/review/validation.ts owns the digest
    vocabulary and the exact-key check the producer validates with and the wire protocol will.
    isReviewSha256Digest accepts only the canonical lowercase form — the case-insensitive
    variant is what let a writer and a reader disagree — with normalizeReviewDigest for values
    arriving from outside and reviewDigestsEqual normalizing both operands. Hashing itself is
    an injected ReviewDigestFn rather than inline createHash calls; the producer supplies
    Node's at the edge (packages/hunk/src/core/reviewDigest.ts), which is also what repaid the shared model's
    last node-debt entry. Resource bounds are constants in core/review/resources.ts that the
    producer imports rather than restates. Wire constants, the action-envelope parser, and the
    two note-filter namings are Phase 3.
    Repaid (Phase 3, wire and broker sites): the resource-read request moved into
    core/review/resources.ts, so the producer and the wire parse one shape with one parser
    instead of the producer owning it and the wire copying it; REVIEW_RESOURCE_LOAD_CONCURRENCY
    moved beside the other resource bounds for the same reason, now that both the producer and
    the daemon run bulk loads. The failure vocabulary is composed rather than restated
    (HunkReviewFailureCodeV1 = resource + request + intent-planning codes), the navigable
    scopes are read out of REVIEW_SELECTION_WRAP_POLICY rather than listed again, the
    catalog's file bound is derived from its own resource count instead of duplicating the
    registration's file limit, and every digest check is isReviewSha256Digest. The one
    coupling the protocol cannot express as an import is the transport frame size — importing
    the broker package would cost the module its browser safety — so
    scripts/quality/review-vocabulary.test.ts asserts it instead, alongside a check that no session
    module re-declares a name the review model exports and that no module writes its own
    64-character digest pattern. Naming isActiveStoredReviewNote beside
    isRenderableStoredReviewNote and commenting parseReviewState are browser-tier work and
    stay open with the rest of D5's browser sites.

E. Presentation helpers

  • E1. File stat badges. Terminal ui/lib/files.ts formatSidebarStat (zero-hiding,
    truncation marker) vs web inline +${additions} −${deletions} in treeSource.ts. One
    shared formatter.
  • E2. Theme. Web hardcodes two standalone palettes disconnected from packages/hunk/src/ui/themes and
    the AppTheme mapping; whether the browser mirrors the terminal theme is an open product
    decision — decide before Phase 5, don't unify by default.

F. Commands and keybindings (preemptive — the prototype browser had none)

The prototype browser client shipped no command system, so these are not observed duplications
like A–E; they are the copies the browser would grow the moment shortcuts are added, recorded
here so the extraction happens before the duplication exists. Design detail in
browser-review-rebuild.md § "Commands and keyboard shortcuts in the browser".

  • F1. Command catalog fused with terminal binding and effects. packages/hunk/src/ui/lib/appCommands.ts
    couples identity (id, title, chords), binding (terminal KeyEvent matchers), and effect
    (closures over live App state) in one table; menus (ui/lib/appMenus.ts) and the help
    dialog render from it, so a browser palette or help screen would have to restate the list.
    Fix: extract a renderer-neutral catalog (id, title, category, default chords, resolution
    locus — semantic / client-local / host-only); terminal keeps matchers and handlers, browser
    adds its own, both render menus/help/palette from the catalog.
    Repaid (Phase 1 PR 3): packages/hunk/src/core/run/commandCatalog.ts carries id, title, category, default
    chords, resolution locus, extension visibility, and menu-closing behavior for all 44 built-ins.
    ui/lib/appCommands.ts builds its dispatch table from it — the handler map is keyed by
    AppCommandId, so a catalogued command with no terminal handler fails to typecheck — and
    menus and help keep reading identity through that table. Parity is asserted in
    appCommands.test.ts ("command catalog parity"): the table is exactly the catalog in catalog
    order, and no menu item or help row names a command the catalog does not declare. Placement
    note: the catalog sits outside core/review, which stays review semantics only, so Phase 5
    must add it to the web boundary gate's allowed import targets.
  • F2. Semantic command effects are closures instead of intent dispatches. The ~15
    review-semantic commands (hunk/file/annotated navigation, start note, toggle gap, toggle
    agent notes, filter) run as App closures; a browser implementation would re-derive each
    behavior — the exact drift class of B1/B3/B6. Fix: lower semantic commands to
    ReviewIntents (the Phase 1 store refactor is the same work); the browser fires them
    through the existing apply-action path, and the agent runtime's hunk session surface
    becomes a third consumer of the same lowering.
    Repaid (Phase 1 PR 3, core and terminal sites): semantic entries declare their effect as data
    (AppCommandReviewEffect), and lowerAppCommandToReviewIntent is the one constructor turning
    a command plus a repeat count into a ReviewIntent. The terminal's navigation handlers read
    the scope and direction from that same declaration rather than restating them. Two semantic
    commands have no intent to lower to yet — starting a note needs caller-owned draft identity,
    and gap expansion is the Phase 2 expansion/toggle intent — and are listed by name in
    SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT, so the gap is a decision rather than an oversight.
    Closed (Phase 2): both landed. notes/start-draft takes caller-owned draft identity and
    an optional client-measured line, falling back to the shared whole-hunk default;
    expansion/toggle resolves through reviewGapAddress and reports the side, ranges, and
    source identity a caller needs to fill the gap. Which gap the command reaches is
    selectReviewGapForSelection in core, replacing the terminal's selectGapForKeyboardToggle.
    SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT is now empty, and every semantic command lowers to
    an intent a remote client could fire.
    Repaid (terminal adoption): useTerminalReview.ts calls
    lowerAppCommandToReviewIntent, so the terminal no longer rebuilds semantic command intents
    inline. The future browser palette remains a second surface, not a prerequisite for terminal
    parity.
  • F3. Keymap resolution is terminal-owned. Chords are shared config strings (keymap.ts,
    [keybindings]), but resolution against defaults and conflict handling lives with the
    terminal table; a browser keymap would duplicate it and drift on user rebinds. Fix: resolve
    user keybindings against the catalog once; each client maps resolved chords to its own event
    type and masks platform-reserved chords (browser Cmd+W etc.).
    Repaid (Phase 1 PR 3, host site): builtinCommandKeyDefaults reads the catalog, so
    [keybindings] resolution, conflict detection against extension commands, and key labels all
    fold user config over catalogued defaults exactly once. Mapping resolved chords onto a client's
    own event type stays per client, which is the part that cannot be shared; the browser's half
    lands in Phase 5.
  • F4. Host-only and extension commands — do not expose, by design. Quit, source refresh,
    edit-in-$EDITOR, agent-skill helpers, and all extension commands execute host-side (with
    dialog access); browser invocation is remote code execution into the terminal session.
    Excluded from the browser until an explicit per-command allowlist exists in the registration
    capability list. This is a scope boundary, not a missing feature.

G. Other preemptive seams (same class as F)

Further capabilities where the terminal (or multi-client operation itself) has semantics the
prototype browser never grew; recorded so the shared primitive exists before a second
implementation does.

  • G1. View defaults and option classification. Terminal view options resolve through the
    layered defaults chain (built-ins → user config → repo .hunk/config.toml → command
    sections → CLI flags); the prototype browser hardcodes its own defaults (dark theme, layout)
    and has no persistence at all. Two decisions to make once: (a) which options are shared
    review state vs per-client view state — showAgentNotes and filter are already shared,
    layout/wrap/theme are per-client — recorded as an explicit classification on the option
    schema, not implied by where code happens to read them; (b) whether the browser receives the
    host's resolved view defaults as its starting point (it should — the host already computed
    them) with per-client overrides persisted client-side. Phase 5.
  • G2. Actor identity and multi-client selection policy. Notes carry an optional author,
    but wire actions carry no actor/client identity — with a terminal, a browser, and agents
    attached to one session, nothing distinguishes who moved the selection or wrote a note, and
    B11 (viewport-driven selection) already showed the two clients fighting over shared
    selection. Decide the model once: actions carry a client/actor tag; selection is either
    shared-with-follow-mode or per-client-with-optional-follow (product decision); note
    authorship defaults from the actor. Wire fields in Phase 3, policy before Phase 5 PR 2.
    Repaid (Phase 3, wire fields): HunkReviewActorV1 — an opaque clientId, a coarse
    kind (terminal / browser / agent), and an optional display name — is required on
    every action and resource-read envelope from the protocol's first version. The finding's
    four parts split cleanly, and only the first is done: (1) actions carry an actor tag, here;
    (2) whether selection is shared-with-follow or per-client-with-follow is a product decision,
    due before Phase 5 PR 2; (3) note authorship defaulting from the actor lands with remote
    note composition in Phase 5; (4) how a client obtains its identity was originally assigned to
    Phase 4. The wire parser validates the tag, the broker forwards it to the owning session, and
    the producer-side action handler drops it before semantic planning. A client cannot widen what
    it may do by claiming a kind; adding policy later changes behavior rather than the schema.
    Amended (Phase 4): part (4) does not land with the HTTP surface after all, and the
    reason is worth stating. The capability authorizes a review, not a client: one link
    may be opened in several tabs, and the surface deliberately cannot tell them apart,
    because a credential that identified a tab would be a credential a tab could be tracked
    by. The actor therefore arrives in the action envelope, opaque and non-authoritative, and
    minting a per-client identity belongs with the client that needs one — Phase 5, beside the
    selection policy that is the only thing which will read it. Parts (2) and (3) are
    unchanged.
  • G3. Semantic addressing / permalinks. The prototype's URL fragment carries only the
    capability token — there is no grammar for addressing a file/hunk/line/note. Three consumers
    will need one: browser deep links and back/forward history, a terminal "copy link" command,
    and agent surfaces already addressing targets by file/hunk. One serialize/parse address
    grammar over semantic keys (fileKey/hunkIndex/side/line/noteId — never array indices or
    rendered rows) in core/review, used everywhere an address crosses a boundary. Core
    primitive in Phase 1; browser adoption Phase 5; opener fragments Phase 6.
    Repaid (Phase 1 PR 3, core primitive): core/review/address.ts serializes and parses the
    four address kinds over percent-encoded semantic identifiers, with round-trip coverage for keys
    carrying separators, percent signs, and non-ASCII characters, and strict rejection of anything
    outside the grammar. No consumers yet, by design — browser deep links are Phase 5 and opener
    fragments Phase 6, which is when this finding closes.
    Reopened (2026-08-17, module-boundaries phase 0): core/review/address.ts is deleted. A
    primitive with no consumers cannot be checked against what its consumers need, and the
    no-dead-modules rule now flags exactly that shape. The grammar above still stands as the
    design; write it again beside the first consumer that addresses a review across a boundary.
  • G4. User-facing error catalog. The repo already solves this once for agents:
    packages/hunk/src/session/agent/errors.ts single-sources every message the generated skill quotes, with
    contract tests. The browser has no equivalent — action rejections (invalid-action,
    stale-generation, resource integrity failures) would surface as ad-hoc strings invented in
    packages/hunk/src/web, drifting from what the terminal shows for the same failure. One error-code →
    user-message catalog beside the wire protocol, consumed by both clients (and reused by the
    agent surface where codes overlap). Phase 4 (codes stabilize) / Phase 5 (browser consumes).
    Repaid (Phase 4, catalog creation): packages/hunk/src/session/reviewErrorCatalog.ts gives every code a
    statement and a remedy, in the agent surface's own pattern. Totality is mechanical rather
    than reviewed: the catalog is a Record over HunkReviewClientErrorCodeV1, itself
    composed — resource plus request plus intent-planning plus the transport's own codes —
    so a code added to any of the four tiers fails to typecheck until it has a message, and
    reviewErrorCatalog.test.ts states the vocabulary by hand rather than reading it back out
    of the thing under test. Messages carry no interpolated caller input, so they can be
    rendered anywhere, including where echoing a request back would be wrong; the HTTP surface
    uses a catalog message unless the producer supplied a more specific one. The status map
    lives with the transport (browserReviewServer.ts) rather than in the catalog, because a
    client reads codes and an HTTP status is not something to tell a person. The agent surface
    keeps its own wording — its codes are hunk session CLI failures rather than these, and
    the two vocabularies do not yet overlap. Browser adoption is Phase 5, which is when this
    finding closes.
  • G5. Undo, if it ever arrives. Note editing today has no undo. If it is added, the
    history/undo semantics belong in the shared reducer (which client undoes what, across
    actors), never in one client's keyboard handler. Recorded as a placement rule, not work.

Verification hooks

The per-phase verification ladder lives in browser-review-rebuild.md § "Per-phase seam
verification". A finding here counts as repaid only when all four hold: duplicate copies
deleted, their paths (for whole files) or banned-symbol entries (for function-level
deletions) appended to the tombstone lists in scripts/quality/source-boundaries.test.ts,
the finding's adversarial fixture landed in the conformance harness, and the consumer
registered against that harness.

  • The seam boundary tests (scripts/quality/source-boundaries.test.ts) keep deleted copies deleted.
  • Renderer parity tests (Phase 5 gate) drive shared fixtures through the terminal planner and
    browser projection and assert identical note placement, gap addressing, reveal targets, and
    default note targets — the drift class import gates cannot catch.
  • contentManifest.ts should additionally cover derived geometry (A1–A4) so parity snapshots
    fail when a renderer re-derives instead of consuming core.
  • Command parity (F1–F3): help/menu/palette listings in both clients render from the shared
    catalog in tests, so a command added to one client without catalog registration fails rather
    than forking the vocabulary.

Changelog On Hunk Dev (`docs/changelog-on-hunk-dev.md`)

Release notes on hunk.dev

CHANGELOG.md is the source of truth for what shipped. scripts/generate/generate-changelog.ts projects it
into the pages published at hunk.dev/changelog, the same way scripts/generate/generate-docs.ts projects
runtime metadata into the CLI and config references. Nothing under the generated paths is
hand-edited; bun run check:changelog fails the build when they drift.

Why the site carries release notes

Hunk publishes 40-plus releases at roughly two a week, and before this that content existed only in
CHANGELOG.md and GitHub Releases — about a quarter as much prose as the whole docs site, none of it
on hunk.dev. Three things follow from hosting it here:

  • Release cadence becomes a freshness signal for a site that is otherwise mostly static docs.
  • Answer engines can finally answer version questions. The site already ships llms.txt,
    llms-full.txt, a .md twin of every page, and an explicit crawler allow-list; release history
    was the largest gap in that corpus. Changelog pages join llms-full.txt automatically and are
    excluded from llms-small.txt, which stays scoped to learning Hunk.
  • Release videos get a permanent home. skills/hunk-launch-video renders the real TUI for every
    release; a GitHub attachment URL is unindexable and unembeddable.

One page per minor series

/changelog/0.18/ covers 0.18.0 and every patch after it, rather than one page per version. Half of
all releases are single-bullet patches, and a URL per version would produce mostly thin pages that
rank for nothing. Grouping yields around twenty substantial pages and matches how the question is
asked — what is in 0.18, not what is in 0.18.2.

Dated prereleases publish on the same series page as stable releases. A series that has only reached
beta gets its page, index row, feed item, and social card as soon as the prerelease tag supplies a
date, while an undated prerelease preparation remains invisible. Prereleases never advance the
stable Latest marker, landing-page ribbon, or default install command.

text
/changelog/                 index over every series
/changelog/0.18/            series page
/changelog/0.18/#v0-18-2    stable per-release anchor
/changelog/0.21/#v0-21-0-beta-0 prerelease anchor
/changelog/rss.xml          feed, one stable item per series plus one item per prerelease
/changelog/0.18.md          Markdown twin, via starlight-dot-md

Astro slugifies filenames and would publish 0.18.md at /changelog/018/, so every series page
pins its URL with an explicit slug in frontmatter. Astro also derives #0182 from a version
heading, so each release emits a readable v0-18-2 anchor beside its heading.

Inputs

Path Owner Contents
CHANGELOG.md Changesets Every release, in both the Changesets format and the pre-0.16 Keep a Changelog format. Both are parsed.
website/releases/notes.json Hand-authored Per-series summary, tagline, links, and video. Every field optional.
website/releases/dates.json Generator Version to release date. Committed.
website/releases/latest.json Generator Newest published stable release, imported by the landing-page ribbon.
website/releases/cards.json Generator Content of each social card, read by the card renderer.

The ### Highlights block in CHANGELOG.md is already hand-written for minor releases, so the page
uses it directly: its lead paragraph becomes the page summary and its bullets become the Highlights
section. The overlay only adds what the changelog has no place for.

Dates and the pre-tag window

The Changesets format carries no dates, and the release tag does not exist when
bun run release:version runs — the version-preparation branch is generated, reviewed, and merged
before the tag is pushed. So the site can build from a commit describing a version that nobody can
install yet.

Two rules handle this without a draft state or an extra publication step:

  • A recorded date is never recomputed. Dates live in website/releases/dates.json; only a
    version missing from it reads the annotated tag's publication date, falling back to the tagged
    commit date for a lightweight tag; either lookup fails soft. Generation is therefore deterministic,
    and check:changelog gates CI on Vercel's shallow, tagless
    checkout without touching Git.
  • Publication state is derived from dates, not from position. An undated stable version renders
    as Unreleased; an undated prerelease remains off every surface until its tag exists. Neither can
    advance the index's Latest marker, enter the feed, or contribute an install command. Stable notes
    are accurate when their preparation branch merges, while beta notes appear only once installable.

website/releases/dates.json is rebuilt from the release list rather than merged onto it, so a
version that leaves CHANGELOG.md does not linger in the committed map. Stable and prerelease tag
dates are both retained.

Step 4 of skills/hunk-release/SKILL.md regenerates after every tag, including backports. Every tag
backfills its date and publishes its notes; only the newest stable tag adds the default install command and advances the
landing-page ribbon. RSS keeps one mutable item per stable series and gives every prerelease an
exact-version anchor and GUID, so the later stable release still reaches subscribers as a new item.

Surfaces

The generator emits Markdown only, so the pages stay clean for the .md twin and the agent corpus.
Presentation lives in the site:

  • BrandHeader.astro carries the nav item; DocsHeader.astro derives the active item from the
    route, because Starlight renders the changelog and the docs through the same shell.
  • DocsMarkdownContent.astro marks changelog articles with data-changelog, which is what lets
    starlight.css style the generated structure — an h3 per version, a meta line, h4 change sections
    — as a release list without per-page markup. Starlight renders headings inline inside a wrapper,
    so the rule between releases sits on the generated .release-separator anchor rather than on the
    heading, where a border would span only its text.
  • Generated pages set editUrl: false; an edit link would invite hand-edits that regeneration
    silently overwrites.
  • The landing page imports website/releases/latest.json for its release ribbon, so the current
    version is a static import rather than a build-time parse of generated Markdown.

vercel.json must keep CHANGELOG.md and scripts/generate/generate-changelog.ts in its ignoreCommand
path list, or release commits will not trigger a deploy.

Social cards

Every changelog page carries its own OpenGraph image instead of the site-wide og.png, because a
release announcement is the most-shared page the site has.

bun run generate:changelog derives what belongs on each card into website/releases/cards.json,
and each page's frontmatter head points at /changelog/og/<slug>.png. A beta-only card labels its
metadata as Prerelease instead of receiving the stable Latest pill. Starlight merges page head
entries over the global ones by tag and property, so a changelog page replaces the site-wide image
while ordinary docs pages keep it.

bun run generate:og paints the cards with Chromium at 1200x630 and commits them. Rendering needs a
browser, so it is a maintainer step rather than part of the website build: the changelog generator
records which cards should exist and reports missing images — failing under --check — while the
renderer is what draws them. Point CHROMIUM at a browser binary to use one other than Playwright's
bundled build.

The layout is the site's own paper surface. Two content rules shape it: a series with no editorial
summary drops the tagline rather than padding it with the factual fallback (about half of them have
none), and patch chips appear only when a series has more than one release.

Not built yet

  • Release videos. The video field is implemented end to end, including VideoObject schema,
    but no release has a hosted video URL. Host the encoded output rather than committing it; the
    launch-video pipeline keeps generated media out of Git.
  • Contributor lists. The GitHub release bodies name first-time contributors, which is community
    goodwill and organic links. CHANGELOG.md does not carry authors, so this needs a second input.
  • The in-app update notice. packages/hunk/src/core/process/updateNotice.ts tells users a new version exists without
    linking what changed. Appending hunk.dev/changelog/<minor> is the highest-intent entry point
    available and is tracked separately.

Extension Api Evaluation (`docs/extension-api-evaluation.md`)

Extension API field notes: Review triage

Historical field report (July 2026). This evaluation describes the API when the review-triage
example was introduced. Use the extension guide and
extension architecture for current behavior. Since this report,
command handlers gained review navigation and pane components gained exact host-owned width and
height. Host-managed persistence, richer dialogs/menu layout, and a public pane viewport-scroll
contract remain open design areas.

examples/extensions/review-triage/ is a deliberately ordinary, user-installable extension built only against hunkdiff/extension. It provides a session-local hunk triage board: a reviewer can open a right sidebar, navigate through public hunk summaries, mark the current hunk approved/investigate/blocked with an optional rationale, and clear decisions. Its commands are ordinary Extensions menu entries, while lifecycle and bus events keep the board current.

Building it validated the API's central path: a third-party extension can compose a React sidebar, menu-reachable commands, host-owned modal dialogs, selection snapshots, lifecycle subscriptions, notifications, and a small inter-extension bus without imports from Hunk internals. The PTY integration test loads this exact directory rather than a string fixture.

Findings

Current status: partially resolved. Pane props now expose exact host-owned width and height;
viewport bounds and a host scroll-item capability are still not public.

At the time of this report, the public sidebar props exposed width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal ScrollBoxRenderable viewport events and scrollChildIntoView; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review.

Suggested addition: expose read-only pane viewport geometry plus a narrow actions.scrollItemIntoView(id) capability (or a supported scrollbox ref contract). This would let extensions virtualize and retain selection visibility without exposing OpenTUI internals.

Extensions have no safe, host-managed persistence

The triage board can only be session-local. Extension config is repository-overridable and expressly untrusted for exec-adjacent decisions; using it as writable storage would be wrong. Writing an arbitrary file from the extension is possible but creates incompatible location, lifecycle, privacy, and cleanup policies for every author. Reloading can also change a file id or hunk index, so blindly persisting the current key would misapply decisions.

Suggested addition: a namespaced, user-owned storage API with explicit scopes such as session and local-user/repository, plus a changeset identity available for reconciliation. Hunk should own the file location and trust semantics.

Command handlers could not navigate the review stream

Current status: resolved. ExtensionCommandContext.navigation now exposes guarded
selectFile, selectHunk, and revealLine actions.

At the time of this report, commands received a selection snapshot, dialogs, and sidebar open/close controls, but no selectFile or selectHunk. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead.

Implemented: command context now exposes the guarded navigation methods used by pane actions.

Dialogs are intentionally simple, but triage exposes their limits

The select/input sequence works well for a short status and single-line rationale. There is no structured option value (only displayed strings), validation hook, multiline input, or way to retain a dialog target if the session reloads; reload cancellation is safe and correct, but an extension has to design around it.

Suggested addition: retain the current simple primitives, then consider labelled { value, label } select choices and a multiline input primitive. Dialog requests should still cancel on reload rather than acting on stale review state.

The Extensions menu is command-generated, not extensible layout

Commands make the extension visible in the Extensions menu and are sufficient for this workflow. But they cannot add a custom submenu, separator, checked state, disabled state, or an entry elsewhere in the menu bar. This is an appropriate initial boundary, but it means command titles must carry more UI work than a purpose-built menu model.

Suggested addition: no change is required yet. If richer menu integration is added, model it as declarative command state rather than arbitrary extension renderables in chrome.

Non-gaps confirmed by the extension

  • A React component loaded from disk can render in Hunk's tree and use hooks when it imports the host-served react module.
  • The public hunk summaries and sidebar selection/index contract are sufficient to render and drive a hunk-level board without accessing opaque diff metadata.
  • useSyncExternalStore is a viable bridge from detached lifecycle callbacks to sidebar rendering, including while the sidebar is closed.
  • Host-rendered dialogs provide appropriate attribution and modal behavior; command registrations provide menu and keyboard access through one mechanism.
  • Lifecycle events and the namespaced bus are sufficient for session-local, fire-and-forget coordination, as long as the extension treats them as observers rather than persistence or request/response channels.

Extension Architecture (`docs/extension-architecture.md`)

Extension system architecture

Maintainer-facing map of how the extension system hangs together. The
authoring guide for extension users is docs/extensions.md;
this doc is about Hunk's own internals. Each module named here carries a
header comment with the full local story — read those for depth; this page
exists so you know which module owns what.

Tiers and loading

Extensions come in user-installed and bundled tiers, all running through the same per-extension
API object and registry collection (packages/hunk/src/extensions/runExtension.ts):

  • User extensions load during app bootstrap after initial config resolution and before final
    session bootstrap (packages/hunk/src/app/extensionBootstrap.ts,
    packages/hunk/src/extensions/startup.ts, packages/hunk/src/extensions/host.ts). Discovery
    groups and trust gating live in packages/hunk/src/extensions/discovery.ts and
    packages/hunk/src/extensions/trust.ts.
  • Bundled extensions are compiled into the binary. The private
    packages/hunk-{git,jj,sapling} provider workspaces are statically imported by
    packages/hunk/src/extensions/default/vcs/index.ts; the app composition root
    (app/vcsCatalog.ts) loads them synchronously before config resolution, so backends exist
    without making core import the extension host. default/ui/index.ts is deliberately not part of
    that list: the UI loads its bundled files pane, delegated review-info panes, and content search
    registrations through runExtensionFactory, once per process. The private packages/hunk-gh
    workspace registers hunk gh through default/core/index.ts into each session-owned extension
    registry, so a delegated temporary patch follows the same shutdown lifetime as that session.

Git, built-in file navigation, and change-request or history-commit identity use the public
registerVcsAdapter and registerPane paths. The external Hunk Lens
extension exercises current-line pane paint through that same public contract.

Bundled extensions are implicitly trusted and stay loaded under
--no-extensions, which governs user discovery and loading only. Independently selectable
capabilities also carry a host-owned selection id, separate from their registration namespace;
for example, hunk.gh selects the bundled GitHub factory while its public command registrations
remain under hunk. packages/hunk/src/core/run/extensionSelection.ts resolves user and repo deny-lists
plus ordered CLI overrides without I/O. startup.ts applies that decision to hunk.gh and user
candidates before factory execution, module import, or trust prompting, and includes the selection
snapshot in staged-load compatibility.

An extension id is a file stem the user chose, and it is the namespace that id
owns for commands (<extensionId>.<commandId>), panes
(<extensionId>:<viewId>), and config ([extension.]). host.ts is the
one place those ids are vetted — discovery stays a pure filesystem walk, and
every way an id can be derived arrives there as candidate.id. It refuses
reserved ids (hunk, plus the base catalog's bundled backend ids), ids outside
/^[A-Za-z0-9][A-Za-z0-9_-]*$/ (a dot or colon would make the composed ids
unsplittable), and the later of two sources claiming one id; each refusal is a
load issue and costs only that extension. The rules themselves are stated in
packages/hunk/src/extensions/extensionIds.ts.

Managed Git and npm installation

extensions/manage/source.ts parses Git/local-checkout and npm specifications;
install.ts owns staging, validation, promotion/rollback, and management operations
for both backends. Existing untagged install records normalize as Git records.
Git acquisition retains clone/ref and dependency behavior. manage/npm.ts invokes
npm with scripts and automatic peer installation disabled, leaving registry,
authentication, integrity checks, and dependency resolution to the package manager.
npm locates the launch project's configuration with npm prefix; a temporary staging
.npmrc preserves project/user/environment precedence while install runs from the
launch directory to preserve relative configuration paths. The copy is removed before
validation or promotion, including failed acquisitions, so project credentials are not
persisted in the workspace.
Local-mode and safety policy apply through both CLI flags and case/separator-normalized npm
environment configuration so child npm processes cannot inherit conflicting settings.
A randomized, non-existent script-shell path fails closed if npm attempts a hook
anyway; this covers Git prepare behavior in npm versions that ignore suppression.

Each npm install owns an isolated workspace containing its package and dependencies
in their normal node_modules layout. A generated workspace-root hunk manifest
points to the installed package's validated entries and retains its API requirement,
so discovery.ts needs no registry-specific semantics and never scans dependencies.
The runtime source hook uses Bun's compiler to preserve CommonJS exports as ESM when
helpers require host modules; compiler-generated host require calls become static
virtual imports so their lazy factories settle before helper evaluation. Dependencies
stay external rather than being bundled into a parallel module graph.
manage/workspace.ts recognizes package entries owned by npm records, including
aliased paths; hostRuntimeModules.ts scopes host-import rewriting to their complete
isolated module workspace so hoisted helpers can consume host peers. Neighboring
installs, unrecorded folders, and Git source scopes are not widened.
The single-entry ID derives from a bounded package-name prefix and full SHA-256;
multiple entries follow existing manifest identity/order rules. CLI package aliases
resolve to that storage name; updates persist the requested selector and resolved
version separately. npm acquisition and validation failures clean staging and leave
the previous directory and record untouched.

One registry model, separate composition paths

Registrations (session behavior, themes, file languages, VCS adapters,
changeset transforms, panes, interactive commands, top-level CLI commands,
lifecycle/UI events, and inter-extension bus listeners) collect into an
ExtensionRegistry (packages/hunk/src/extensions/types.ts). Bundled VCS, bundled UI, and user
extensions use separate registry instances and lifecycle owners. app/vcsCatalog.ts composes
bundled VCS registrations directly; ui/lib/extensionPanes.ts reads bundled UI pane registrations
and ui/lib/sessionRegistrations.ts composes bundled UI commands and line highlighters ahead of
the user registry's (so the bundled / search stays active under --no-extensions); and
extensions/apply.ts applies user registrations during session bootstrap and reload.
File-language registrations stay as
declarative extension, filename, or glob selectors until fileLanguageLookup.ts resolves them;
Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting
language. A live reload replaces the compiled selector generation while preparing its changeset
and restores the previous generation if any pre-commit step fails. Staged external-VCS bootstrap
retains the provisional candidate/config snapshot: a final pass that
only appends repo candidates extends the same registry, while a changed prefix
receives bounded shutdown before being rebuilt. Live registry replacement uses
the same shutdown/startup lifecycle. src/extensions/session.ts owns active, provisional, and
retiring registries by identity; it synchronously closes authority at adoption/shutdown and drains
all known bounded retirements. Surfaces borrow that session and cannot retire it independently.
A user or bundled-VCS factory that throws is rolled back to its pre-run registration counts
(runExtension.ts); failures cost a warning, not the session. Bundled UI registration instead
requires every pane its factories declare and throws if Hunk's own invariant fails.

Generic CLI commands deliberately remain separate from the interactive named-command
table. parseCli resolves known built-ins first, preserving static help/version and
headless fast paths. Only an unknown top-level token produces an extension-cli
envelope and enters the extension bootstrap. Enabled bundled core registrations load first even
when user extensions are disabled; optional user discovery follows, and the first registration owns
the raw subtree and runs through leased process I/O. An exit result retires the
registry before returning an exit plan; a one-time built-in delegation reparses
through the ordinary planner. Delegated reviews reconcile the already loaded
candidate/config prefix and hand the same registry to AppBootstrap, so factories
are not rerun merely for the handoff. A delegated patch may attach a validated, bounded
provider-neutral review descriptor. Startup carries it beside the input on AppBootstrap; it does
not enter the changeset transform pipeline or ReviewDocumentV1. The host preserves it only while
the same file-backed patch identity reloads and clears it when a reload selects another input.
Session registration projects the same optional bounded descriptor into list, selected-context, and
review exports through strict app-wire validation. It remains registration metadata rather than a
ReviewDocumentV1 field and does not imply any provider or remote-reload capability. Headless
delegation retires before executing the built-in plan. Terminal probing occurs only after
the handler releases I/O.

Host-served runtime modules

Extension files import react, @opentui/*, and hunkdiff/extension as
host-served runtime modules (packages/hunk/src/extensions/hostRuntimeModules.ts): a
per-extension-directory Bun loader hook transpiles extension source and
rewrites those specifiers to prefixed virtual modules backed by the host's
own instances. That identity is what lets registerPane components
render inside the app's React tree with working hooks. The module header
documents why the obvious alternatives don't work (process-wide specifier
claims break the host's lazy imports; the loaders resolve lazily so headless
commands never pay OpenTUI's native-library extraction).

The rewrite reads transpiled syntax and lexical scopes, so documentation strings,
member calls, and locally bound require functions stay unchanged. Other bare
package references resolve through installedPackageResolution.ts against the
importer's real filesystem directory, including nested/subpath exports and
Bun/Node import-versus-require conditions. Reachable ESM dependency files receive
file-scoped hooks too, including relative helpers outside the entry directory;
unrelated files and native CommonJS packages are not claimed by those hooks.
Resolution failures do not fall back to the compiled loader, which can bypass
blocked exports. Failed dynamic imports and runtime require calls target lazy
failure modules so the extension can catch the error when the operation executes.
The asynchronous source hook still cannot serve synchronous require() of
JavaScript/CommonJS source; this change does not introduce a second evaluator
to work around it.

Four-edge pane system

packages/hunk/src/ui/lib/extensionPanes.ts owns open state, availability, and one rectangle
plan for panes, dividers, and review bounds. Left/right panes consume columns;
top/bottom panes consume rows from the central review column, outside review
stream coordinates. Pane registrations may opt into a body-axis fraction;
the planner resolves it to an integer target before applying bounds and lets a
session-local divider drag override that automatic size.

packages/hunk/src/ui/components/panes/ExtensionPane.tsx mounts panes with guarded actions,
immutable review metadata, and failure containment. The fixed three-row hunk:review-info top
pane uses one border row above two metadata rows and is available for delegated change requests or
commits selected from interactive history, so ordinary reviews spend no geometry on it. DiffPane exposes optional current-line paint — the row
painter plus the public { side, line } address — without publishing Pierre
rows, plans, cursor keys, or caches. Deprecated sidebar APIs
normalize into this same registry and layout path.

File-view system

File-view registrations are selected per file but remain inside the one
host-owned review stream. packages/hunk/src/ui/fileViews/useFileViews.ts bounds asynchronous
extension work and retains only immutable layouts accepted by
packages/hunk/src/ui/fileViews/layout.ts; width and registration identity are part of that
accepted geometry. A stateful view has no such identity to change, so
ctx.fileViews.refresh bumps an invalidation epoch owned by
packages/hunk/src/ui/fileViews/useFilePresentationController.ts and modeled in
packages/hunk/src/ui/fileViews/state.ts. That epoch participates in the same retention key,
re-preparing the files presenting that view while their current rows stay
visible. One map counts both view-wide and per-file invalidation, and
fileViewLayoutEpoch is the single place that composes them into the epoch a
(file, view) preparation is retained under. packages/hunk/src/ui/fileViews/renderPlan.ts is the shared insertion
plan for validated extension rows and host-owned inline notes. It resolves only
unambiguous exact-source bindings and returns an explicit unresolved set, so
DiffPane falls the complete file back to Pierre rather than guessing or
silently dropping review data. packages/hunk/src/ui/fileViews/geometry.ts measures that same
plan, and packages/hunk/src/ui/components/panes/FileView.tsx windows and paints it. Extension
components can paint only their fixed validated rectangles; note cards,
scrolling, hunk bounds, and navigation remain host-owned.

Line-highlight system

Line highlighters mark character ranges inside Hunk's own diff rendering, so
the system is deliberately split between a pull-based preparation half and a
paint-only application half. packages/hunk/src/ui/highlights/useLineHighlights.ts bounds
asynchronous extension work with the same timeout/concurrency discipline as
file views and retains only marks accepted by
packages/hunk/src/ui/highlights/validate.ts; results cache under (file, highlighter, epoch), and each file's merged mark array keeps a stable identity while its
inputs are unchanged so row memoization can hold. The epoch is owned by
packages/hunk/src/ui/highlights/useLineHighlightsController.ts behind
ctx.highlights.refresh, using the shared scoped-epoch policy in
packages/hunk/src/ui/lib/scopedEpochs.ts — the same module packages/hunk/src/ui/fileViews/state.ts
delegates to — and the shared bounded readDocument capability lives in
packages/hunk/src/ui/lib/extensionDocumentReader.ts.

Application is paint-time by construction. packages/hunk/src/ui/diff/lineHighlightPaint.ts
owns the one mapping from source coordinates (raw code-unit offsets) to
terminal columns — sanitize-aware, tab-aware, snapped outward to grapheme
clusters, with context and gap lines sharing one range list under both side
keys — and the one span transform that repaints backgrounds without changing
text. packages/hunk/src/ui/diff/rowStyle.ts resolves tones against the actual line
background with the word-diff minimum-contrast guarantee.
packages/hunk/src/ui/diff/CodeRowView.tsx applies the transform through the cell painter,
which keeps highlights out of buildDiffSectionRowPlan, its caches, and every
geometry measurement: a highlight change is a repaint, never a re-plan.
packages/hunk/src/ui/diff/DiffRowView.tsx remains only the memoized dispatch facade; raw-row
adaptation there supports the public OpenTUI and extension current-line surfaces, while
packages/hunk/src/ui/diff/cursorHighlight.ts owns stable-key cursor matching. The static pager never
runs extension code, so highlights are interactive-only.

Agent attention marks (hunk session highlight add / clear) join this same
pipeline rather than growing a second one: useTerminalReview.ts validates
each daemon-pushed mark with the same validate.ts contract and caps, holds
them per file, and packages/hunk/src/ui/highlights/merge.ts appends them after extension
marks in the one map DiffPane paints from — so agent marks share paint,
contrast, and geometry guarantees, and win where ranges overlap. Unlike
extension marks, nothing re-derives agent marks after a reload, so
packages/hunk/src/ui/highlights/reconcile.ts carries them across a document replacement only
for files whose contentIdentity is unchanged — those still show the same
characters — and drops the rest. Line-target session navigate reuses the same
revealLine landing policy ctx.navigation.revealLine gets.

packages/hunk/src/ui/fileViews/mode.ts owns file-view mode activation, validity, and callback
containment. The presentation controller stores the active mode and funnels all
exit paths through one teardown, including re-entrant handoffs.

Keyboard routing checks file-view modes after focused inputs and before session
keyboard modes and app commands. "handled" and "exit" consume the key;
"pass" continues normal routing. Escape remains host-owned.

Session-wide modes registered through registerKeyboardMode are resolved with
the same extension ownership and first-registration rules as other surfaces.
packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts owns the one active session
mode, with eager ref state for input chunks, registry-generation authority,
contained synchronous lifecycle callbacks, and one teardown used by Escape,
status, menu, reload, and unmount. Mode controls are activation-scoped;
onEnter and onExit cannot change ownership, while onKey may deliberately
replace its activation without letting the outgoing callback defeat recovery or
manipulate the replacement.
packages/hunk/src/ui/lib/extensionKeyEvent.ts freezes the method-free public key snapshot
used by both session and file-view mode delivery, so OpenTUI events and their
consumption methods never cross the extension boundary. Their shared
packages/hunk/src/ui/lib/synchronousExtensionCallback.ts path contains lifecycle failures,
rejects thenables without leaving unhandled rejections, and normalizes key
results; each mode module supplies only its context and attributed warnings. A
focused file-view mode may overlap and temporarily outrank a session mode;
leaving it resumes the session mode rather than destroying unrelated state.

Command system

Every app-level keyboard shortcut is a named command in one dispatch table
(packages/hunk/src/ui/lib/appCommands.ts), each id under Hunk's reserved vendor namespace
(hunk.app.quit, hunk.review.nextHunk) — which is what keeps built-in ids
and extension-owned ids in disjoint spaces however either grows; modal surfaces
(dialogs, menus, focused inputs) own their keys first and are deliberately not
commands. Extension
registerCommand entries join the same table via
packages/hunk/src/ui/lib/extensionCommands.ts — built-ins win key conflicts, refused one
chord at a time and detected by probing matchers with a synthesized event
(packages/hunk/src/lib/commandKeys.ts). Command handlers receive pane controls and a selection snapshot from
packages/hunk/src/ui/lib/extensionSelection.ts, derived from the same frozen file views the
panes render plus a copied source address for the active current-line cursor.
App reads it through a ref so the dispatch table stays stable while line
navigation moves. ctx.review.snapshot() takes the complementary whole-review
path: packages/hunk/src/extensions/reviewSnapshot.ts copies the active shared ReviewStore's
document identities and complete saved-note collections, preserving core-owned
anchors and reconciliation verdicts. App pairs that state with the producer's
current generation under the same review capability lease, so retained controls
return null after reload instead of reading replacement content. The extension
projection is registered in test/review-conformance/ as a real semantic
consumer rather than rebuilding note placement in the command host.

packages/hunk/src/ui/lib/extensionNavigation.ts mints the guarded navigation behind both
ctx.navigation and a pane's actions, so a jump from either surface is
validated, attributed, and reported the same way. It owns argument policy only
— visible-file validation, hunk clamping, revealLine's side and line-number
checks — and delegates the move itself to the terminal review adapter. Where a
jump puts a line on screen stays host policy: useTerminalReview tags each
current-line reveal with a placement, and DiffPane reads it to choose between
stepping's minimum-distance scroll and the top-padded position hunk, note, and
revealLine reveals share. Extensions name a target; they never name a scroll
position.

After any named command dispatches in the terminal host, App emits
command_executed with its stable id. This observes the accepted action after
synchronous AppCommand.run, not settlement of detached extension promises.
The event decorates the assembled terminal command table, so
keyboard dispatch, menus, and extension command controls share one observation
path. Browser/session review actions lower through ReviewIntent instead; they
are semantic effects rather than terminal command invocations. Widget-owned
modal keys also remain outside the table and therefore outside the event.

ctx.dialogs is the one place extension code can interrupt the user, so its
ordering and settlement live outside React in
packages/hunk/src/ui/lib/extensionDialogs.ts — one FIFO queue per App instance, minting a
per-extension dialogs object, normalizing (and sanitizing) extension-authored
text into a request the host draws, and answering by request id so a duplicated
Enter cannot spill onto whatever was queued behind. App subscribes with
useSyncExternalStore, renders the current request through
packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx (confirm reuses ConfirmDialog;
select and input are ModalFrame surfaces), and unmount calls shutdown() so
every pending and queued dialog resolves its cancel value instead of leaving a
handler awaiting forever. Key precedence in useAppKeyboardShortcuts places
dialogs below Hunk's own app-critical prompts (repo trust, save-on-quit) and
above menus, help, the theme selector, focused inputs, file-view modes, session
keyboard modes, and the command table: an extension may
interrupt review navigation, never a decision about the session itself. The
frame carries an ext attribution row — the toast marker — for every
user-installed extension, because its title is extension-authored and a prompt
must not be able to impersonate Hunk. The host derives the extension's trusted
bundled origin from registry metadata and omits the redundant marker only for
Hunk-owned bundled UI. packages/hunk/src/ui/lib/modalGeometry.ts clamps the frame before
extension text is wrapped or windowed, so measurement and rendering use the
same terminal width; body/options yield rows to a pinned mouse-clickable action
footer on short terminals.

The bottom status row is the same kind of host-owned surface with the same
lifetimes. packages/hunk/src/ui/statusLine/ holds a renderer-free store
(store.ts: insertion-ordered items plus a FIFO prompt queue that settles like
the dialog queue), a deterministic width/priority layout (layout.ts), one
StatusLine component that owns the focused input and the badge's
click-to-exit, and extensionControls.ts, which mints the per-extension
statusLine and prompts objects, namespaces item ids under the extension, and
validates extension-authored items and options. App and LogApp each mount
one store: the file filter and hunk log search are its first consumers, so the
host and extensions share one prompt path and one overflow policy. A prompt is a
host focused input and routes with the filter, ahead of file-view and session
modes; items survive content reloads and clear with the registry.

Lifecycle and bus handlers receive that same attributed dialog queue plus the
same guarded live navigation commands use. They can also request a current-input soft reload;
the current-review controller registers the latest reloadable descriptor, then AppHost resolves
that descriptor at queue execution and coalesces extension requests while serializing them with
manual, watch, workspace, and daemon reloads. App installs these controls through the
per-extension event-context provider, while AppHost keeps the content/broker/React commit gate
and asks the owning ExtensionSession to adopt only after those facts agree. AppHost publishes mounted
lifecycle order (startup, then changeset_loaded; reloads add
session_reload) only after the matching child commit. Headless or pre-mount delivery resolves
dialogs to their cancel values and refuses navigation with a warning.
packages/hunk/src/ui/lib/extensionCapabilityLease.ts binds retained pane, navigation,
dialog, review-reload, and workspace controls to one App, extension registry, and review
generation. Soft
reload or registry retirement therefore makes old host-mediated capabilities
inert before shutdown begins. Session behavior requests are registry data too:
resolveExtensionSessionOptions applies the shared policy that any
configureSession({ viewPreferences: "transient" }) request makes practice and
presentation view changes ephemeral without teaching App about an extension
id.

packages/hunk/src/ui/lib/extensionWorkspace.ts owns the policy for ctx.workspace. Reads
resolve reviewed file ids through the existing source fetcher, which retains
ownership of caching and size limits. Missing or unreadable sources become
null.

Writes are limited to reloadable working-tree reviews and reviewed paths inside
the review root. App supplies the current input, unfiltered changeset, and root
through refs so soft reloads update the policy inputs. The host verifies the
filesystem target before and after consent, writes it, then calls
refreshCurrentInput. Consent uses the existing extension-dialog queue.

Commands declare chords, not matchers: packages/hunk/src/ui/lib/keymap.ts folds every
command's defaultKeys against the user's [keybindings] table (user config
layer only) into one id-to-chords answer, from which matchers, key labels, and
conflict probes are all derived — a user-bound chord is exclusive, so whatever
held it by default gives it up. The chord grammar itself lives in
packages/hunk/src/extension-api/keys.ts because it is published as hunkdiff/extension
(matchesKey, parseKeyChord, matchesKeyChord) for extension components
that need internal keys; packages/hunk/src/lib/commandKeys.ts re-exports it inward and
keeps the host-only pieces.

The table is also the only description of what each action is called and which
key runs it, so the mouse surfaces read from it rather than restating it: the
dropdown menus (packages/hunk/src/ui/lib/appMenus.ts) declare items as command ids plus
menu-specific wording and checkbox state, and the controls help dialog
(packages/hunk/src/ui/lib/helpContent.ts) declares curated rows the same way — both render
their key text from resolved keyLabels and run entries through
executeAppCommand. A few commands ship with defaultKeys: [] because they
exist for a menu item; they never match a key but remain bindable by id.

Command handlers receive guarded ctx.commands controls built by
packages/hunk/src/ui/lib/extensionCommandControls.ts. They resolve the live App command table on every call,
then expose only built-ins carrying explicit public metadata. Counted movement reaches the same
command callback once with a normalized delta; it is never implemented as repeated synchronous
dispatch. Current-line alignment is also semantic: App raises an alignment request and DiffPane
resolves it against its private row geometry, so no renderer or scrollbox leaks into the API.
Extension commands remain private to prevent recursion and cross-extension execution.

The Extensions menu is generated from the registered extension commands, one
item per command grouped by extension, and is absent entirely when there are
none — which is why the visible menu list is derived from the menus record
(buildMenuSpecs in packages/hunk/src/ui/components/chrome/menu.ts) rather than fixed.

VCS adapters

packages/hunk/src/core/vcs/index.ts owns provider-neutral catalog ordering, lookup,
detection, and operation dispatch. packages/hunk/src/app/vcsCatalog.ts composes bundled
registrations, while packages/hunk/src/app/sessionBootstrap.ts extends that catalog with
accepted user adapters and threads the same value through loading, reload, and
watch. Detection is uniform across tiers: nearest checkout wins, priority breaks
equal-distance ties, and an explicit vcs id owned by the catalog wins.

Provider implementations — command construction, spawning, error translation,
and exact-source reading — live entirely in the private packages/hunk-{git,jj,sapling}
workspaces and import the public hunkdiff/extension contract plus only explicit
provider-neutral @hunk/vcs/* implementation subpaths.
packages/hunk/src/extensions/vcsPatchResult.ts is
the one conversion boundary where a published ExtensionVcsPatchResult
becomes Hunk's internal diff model, including structural too-large source
results. packages/hunk/src/core/process/projectRoot.ts treats .hunk as a provider-independent
bootstrap marker and also consults the available catalog; startup performs a
second root/config pass when a global, config-path, or CLI adapter recognizes a
repository unavailable to the bundled catalog.

hunk log follows the same boundary. Core/app and src/ui/history/ own the built-in command,
validated graph planning, presentation, themes, paging, and child-process orchestration. The shared
src/ui/session/ runner owns the process-level renderer/root lifetime, while its closed host routes
retained history and fresh review surfaces without giving either surface terminal ownership. The
selected adapter's public history capability owns traversal, filtering,
immutable revision and parent identities, structured decorations, and the declarative review action
for a selected item. The host treats those ids as opaque and never constructs provider revision
syntax or decides root/merge comparison semantics. History pages remain child-before-parent across
the full cursor, including page boundaries; the extension conversion boundary validates that
ordering before core or UI consumes it.

Public contract rules

The authoring surface is the hunkdiff/extension export — a façade over
internal types, declared in packages/hunk/src/extension-api/types.ts. That module must
stay import-free: declaration emission ships every module the entry reaches,
so an import there publishes Hunk internals (scripts/packaging/check-pack.ts fails
the pack when it does, and typechecks every docs/extensions.md example as
a consumer). Shapes shared with internal code are declared there and
re-exported inward.


Extension System Exploration (`docs/extension-system-exploration.md`)

Hunk extensions: an exploration

Historical design note (July 2026). This proposal preceded Hunk's implemented extension
system. See the extension guide and
extension architecture for the current contract. The implementation
now includes trust-gated TypeScript loading, named commands, panes, file views, VCS adapters,
review controls, and API versioning.

The proposal modeled a JavaScript/TypeScript extension system on
pi (@earendil-works/pi) and Hunk's architecture.

1. How pi does it

pi is a terminal coding agent whose stated philosophy is "adapt pi to your
workflow without forking it". Its extension system is the main mechanism for
that, and it has produced a real ecosystem (LSP integration, plan mode,
subagents, usage dashboards, statuslines, git-worktree management, browser
automation, even minigames that run while tests execute).

The mechanics that matter for us:

  • An extension is one TS file (or folder) exporting a default factory:

    ts
    export default function (pi: ExtensionAPI) {
      pi.registerCommand("recap", { run: async (ctx) => { ... } });
      pi.registerShortcut("ctrl+g", { run: ... });
      pi.on("turn_end", async (event, ctx) => { ... });
    }

    No manifest, no build step. The runtime executes TS directly.

  • Auto-discovery from trusted locations: ~/.pi/agent/extensions/*.ts
    (global) and .pi/extensions/*.ts (project-local, loaded only after the
    project is explicitly trusted). Additional sources via settings:
    "packages": ["npm:@foo/[email protected]", "git:github.com/user/repo@v1"], plus a
    -e ./path.ts flag for development and /reload for hot reload.

  • One capability-granting API object, not global hooks. Everything an
    extension can do goes through ExtensionAPI / ExtensionContext:
    registration (registerCommand, registerShortcut, registerTool,
    registerFlag, renderers), lifecycle + pipeline events (session_start,
    turn_start/end, tool_call with block/mutate power, context filtering,
    input interception), UI services (ctx.ui.select/confirm/input/notify,
    status bar slots, widgets, custom footers, autocomplete providers), and a
    shared event bus for extension-to-extension communication.

  • Events are interception points, not just notifications. tool_call can
    block or rewrite a tool invocation; message_end can replace a message;
    input can consume user input entirely. This is what lets extensions change
    behavior, not just decorate it.

  • Packages bundle extensions + skills + prompts + themes and are installed
    with pi install. Notably, Hunk already participates in this ecosystem from
    the other side: our package.json declares "pi": { "skills": ["./skills"] }
    so pi users get the hunk-review skill automatically.

2. Why this fits Hunk

Hunk's positioning — "modern desktop diff tool in a terminal", review-first,
agent-adjacent — means users live in it during review and want it wired into
their workflow: their editor, their forge, their VCS, their team's review
conventions, their agent. We cannot build all of that in core, and shouldn't.
The pi lesson is that a small, well-chosen API surface plus "coding agents make
writing plugins cheap" yields an ecosystem: most pi extensions are clearly
agent-written from a README and an afternoon.

The same force works for us twice over: agents can write Hunk extensions, and
Hunk is used to review agent changesets, so extensions that connect review to
agent workflows (post a comment back to the agent session, re-run the agent on
a rejected hunk) are the most natural third-party contributions imaginable.

3. Example extensions (use-case catalog)

Roughly ordered from "certain someone builds this in week one" to speculative:

  • Open-in-X: a key that opens the selected hunk in $EDITOR, VS Code,
    IntelliJ, or a browser at the forge URL. (Core has one editor action today;
    everyone's variant differs.)
  • Copy-as: copy the selected hunk as a fenced patch, a GitHub-suggestion
    block, or a permalink, ready to paste into a PR comment or Slack.
  • Forge review bridge: publish accumulated review notes as a GitHub /
    GitLab / Gerrit review from inside Hunk; show existing PR review threads as
    notes beside the hunks they target.
  • Send-to-agent: a shortcut that packages the selected hunk plus your draft
    note and sends it to a running pi / Claude Code session ("fix this"), using
    the daemon the same way hunk session comment does — in reverse.
  • Changeset transforms: auto-collapse lockfiles and generated files,
    reorder files by review priority, hide vendored dirs — per-team policy as a
    10-line extension instead of N config flags.
  • Note templates / conventional comments: nit:, blocking:, praise:
    prefixes with severity colors; team review checklists rendered as a note.
  • Extra VCS backends: Mercurial, Fossil, Perforce — the VcsAdapter
    interface already exists; an extension just registers another one.
  • Context enrichment: show git blame age, CODEOWNERS, CI status, or the
    Linear ticket for the branch in the file header; LSP hover for the symbol
    under the selected line.
  • Explain-this-hunk: call the user's own model API key to annotate a hunk
    on demand, rendering the answer as an agent note (STML makes these render
    well).
  • Theme + language packs: ship N custom themes and file-extension →
    language mappings as an installable package.
  • Review metrics: time-per-file, notes-per-changeset dashboards; export a
    review session as a markdown report.

4. What Hunk already has (integration-surface audit)

An audit of the codebase found no intentional plugin mechanism, but several
seams in very different states of readiness:

Nearly plugin-shaped already:

  • vcsAdapters (packages/hunk/src/core/vcs/index.ts) — a real adapter pattern: detection,
    per-operation handler maps, capability probing. Making the array appendable
    is almost the whole job for third-party VCS support.
  • The session broker (packages/session-broker*) is generic over session
    info/state/message types by design, and the app installs its command
    dispatcher at runtime via createHunkSessionBridge(handlers) +
    hostClient.setBridge(...) (packages/hunk/src/app/session/bridge.ts). That bridge is
    the single clearest injection point for extension-provided session commands.
  • Dynamic theme registration exists (ensureSyntaxHighlightThemeRegistered →
    Pierre's registerCustomTheme), but the config layer caps custom themes at
    one "custom" slot.
  • Pierre's setCustomExtension (packages/hunk/src/core/changeset/fileLanguage.ts) is already used at
    import time for .mts/.cts; extending it to plugin-declared mappings is
    trivial.
  • StartupDeps (packages/hunk/src/app/startup.ts) fully injects the startup pipeline
    (currently only tests use it), and unknown TOML keys are silently ignored, so
    an [extensions] config section is backward-compatible on day one.

The gaps:

  • No action registry. Keyboard handling is a 650-line if/else ladder over
    key.name (useAppKeyboardShortcuts.ts); the same ~30 actions are
    re-enumerated as callback props there, in buildAppMenus, and in a
    hardcoded HelpDialog sections array. Extensions can't contribute a command
    or a keybinding because core has no named-command concept to contribute to.
  • Generic CLI dispatch now has an extension fallback. Built-ins still win
    through parseCli's closed fast path, while an unknown top-level token loads
    enabled/trusted extensions and resolves registerCliCommand registrations.
    Handlers own raw nested args and leased I/O, and may exit or delegate once to
    a built-in plan without rerunning an unchanged factory prefix. Bare help stays
    static; unloaded command discovery remains a future manifest/listing problem.
  • Session actions take five files in lockstep (protocol.ts union +
    version bump, brokerServer.ts switch + supported-actions list, bridge.ts,
    session cli.ts, core/cli.ts parser). A registry keyed by action name
    would collapse that — and is a prerequisite for extension session commands.
  • MenuId is a closed union, though menu entries are already data-shaped
    ({label, hint, checked, action}), so contribution points are cheap once
    actions exist.
  • No dynamic user-code loading anywhere, and Hunk ships as a compiled Bun
    binary — see the open question below.

5. Proposed shape

Mirror pi's model closely; it is proven and our users overlap with pi's.

Extension form and discovery

ts
// ~/.config/hunk/extensions/copy-as-suggestion.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerAction({
    id: "copy-as-suggestion",
    label: "Copy as GitHub suggestion",
    keys: ["ctrl+shift+c"],
    menu: "file",
    when: (ctx) => ctx.selection.hunk !== null,
    run: async (ctx) => {
      const patch = ctx.selection.hunkPatchText();
      await ctx.clipboard.write("```suggestion\n" + patch + "\n```");
      ctx.ui.notify("Copied as suggestion");
    },
  });
}
  • Discovery: ~/.config/hunk/extensions/*.ts and */index.ts (global, follows
    our existing XDG path logic in packages/hunk/src/core/run/paths.ts), .hunk/extensions/
    (repo-local, trust-gated, same posture as pi's project trust), explicit
    [extensions] paths = [...] in config.toml, and a --extension
    dev flag. --no-extensions for a clean run and for bug triage.
  • Per-extension config lives in the extension's own TOML table
    ([extension.copy-as-suggestion]), read through the existing layering engine
    so repo config can override user config exactly like core options.

API surface, grouped by the seam it rides on

Capability Backed by Refactor needed
registerAction({id, label, keys, menu, when, run}) new action registry feeding keyboard hook, buildAppMenus, HelpDialog the big one — see below
on(event, handler) — startup, changeset_loaded, selection_changed, note_added, note_saved, session_reload, shutdown emit from AppHost / useReviewController mutators small
transformChangeset(fn) run after loadAppBootstrap, before first render, in AppHost.reloadSession too small; must preserve sidecar ordering contract
registerVcsAdapter(adapter) vcsAdapters array trivial
registerTheme(theme) generalize the single "custom" slot to N; Shiki side already dynamic small
registerFileLanguage(ext, lang) Pierre setCustomExtension trivial
registerSessionCommand(name, parse, handler) bridge dispatcher + broker action registry medium; collapses the 5-file lockstep first
registerCliCommand(...) command table replacing parseCli switch + help generation medium; defer
ctx.review (read + navigate: files, selection, notes, navigateTo) thin façade over ReviewController small, but must stay a façade — never hand out the raw controller
ctx.ui.confirm/select/input/notify reuse ConfirmDialog + a toast/status line small
ctx.exec, ctx.clipboard, ctx.cwd, ctx.changeset plumbing small

Two pi ideas worth copying verbatim: the ctx object passed to every
handler
(capability access is explicit and testable, and there's no global to
monkey-patch), and events that can intercept, not just observe — e.g. a
note_saving event that can rewrite a note body is what makes conventional-
comment templates possible without core knowing about them.

Two pi ideas to skip for now: provider/model registration (not our domain)
and custom message renderers (our equivalent — custom note renderables — fights
the STML deterministic-layout rule; extensions should emit STML markup instead,
which keeps measurement exact and theme-independent by construction).

The enabling refactor: a named-action registry

Every UI-facing capability above lands on the same prerequisite: core actions
become data ({id, label, hint, keys, menuId, isAvailable, run}) in one
registry that useAppKeyboardShortcuts, buildAppMenus, and HelpDialog all
derive from. This is worth doing even if extensions never ship — it deletes the
existing triplication and the 650-line key ladder while giving each user-visible behavior one
owner. The same structure can support user keybinding remapping.

6. Suggested phasing

  1. Phase 0 — loader spike. Prove that a compiled bun build --compile
    binary can import() user TS from disk (see open questions). Build the
    extension host: discovery, trust prompt for repo-local, error isolation
    (one broken extension logs a startup notice — the existing
    startupNotices channel — and is skipped, never crashes review),
    --no-extensions.
  2. Phase 1 — cheap registrations. Themes (N custom), file languages, VCS
    adapters, transformChangeset, lifecycle events, ctx.ui.notify. No UI
    contribution yet; already enough for theme packs, lockfile-collapsing, and
    a Mercurial adapter.
  3. Phase 2 — the action registry. Refactor core actions, then open
    registerAction with keys + menu + help integration, ctx.review façade,
    ctx.ui prompts. This unlocks the whole open-in-X / copy-as / send-to-agent
    family — the highest-demand tier.
  4. Phase 3 — protocol surfaces. Session-command registry (after collapsing
    the 5-file lockstep), then CLI command table. Extension session commands
    need a capability story in the daemon handshake so hunk session <ext-cmd>
    fails cleanly against a session that lacks the extension.
  5. Phase 4 — distribution. hunk install npm:... package support,
    hot-reload for extension development, and a docs page whose real audience is
    coding agents (pi ships an extending-pi guidance extension; our equivalent
    is a hunk-extension-authoring skill — same trick we already use for
    hunk-review).

7. Open questions

  • Compiled-binary loading. The installed hunk is a bun build --compile
    snapshot. Runtime import() of arbitrary on-disk TS from a compiled binary
    is the load-bearing assumption behind "one TS file, no build step" — it must
    be spiked first. Fallbacks if it fails: transpile with Bun.Transpiler and
    evaluate, or require plain .js/.mjs for the binary while bun run keeps
    full TS. This decides Phase 0.
  • Security posture. Extensions run with full user permissions (same as pi;
    same as any dotfile). The trust gate for repo-local extensions is the
    critical piece — reviewing a hostile repo's changeset must never execute
    that repo's code without an explicit prompt. This matters more for Hunk
    than for pi: pointing a diff tool at untrusted code is a core use case.
  • API stability. pi treats extension API churn as acceptable pre-1.0. We
    should version the API (hunk.apiVersion) from day one, and keep the
    surface minimal — everything in the table above is a façade we control, not
    a leaked internal type. Pierre types (FileDiffMetadata) should not appear
    in the extension API.
  • Windows. Discovery paths and ctx.exec must stay portable per our
    cross-platform rules; nothing here is inherently Unix-only.
  • Does the OpenTUI embedding API make some of this moot? hunkdiff/opentui
    already offers "extension by embedding" for people building their own review
    UIs. The answer is no — embedding serves builders of other tools;
    extensions serve users of Hunk. But the two should share the same façade
    types where they overlap.

8. Bottom line

The pi model transplants well. Hunk's architecture is closer to extension-ready
than expected in three places (VCS adapters, the session bridge, theme
registration) and blocked in one structural place (no named-action registry —
a refactor that is independently justified). The recommended path is the
phasing above, with the compiled-binary loader spike as the gating first step.


Extensions (`docs/extensions.md`)

Writing Hunk extensions

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.

ts
// ~/.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/extension surface 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, and hunk.apiVersion identifies
the surface an extension was written against.

Writing one with a coding agent? hunk skill path hunk-extensions prints a
bundled skill that maps the touchpoints below for agents, the way
hunk skill path does for reviewing.

Where Hunk looks for extensions

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 (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:

text
~/.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 import them from the extension. This also works in the compiled Hunk binary;
package lookup follows the importing file and honors package export conditions
and subpaths, including transitive ES module dependencies outside the entry's
source directory. Optional dynamic imports can still catch missing-package or
blocked-export errors at execution time.

Use ES module imports (import ... from "package" or await import("package"))
for source dependencies. Synchronous require() of JavaScript/CommonJS files
handled by Hunk's asynchronous source-loading hook is not supported. Resolving
the correct require export does not remove that existing Bun limitation.

The hunk field may also state the minimum extension API version the folder
needs:

json
{
  "name": "my-ext",
  "version": "1.0.0",
  "description": "What the extension does",
  "hunk": { "extensions": ["./src/index.ts"], "apiVersion": 3 }
}

A Hunk whose extension API is older than apiVersion refuses the folder with a
startup notice naming the version it would need, instead of failing somewhere
inside the factory with whatever error the missing surface happens to produce.
Omit it while you only use surface that has been around a while; declare it when
you depend on something recent (the current version is exported as
HUNK_EXTENSION_API_VERSION from hunkdiff/extension and handed to factories
as hunk.apiVersion). The standard name, version, and description fields
are how tooling and humans identify a shared extension, so fill them in on
anything you publish.

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.] 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
.<commandId> and its panes :<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. To disable one extension
instead, use its exact selection id in [extensions] disabled or pass
--disable-extension ; --enable-extension is the explicit one-run
override for a configured disable.

--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.

Sharing and installing extensions

Extensions can be shared as Git repositories or npm packages.
hunk extension install installs either source into a managed directory under
~/.config/hunk/extensions/installed/, validates its extension entries, and
records the requested source plus its resolved Git commit or npm version.
Git and local-checkout sources keep the repository name as their install directory:

bash
hunk extension install acme/hunk-word-diff          # GitHub shorthand
hunk extension install acme/[email protected]   # pin a tag, branch, or commit
hunk extension install git:codeberg.org/acme/ext    # any host; https:// is assumed
hunk extension install https://github.com/acme/hunk-word-diff.git
hunk extension install ~/dev/hunk-word-diff         # a local checkout, for testing
hunk extension install npm:@acme/hunk-word-diff     # npm latest
hunk extension install npm:@acme/[email protected]
hunk extension install 'npm:@acme/hunk-word-diff@^1.2.0'
hunk extension install npm:@acme/hunk-word-diff@next

Managed installs load through the global source group — same origin, same
precedence, no trust prompt — because installing one is the explicit consent:
the install asks for confirmation (or --yes) after stating that extensions
run with your full user permissions. Only install sources you trust.

npm installs require Node.js and npm on PATH and honor normal npm registry and
credential configuration, including the launch project's .npmrc, user/global
configuration, and environment overrides. Project credentials are forwarded only
during acquisition, not saved into the managed install. Hunk forces local managed
installation even if npm is
configured globally. Each package has an isolated dependency workspace, and its
hoisted helpers receive the same host APIs and React instance as the entry.
Installation disables lifecycle scripts and automatic peer installation; publish
ready-to-load entries, and declare host-provided packages as peers or development
dependencies rather than runtime dependencies. Packages requiring install-time
builds or native dependency scripts are not supported automatically. If npm tries
to run a hook despite script suppression (including Git dependency prepare
hooks in some npm versions), Hunk blocks the script and fails the staged install
without replacing the previous version.

hunk extension list shows every managed install with its resolved version or
commit and requested source. hunk extension update [name] refreshes one install
(or all of them) from its recorded source. Git @ref pins keep their ref;
exact npm versions stay pinned, while ranges and tags are resolved again.
hunk extension remove <name> deletes the install and its record.
For npm packages, use the name shown by list, such as
hunk extension update npm:@acme/hunk-word-diff or
hunk extension remove npm:@acme/hunk-word-diff.

npm directories and single-entry extension IDs use a stable npm- name containing
a readable package-name prefix and its SHA-256 digest. This distinguishes scopes
and punctuation, stays filesystem-safe, and does not change with the version.
Use that ID in [extension.] configuration; multi-entry manifests retain the
existing entry-derived IDs. Failed acquisition or validation leaves the previous
install intact. Managed installs never collide with
extensions you copied into ~/.config/hunk/extensions/ by hand, and the
installer refuses to overwrite an unmanaged directory of the same name.

Publishing an extension

A publishable extension repository is just the folder-extension layout at the
repository root:

text
hunk-word-diff/
  package.json          # name, version, description, hunk field
  index.ts              # or entries declared in "hunk": {"extensions": [...]}
  README.md

To publish one:

  1. Give package.json a real name, version, and description, declare
    entries under the hunk field, and state "hunk": {"apiVersion": N} if you
    rely on recent API surface (see the manifest).
  2. Keep it dependency-light. Declared dependencies are installed with
    bun install at install time when the user has bun on PATH; without it
    they get a warning and instructions. react, @opentui/*, and
    hunkdiff/extension come from the host at runtime and belong in
    devDependencies (types only), never dependencies.
  3. Tag releases (v1.2.0) so users can pin with @v1.2.0 instead of tracking
    your default branch.
  4. Push the repository to any git host and add the hunk-extension GitHub
    topic so people can find it: every public repository with that topic shows
    up at https://github.com/topics/hunk-extension. Opening a pull request
    against website/src/data/extensions.ts also lists it on
    https://hunk.dev/extensions.

For npm publishing, the package must explicitly declare hunk.extensions.
Every declared entry must be a shipped file inside the package; check npm pack --dry-run to verify the tarball includes the entries and their local imports.
Publish with npm publish; users can then install npm:.

Before publishing, exercise the exact layout users will install:
hunk extension install /path/to/your/checkout installs from a local
repository, and hunk diff --extension /path/to/your/checkout loads it for one
run without installing anything.

Bundled extensions

Every VCS backend Hunk ships — Git, Jujutsu, and Sapling — is an extension,
and so are hunk gh GitHub review commands, the built-in file-navigation pane, the commit
and change-request info panes, and the / content search (hunk.search.find / next /
previous, with its match marks and status-row report). Provider implementations live in the private
packages/hunk-{git,jj,sapling} workspaces and are statically imported by
packages/hunk/src/extensions/default/vcs/index.ts. The GitHub command implementation lives in
packages/hunk-gh and loads through packages/hunk/src/extensions/default/core/; bundled UI
registrations live under packages/hunk/src/extensions/default/ui/. All register through the same
hunk.registerVcsAdapter, hunk.registerCliCommand, hunk.registerPane,
hunk.registerCommand, and hunk.registerLineHighlighter contract documented here, and their commands, highlighters, and
panes are composed ahead of yours; there is no private registration path.

Git exercises exact file sources, skipped-too-large placeholders, untracked files, watch plans,
and structured failures through the public adapter contract. Its package and boundary tests keep
those capabilities on the same registration path available to third-party adapters.

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.] config table.
  • They stay loaded under --no-extensions and [extensions] enabled = false.
    Those switches exist to triage extensions you installed. Independently selectable bundled
    capabilities use host-owned selection ids; the GitHub command extension is hunk.gh.

A bundled VCS factory failure becomes a load issue rather than crashing the session. Bundled UI
panes are required host code, so failure to register the expected panes aborts startup. The ids
git, jj, and sl are reserved as a result — see registerVcsAdapter below — and so is hunk,
the id the bundled files pane, the bundled search, and every built-in command are named under.
Process-cached bundled UI factories run with no config, so an interactive bundled command derives
session state from its context rather than closing over a review. Bundled core CLI factories instead
join the session-owned registry because delegated commands may retain resources until shutdown.

Trust

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:

text
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.

Failure isolation

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. For reviewed files, prefer ctx.workspace. It attributes
writes to the extension and asks for consent.

The API

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. Keep the factory registration-only:
start watchers, processes, connections, and other long-lived resources from
startup, and release them from shutdown. Extension-registry reloads create
new instances and run that shutdown/startup pair around the replacement.

One host-owned ExtensionSession holds active, provisional, and retiring registries for a
command lifetime. It revokes replaced authority at the review commit gate, drains bounded shutdown
handlers before terminal teardown, and prevents surfaces from independently replacing or retiring
the shared registry.

An interactive history workspace owns one extension instance for its complete
lifetime. Opening a commit review inside that workspace borrows the same
instance: the factory and startup do not run again, and returning to history
does not send shutdown. Review-specific changeset_loaded events still run
for each opened commit. The owning history workspace sends the one eventual
shutdown when it exits. This makes module-local clients and stores safe to
share deliberately between retained history and its commit reviews without a
review closing resources that history still uses.

Keep mutable review-generation data keyed by the identities in event payloads
or replace it on changeset_loaded; module scope is workspace state, not a
fresh namespace per opened commit. An embedded review cannot replace its
borrowed registry; extension replacement remains an operation of the owning
workspace. A true owner-driven extension-registry reload creates a new instance
and retires the replaced instance at that explicit ownership boundary.

`hunk.apiVersion`

The API generation this Hunk speaks (currently 31). Branch on it if you want
one file to support several Hunk versions. Version 31 adds optional read-only workspace status,
sibling inspection and status review planning; version 30 adds the opaque current review generation
in pane props; version 29 adds generation-scoped extension review presentation scopes; version 28
adds host-owned syntax highlighting for file-view code documents;
version 27 adds ctx.selection.files, the visible files in review order;
version 26 adds the status line (ctx.statusLine items and ctx.prompts.line() inline prompts);
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 rangeEndpoints
to two-revision VCS diff requests; version 13 added saved-note parent identities and
committed note-edit events; version 12 adds 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 event 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.

`hunk.registerCliCommand(command, handler)`

Register a generic top-level command tree. The name must use lowercase kebab
case, cannot replace a built-in command or alias, and is global across loaded
extensions. Discovery order decides collisions: explicit flag, user-config path,
global/managed, then trusted repo extensions; the first claim wins.

ts
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", code: 0 };
    }

    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,
      },
    };
  },
);

The handler receives the frozen raw tokens below its top-level name plus
ctx.cwd, cooperative ctx.signal, byte-streaming ctx.stdin, and leased,
backpressure-aware ctx.stdout/ctx.stderr. It may use ordinary JavaScript APIs
to access networks, processes, services, and files. Return { kind: "exit", code? } with a status from 0 through 255, or delegate exactly once to a
built-in Hunk command.

A delegated built-in patch command may include a provider-neutral review descriptor. Its
kind is change-request, commit, or comparison; each exact shape combines bounded display
strings with an optional credential-free HTTPS URL. Hunk rejects unknown fields, control
characters, invalid types, unsafe URLs, fields over their byte limits, and descriptors over 4 KiB,
then copies and freezes the accepted value. 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. Exit results and delegation to any built-in other than
patch cannot carry review metadata. An ordinary hunk patch has no descriptor.

The descriptor describes the review source rather than its diff contents: it stays on the app
bootstrap and does not enter changeset transforms or ReviewDocumentV1. Refreshing the same
file-backed patch preserves it, including watch and manual refresh; an explicit reload to a
different patch path or input kind clears it. Opening a commit from interactive hunk log attaches
a commit descriptor from the selected provider history row and preserves it while refreshing that
exact provider review request. Live-session list, context, and review JSON snapshots project the
same optional descriptor from registration metadata; it remains outside the semantic
review document and grants no remote reload or provider capability.

Delegation cannot target another extension command or change extension bootstrap
flags. Do not write stdout or read stdin before delegating; use stderr for
progress. Reading stdin is an exit-only workflow because even a pending read can
steal terminal input from the delegated command. Writers and stdin iterators
reject after the handler settles. SIGINT and SIGTERM abort
ctx.signal; handlers should stop promptly. Extensions are not sandboxed, so
direct process stream access cannot be enforced by these capabilities and must
be avoided.

Bare hunk --help remains static and does not load extensions. The extension
owns hunk <name> --help and receives --help unchanged.

summary and usage are what Hunk shows when a top-level token reaches
extension discovery but no extension claims it. That failure has already paid
for the registry, so it lists every loaded command rather than only naming the
token that was wrong:

text
hunk: Unknown command: nosuchthing

Extension commands available here:
hunk cli-tools <status|review> [args...] — Demonstrate extension-provided CLI workflows
hunk gh <pr|commit|compare> <target> [--repo <owner/repo>] — Review GitHub-hosted changes

Both fields are collapsed to one sanitized line, so an extension cannot forge
host output with newlines or escape sequences.

Hunk's dependency-free @hunk/gh bundled extension is a complete
network workflow built on this contract. It discovers pull requests from the current branch, fetches
bounded GitHub pull-request, commit, and comparison diffs without the gh CLI, writes a temporary
patch with restrictive POSIX modes (and inherited temporary-directory ACLs on Windows), delegates
to the built-in patch command, and removes the patch on extension shutdown. Because it is bundled,
hunk gh remains available under --no-extensions; that switch disables user extensions only.
Disable it specifically with [extensions] disabled = ["hunk.gh"], or restore it for one run with
hunk --enable-extension hunk.gh gh ….

`hunk.configureSession(options)`

Request host-level behavior for the review session loading the extension. Use
{ viewPreferences: "transient" } for training, demos, and presentations that
deliberately exercise view controls but must never offer to save their final
practice state into the user's config. If any loaded extension requests it, the
shared session skips the save-view-preferences prompt on quit.

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

The default is { viewPreferences: "default" }. Like every registration-time
call, this must run synchronously while the factory is loading.

`hunk.registerTheme(theme)`

Contribute one selectable theme. The object is the same shape as a
[themes.] config table:

ts
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.

`hunk.registerFileLanguage(matcher, language)`

Map file extensions, exact filenames, or globs to an existing syntax-highlighting language. The
string shorthand registers a case-insensitive extension with or without its leading dot. Explicit
extension matchers use the same trimming, leading-dot removal, and lowercasing:

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

Filename and glob matching is case-sensitive on every platform. Exact filenames match a basename
at any directory depth. Globs use Bun's shell-style glob syntax and must explicitly target either
the basename or the review path exactly as Hunk decoded it. / is the review-path separator;
backslashes remain literal filename characters. Exact filename and glob values preserve leading and
trailing whitespace. Globs reject NUL and do not run against NUL-bearing decoded patch paths; exact
filename selectors can still address those paths. VCS review paths are normally repo-relative,
while generic patch input may carry an absolute path.

Hunk's reserved .mts and .cts mappings run first and cannot be overridden. Otherwise, exact
filenames take precedence over globs, which take precedence over extensions. The longest matching
extension wins, and later registrations win ties within each category. Direct attempts to register
those two reserved extensions are skipped with a notice.

This API selects a language already available to Pierre/Shiki. It does not load a new syntax
grammar; an unknown language remains plain text.

Workspace status capability

API version 31 adds optional ExtensionVcsAdapter.status, independent of history and
review operations. Providers without it report unsupported for hunk status; detection never
falls back from a native JJ/Sapling workspace to Git status semantics. The bundled Git provider
implements the capability in packages/hunk-git/src/status.ts.

  • read({ targetPath? }, context) returns an ExtensionVcsStatusSnapshot with schemaVersion: 1.
    context.cwd is the launch repository authority; a target may inspect only that repository's
    worktrees. A missing repository/worktree rejects. No process-wide cwd change, extension loading,
    fetch, checkout, staging or other mutation belongs in this capability.
  • readSiblings(snapshot, context) returns bounded same-repository summaries. Worktree ids and
    paths must each be unique. Every row must share the inspected repositoryId; a row matching
    the current worktree's id or path must match both. These checks also apply to embedded
    siblings: { state: "ready", value } snapshots. Return an unavailable/error row, not zero
    changes, when a sibling cannot be read. The initial snapshot can publish
    siblings: { state: "loading" } before this slower scan completes.
  • reviewActions supports only the semantic ids staged (index comparison) and unstaged
    (working-copy comparison), each at most once; ids are not opaque. Labels are provider-owned.
    A provider without an index offers only unstaged, with a suitable label such as "Working
    copy changes", and omits paths[].index. It need not invent Git staging semantics.
  • planReview(snapshot, actionId, context) revalidates source/target identity and the opaque
    snapshot token, then returns { cwd, input: ExtensionVcsStatusReviewInput } for a full
    comparison. This exported input type retains the exclusive range / rangeEndpoints
    variants but excludes pathspecs. The boundary explicitly rejects a pathspecs field, even an
    empty list or undefined, rather than silently stripping it. cwd must retain the inspected
    worktree path and input.staged must agree with the offered semantic action id. The UI passes
    only a provider-offered id; it does not construct revision expressions or narrow the comparison
    to a selected file. A live working tree can still change after planning; the token is not
    content attestation.
  • Optional async watchPlan(snapshot, context) uses the existing watch-plan vocabulary for
    worktree and shared/per-worktree metadata. Hosts keep periodic polling for missed events,
    serialize/coalesce refreshes, cancel superseded reads and reject late generations.

Every asynchronous operation receives context.signal and must honor cancellation, bound its
I/O, and reap subprocesses. Status results allow at most 20,000 unique changed paths and 100
sibling rows. The defensive review-action collection limit is 32, but the supported ids permit
at most two unique actions. Ready operation arrays allow five entries. Watch plans allow 256
targets, four sources per target, and 20,000 ignored roots or entries per target. Each result
also has a 40,000-entry aggregate collection budget, counting nested arrays and outer rows/targets,
and an 8 MiB aggregate UTF-8 text budget across normalized fields. The boundary preflights collection
lengths before Zod traverses array elements and checks text before publishing copied results. Oversized results fail
explicitly; providers returning bounded sibling lists set truncated. Fields marked unknown/error
must not become clean, aligned, or zero-count results. The extension boundary validates and copies
snapshots before publishing them. Paths retain literal Unicode, tabs and newlines for
navigation/JSON; renderers escape control characters.
Providers without an index omit paths[].index rather than inventing Git staging states.

changedPathCount counts unique destination paths, including rename destinations and untracked
files. A partially staged path appears once with independent index/worktree states. Submodule
commit/tracked/untracked changes and conflicts stay explicit. No summed numstat is presented as
a net change; detailed comparisons belong in the existing diff view.

Fetch timestamps are optional facts with provenance, not observation timestamps. Git retains the
inspected worktree's local FETCH_HEAD mtime with local-fetch-head-mtime provenance in structured
facts: it is not authoritative for the named upstream and may describe a different remote or a
manually modified file. Human output therefore omits age; (last fetched 18m ago) may appear only
when provenance supports that claim. Hunk never fetches to populate it. Missing/unreadable metadata
remains unknown/error. See workspace status for command and scan policy.

`hunk.registerVcsAdapter(adapter)`

Contribute an additional VCS backend. This is the same call Hunk's own bundled
Git, Jujutsu, and Sapling backends make.

ts
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.

API version 19 adds the optional, read-only history capability used by the built-in hunk log surface. API version 21 adds optional inclusive range planning:

ts
hunk.registerVcsAdapter({
  id: "hg-history",
  name: "Mercurial history",
  detect: () => null,
  history: {
    async open() {
      return {
        async read({ signal }) {
          signal?.throwIfAborted();
          return { commits: [], done: true };
        },
        close() {},
      };
    },
    planReview(commit) {
      return commit.parentRevisionIds[0]
        ? {
            kind: "revision-range",
            fromRevisionId: commit.parentRevisionIds[0],
            toRevisionId: commit.revisionId,
          }
        : { kind: "revision-show", revisionId: commit.revisionId };
    },
    planRangeReview({ newestCommit, oldestCommit }, _context, options) {
      const parent = options?.parentRevisionId ?? oldestCommit.parentRevisionIds[0];
      if (!parent) throw new Error("Resolve this provider's empty root baseline here.");
      return {
        kind: "revision-range",
        fromRevisionId: parent,
        toRevisionId: newestCommit.revisionId,
      };
    },
  },
});

The snippet above demonstrates static history production only; it is not a complete interactive
adapter. Add a revision-show operation for revision-show actions and a working-tree-diff
operation that accepts rangeEndpoints for revision-range actions before advertising interactive
opening. Otherwise Enter reports that the corresponding review operation is unsupported.

History is deliberately separate from patch-producing operations. The built-in host owns command
routing, graph planning, themes, terminal lifecycle, and static/interactive presentation. The
adapter owns every repository semantic: traversal and filtering, immutable identities, refs, and
review-planning decisions about roots, merges, ancestry, and direct endpoints. planRangeReview is
optional so older adapters remain compatible; without it, Hunk reports multi-commit opening as
unsupported rather than silently opening one commit. A range planner compares the chosen parent—or
provider-specific empty/root baseline—of oldestCommit directly with newestCommit and must not use
merge-base/triple-dot semantics. Hunk treats revision ids as opaque strings and never invents provider
revision syntax.

Commits must carry an immutable full revisionId, display id, ordered parent ids, subject, optional
message body, author (and optional email), ISO authored time, and structured ref decorations. The
optional logicalId identifies the same logical change across provider rewrites (for example, a
Jujutsu change id); Hunk treats it as metadata and continues to key graph and review operations by
immutable revisionId. A head decoration carries an optional attachedLocalBranch; use that field
rather than embedding an arrow or branch identity in its display label.

Every source must emit commits in child-before-parent topological order. If both a child and one
of its parents are included, the child appears first. This invariant spans the source's complete
lifetime: page boundaries do not reset it, and a parent returned on one page cannot be followed by
its child on a later page. Reads may return at most the requested limit and must distinguish a page
boundary from repository end with done. Hunk copies and validates every page, strips terminal
controls from display metadata, rejects duplicate revisions and parent-before-child output across
pages, forwards cancellation, and closes the source at EOF or failure.

The bundled Git and Jujutsu extensions implement this public capability today; Sapling currently
reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, tags, traversal,
and native merge-review semantics without routing through a colocated Git repository. Third-party
adapters use exactly the same contract.

Every operation load and watchSignature receives optional context.signal.
Use asynchronous subprocess APIs, pass cancellation through, and terminate plus
reap provider processes when it aborts; a synchronous spawn blocks Hunk's renderer
and prevents the abort handler from running.

A load result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing. API version 24 adds review:

Field What it adds
review commit or comparison context above a revision-backed review
untrackedPaths files your VCS calls unknown, synthesized into added-file diffs
readFileSource exact whole-file contents, for context expansion and highlighting
sourceCacheKey stable source-snapshot identity for highlight reuse across reloads
extraFiles files reviewed outside the patch, including skipped placeholders

Use the same ExtensionReviewDescriptor accepted by delegated CLI reviews. Return a commit
descriptor when the operation resolves one reviewed commit, or a comparison descriptor when both
sides resolve to commits. Comparison commits are newest-first and bounded to eight entries; retain
the exact total in commitCount when known. Commit descriptors and comparison commit rows carry the
full immutable ID in revision for copying and should carry the provider-formatted short ID in
displayRevision for display. displayRevision remains optional on a single commit for extensions
built against an older API; Hunk abbreviates revision when it is absent. Omit review when either
side is working-copy, staged, stash, or otherwise cannot be identified accurately. Hunk validates,
copies, and freezes the descriptor before mounting it, then recomputes provider-supplied metadata on
reload so moving refs do not retain stale information.

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 order

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_VCS_DETECTION_BASELINE_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.

ts
import { HUNK_VCS_DETECTION_BASELINE_PRIORITY } from "hunkdiff/extension";

hunk.registerVcsAdapter({
  id: "hg",
  name: "Mercurial",
  detectionPriority: HUNK_VCS_DETECTION_BASELINE_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. Hunk first resolves config and project root with the available catalog. If newly loaded
adapters change the detected project root, it reruns root and config resolution before loading the
session.

What detection never overrides is an explicit choice: a vcs = "" in Hunk
config naming a backend this session loaded is honored as-is, however near a
checkout some other adapter finds. A repository-local adapter can bootstrap a
provider Hunk has never seen because .hunk itself establishes the project root;
global, config-path, and --extension adapters also participate in a staged
root/config pass before the review loads. When the final root only adds repo
candidates, Hunk extends the provisional registry instead of executing its
already loaded factories again. If repo config changes an existing extension's
factory config, Hunk sends that provisional instance shutdown before rebuilding it.

Watch support

Promise-returning watchSignature hooks and watch cancellation require API version 25.
Declare "hunk": { "apiVersion": 25 } in the extension manifest so older hosts refuse to
load it, or branch on hunk.apiVersion and keep a synchronous hook on older hosts.
Existing synchronous hooks remain supported.

--watch works through extension adapters. Each operation may add:

  • watchSignature(input, ctx) — a fingerprint of the reviewed state, returning
    string | Promise<string>. Hunk awaits it and reloads when it changes. Prefer
    async I/O and honor ctx.signal, which aborts when observation closes.
  • watchPlan(input, ctx) — the filesystem targets that cover that state, so
    Hunk reacts to events instead of polling on a timer.
ts
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.

Exact file sources

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.

ts
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),
    sourceCacheKey: `${oldRev}:${newRev}`,
    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. Return
{ kind: "too-large", maxBytes } when fetching the source would exceed your
resource limit; Hunk shows expansion as unavailable without treating the result
as an extension failure. 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. When equivalent reloads close over the same source base, return the same
opaque sourceCacheKey so Hunk can reuse its highlighted output. An equal per-file
patch plus that key must guarantee equal old/new source answers for the file; change
it when source state outside the patch changes. Omit it when the adapter cannot prove
stable identity and Hunk will invalidate conservatively. Leaving
readFileSource off is fine: Hunk falls back to the content the patch itself carries,
which renders the same diff with less context available.

Files outside the patch

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:

ts
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:

ts
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.

Moved lines

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.

Failures the user can fix

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.

ts
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.

`hunk.registerPane(pane)`

Render a React component on the left, right, top, or bottom edge of the
review. Pair it with registerCommand so a key opens it:

tsx
// ~/.config/hunk/extensions/flat-pane.tsx
import { useMemo } from "react";
import type { ExtensionPaneProps, HunkExtensionAPI } from "hunkdiff/extension";

function FlatPane({ files, selectedFileId, theme, actions }: ExtensionPaneProps) {
  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.registerPane({
    id: "flat",
    title: "Flat files",
    placement: "right",
    component: FlatPane,
  });
  hunk.registerCommand({ id: "toggle-flat", title: "Toggle flat pane", key: "ctrl+f" }, (ctx) => {
    ctx.panes.toggle("flat");
  });
}

placement defaults to "left". Left/right panes use width; top/bottom panes
use height. Both accept { preferred, min?, max?, fraction? }; equal bounds
make a fixed pane. Defaults are { preferred: 34, min: 22 } columns and
{ preferred: 8, min: 3 } rows.

fraction opts into live responsive sizing until the user drags the divider. It
must be greater than 0 and at most 1; Hunk rounds that fraction of the full
host body width or height to a terminal cell, then applies min, max, and the
space required by the review. preferred remains the fixed-cell target when
fraction is omitted. A divider drag establishes a session-local cell override:
later terminal shrink may clamp it temporarily, and expanding restores it.
Panes without fraction retain their fixed preferred startup size. Folder
extensions that use fraction should declare "hunk": { "apiVersion": 12 } in
their manifest.

preferredSize(context) can derive that automatic cell target from current
review facts. Hunk invokes it synchronously with the same context as
available, clamps its positive whole-number result to min/max, and still
lets a session-local divider drag take precedence. Set resizable: false when a
dynamic pane should track that target without exposing a divider. These options
require API version 22.

Use defaultOpen to open a pane initially, replaces: "hunk:files" to replace
the initial files pane (and override defaultOpen), and available(context) to
hide it conditionally. One pane may replace each named target; the first
registration owns that slot and later claims are skipped with a warning.
replaces may also name another pane by its fully qualified
"<extensionId>:" key, and Hunk follows those replacement chains.
Both available(context) and the mounted component receive review: immutable
metadata supplied by a delegated patch command or an interactive history selection, or null for
ordinary reviews. The bundled hunk:review-info top pane uses this to show change-request and
commit identity without taking any rows when no supported descriptor exists. Pane extensions that read
review should declare "hunk": { "apiVersion": 17 } in their manifest so older Hunk versions
refuse them cleanly instead of mounting with an incomplete prop contract.

onActivate() observes a primary mouse press anywhere in the pane's content,
including content nested in a <scrollbox>. Use it to focus an extension-owned
editor or update pane-local active state without adding mouse handlers to every
row. Hunk does not stop propagation or prevent the press, so extension-local
mouse behavior can continue. Other mouse buttons do not activate the pane. A
thrown or rejected callback is contained and reported as an attributed warning.

hunk:files is a named role, not a left-edge location. The
hunk.view.toggleFilesPane command (s by default) and View → Files pane
follow the resolved owner of that slot, whether the replacement is on the left,
right, top, or bottom. They toggle only that owner; independently registered
panes keep their own open state. User remaps and unbindings of
hunk.view.toggleFilesPane apply to the resolved slot in the usual way. The
former hunk.view.toggleSidebar id remains a compatibility alias.

currentLine: true opts into the selected-row painter. currentLine.render(side, width)
paints one side as a clipped row; currentLine.side and currentLine.line are
the same public source address command handlers see on ctx.selection.currentLine
(context rows use Hunk's canonical new-side). The installable
Hunk Lens extension uses the painter; a
blame or diagnostic pane can use the address without waiting for a keypress.
Install the lens with hunk extension install modem-dev/hunk-lens.

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
review immutable review-source metadata (change-request, commit, or comparison), or null for ordinary reviews
reviewGeneration opaque current generation string for actions.setPresentationScope, or null before the review settles
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
placement the accepted terminal edge
width exact terminal columns in the host-owned rectangle
height exact terminal rows in the host-owned rectangle
currentLine selected-row painter plus { side, line } when the registration opts in, otherwise null
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, clipboard, and notifications the pane may trigger

API-v3 sidebar names remain as deprecated aliases: use registerPane,
ExtensionPane*, ctx.panes, and replaces: "hunk:files" in new code.

actions.selectFile(fileId), actions.selectHunk(fileId, hunkIndex), and
actions.revealLine(fileId, side, line) route through the same review
controller as the built-in files pane 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.copyText(text) uses the terminal's
OSC 52 clipboard integration and returns false when unavailable. Extensions that call it or read
theme.copyAction should declare "hunk": { "apiVersion": 20 } in their manifest. actions.notify(message, type?) shows a toast attributed to your extension. To scope from a pane mouse
handler, use props.reviewGeneration as the scope's generation and the
visible file id plus hunk indexes as its targets:

ts
import type { ExtensionPaneProps } from "hunkdiff/extension";

/** Focus the first hunk of the first visible file from a pane action. */
export function focusFirstHunk(props: ExtensionPaneProps): boolean {
  const file = props.files[0];
  const targetHunk = file?.hunks?.[0];
  if (!props.reviewGeneration || !file || !targetHunk) return false;

  return props.actions.setPresentationScope({
    generation: props.reviewGeneration,
    files: [{ fileId: file.id, hunkIndexes: [targetHunk.index] }],
  });
}

Guard the call when reviewGeneration is null; the host rejects stale or malformed
requests. An action given a file id
that is not currently visible is refused with a warning rather than corrupting
the selection. A pane's actions carry the same navigation methods a command
handler's ctx.navigation does, with the same
guarantees.

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:

ts
import type { ExtensionKeyEvent, ExtensionPaneProps } from "hunkdiff/extension";

export function handlePaneKey(props: ExtensionPaneProps, 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 owns pane geometry, dividers, and responsive omission. Render failures are
contained to that pane; a failed hunk:files replacement restores file
navigation.

Props carry the pane's exact width and height. Use a <scrollbox> ref for
scroll position and selection following; Hunk serves the matching
@opentui/core instance to extensions.

Scrolling: the scrollbox ref contract

The one behavior a list pane 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:

tsx
import { useEffect, useRef } from "react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ExtensionPaneProps } from "hunkdiff/extension";

function HunkList({
  files,
  selectedFileId,
  selectedHunkIndex,
  theme,
  actions,
}: ExtensionPaneProps) {
  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 files pane
runs on:

  • scrollChildIntoView(id) scrolls the descendant with that id prop
    into view.
  • scrollTop and viewport.height read the current scroll offset and
    the scrollbox's live viewport rows. A read before the first layout pass
    reports 0, so viewport-dependent code belongs behind the events below
    rather than a bare mount effect.
  • verticalScrollBar.on("change", handler),
    viewport.on("layout-changed", handler), and
    viewport.on("resized", handler) report scrolling and pane resizes;
    unsubscribe with the matching .off in your effect's cleanup.

That is enough to window a long list yourself: the built-in files pane 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 files pane uses the same calls, so changes that break this contract
break Hunk first. Keep scroll handling small and behind your own helpers.

Its implementation lives in packages/hunk/src/extensions/default/ui/sidebar/ and serves as
the reference for third-party panes.

Pane state from events

Lifecycle handlers run outside React, but a pane 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.

tsx
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.registerPane({ 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.

Status line

The bottom status row — where Hunk shows the file filter, notices, and the
keyboard-mode badge — is a host-owned surface extensions write to through two
small capabilities: persistent items and one inline prompt.

This example counts literal, non-overlapping matches in the selected file's
patch text (including patch headers), not its whole source document. The right
item counts file_viewed events, including revisits and reloads, rather than
unique files.

ts
import type { ExtensionDiffFile, HunkExtensionAPI } from "hunkdiff/extension";

/** Count literal, non-overlapping occurrences in the selected patch. */
function countMatches(query: string, file: ExtensionDiffFile | null): number {
  if (!query || !file) return 0;
  return file.patch.split(query).length - 1;
}

export default function (hunk: HunkExtensionAPI) {
  let viewed = 0;

  hunk.registerCommand({ id: "find", title: "Search diff content", key: "ctrl+f" }, async (ctx) => {
    const query = await ctx.prompts.line({ prefix: "/", placeholder: "literal text" });
    if (query === null) return;

    const hits = countMatches(query, ctx.selection.file);
    ctx.statusLine.set({
      id: "status",
      spans: [
        { text: `[${hits}] `, tone: "accent" },
        { text: query, tone: "muted" },
      ],
    });
  });

  hunk.on("file_viewed", (_payload, ctx) => {
    viewed += 1;
    ctx.statusLine.set({
      id: "viewed",
      spans: [{ text: `${viewed} viewed` }],
      alignment: "right",
    });
  });
}

Items are declarative text, not components. ctx.statusLine.set(item)
sets or replaces one item and clear(id) removes it; ids are scoped to your
extension. spans use the same symbolic vocabulary as host-rendered file-view
rows — text, an optional tone (muted, accent, accent-muted, syntax,
added, removed), and optional attributes (bold, italic, underline,
strikethrough) — so Hunk measures them without a theme and paints them with
the active one. alignment defaults to "left"; right items sit beside the
host badge. When the row overflows, the lowest-priority items (default 0)
are dropped whole, newest first among equals, and the last survivor is
truncated with an ellipsis; the badge is never dropped. A set item keeps the
row on screen, exactly like a non-empty filter does, so clear items that
should not cost a row while idle. Items persist across ordinary content reloads
and clear when the extension registry is replaced or the review unmounts.

Prompts are promise-shaped. ctx.prompts.line({ prefix?, placeholder?, initial?, onChange? }) draws a real input with a cursor on the status row and
resolves the submitted text, or null on cancel. prefix is painted before
the input and is not part of the value; initial is where the field starts
(use it to reopen with the last query); onChange is called on every edit for
consumers that react while the user types. Enter resolves; Escape clears a
non-empty buffer first and cancels second — the same two-step Escape the file
filter has. While a prompt is open it owns typing the way the filter does:
after dialogs and menus, before file-view and session keyboard modes and the
command table, so a bound key is text rather than a command. One prompt is
open at a time; a second request queues behind the first, across extensions
too. A session reload cancels open and queued prompts, and a request during
teardown resolves null immediately. Prompts from installed extensions carry
the ext <your-id> marker before the prefix, like toasts and dialogs.

Where the controls appear: command handlers get ctx.statusLine and
ctx.prompts; event, bus, and keyboard-mode handlers get ctx.statusLine
only. The factory object gets neither, so every write belongs to a handler
whose lifetime Hunk can scope. A malformed item (blank id, non-array spans,
a span without string text, an unknown tone) throws from set; malformed
prompt options reject the promise; a throwing onChange is reported once and
the prompt continues.

`hunk.registerFileView(view)` (experimental)

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.

ts
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 panes 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.

Host-owned syntax paint (API v28)

A file view may declare complete codeDocuments and map individual symbolic spans into them.
Extensions provide code and coordinates, never colors, Shiki/Pierre objects, HAST, grammars, or
terminal tokens. Hunk resolves the language and active syntax theme, tokenizes the complete
document so multiline comments, strings, templates, and Markdown fences retain lexical context,
and projects only the ranges demanded by the mounted row window. Highlighting arrives
asynchronously and is paint-only: it cannot change retained text, wrapping, row height, layout
generation, note placement, navigation, selection, or scroll position.

text
/* Detailed source-code truncated for AI context efficiency. */

ExtensionFileViewCodeDocument has a layout-local unique id, complete text, and optional
language. An omitted language uses the reviewed file's host-detected language. An explicit
language is still resolved by Hunk; an unavailable grammar leaves ordinary symbolic paint. A
span's syntax is an ExtensionFileViewSyntaxReference: documentId, a one-based line, and an
optional zero-based, half-open UTF-16 range. Omit range to reference the complete line. This
supports generated/transformed documents as well as reviewed old/new source, and one split-style
row may independently reference old and new documents. Keep gutters, blame metadata, diff markers,
ellipses, separators, and padding in separate non-syntax spans.

Hunk normalizes CRLF and lone CR to LF without inventing a line after a final newline, strips
terminal controls line by line, and snapshots the resulting terminal-safe document. The retained
terminal-safe span.text must exactly equal the referenced complete line or slice. References use
JavaScript UTF-16 columns before terminal-cell conversion, so astral code points occupy two column
units. Horizontal tabs remain one UTF-16 unit and pass through to OpenTUI, which displays each tab
at its fixed two-cell width. If sanitization would make the authored span and document slice differ,
the layout is rejected rather than guessing an offset.

When syntax succeeds, its token foreground overrides the span's tone; token gaps retain the tone,
and authored attributes apply to every projected run. Hunk continues to own selected-hunk and
current-row backgrounds. added and removed remain their existing semantic foreground tones;
code documents do not opt into raw-diff backgrounds. Custom row components never receive syntax
colors or token data: only their symbolic fallback spans can use syntax, while successful custom
component output remains untouched.

syntax and sourceRanges are deliberately independent. Syntax references address paint and may
appear outside hunk rows; they never establish hunk ownership, note placement, navigation aliases,
or source provenance. sourceRanges retain the exact note/navigation semantics below.

Code-document validation is bounded to 64 documents, 1,000,000 aggregate UTF-16 code units, and
10,000 aggregate normalized lines per layout. Highlighting applies the same UTF-16 input and
normalized-line ceilings, requires every tokenized line to stay below 1,000 UTF-16 code units, bounds
compact output and completed caches by bytes and entries, and admits at most 16 unique outstanding
document jobs. Identical requests share one job; subscribers to that same job are not subject to a
separate numeric ceiling. Hunk only starts work when a visible/halo row demands a document, cancels
subscribers that leave demand, and discards stale theme/file/view/layout results. Unsupported
languages, oversize input, queue pressure, cancellation, worker failure, and tokenization failure
all retain the original symbolic FileView content. By contrast, invalid layout data or unavailable
native text measurement fails layout preparation and falls back to the raw diff, because Hunk
cannot safely retain a view without exact geometry. Folder extensions using codeDocuments or
syntax must declare "hunk": { "apiVersion": 28 }.

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; Hunk renders the bound file-view row first and inserts its note cards afterward. 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 panes. 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. The focused
code-document-file-view example declares
complete old/new documents and maps full and partial code slices beside non-syntax gutters.

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.

ctx.fileViews.refresh("view-id") invalidates that view's prepared layouts.
Hunk treats layout as a pure derivation of (file, width) and reuses a
prepared result until one of those changes, so a view holding its own state — a
fold, a toggled overlay, a display mode — has nothing to change and would
otherwise keep painting its first answer. Flip the state, then ask for the
re-derivation:

ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  let expanded = false;

  hunk.registerFileView({
    id: "outline",
    title: "Outline",
    matches: (file) => file.path.endsWith(".ts"),
    layout: ({ file }) => ({
      rows: (file.hunks ?? []).map((entry) => ({
        id: `hunk:${entry.index}`,
        spans: [{ text: expanded ? `Hunk ${entry.index + 1} · ${entry.header}` : entry.header }],
      })),
      hunkRows: (file.hunks ?? []).map((entry) => ({ startRow: entry.index, endRow: entry.index })),
    }),
  });

  hunk.registerCommand(
    { id: "toggle-detail", title: "Toggle outline detail", key: "f9" },
    (ctx) => {
      expanded = !expanded;
      ctx.fileViews.refresh("outline");
    },
  );
}

Refresh defaults to view-wide, not current-file: every file presenting the view
re-runs matches and layout, while files on raw diff or on another view do no
work. The rows already on screen stay there until their replacement resolves, so
a refresh never flashes back to raw diff mid-flight; a re-layout that declines,
throws, or times out falls back to raw exactly like any other failed layout.
Unknown ids warn and do nothing, and ids resolve the same way select resolves
them.

When the state that changed belongs to one file rather than the whole view — a
fold, a per-file edit buffer — scope the invalidation with { fileId } so the
other files presenting the view keep their prepared rows:

ts
const folded = new Map<string, boolean>();

hunk.registerCommand({ id: "fold", title: "Fold this file", key: "f10" }, (ctx) => {
  const fileId = ctx.selection.file?.id;
  if (!fileId) return;
  folded.set(fileId, !folded.get(fileId));
  ctx.fileViews.refresh("outline", { fileId });
});

That matters because View → Apply “…” to all matching files can leave one
view presenting every matching file in the changeset, and each of those files
would otherwise re-run third-party layout for a change only one of them made.
A fileId no reviewed file carries invalidates nothing and warns about nothing,
since ids can race a reload.

Interactive file views

Add a mode when a file view needs keyboard input. Hunk sends it keys after
focused inputs and dialogs, but before app commands.

ts
let showPath = true;

hunk.registerFileView({
  id: "outline",
  title: "Outline",
  matches: () => true,
  layout: ({ file }) => ({
    rows: [{ id: "summary", spans: [{ text: showPath ? file.path : "Outline" }] }],
    hunkRows: [],
  }),
  mode: {
    onKey: (key, ctx) => {
      if (key.name !== "space") return "pass";
      showPath = !showPath;
      ctx.fileViews.refresh("outline");
      return "handled";
    },
  },
});

hunk.registerCommand({ id: "outline-keys", title: "Outline keys", key: "f9" }, (ctx) => {
  ctx.fileViews.enterMode("outline");
});

enterMode(viewId) selects the view and starts its mode, returning false if it
cannot. Only one file-view mode runs at a time. exitMode() stops it;
isModeActive(viewId) checks it.

onKey must return synchronously:

  • "handled" consumes the key.
  • "pass" continues through any active session keyboard mode, then Hunk's
    commands and focused scrolling.
  • "exit" consumes the key and stops the mode.

When the file-view mode is the highest-priority input owner, Escape exits it and
never reaches onKey. Hunk also exits when the selected file, active presentation,
extensions, or review session changes. Optional onEnter and onExit lifecycle
callbacks must also return synchronously, and onExit runs exactly once per
activation. A failing or asynchronous onEnter or onKey exits the mode; any
callback failure warns without breaking the review.

`hunk.registerLineHighlighter(highlighter)`

A line highlighter marks character ranges inside Hunk's own diff rendering
— syntax highlighting, word diff, and layout stay exactly as they are, and the
marked characters get a resolved background. It is the lever for search hits,
diagnostics mapped onto changed lines, secret scanning, coverage, and anything
else that wants to say “these exact characters, here.”
For a whole alternate presentation, use registerFileView instead.

ts
import type { ExtensionLineHighlight, HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerLineHighlighter({
    id: "todos",
    highlight({ file }) {
      const marks: ExtensionLineHighlight[] = [];
      let newLine = 0;
      for (const raw of file.patch.split("\n")) {
        const header = /^@@ -\d+(?:,\d+)? \+(\d+)/.exec(raw);
        if (header) {
          newLine = Number(header[1]);
          continue;
        }
        if (newLine === 0 || raw.startsWith("-")) continue;
        const start = raw.slice(1).indexOf("TODO");
        if (start !== -1) {
          marks.push({ side: "new", line: newLine, range: [start, start + 4], tone: "warning" });
        }
        newLine += 1;
      }
      return marks;
    },
  });
}

Marks are addressed by source coordinates, never by rendered rows: side
("old" or "new"), a 1-based line on that side, and a [start, end)
range in UTF-16 code units of the line's raw text — exactly what indexOf and
RegExp.exec return against file.patch lines or a readDocument result.
That addressing survives split vs unified layout, line wrapping, horizontal
scrolling, and collapsed-context expansion, and the extension never learns
Hunk's row model. A context line may be addressed through either side's line
number; split view mirrors the mark onto both halves of the row. Offsets that
land inside an emoji or a wide character widen outward to the whole glyph. A
mark paints terminal columns, so a range covering only characters that occupy
no column — bidi controls, zero-width spaces and joiners — paints nothing.

The tone says what a mark means — "match" (the default), "current" for
the one hit a search is standing on, "info", "warning", "error", or
"dim". Tones exist because a color would be the extension's problem to get
right and it cannot be
: a fixed background that reads on a context line is
invisible on an added line's green. Hunk resolves each tinted tone against the
actual background of each marked line until it clears a minimum perceptual
distance — stronger than its own word-diff emphasis, backing off only before
the code on top would stop being readable — per theme, so a mark is never
invisible on a line kind or a theme. "dim" recedes the marked text toward the
line background (blending token foregrounds ~45% into the line background while
preserving token hues) with a guaranteed readability floor, ideal for
review-progress workflows (e.g. marked-as-reviewed hunks) or noise de-emphasis.
On a transparent cell there is no color to blend against, so resolution falls
back to the theme background and then to the appearance's own extreme: the mark
still paints, chosen against the surface Hunk assumes rather than the one behind
the terminal. "current" renders as reverse video (theme text as the block,
theme background as the glyphs), the convention less and vim use for the
active hit.

highlight({ file, signal, readDocument }) may be sync or async, and returns
the complete set of marks for one file — or null for none. Hunk calls it per
reviewed file, bounded by the same timeout and concurrency discipline as file
views, and treats the result as a pure derivation of the file plus an
invalidation epoch: results are cached until ctx.highlights.refresh bumps the
epoch or the review reloads. A search that moves to the next match flips its
own state and refreshes rather than pushing marks into the host:

ts
hunk.registerCommand({ id: "next", title: "Next match", key: "f9" }, (ctx) => {
  ctx.highlights.refresh("todos");
});

Refresh defaults to highlighter-wide; pass { fileId } to re-derive one
file's marks and leave the rest untouched. Bare ids address the calling
extension's own highlighter, "other-extension:highlighter-id" addresses any
registered one, unknown ids warn and do nothing, and a fileId no reviewed
file carries invalidates nothing — ids can race a reload. The same controls
are available to session keyboard modes through their context, so a prompt's
submit can refresh marks directly.

Containment matches the rest of the system. Marks addressing lines the review
is not showing — inside a collapsed gap, absent from a partial patch — are
silently invisible rather than errors; expanding a gap reveals marks addressed
into it when the file's source is loaded. Structurally invalid entries are
dropped with one warning per file; a raw result longer than 10,000 entries, or
one larger than 2,000 ranges per file or 100 per line, is rejected whole rather
than truncated silently; a highlighter whose marks would push one file past
4,000 merged ranges across every highlighter is dropped for that file;
overlapping ranges resolve deterministically with the later range winning. A throwing,
rejecting, or timed-out highlight costs that file's marks from that
highlighter and nothing else. Because highlights are paint-only — they change
colors, never text or geometry — the failure mode is always “no marks,” never
a broken review. Highlights render in interactive sessions only; the static
pager fallback never runs extension code.

Session keyboard modes

Register a session-wide mode when an extension needs to interpret review keys
without replacing a pane or exposing renderer internals. Registration is inert;
a command deliberately enters the mode through its own scoped controls:

ts
let pending = "";

hunk.registerKeyboardMode({
  id: "normal",
  title: "Vim navigation",
  onEnter: () => {
    pending = "";
  },
  onExit: () => {
    pending = "";
  },
  onKey: (key, ctx) => {
    if (key.sequence === "g") {
      if (pending === "g") {
        pending = "";
        ctx.commands.execute("hunk.review.jumpToTop");
      } else {
        pending = "g";
      }
      return "handled";
    }

    pending = "";
    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");
  }
});

ctx.keyboardModes.enterMode(id) resolves only a mode registered by the same
extension. exitMode() and isActive(id?) likewise act only on that extension's
active mode, so one extension cannot inspect or stop another. Entering a mode
replaces the previous session mode and runs its onExit first. While onEnter or
onExit runs, enterMode() and exitMode() return false; lifecycle callbacks
reset extension-owned state but cannot change keyboard ownership. Only one session
keyboard mode runs at a time.

onKey returns synchronously:

  • "handled" consumes the key.
  • "pass" continues through ordinary Hunk commands and focused scrolling.
  • "exit" consumes the key and leaves the mode.

The context is intentionally small: cwd, notify, live public commands,
activation-scoped keyboardModes, highlights (so a mode can refresh line
marks directly), and statusLine (so a mode can show its live buffer or count
on the status row). A mode never needs a prompt: a prompt-shaped interaction is
a command plus ctx.prompts.line(), described under
Status line. Keys are frozen plain snapshots, not OpenTUI
events. Async/throwing callbacks are contained and exit safely. When the session
mode is the highest-priority active input owner, host-owned Escape exits without
reaching onKey; the status badge and a host-owned Extensions menu item are
clickable exits too. Controls handed to a mode are activation-scoped: after that
activation exits, retained callbacks cannot inspect, stop, or replace a later mode.
An active onKey may deliberately enter another mode from the same extension; its
outgoing lifecycle callback cannot supersede that replacement.

Dialogs, menus, focused filter/note inputs, and interactive file-view modes run
before a session mode. A file-view mode may temporarily overlap it: the first
Escape leaves the focused file-view mode, and the second leaves the resumed
session mode. Ordinary content soft reloads preserve a session mode, while an
extension reload, registry closure, or App teardown exits it exactly once.

Multi-key grammar and numeric prefixes belong to the extension. Resolve a count,
then call ctx.commands.execute(id, { count }) once so the host applies movement
atomically. See the dependency-free
vim-navigation example for j/k,
gg/G, hunk movement, alignment, capped counts, Ctrl chords, and a focused
: command line composed from a registered command plus ctx.prompts.line().

`hunk.registerCommand(command, handler)`

Register a named command, optionally bound to a key. Commands share Hunk's
built-in dispatch table, with built-ins taking precedence.

ts
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. ctrl+<letter> also matches an
unnamed bare control byte; named Tab and Enter events stay distinct. An
unparsable chord fails registration. A chord already owned by a built-in or an
earlier extension stays with its owner and produces a warning. Omit key to
register a command with no binding.

key also takes a list, binding the command to every chord in it:

ts
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.panes, the controls for opening panes:

  • ctx.panes.open(paneId) / close(paneId) / toggle(paneId) — a bare id
    names your own extension's pane, while a fully qualified
    "<extensionId>:" key addresses any registered pane. Use
    "hunk:files" for the literal built-in pane. These controls address
    registrations directly; they do not resolve a replacement slot. To toggle
    whichever pane currently owns the files role,
    call ctx.commands.execute("hunk.view.toggleFilesPane"). Opening a left/right
    pane also reveals its own edge when responsive layout has hidden it, without
    revealing the opposite edge or changing other panes' logical open preferences.
    Top/bottom pane state is independent of the side edges.

  • ctx.panes.isOpen(paneId) reports the logical open preference, including
    while availability or terminal bounds temporarily omit the pane. toggle()
    toggles this logical preference.

  • ctx.panes.isVisible(paneId) reports whether the pane occupies the current
    committed layout, accounting for responsive hiding, availability, failures,
    and terminal bounds. Unknown panes and stale controls return false.
    To toggle rendered visibility without touching other panes:

    ts
    if (ctx.panes.isVisible("guide")) ctx.panes.close("guide");
    else ctx.panes.open("guide");

    Opening still respects availability and minimum review dimensions; it cannot
    force a pane to render when there is insufficient room.

ctx.selection is where the review was pointing when the command fired — the
same selection a pane component sees in its props, so a command never has to
track selection_changed itself to know what the user is looking at:

ts
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
pane's files prop. Extensions only receive visible files, so it is null
when filtering hides the selected file or when no files are visible.
selection.hunkIndex is that file's selected hunk, and null whenever file
is — or when the file has no hunks to select. selection.currentLine is the
one-based { side, line } source address carrying the current-line marker, or
null when the 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 a
context row, and can be passed directly to navigation.revealLine.
selection.files is every visible file in review order — the same frozen
views a pane's files prop carries — so a command that works across the whole
review (a content search, a bulk action) reads its corpus here instead of
shadow-tracking changeset_loaded; selection.file is one of its entries or
null. 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.commands invokes Hunk's documented semantic commands through the exact same live command
table used by the keyboard, menus, and help:

ts
hunk.registerCommand({ id: "skip-three", title: "Skip three hunks", key: "ctrl+j" }, (ctx) => {
  if (ctx.commands.isEnabled("hunk.review.nextHunk")) {
    ctx.commands.execute("hunk.review.nextHunk", { count: 3 });
  }
});

Only explicitly public built-in hunk.* commands can be invoked. Unknown, disabled,
extension-owned, or stale-session commands return false; an extension cannot recursively invoke
itself or another extension. isEnabled also returns false for malformed ids, while malformed
execute ids, options, and counts throw as extension programming errors. The public ids are the built-ins listed in
keybindings, including the unbound
hunk.review.alignCurrentLineTop, hunk.review.alignCurrentLineCenter, and
hunk.review.alignCurrentLineBottom commands.

count defaults to 1 and must be a positive safe integer no greater than 10,000. Relative
line, viewport, horizontal, file, hunk, and annotated navigation applies the count atomically in
one host transition. Absolute positioning and one-shot commands run once. This avoids stale React
state without exposing scroll boxes, renderer refs, or coordinates. Both methods read live command
state, so they remain valid after an await or an ordinary content soft reload that retains the
extension registry. Controls retained across an extension-registry reload or App remount return
false.

ctx.keyboardModes enters, exits, or probes the command's own registered
session keyboard modes. See Session keyboard modes.

ctx.highlights refreshes prepared line-highlight marks, whole or
{ fileId }-scoped. See
hunk.registerLineHighlighter.

Reading the authoritative review

ctx.review.snapshot() returns a deeply immutable projection of the shared
ReviewStore, or null after this command's review generation has been retired. The same
ctx.review object can set or clear a transient presentation scope for the current generation;
the host intersects scopes from active extensions and applies the result after the user's file
filter without changing canonical review state. Each scope names files by fileId and the
zero-based hunkIndexes to retain:

ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerCommand({ id: "focus-first", title: "Focus first hunk" }, (ctx) => {
    const review = ctx.review.snapshot();
    const file = ctx.selection.files[0];
    const targetHunk = file?.hunks?.[0];
    if (!review || !file || !targetHunk) return;

    ctx.review.setPresentationScope({
      generation: review.generation,
      files: [{ fileId: file.id, hunkIndexes: [targetHunk.index] }],
    });
  });
}

The scope is rejected when its generation, file ids, or hunk indexes are stale. Clear it with
ctx.review.clearPresentationScope(); reloads, extension retirement, and failed commands clear
owned scopes automatically. It contains the opaque producer generation, the store's stateRevision, every
file in authoritative review/sidebar order, and every saved live or reviewer
note. Files carry their stable fileKey, transient runtimeId, content identity,
paths, stats, and flags; notes carry their complete resolved old/new anchor,
optional direct parentId, and active/stale/orphaned reconciliation status.

This is the command-time source for exporters, publishers, and audit tools. Drafts
are excluded because they are not saved. Static sidecar annotations that never
entered ReviewStore remain available on the changeset's file views, not in this
snapshot. Saved notes appear in live-arrival order followed by reviewer-creation
order, including orphaned notes a publisher may need to move into a summary.

ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
  hunk.registerCommand({ id: "publish", title: "Publish review" }, async (ctx) => {
    const captured = ctx.review.snapshot();
    if (!captured) return;

    await Promise.resolve(); // Prepare an external request from `captured` here.
    const current = ctx.review.snapshot();
    if (
      !current ||
      current.generation !== captured.generation ||
      current.stateRevision !== captured.stateRevision
    ) {
      ctx.notify("Review changed; rebuild the request", "warning");
      return;
    }
  });
}

Call snapshot() again before irreversible asynchronous work and compare both
fields: revisions are comparable only within one generation. Use fileKey for
semantic addressing, contentIdentity to detect changed reviewed content, and
runtimeId only for navigation inside that exact generation. The
review-snapshot-export
example writes the complete value as JSON and demonstrates this stale-work check. The
review-note-navigator example composes
the complete note inventory, authoritative anchors, a selector dialog, and guarded navigation
to currently visible files.

ctx.navigation moves the review stream: selectFile(fileId),
selectHunk(fileId, hunkIndex), and revealLine(fileId, side, line), the same
guarded navigation a pane'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 pane 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.

revealLine is the finest target there is, and the one to reach for when your
extension knows exactly which line it means — a search hit, a lint finding, the
line a mark from registerLineHighlighter
sits on. A hunk hundreds of lines tall has one anchor, so selectHunk can leave
the line you meant pages below the viewport; revealLine scrolls to the line
itself, lands it a little below the viewport top like every other Hunk reveal,
and makes it the current line so the reverse-video marker sits on it.

line is 1-based on side as the patch numbers it, so a context line answers
to either side's number. Two things soften the target rather than failing it:
when no rendered row carries that line — it is inside a collapsed gap, absent
from a partial patch, or the reviewer turned the current-line marker off
(view.cursor_line = "off") — the jump lands on the hunk containing the line
instead. Only a line no hunk of the file covers is refused, with a warning
naming your extension, and so are a side outside "old"/"new" and a line
number that is not a positive whole number.

ts
hunk.registerCommand({ id: "first-todo", title: "Jump to the first TODO" }, async (ctx) => {
  const file = ctx.selection.file;
  if (!file) {
    return;
  }

  const document = await ctx.workspace.readDocument(file.id, "new");
  const index = (document ?? "").split("\n").findIndex((line) => line.includes("TODO"));
  if (index >= 0) {
    ctx.navigation.revealLine(file.id, "new", index + 1);
  }
});

A handler may be async; a failure (sync or rejected) becomes a warning naming
your extension.

Asking the user

ctx.dialogs puts a question on screen and waits for the answer. Three shapes,
all promise-returning:

  • 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
ts
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:

ts
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 dialogs from installed extensions carry an ext <your-id> attribution line
— the same marker notify toasts use — so a third-party prompt can never present
itself as Hunk asking. Hunk's own bundled extensions omit that redundant marker.

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.

Workspace documents

ctx.workspace reads full documents from the current review and can replace an
eligible working-tree file.

Method Result
readDocument(fileId, "old" | "new") The reviewed source text, or null
canWriteDocument(fileId) Whether the review and file allow writes
writeDocument({ fileId, text }) { ok: true } or { ok: false, reason, detail }

A command can read, transform, and write a selected file:

ts
hunk.registerCommand({ id: "shout-headings", title: "Shout headings", key: "f7" }, async (ctx) => {
  const file = ctx.selection.file;
  if (!file || !ctx.workspace.canWriteDocument(file.id)) return;

  const current = await ctx.workspace.readDocument(file.id, "new");
  if (current === null) return;

  const result = await ctx.workspace.writeDocument({
    fileId: file.id,
    text: current.replace(/^(#+ .+)$/gm, (heading) => heading.toUpperCase()),
  });

  if (!result.ok && result.reason !== "cancelled") {
    ctx.notify(result.detail, "warning");
  }
});

readDocument returns the exact source represented by the review, not the
file's patch. It works for every review kind. For example, the "new" side in
hunk show HEAD is the file at that commit, not the working-tree file. It
returns null when the file or side is absent, no source is available, reading
fails, or the document exceeds Hunk's size limit. Reads never prompt. An invalid
side rejects the promise.

Writes require all of the following:

  • an unstaged working-tree review (hunk diff with no revision range)
  • a reloadable session; --agent-context - sessions cannot write
  • a reviewed file with writable new-side text
  • a regular target inside the review root

Revision, stash, range, staged, patch, and file-pair reviews are read-only.
Deleted, binary, oversized, missing, symlinked, and root-escaping targets are
also refused. Targets are identified by reviewed file id, never by an arbitrary
path.

canWriteDocument checks the review and file policy without prompting or
inspecting the filesystem. A later writeDocument can still refuse if the file
has moved or become unsafe.

writeDocument verifies the target, asks for consent through the attributed
ctx.dialogs queue, then verifies it again before writing. The second check
prevents deletion and symlink-swap races while the dialog is open. Authority is
checked immediately before the filesystem call; once that irreversible write
starts, its actual success or failure wins even if another reload happens, and
graceful shutdown waits for it to settle. A successful write queues
reconciliation of the review then active, and the write promise may settle
before that reload finishes.

A declined prompt returns cancelled, an ineligible or unsafe target returns
unavailable, and an attempted write failure returns failed with a
displayable detail. Malformed requests reject the promise.

`hunk.transformChangeset(fn)`

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.

ts
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 panes and 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, 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 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.

`hunk.on(event, handler)`

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.panes, live ctx.navigation, attributed ctx.dialogs, and review reload
controls. ctx.sidebars is a deprecated alias for ctx.panes. That means a startup handler can present
one focused welcome question and navigate to its first example, while a
changeset_loaded handler can reveal a pane when it finds something worth
showing — no keypress required. Dialog calls made before the mounted app is
ready resolve to their cancel value with a warning rather than opening later.
Controls retained across a review or extension-registry replacement expire:
navigation and pane mutations warn and do nothing, dialogs resolve to their
normal cancel value, and workspace reads or not-yet-started writes return
null/unavailable instead of acting on replacement content. Once a consented
filesystem write starts, it reports its actual outcome and success reconciles
the review then active.

Event Payload When
startup { cwd } once per loaded instance, after its review UI mounts
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 } a draft body changes or an existing note is saved
note_changed { kind, note } a saved ReviewStore note is created, updated, or removed
session_reload { changeset, reason } on every session reload
shutdown {} before instance replacement or exit, with a short timeout

A newly mounted extension instance receives startup before its first
changeset_loaded; reloads then deliver changeset_loaded before
session_reload once the matching review generation has committed.

Starting with extension API v23, layout_changed adds canonicalMode and
canonicalLayout. They emit "auto", "split", or "unified" for the mode and
"split" or "unified" for its resolved layout. 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 the deprecated "stack"
literal and legacy event fields until a separately announced major API revision.

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.

hunk_viewed is the hunk-grain sibling of file_viewed. It fires when the
settled (file, hunk) pair changes — including [/] inside one file — and
does not fire for current-line movement within a hunk. file_viewed still fires
only when the selected file object changes, so a soft reload can report a fresh
file without counting as a new hunk read.

command_executed reports a stable command id after the terminal dispatcher invokes
it, whether the user reached it through a key, a menu, an old command alias, or
ctx.commands.execute. When a command was renamed, commandId preserves its deprecated
identity for existing handlers and canonicalCommandId names the replacement. Extension commands
may still have detached async work in flight; this event observes the accepted user action, not
promise settlement. Listen for ids rather than key chords so behavior follows the user's live
[keybindings] table. Browser/session actions lower to shared review intents rather than terminal
commands and do not emit this event. Modal widget keys such as Escape, Enter,
and F10 menu navigation are also not commands. The note composer's save shortcut
is hunk.review.saveNote and does emit this event.

session_reload's reason is "watch" (the watcher saw the source change),
"daemon" (an agent command through the session broker), "extension" (an
in-process extension request), 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. note_edited carries note.draft: true for composer changes and
note.draft: false for an identity-preserving saved-note edit. Replies include their direct
parentId. Optional note.oldRange and note.newRange values are inclusive, one-based source
ranges: line notes may carry singleton ranges, while replacement selections may carry both.
note.side and note.line identify the preferred endpoint used to place the note. 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. Use them for incremental UI reactions only.

note_changed is the store-backed path: kind is "created", "updated", or
"removed", and note is the same snapshot shape ctx.review.snapshot()
returns. It fires for user saves and deletes and for agent session comments.
Drafts never appear. A TUI save therefore emits both note_created (or
note_edited) and note_changed. Reloads that remap or drop notes do not emit
note_changed; listen for session_reload to invalidate extension-owned state,
then read ctx.review.snapshot() from a later command when it needs the complete
current saved-note record.

shutdown handlers get a short window (250ms) to finish before Hunk replaces
the extension registry or exits anyway, so make cleanup prompt and idempotent.
Host-mediated UI authority is already revoked when shutdown begins: use the
event to release extension-owned resources, not to navigate or open dialogs.
The replacement instance receives startup after its review is mounted.

`hunk.events`

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.panes, ctx.navigation, ctx.dialogs, and ctx.review reload
controls as lifecycle handlers; ctx.sidebars remains a deprecated pane alias. 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.

ts
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

Request a soft reload of the currently mounted review after an external agent,
service, or process changes its inputs. This does not require --watch. Hunk
reuses the current input and live view options, preserves mounted UI state and
selection where possible, serializes the work with every other reload, and
coalesces concurrent extension requests into one operation.

ts
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 review commits and emits
session_reload with reason "extension". Non-reloadable inputs and controls
retained from an expired review resolve unavailable; a reload that starts but
cannot complete resolves failed with a displayable detail. Factory-time bus
events can run before the app mounts, so their reload requests are unavailable.
Once a live request starts, it reports that operation's actual result even
though a successful reload expires the context that requested it.
Requests made from the successor's lifecycle handlers are a new generation and
can schedule a trailing reload. Check session_reload.reason before requesting
from that event so an extension does not create its own reload loop.

`hunk.config`

Your extension's own [extension.] 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.config must never be trusted for
exec-adjacent decisions such as binary paths, shell commands, or module
loading. Validate those against something the user controls.

toml
# ~/.config/hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "dist/**"]
ts
const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];

`ctx.notify(message, type?)`

Every handler and transform receives a context object with cwd and notify.
Event and bus handlers additionally receive panes, review.requestReload, and events.emit; command
handlers receive panes, selection, navigation, and dialogs. The deprecated
sidebars alias remains available during the API-v4 compatibility window. 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.

`hunk.log(message)`

Record a diagnostic line. Logs are collected per extension rather than written
to the terminal, because the TUI owns the screen.

A complete example

Installable extensions and examples include:

Collapse lockfiles and generated output out of every review, and say how many
files were hidden.

ts
// ~/.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:

toml
# .hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "bun.lockb", "generated/*"]

Try it against the working tree without installing it:

bash
hunk diff --extension ./collapse-generated.ts

CLI flags and config reference

bash
hunk diff --extension ./path/to/entry.ts   # load one entry file for a review (repeatable)
hunk diff --extension ./my-ext             # a folder extension: loads ./my-ext/index.ts
hunk --extension ./my-ext cli-tools status # load then run its top-level CLI command
hunk --no-extensions cli-tools status      # no discovery or import; command is unavailable
hunk diff --no-extensions                  # disable user extensions for this review
hunk --disable-extension hunk.gh gh pr 123 # disable one extension for this run
hunk --enable-extension hunk.gh gh pr 123  # override a configured disable for this run
toml
# ~/.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
disabled = ["hunk.gh", "my-extension"] # exact selection ids

[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. User and repo disabled lists combine as a
union, so a repository cannot re-enable a user-disabled extension; only an explicit
--enable-extension can do that for one invocation. Disabled user candidates are filtered
before module import and before repo trust prompting. [extensions] paths from a repo config is
trust-gated the same way .hunk/extensions is, because it is repo-controlled either way.

Not contributable yet

Menu entries, standalone keybindings (a chord contributed without a command —
commands registered through registerCommand are already user-remappable
via [keybindings]), custom note renderers, and session commands are not
contributable yet. Generic top-level CLI trees use registerCliCommand; TUI
commands and their default key bindings use registerCommand. See the
extension architecture for the current host design. The
original exploration records historical rationale and phasing.


File View Jsx Poc (`docs/file-view-jsx-poc.md`)

Fixed-height JSX file-view rows (POC)

This worktree experiments with one constrained escape hatch in the symbolic file-view contract. A validated row may include one atomic component: { height, render } descriptor. Hunk mounts render as a real React/OpenTUI component while keeping the normal review-stream layout declarative and host-owned. Like all Hunk extensions, this is a cooperative trusted-code contract rather than a sandbox.

Contract

  • Layout still happens before paint and supplies stable row IDs plus one inclusive row range for every parsed hunk.
  • Every custom row must retain symbolic spans. They are rendered if the component fails, clipped to the same declared fixed height as the component painter. Symbolic-only layouts continue through the existing renderer unchanged.
  • A component row declares height and renderer atomically as component: { height, render }. render must be a function, one row is limited to 256 terminal lines, and the measured height of all symbolic and component rows together is limited to 100,000 terminal lines. Existing row/span/text limits still apply. An invalid layout falls back to raw diff.
  • Hunk passes only width, fixed height, selected, zero-based rowIndex, and the shared semantic theme palette used by extension sidebars. Theme is paint-only: switching it rerenders mounted painters without relayout or geometry changes. A per-row closure can capture arbitrary parsed or semantic data without adding an opaque payload to the host contract.
  • Hunk mounts component.render inside a fixed component.height/minHeight/maxHeight, flexShrink: 0, overflow-hidden wrapper. No post-mount measurement feeds back into geometry. Stable IDs, hunk bounds, selection, scrolling, and row windowing remain host-owned.

Deliberate limits

  • Hook state is ephemeral paint state. It is retained while a row stays mounted across selected-hunk prop updates. A component outside the host row window is unmounted and loses that state. State also resets for a file-view switch, extension/session reload, width-driven relayout, or any replacement layout generation. Extensions that need a durable review action must use registered commands rather than component-local state.
  • Component resizing is ignored. Content that asks for a different size is clipped to the declared row height. There is no resize callback or measurement pass.
  • Custom rows are non-focusable paint surfaces. They do not own review keyboard input; registered commands and menus are the supported keyboard path and preserve mouse/keyboard parity. Pointer delivery is experimental and cooperative: when normal routing delivers an un-dragged left-button mouse-up, a row may act, call preventDefault(), and stop propagation. Wheel, drag, and every unhandled input remain host-owned for scrolling and copy selection. Hunk provides no focus, portal, renderer, or input-delivery guarantee to custom rows.
  • Inline notes require exact bindings. Rows may expose non-overlapping sourceRanges; Hunk inserts note cards before the uniquely bound row using the same plan for measurement and rendering. If any visible note lacks a bound preferred-side anchor, the complete file temporarily falls back to raw diff rather than hiding or guessing at review data. Draft editing remains raw-only.
  • Error containment is row-local only. Synchronous render/lifecycle errors caught by React's row boundary replace that component with its symbolic spans. Hunk emits one attributed warning per extension, view, file, row, layout generation, and error message through the extension notice path; a new generation may warn again. Event-handler, promise, timer, portal, renderer-global, and other asynchronous errors are outside React error-boundary containment.
  • Clipping is not a security boundary. Host-served runtime modules may expose capabilities outside normal row composition, but file views make no promise that portals, renderer access, focus, or input hooks work. A malicious or careless extension can still affect its trusted process. The POC proves geometry preservation for cooperative components, not enforceable containment of arbitrary code.
  • Cooperative custom rows cannot replace the file section, control outer flex layout, request post-mount geometry changes, or bypass the host's raw fallback and resource validation through normal descendant rendering.

See examples/extensions/jsx-file-view/ for the smallest opt-in hook-using example. The checked-in examples/extensions/jsx-file-view-gallery/ adds three real-diff demos: a responsive TypeScript change atlas, exact-source CSS color swatches, and semantic package.json version highlights.


Keybindings (`docs/keybindings.md`)

Keybindings

Every keyboard shortcut is a named command, and the [keybindings] table maps
command ids to the keys you want them on:

toml
[keybindings]
"hunk.app.quit" = "ctrl+x"               # one chord
"hunk.review.nextHunk" = ["]", "ctrl+n"] # several chords for one command
"hunk.review.focusFilter" = "/"          # takes "/" back from content search
"hunk.view.toggleMenuBar" = false        # unbind it entirely
"myext.toggle" = "ctrl+g"                # extension commands too

Every id starts with the name of whoever owns the command: Hunk's own commands
live under hunk., and an extension's live under its extension id. That split
is structural — hunk is a reserved extension id, so an extension can never
mint a command id that shadows a built-in, whatever Hunk adds later. Extension commands currently
run on review surfaces; history resolves its built-in command set without claiming review-only extension chords.

Rules worth knowing:

  • User bindings replace defaults. Listing chords for a command is the
    complete set of keys it answers to, not an addition to the shipped ones.
  • A key you bind is yours. Any command that held the same chord only as a
    default gives it up, keeping its other keys. Above, hunk.search.find hands
    / to the filter while n / N still step search matches.
  • false (or []) unbinds a command, leaving its keys doing nothing.
  • Unbound commands are one line away. The file filter ships without a key
    (Tab and the File menu reach it), as do hunk.review.nextNote /
    previousNote (} / { step through annotated hunks); one [keybindings]
    line gives any of them a chord.
  • Two entries claiming one chord is a conflict: the first in the file wins and
    the session reports the other. Unknown command ids and unusable chords are
    reported the same way, and the rest of the table still applies.
  • Escape clears an active visual selection contextually after overlays and
    extension modes have had their normal ownership. Without a selection, Escape
    remains available to extension commands because clear-selection is unbound.

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 ("tab", "pageup", "left", "f2").
shift applies to letters and named keys only — for a shifted symbol or digit,
write the character the shift produces ("!", not "shift+1"), since that is
what terminals report. ctrl+<letter> also matches an unnamed bare control
byte; named Tab and Enter events stay distinct. alt/option matches both
explicit Alt events and the Escape-prefixed form used by legacy terminals. A
legacy terminal cannot distinguish Alt from Meta, so alt+n and meta+n may
overlap there; Kitty keyboard events keep them distinct.

Inline saved notes also expose clickable Edit, Reply, and (for reply-free user notes)
Delete actions. E edits the first editable user note in the selected hunk and R replies
to its first visible stored note. Replies inherit the code anchor and may be nested without a
product depth limit. Static sidecar annotations are not reply targets, and a parent cannot be
deleted until its replies are removed.

The built-in commands and the keys they ship with:

On a terminal, hunk log opens its read-only history browser automatically. Its hunk.history.*
commands use the same configurable keybinding resolver as review while retaining history-specific effects.
Shared application commands such as hunk.app.quit, hunk.app.toggleHelp, and
hunk.view.openThemeSelector keep the same ids on both surfaces. F10 opens File, View, Navigate, Commit, and Help menus;
View includes Hunk's shared theme selector and an optional Graph view that replaces the default
day-grouped timeline with commit-topology lanes. It uses Up/Down or j/k to move; press v first to
extend a contiguous commit selection with those same keys. Shift+Up/Shift+Down or uppercase K/J extend
directly, and Escape collapses the selection. PageUp/PageDown, b/f, or Shift+Space/Space page
through history; u/d or Ctrl-U/Ctrl-D move by half a page; g/G or Home/End jump,
/ to search, n/N for matches, t to choose a theme, r to refresh, y to copy the focused commit's full id,
Enter to open the selection in normal Hunk review, and q or Ctrl-C to quit. With a mouse, Shift-click a row to extend the
selection when the terminal forwards modifiers, click a commit id to open it immediately, click the adjacent copy
icon to copy its full immutable id, click elsewhere on a row to select it, or double-click a row to open it.
Range selection is unavailable with --all or author, message, date, and path filters because those traversals
can interleave unrelated commits or hide intermediate commits. Quitting the opened review returns to the retained history selection and viewport. The Commit menu's
Compare with first parent and Compare with parent… actions compare the selected commit against
an ordered provider-owned parent; they do not navigate the history selection to that parent.

History-specific commands:

Command id Does Default keys
hunk.history.openSelection Open the selected commit(s) enter
hunk.history.copyRevision Copy the focused commit id y
hunk.history.refresh Refresh repository history r
hunk.history.previousCommit Move to the previous commit up, k
hunk.history.nextCommit Move to the next commit down, j
hunk.history.startVisualSelection Start visual selection v
hunk.history.clearSelection Clear visual selection escape
hunk.history.extendPrevious Extend selection upward shift+up, K
hunk.history.extendNext Extend selection downward shift+down, J
hunk.history.pageUp Move up one page pageup, b, shift+space
hunk.history.pageDown Move down one page pagedown, space, f
hunk.history.halfPageUp Move up half a page u, ctrl+u
hunk.history.halfPageDown Move down half a page d, ctrl+d
hunk.history.jumpToFirst Jump to the first commit home, g
hunk.history.jumpToLast Jump to the last commit end, G
hunk.history.search Search history /
hunk.history.nextMatch Select the next match n
hunk.history.previousMatch Select the previous match N
hunk.history.toggleGraph Toggle graph presentation (none)
hunk.history.toggleUnicode Toggle Unicode graph lines (none)
hunk.history.toggleAuthor Toggle author metadata (none)
hunk.history.toggleDate Toggle date metadata (none)
hunk.history.toggleDecorations Toggle ref decorations (none)
hunk.history.openFirstParent Compare with first parent (none)
hunk.history.openParent Choose a parent to compare (none)
hunk.history.showAbout Show application information (none)

Review and shared commands:

Command id Does Default keys
hunk.app.openAgentSkill Show agent skill (none)
hunk.app.quit Quit q
hunk.app.refresh Refresh the review r
hunk.app.toggleFocusArea Switch focus between files and filter tab
hunk.app.toggleHelp Toggle help ?
hunk.review.alignCurrentLineBottom Align current line to viewport bottom (none)
hunk.review.alignCurrentLineCenter Center current line in viewport (none)
hunk.review.alignCurrentLineTop Align current line to viewport top (none)
hunk.review.clearSelection Clear the active visual selection (none)
hunk.review.copySelection Copy the active visual selection y
hunk.review.deleteActiveNote Delete active review note D
hunk.review.editActiveNote Edit active review note E
hunk.review.editSelectedFile Open the selected file in your editor e
hunk.review.focusFilter Focus the file filter (none)
hunk.review.halfPageDown Scroll down half a page d, ctrl+d
hunk.review.halfPageUp Scroll up half a page u, ctrl+u
hunk.review.jumpToBottom Jump to end G, end
hunk.review.jumpToTop Jump to start g, home
hunk.review.nextAnnotatedFile Next annotated file (none)
hunk.review.nextAnnotatedHunk Next annotated hunk }
hunk.review.nextFile Next file .
hunk.review.nextHunk Next hunk ]
hunk.review.nextNote Next review note (none)
hunk.review.pageDown Scroll down one page pagedown, space, f
hunk.review.pageUp Scroll up one page pageup, b, shift+space
hunk.review.previousAnnotatedFile Previous annotated file (none)
hunk.review.previousAnnotatedHunk Previous annotated hunk {
hunk.review.previousFile Previous file ,
hunk.review.previousHunk Previous hunk [
hunk.review.previousNote Previous review note (none)
hunk.review.replyToActiveNote Reply to active review note R
hunk.review.saveNote Save a draft review note ctrl+s
hunk.review.scrollCodeLeft Scroll code left (shifted scrolls fast) left, shift+left
hunk.review.scrollCodeRight Scroll code right (shifted scrolls fast) right, shift+right
hunk.review.startNote Add a review note c
hunk.review.startVisualSelection Start visual line selection v
hunk.review.stepDown Move down one line or note down, j
hunk.review.stepUp Move up one line or note up, k
hunk.review.toggleHunkGap Expand or collapse the selected context z
hunk.search.find Search diff content /
hunk.search.next Next search match n
hunk.search.previous Previous search match N
hunk.view.applyFilePresentationToAllMatching Apply current file presentation to all matches (none)
hunk.view.cursorLineNumber Mark the current line number (none)
hunk.view.cursorLineOff Hide the current-line marker (none)
hunk.view.cursorLineRow Highlight the current row (none)
hunk.view.layoutAuto Auto layout 0
hunk.view.layoutSplit Split layout 2
hunk.view.layoutUnified Unified layout 1
hunk.view.openThemeSelector Choose theme t
hunk.view.toggleAgentNotes Toggle agent notes a
hunk.view.toggleCopyDecorations Toggle copy decorations (none)
hunk.view.toggleFilesPane Toggle files pane s
hunk.view.toggleHunkHeaders Toggle hunk headers m
hunk.view.toggleLineNumbers Toggle line numbers l
hunk.view.toggleLineWrap Toggle line wrapping w
hunk.view.toggleMenuBar Toggle menu bar M

The files-pane command follows the named hunk:files role. If an extension
replaces that role, the command and View → Files pane toggle the resolved
replacement on any terminal edge without changing unrelated panes. Remapping or
unbinding hunk.view.toggleFilesPane changes that role-aware action, not an
extension pane's own commands. The former hunk.view.toggleSidebar id remains a
compatibility alias; prefer the files-pane name in new config and extension
code. Likewise, hunk.view.layoutStack remains a deprecated alias for
hunk.view.layoutUnified, so existing keybindings and extension command calls
continue to select the canonical unified layout.

Commands marked (none) ship without a key: they remain callable by command id
and can be assigned a shortcut through [keybindings]. Some also appear in a
menu, while semantic commands such as current-line alignment do not need a menu
entry.

The menus and the controls help dialog (?) show the keys for the commands they
present, so remapping something changes what they advertise. Unbinding a menu
command keeps its menu item and simply stops showing a key.

Extension commands are named <extensionId>.<commandId> and remap the same way
(see docs/extensions.md). An explicitly activated extension
keyboard mode is a routing layer rather than a second command table: it may
consume a key, pass it to these resolved bindings, or consume it and exit. Its
multi-key grammar and counts are extension-owned, but resolved actions should
invoke these same public hunk.* commands.

Routing precedence is host prompts and dialogs, menus/overlays, focused text
inputs, an interactive file-view mode, a session extension keyboard mode, then
the command table and focused review widget. Keys that belong to a dialog,
menu, or focused text input — Esc, Enter — are part of those widgets rather
than commands, and are not remappable. The note composer's save shortcut is the
command hunk.review.saveNote (default ctrl+s) and is remappable; while the
composer is focused it still wins over the command table, using the resolved
chord. Escape is also the reserved exit from each active extension mode, so an
extension cannot trap the keyboard.

toml
[keybindings]
"hunk.review.saveNote" = "ctrl+enter"   # Zellij-friendly; default is ctrl+s

[keybindings] is read from your user config only — never from a repository's
.hunk/config.toml. Which keys do what is a property of your keyboard and your
habits, so a checkout you review cannot rearrange them.


Module Boundaries (`docs/module-boundaries.md`)

Module boundaries

Defines Hunk's current import boundaries and preserves the migration record that established them. The boundaries are enforced by
dependency-cruiser over the production import
graph (packages/hunk/src/ plus packages/, tests excluded):

  • bun run deps:check — fails CI on any boundary violation not in the baseline.
  • bun run deps:baseline — regenerates .dependency-cruiser-known-violations.json after fixing
    a violation. The baseline is shrink-only: entries leave when the underlying edge is fixed,
    and nothing is ever added. New code must respect the boundaries from day one.
  • Rules live in .dependency-cruiser.cjs; each rule's comment states the boundary it protects.

scripts/quality/source-boundaries.test.ts remains the deeper, hand-authored gate for the review seam
(browser-safe closure, Node-debt tombstones). The dependency-cruiser rules are the coarse
tier-level complement, with real module resolution instead of regex import scanning.

Current architecture

The product source follows these tiers, bottom to top. A tier imports only lower tiers unless an
explicit rule below names a facade or adapter exception.

text
packages/hunk/src/extension-api   published contract; imports nothing
packages/hunk-vcs/                private dependency-bottom VCS helpers; explicit leaf exports only
packages/hunk/src/lib             small compatibility helpers
packages/hunk/src/core            product model, non-rendering runtime primitives, and policy
packages/hunk/src/extensions      extension host and bundled registration adapters
packages/hunk/src/session         daemon/broker transport and protocol
packages/hunk/src/app             CLI/startup/session composition; no rendering
packages/hunk/src/ui              terminal surface and named app/session adapters
packages/hunk/src/opentui         published facade re-exporting selected ui/core pieces
packages/hunk/src/main.tsx        final CLI composition and entrypoint

Other workspaces are bounded units rather than one tier in that stack:

  • packages/hunk-{git,jj,sapling} implement private bundled providers through
    hunkdiff/extension and explicit @hunk/vcs/* leaves.
  • packages/session-broker{,-core,-bun,-node} form a runtime-neutral broker stack plus listener
    adapters. They do not import Hunk source internals.
  • packages/term-video owns terminal capture tooling and does not import Hunk source internals.

Current core/ modules are changeset/, documents/, history/, install/, patch/, process/, review/,
run/, theme/, vcs/, and watch/. Its root contains bootstrap.ts, liveComments.ts,
reviewDescriptor.ts, and reviewDigest.ts plus tests.

Intentional exceptions, allowed by the rules:

  • packages/hunk/src/opentui imports packages/hunk/src/ui internals: it is a packaging facade whose job is re-export.
  • packages/hunk/src/hunk-review imports packages/hunk/src/session/agent: the skill document is generated from the agent
    surface by design.
  • Tests are excluded: they are colocated and free to reach across boundaries.

Complete-document browsing uses core/documents/source.ts rather than DiffFile or review
state. app/documents owns filesystem I/O and optional Git metadata; ui/documents consumes
only document capabilities and finalized preferences. HunkSessionHost routes documents,
history, and review in one renderer lifetime. Language selectors live in core/documents;
complete-document syntax paint lives in ui/syntax, and native word-wrap measurement lives in
ui/text. Review/file-view consumers use those same services. The existing public extension
review contracts and broker protocol remain unchanged.

Module interiors

Tier rules say which trees may reach each other; interior rules say which files in a tree
outsiders may name. A module's interior is enforced the same way as a tier — one rule per
protected file, from everything outside the module's directory, to the file — so an
accidental reach-in fails bun run deps:check instead of quietly becoming API.

Two supporting rules keep the interiors honest:

  • no-dead-modules flags any module under packages/hunk/src/ that no entry point reaches
    (main.tsx, highlightWorkerEntry.ts, the opentui and extension-api facades, and the
    skill generator). It uses reachable: false rather than orphan, which only catches fully
    disconnected files and so misses dead code that still imports. A hit is deleted, or — when
    tests are its only genuine consumer — listed in the rule's TEST_ONLY_MODULES allowlist
    with the reason. That allowlist is shrink-only, like the baseline.
  • core-leaves-stay-below-bootstrap freezes the cycle fix below: core/bootstrap.ts
    describes one composed launch, so the core module paths named by the rule may not import it back.
    (Through phase 3 this rule was core-leaves-never-reimport-types and guarded
    core/types.ts; phase 4 melted that shell and repointed the rule at what replaced it.)

Historical migration record

The following dated phases explain why the current rules exist. Paths and inventories describe the
repository at that phase unless a later note updates them.

Phase 0 (2026-08-17) established the mechanism: it deleted core/review/address.ts (a
speculative primitive with no consumers), added the two rules above, and froze the first
interior — core/review/reducer.ts is importable only from within core/review/, because
callers state intent and planReviewIntent owns the transition. Later phases extend the same
pattern across packages/hunk/src/core as its subdirectories take shape; the review model's named modules
(document, identity, geometry, state, …) stay public by design.

Phase 1 (2026-08-17) grouped the changeset model and its acquisition pipeline — twelve loose
files at core/* root — into core/changeset/, with the surface split enforced by
changeset-internals-stay-in-module:

  • Public: model (the Changeset / DiffFile / SidecarContext shapes), loaders
    (every input source, plus the app bootstrap built around one), diffFile, fileSource,
    fileLanguage, binary, diffPaths, hunkHeader, hunkSummary.
  • Interior: fromPatch turns patch text into the model and is reached through loaders;
    fileLanguageLookup is the only reader/writer of Pierre's process-global extension table
    and the import that drags in the diff engine; sidecar reads --agent-context as one step
    of acquiring a changeset.

The renames are path-only — changeset.ts → changeset/model.ts, changesetLoaders.ts →
changeset/loaders.ts, changesetFromPatch.ts → changeset/fromPatch.ts, the rest keep their
basenames — and no exported symbol changed. core/types.ts still re-exports the changeset and
sidecar shapes for legacy import sites (since phase 4: those sites name changeset/model
directly); it names changeset/model, which is public, so the interior rule needs no
exception. core/patch/ stayed where it is: it was already coherent.

Phase 2 (2026-08-17) grouped how a run is asked for into core/run/: the command
inputs (commandInputs), the layered config resolver (config), the app command catalog
(commandCatalog), the invocation errors (errors — "a failure Hunk raises because of how it
was invoked"), launch-scoped experimental features (experimental), XDG/app paths (paths),
tab-width validation (tabWidth), reload eligibility (inputReload), and the CLI version
(version). The move is path-only; no exported symbol changed.

Every one of the nine is public: each has production importers outside the module, so this
phase adds no run-internals-stay-in-module rule. Its value is the grouping plus
extending the freeze: core-leaves-never-reimport-types now names
core/run/commandInputs.ts in place of the old root path. Only commandInputs is a
types-leaf — config, experimental, and inputReload import core/types legally, since
core/types re-exports from commandInputs and never the other way round. (Since phase 4
there is no shell to import: those three name commandInputs directly, and config declares
the config-owned shapes itself.)

commandInputs also absorbed the CLI-input half that was still declared in core/types.ts:
HelpCommandInput, PagerCommandInput, DaemonServeCommandInput, the whole
Session*CommandInput family with SessionSelectorInput / SessionCommandOutput /
SessionCommentApplyItemInput, MarkupRenderCommandInput, MarkupGuideCommandInput, the
Extension*CommandInput family, and ParsedCliInput. Two small aliases came with them because
those inputs name them and the leaf may not import core/types back:
SessionCommentListType and the ReviewNoteSource it unions over. core/types.ts re-exports
all of them, so no import site changed at the time; phase 4 moved the import sites onto
commandInputs and deleted the re-exports.

Phase 3 (2026-08-17) grouped the process and terminal a run lives in into core/process/:
TTY capability detection and runtime CLI-input resolution (terminal), the external pager and
its plain-text fallback (pager), SIGTSTP/SIGINT job control (jobControl), ordered session
teardown (shutdown), .hunk/VCS project-root discovery (projectRoot), the atomically
written app-state file (appStateFile), the version-check notice built on it (updateNotice),
and the startup-notice shape every tier reports through (startupNotice). The move is
path-only; no exported symbol changed, and core/types.ts now re-exports StartupNotice from
process/startupNotice (a re-export nothing ever imported, deleted unused in phase 4).

All eight are public — each has production importers outside the module — so this phase adds
no process-internals-stay-in-module rule; the grouping is its value. The audiences are worth
naming, because they are why these files never belonged at core/* root together with the review
model: terminal, jobControl, shutdown, and updateNotice serve the interactive surface;
pager serves the CLI entry and the startup plan; projectRoot and appStateFile serve the
extension host and config resolution.

After this phase packages/hunk/src/core/ root holds only types.ts, reviewDigest.ts, and liveComments.ts
beside the seven subdirectories. Phase 4 melts what is left of core/types.ts.

Phase 4 (2026-08-17) melted that shell. core/types.ts had stopped declaring most of what it
exported: 147 files imported it, almost all for names phases 1–3 had already moved elsewhere,
and the re-export list was the only thing holding those import sites to a module that no longer
owned the answer. A grab-bag that re-exports is still a grab-bag — every importer binds to it,
so nothing downstream reveals which module it actually depends on.

Every import site was retargeted at the declaring module (mixed statements split one target per
module), the re-exports were deleted, and the file was renamed core/types.ts →
core/bootstrap.ts for what is genuinely left: AppBootstrap and ReloadContext, the contract
a composed launch hands the interactive shell. The stragglers it still declared went to the
module that owns their behaviour, one home each:

  • TerminalThemeMode → core/theme/detection.ts, which probes the terminal for it and had
    been re-exporting the name from the shell.
  • ExtensionsConfig, UserKeyBinding, PersistedViewPreferences → core/run/config.ts,
    which resolves [extensions], [keybindings], and the persisted view options.
  • UserNoteLineTarget → core/liveComments.ts, beside DiffSide and CommentTargetInput: it
    is the line a user note hangs on, and every consumer reaches it through note code.

Deleting the re-exports made one hidden dependency visible: core/review/annotations.ts names
AgentAnnotation, which is declared in packages/hunk/src/extension-api/types.ts because it is
simultaneously an internal model type and part of the published contract. Routing that through
core/types.ts had disguised it as a core-local import, and scripts/quality/source-boundaries.test.ts
("keeps the review model contained in core") caught it the moment the disguise came off. The
allowance is now explicit and narrow — that one file, not the tree — and it cannot widen the
seam, since extension-api-is-import-free forbids extension-api/types.ts any import at all.

Fan-in tells the story: 147 importing files became 28 (13 outside tests) — the review stream,
the diff renderer, and the session surfaces never needed the bootstrap contract, only the
changeset and command-input models they now name. core/bootstrap.ts imports downward into
changeset/model, run/commandInputs, run/config, process/startupNotice,
theme/detection, and vcs/types, and core-leaves-stay-below-bootstrap forbids the reverse
edge from every module directory. One exception is carved out and named in the rule:
core/changeset/loaders.ts returns an AppBootstrap from loadAppBootstrap, so it names the
shape it assembles; that function is composition living in the domain tier, and moving it to
packages/hunk/src/app retires the exception.

packages/hunk/src/core/ root now holds bootstrap.ts, reviewDigest.ts, and liveComments.ts beside the
eight module directories.

Phase 5 (2026-08-18) grouped how this binary was installed and how it gets replaced into
core/install/: install-source detection (installSource — which channel owns the executable),
per-channel release lookup (latestRelease), and the hunk update execution (selfUpdate).
These arrived with the self-update feature as core/process/ files because the startup update
notice lived there, but they are one feature family about the install lifecycle, not about the
process a run lives in. process/updateNotice stays where phase 3 put it — it is a
startup-notice producer built on appStateFile — and consumes core/install for detection and
release lookup. The move is path-only; no exported symbol changed.

Historical snapshot (2026-08-17, v0.19.0)

This snapshot records the migration baseline; it is not a count of current main. At that point the
graph contained 331 production modules, 1322 internal edges, zero boundary violations and zero import
cycles
— the baseline is empty. (The edge count grew from 1282 in phase 4: import sites that
used to funnel through one re-export shell now name the modules they actually depend on, so the
same dependencies are finally visible in the graph.) The initial audit (2026-08-16) found 28
violations in five
clusters and 5 file-level cycles; all were repaid in the same change series that introduced the
rules:

  • Cycles. Each cycle was a type-only back-edge from a lower module into a grab-bag above
    it. The cuts: core/types.ts gave its changeset model to core/changeset.ts (since phase 1,
    core/changeset/model.ts) and its command-input model to core/commandInputs.ts (since phase
    2, core/run/commandInputs.ts; re-exported from core/types so import sites kept
    working, until phase 4 melted that shell into core/bootstrap.ts and moved the sites onto the
    declaring modules); the diff row model moved to
    ui/diff/diffRowModel.ts; the worker's
    compact encoder was retyped structurally (HighlightedHastLines); HunkSessionBrokerClient
    moved beside the client class it aliases; CopySelectedRowRange moved into
    ui/lib/diffSpatial.ts; extensions/notifications.ts now imports ExtensionNotifyType
    from its declaring module.
  • packages/hunk/src/core/cli.ts → packages/hunk/src/app/cli.ts. CLI parsing that registers every tier's command
    surface (including hunk session * from session/agent/surface.ts) is composition, not
    domain — moving it made the core→session edges legal app→session edges.
  • packages/hunk/src/session/app/ → packages/hunk/src/app/session/. The mounted-review registration, bridge, and
    reload-authorization modules compose the app process with the session broker, and nothing
    inside packages/hunk/src/session imported them — they were app-tier code homed on the wrong side.
    Moving the directory removed every session→app edge at once.
  • packages/hunk/src/lib/reviewDigest.ts → packages/hunk/src/core/reviewDigest.ts. The Node digest implementation
    is review-semantic and platform-bound; core root (Node-full, outside the platform-free
    core/review/ seam) is its tier.
  • ui/lib/reviewState.ts resolves session-daemon navigation for the adapter hooks and is
    now a named entry in the adapter allowlist rather than an accidental reach-in.
  • The bundled sidebar's packages/hunk/src/ui imports are documented design, not debt. Its module
    header defines the dogfooding boundary as the published props contract (data, actions,
    theme); rendering helpers are host code. The rules now encode exactly that:
    packages/hunk/src/extensions/default/ui/ may consume packages/hunk/src/ui, and still may never touch
    packages/hunk/src/app/packages/hunk/src/session.

After the baseline: next targets

The tier rules now hold with no exceptions. Two follow-ups are worth doing next:

  1. Give packages/hunk/src/core an interior. Done (phases 0–4, see Module interiors). Every group is a
    module directory — review/, vcs/, theme/, watch/, patch/, changeset/,
    history/, install/, run/, process/ — and core/* root contains bootstrap.ts,
    reviewDigest.ts, reviewDescriptor.ts, and liveComments.ts, with no grab-bag re-export shell. What remains is per-file public
    surfaces for the modules that never got one: changeset has
    changeset-internals-stay-in-module and review has review-reducer-is-module-internal,
    while run, process, theme, vcs, watch, and patch are still public in full
    because every file in them has an outside importer today. Two named follow-ups: move
    loadAppBootstrap out of core/changeset/loaders.ts into packages/hunk/src/app (it is composition, and
    it is the one exception core-leaves-stay-below-bootstrap has to carve out), and split
    core/run/config.ts, whose readers reach it for three unrelated reasons — the
    resolved HunkConfigResolution, the persisted view preferences, and the extension/keybinding
    tables. The review seam's named modules (document, geometry, state, …) stay public;
    their helpers become internal.
  2. Tighten the adapter allowlist. ui-couples-to-session-via-adapters names its current files
    in UI_SESSION_ADAPTERS inside .dependency-cruiser.cjs. As session coupling consolidates into
    useTerminalReview / useHunkSessionBridge, shrink that list rather than copying a count here.

Open (`docs/open.md`)

Complete-document browsing

hunk open [path] views a file or browses a directory. The default path is .. It requires an
interactive terminal, but not a repository or a diff.

bash
hunk open README.md
hunk open ~/project
hunk open hypr/ --theme nord
hunk open file.ts --wrap --tab-width 4

Controls

  • Arrow keys or j/k: move through the focused tree or document.
  • Enter/Space: open a file or expand/collapse a directory.
  • Left/Right: collapse/expand the selected directory, or scroll the focused document horizontally.
  • Tab: switch tree/document focus. s: toggle the tree.
  • PageUp/PageDown, g/G, Home/End: scroll the focused surface.
  • i: toggle hidden and Git-ignored entries together. Hidden entries are names starting with ..
  • e: edit a private copy of the displayed regular file in $EDITOR, using the visible line as
    the editor target; save back when the editor exits.
  • t, l, w, ?, r, q: themes, line numbers, wrapping, help, refresh, quit.
  • File/View/Help menus provide the same actions with mouse support. F10 opens the menu by keyboard.

Common app/view commands keep their existing command ids. Document actions use hunk.documents.*:
activate, edit, stepDown, stepUp, pageDown, pageUp, jumpToTop, jumpToBottom,
scrollLeft, scrollRight, and toggleExcluded. User [keybindings] remaps apply to menus and help.

toml
[open]
theme = "nord"
wrap_lines = true

[keybindings]
"hunk.documents.stepDown" = "ctrl+n"
"hunk.documents.stepUp" = "ctrl+p"

Filesystem policy

Only immediate children of expanded directories are read. The document viewer mounts a bounded
row window and reads only the selected document. Refresh preserves the displayed document and
scroll position; selecting a directory does not replace the document already displayed.

In a Git worktree, Git supplies ignore rules and lightweight status markers. Metadata queries are
optional, bounded, and asynchronous; repository fsmonitor hooks and clean/process filters are
disabled. Status queries do not recurse into submodules or fetch missing objects. Ordinary
directory browsing works without Git. An interrupted or failed ignore query makes that directory
unavailable rather than treating partial ignore output as complete. Hidden and
ignored entries are initially excluded, but an explicitly named hidden file still opens normally.

Symlinks are listed but not followed, including explicitly opened links and symlinked descendants.
Devices and other non-regular entries are not opened. Binary/non-UTF-8 files, files over 1 MiB,
and missing or unreadable entries show a placeholder. Each directory is limited to 10,000 entries
and at most 128 directories may be expanded. Source reads remain bounded when a file grows while
it is being read. Terminal control sequences are stripped before painting.

On Linux, directory enumeration uses the checked directory's retained handle. Other platforms
recheck ancestry and directory identity before publishing entries, but portable Node APIs do not
provide handle-relative directory enumeration. Browsing is not a sandbox against a hostile process
that can repeatedly replace and restore ancestors; use it on trusted local directories.

Editors receive a private temporary copy, not a checked collection path. Hunk retains the original
file handle and checks that the file identity and content still match before writeback. Detected
replacement or modification cancels writeback and retains the edited copy at the path shown in the
notice. Editor errors also retain that copy. Known GUI editors receive --wait; custom editor
wrappers must wait until editing finishes. Each document browser permits one active editor action;
repeated editor actions in that browser are rejected while it is active. Quitting waits for the editor transaction to settle, and late
failures/recovery paths are printed after the terminal is restored. $EDITOR itself is trusted
code, not sandboxed.

Updated Hunk sessions in the same OS account and process namespace coordinate validation and writeback with
private, inode-keyed save claims under the OS account home's .config/hunk/document-save-claims/
directory. This state directory does not create a .hunk project marker in the account home.
This location is independent of per-session HOME, XDG and temporary-directory overrides. Account
lookup uses OS records, including built-in PowerShell without profiles on Windows; lookup or private
directory failures refuse writeback and retain the edited copy. Competing
saves are rejected with their editor copies retained; claims left by definitely dead processes are
removed. An unrelated process reusing an owner's PID can conservatively delay saving until it exits.
Save claims do not coordinate different hosts/accounts or container process namespaces, older Hunk
versions, or non-Hunk writers.
Those writers can still race the final conflict check, and in-place writeback is not crash-atomic.

Expanded directories and the displayed file's parent are observed for changes, covering atomic
file replacements and deletions. Watch notifications are debounced; manual and watch refreshes
serialize. A failed watcher can be worked around with r. Shutdown cancels pending source work,
releases observers, and waits for started reads to settle before tearing down the terminal.

Architecture and current boundaries

core/documents defines opaque entry identities, complete text and availability results, and lazy
source capabilities. Filesystem operations and Git metadata live in app/documents; the browser
controller and geometry live in ui/documents. The existing session host routes this surface
alongside history and review, reusing terminal lifecycle, theme ownership, menu components,
keymaps, editor launching, syntax paint, and exact native text measurement.

Browsing never constructs DiffFile, hunks, or ReviewDocument, and it does not publish a review
session to the broker. User extensions remain review-specific and are not loaded by open.
The source contract is currently internal; a future public document extension API should expose
explicit capabilities without weakening review navigation or workspace-write policies.

Stdin, multiple path arguments, and file:line addressing are deferred. The browser writes only
when saving a private copy from an explicitly launched external editor; it does not reopen the
collection path for writeback.


Opentui Component (`docs/opentui-component.md`)

OpenTUI component

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

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

Install

bash
npm i hunkdiff @pierre/[email protected] @opentui/core@^0.5.6 @opentui/react@^0.5.6 react

hunkdiff declares Pierre diffs, OpenTUI, and React as peer dependencies, so install them in your app. Pierre diffs is optional for CLI-only installs but required when importing hunkdiff/opentui.

Quick start

tsx
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { HunkDiffView, createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";

const metadata = parseDiffFromFile(
  {
    cacheKey: "before",
    contents: "export const value = 1;\n",
    name: "example.ts",
  },
  {
    cacheKey: "after",
    contents: "export const value = 2;\nexport const added = true;\n",
    name: "example.ts",
  },
  { context: 3 },
  true,
);

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

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

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

Convenience vs primitives

`HunkDiffView`

HunkDiffView renders one file and can own an OpenTUI scrollbox:

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

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

`HunkDiffBody`

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

tsx
<scrollbox width="100%" height="100%" scrollY>
  <HunkDiffBody file={file} width={88} canonicalLayout="unified" selectedHunkIndex={2} />
</scrollbox>

Use it when your app owns scrolling or surrounding layout.

`HunkDiffFileHeader`

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

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

`HunkReviewStream`

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

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

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

`HunkFileNav`

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

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

Building file inputs

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

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

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

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

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

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

From before/after contents

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

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

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

From unified diff text

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

tsx
import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui";

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

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

Common props

Use canonicalLayout in new integrations. The legacy layout prop and HunkDiffLayout type keep
their original split | stack shape so existing exhaustive TypeScript consumers continue to
compile; when both props are present, canonicalLayout wins.

Prop Type Default Notes
canonicalLayout "split" | "unified" "split" Chooses side-by-side or unified rendering in new integrations.
layout "split" | "stack" undefined Legacy source-compatible input; stack renders as unified.
width number — Required content width in terminal columns.
theme "graphite" | "midnight" | "paper" | "ember" | "catppuccin-latte" | "catppuccin-frappe" | "catppuccin-macchiato" | "catppuccin-mocha" | "zenburn" "graphite" Matches Hunk's built-in themes.
showLineNumbers boolean true Toggles line-number columns.
showHunkHeaders boolean true Toggles @@ ... @@ hunk header rows.
tabWidth number 4 Sets source-code tab stops from 1 to 16 columns.
fileGap number 1 Rows between files in HunkReviewStream, including the ─ rule. 0 hides it.
hunkGap number 0 Blank rows before each hunk after the first.
showFileSeparators boolean true Toggles separator rows between files in HunkReviewStream.
wrapLines boolean false Wraps long lines instead of clipping horizontally.
horizontalOffset number 0 Scroll offset for non-wrapped code rows.
highlight boolean true Enables syntax highlighting.
selectedHunkIndex number 0 Highlights one hunk as the active target for single-file components.
scrollable boolean true HunkDiffView only; primitives should be wrapped in OpenTUI scrollbox when needed.

Other exports

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

parseDiffFromFile, parsePatchFiles, and FileDiffMetadata are re-exported from the @pierre/diffs peer dependency so the component and your app share one diff implementation.

Examples

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


Selective Extension Control Plan (`docs/selective-extension-control-plan.md`)

Selective extension control plan

Status

Implemented in this branch for the shared selection model, configuration and CLI overrides,
hunk.gh, and user extensions (phases 1–3). Bundled VCS/UI selection and management commands
(phases 4–5) remain follow-up work; the stable ids below reserve their intended names without
claiming those factories are filtered yet.

Goal

Let users disable or re-enable one Hunk extension without uninstalling it, including extensions
compiled into Hunk. Apply the decision before extension code executes, preserve the existing trust
model, and keep --no-extensions compatible with its current meaning.

The first bundled consumer is hunk gh, but the mechanism must also cover bundled VCS and UI
extensions and user-installed extensions without introducing feature-specific settings.

Product decisions

Stable selection identities

Give every independently selectable bundled extension a stable host-owned identity:

Capability Selection ID
GitHub commands hunk.gh
Git provider hunk.vcs.git
Jujutsu provider hunk.vcs.jj
Sapling provider hunk.vcs.sl
Content search hunk.search
Files pane hunk.files
Review-info panes hunk.review-info

User extensions keep their discovered IDs, such as review-triage.

A selection identity is not a registration namespace. Bundled UI registrations may continue to
own commands and panes under the hunk vendor namespace while being selected independently. This
avoids changing public command IDs such as hunk.search.find or pane IDs such as hunk:files.

Configuration

Add an exact-ID deny-list to [extensions]:

toml
[extensions]
enabled = true
disabled = ["hunk.gh", "hunk.search"]
paths = ["~/dev/my-extension"]

Keep the existing master switch:

toml
[extensions]
enabled = false

enabled = false continues to disable user-extension discovery and loading. Bundled extensions
remain available unless selected by disabled.

Do not interpret enabled inside [extension.]. Those tables remain opaque configuration
owned by the extension:

toml
[extension.review-triage]
some_setting = true

Start with exact IDs rather than wildcard rules. Exact matching keeps diagnostics, layering, and
configuration editing deterministic. A future release can add patterns if concrete use cases
justify them.

Configuration layering

Combine user and repository disabled lists as a union:

text
effective disabled = user disabled ∪ repository disabled ∪ CLI disables

A repository may further restrict extension execution but cannot re-enable an extension disabled
by the user. This keeps repository-controlled configuration from escalating extension authority.

An explicit CLI enable is the only one-run override:

text
effective enabled override = explicit CLI enables

Within CLI arguments, the last enable/disable occurrence for one identity wins.

CLI overrides

Add repeatable global options:

bash
hunk --disable-extension hunk.gh gh pr 123
hunk diff --disable-extension hunk.search
hunk diff --enable-extension hunk.search

Multiple identities can be selected:

bash
hunk diff \
  --disable-extension hunk.search \
  --disable-extension review-triage

Keep --no-extensions as the hard one-run switch for user extensions. It does not disable bundled
extensions. An explicit --extension still obeys the effective disabled set; pair it with
--enable-extension to override a persistent disable deliberately.

Management commands

Extend extension management with inspection and persistent state changes:

bash
hunk extension list
hunk extension list --builtin
hunk extension list --all

hunk extension disable hunk.gh
hunk extension enable hunk.gh

hunk extension disable review-triage --repo
hunk extension enable review-triage --repo

The default mutation scope is the user config. --repo edits .hunk/config.toml. remove remains
limited to installed extensions; bundled extensions can be disabled but not removed.

List output should expose identity, source, and effective state:

text
ID                    SOURCE      STATE
hunk.gh               bundled     disabled (user config)
hunk.search           bundled     enabled
hunk.vcs.git          bundled     enabled
review-triage         installed   disabled (repo config)

Config mutation must preserve unrelated TOML content and use the same serialization and atomic-write
policy as other Hunk-owned config updates. If no safe writer exists when this work begins, ship
read-only config plus one-run flags first and defer extension enable/disable rather than adding a
lossy editor.

Disabled command diagnostics

Keep enough static bundled metadata to recognize a command owned by a disabled extension. Do not
report it as an unknown command:

text
hunk: Extension "hunk.gh" is disabled.

Enable it for this run:
  hunk --enable-extension hunk.gh gh pr 123

Enable it permanently:
  hunk extension enable hunk.gh

Bare hunk --help remains static and may continue listing bundled commands. It should not load
configuration or extension code merely to annotate disabled entries.

VCS providers

Allow bundled VCS providers to be disabled. Raw files and patches must continue to work without a
VCS provider. When repository detection finds only a disabled provider, report that provider and an
actionable one-run enable command:

text
No enabled VCS provider recognizes this repository.

Disabled matching provider: hunk.vcs.jj
Enable it with:
  hunk --enable-extension hunk.vcs.jj diff

Do not model indispensable host machinery as an extension. Anything that cannot sensibly be
disabled should remain host code rather than receive a selectable identity.

Architecture

Bundled definition catalog

Introduce a renderer-free catalog of bundled extension definitions. Each definition carries:

ts
interface BundledExtensionDefinition {
  selectionId: string;
  registrationId: string;
  sourcePath: string;
  kind: "core" | "vcs" | "ui";
  factory: ExtensionFactory;
  commands?: readonly string[];
}

selectionId controls enablement. registrationId preserves public command, pane, and config
namespaces. commands is bounded static metadata used only for disabled-command diagnostics; the
factory remains authoritative when enabled.

The catalog must not merge VCS, core CLI, and UI lifecycles. Existing composition owners continue
to create their own registries:

  • bundled VCS loads before configuration-dependent repository resolution;
  • bundled core CLI capabilities use the session-owned extension lifetime;
  • bundled UI remains process-owned and renderer-local.

All three consume the same resolved selection policy before running factories.

Selection policy

Add a small platform-free policy module that accepts:

  • known bundled definitions;
  • discovered user candidates;
  • user-layer disabled IDs;
  • repository-layer disabled IDs;
  • ordered CLI enable/disable operations;
  • the existing user-extension master switch.

It returns an immutable decision per identity with the reason and source:

ts
interface ExtensionSelectionDecision {
  id: string;
  enabled: boolean;
  source: "default" | "user-config" | "repo-config" | "cli" | "master-switch";
}

Keep this policy separate from filesystem discovery, trust decisions, module imports, and registry
mutation. Callers apply decisions before import or factory execution.

Loading order

For every startup path:

  1. Resolve user and repository configuration.
  2. Parse ordered CLI extension overrides.
  3. Resolve bundled selection decisions.
  4. Run only enabled bundled factories in their existing lifecycle owners.
  5. Discover user candidates when the existing master switch permits it.
  6. Resolve candidate IDs and filter disabled candidates before module import or trust prompting.
  7. Trust-gate and import only remaining user candidates.
  8. Resolve registration collisions among enabled extensions.

A disabled user extension must not be imported. A disabled repository extension must not trigger a
trust prompt. A statically bundled module may exist in the binary, but its factory must not run and
it must register no handlers or resources.

Reload and lifetime invariants

Include the resolved selection snapshot in extension load compatibility. A change to effective
enablement requires registry replacement even when discovered candidates and extension config are
unchanged.

Preserve these invariants:

  • delegated hunk gh patches live until the session owning the enabled factory shuts down;
  • disabling hunk.gh on a later startup never runs its factory;
  • disabling an active extension during config-driven reload revokes its capabilities before
    shutdown;
  • staged root refinement does not execute an unchanged enabled factory twice;
  • registry retirement remains idempotent.

Implementation phases

Phase 1: selection model and configuration

  1. Add disabled: readonly string[] to resolved extension configuration.
  2. Parse exact non-empty IDs from user and repository [extensions] tables.
  3. Add ordered CLI override representation to command inputs.
  4. Implement and unit-test the pure selection policy.
  5. Document precedence, repository narrowing, and the distinction from [extension.].

Acceptance:

  • invalid list members are rejected or normalized consistently with existing config policy;
  • user and repository disables union correctly;
  • CLI enable and disable operations resolve last-wins per ID;
  • existing configs behave identically when disabled is absent.

Phase 2: bundled definitions and `hunk.gh`

  1. Give the bundled GitHub definition selection ID hunk.gh.
  2. Keep its current registration namespace and session-owned resource lifetime.
  3. Filter it before createGitHubPrExtension runs.
  4. Retain static ownership metadata for gh diagnostics.
  5. Add the disabled-command error and one-run recovery suggestion.

Acceptance:

  • [extensions] disabled = ["hunk.gh"] prevents gh registration;
  • hunk gh ... reports disabled, not unknown;
  • --enable-extension hunk.gh restores it for one invocation;
  • --no-extensions alone does not disable it;
  • no GitHub temporary directory or network request is created while disabled.

Phase 3: user extensions

  1. Apply exact-ID selection after discovery identifies a candidate and before import.
  2. Ensure explicit --extension paths obey the same policy.
  3. Suppress repo trust prompts when every repo candidate is disabled.
  4. Carry selection state through staged loading and registry replacement.

Acceptance:

  • disabled modules cannot run top-level code;
  • disabled repo extensions do not prompt for trust;
  • explicit paths require --enable-extension when persistently disabled;
  • enabled candidates retain existing ordering and collision behavior.

Phase 4: bundled UI and VCS

  1. Add selection identities to the bundled VCS definitions.
  2. Filter providers before catalog construction and project-root detection.
  3. Add actionable diagnostics when only disabled providers match.
  4. Add selection identities to independently optional bundled UI factories.
  5. Keep registration IDs stable so commands, panes, and keybindings do not change.

Acceptance:

  • disabling JJ or Sapling removes it from detection without affecting Git;
  • explicit --vcs reports when the corresponding extension is disabled;
  • disabling search removes its commands and marks while review remains usable;
  • disabling review-info or files panes does not change unrelated geometry or commands.

Phase 5: management UX

  1. Extend extension list with bundled and effective-state views.
  2. Add safe user-config mutations for extension enable/disable.
  3. Add explicit --repo mutation scope only if TOML preservation is reliable.
  4. Surface stale IDs in listings without treating them as fatal configuration errors.

Acceptance:

  • list output distinguishes bundled, installed, config-path, flag, and repo sources;
  • mutation preserves comments and unrelated config or refuses safely;
  • enabling removes the relevant disable entry rather than writing extension-owned config;
  • bundled extensions cannot be removed accidentally.

Documentation updates

Update together:

  • docs/extensions.md for configuration, flags, management commands, and trust behavior;
  • docs/extension-architecture.md for selection identity versus registration identity;
  • generated CLI/config reference sources and website companion pages;
  • packages/hunk/skills/hunk-extensions/SKILL.md;
  • root and package READMEs if hunk gh examples need recovery instructions;
  • a user-visible Changeset.

Test plan

Unit coverage

  • config parsing and layer union;
  • ordered CLI overrides;
  • pure selection decisions and reason attribution;
  • bundled catalog uniqueness and command ownership metadata;
  • disabled-command diagnostics;
  • load compatibility when selection changes;
  • VCS detection with disabled providers;
  • list projection and config mutation.

Integration coverage

  • hunk --disable-extension hunk.gh gh --help reports disabled;
  • config-disabled hunk.gh plus CLI enable reaches bundled help;
  • disabled explicit user extension never evaluates its module;
  • disabled repo extension never prompts for trust;
  • enabled hunk gh delegation retains and cleans its temporary patch;
  • compiled headless disabled-command paths do not extract OpenTUI;
  • PTY review remains usable with optional bundled UI extensions disabled.

Verification

Run the standard relevant gates:

bash
bun run format:check
bun run lint
bun run typecheck
bun run deps:check
bun run test
bun run test:integration
bun run check:pack
bun run website:check

Run the real-TTY smoke suite when bundled UI selection changes.

Rollout

Ship exact-ID disabling first. Do not add wildcard selectors until the stable bundled identity list
has shipped and real configuration use demonstrates a need. Preserve unknown disabled IDs so a
config can cover an extension that is temporarily absent or installed later; show them as stale in
hunk extension list --all rather than silently deleting user intent.


Session Broker Sdk (`docs/session-broker-sdk.md`)

Session broker SDK contract

This document defines the implementation and migration contract for Hunk's reusable local session
broker SDK. Must, must not, should, and may are normative. Current code does not yet
meet every requirement; security and compatibility requirements are release gates.

Product contract

One daemon serves all live sessions/windows for exactly one immutable application identity. The
daemon owns transport, authentication, runtime validation, routing, correlation, reconnect state,
resource bounds, lifecycle, and diagnostics. The application owns payload schemas, command
semantics, authorization/approval policy, and semantic results. {appId, sessionId} is the durable
routing namespace; the daemon must never become a cross-application hub.

Supported environments

The first public release is ESM-only built JavaScript with declarations and supports Node 22+, Bun
1.3.14+, and non-PTY behavior on macOS, Linux, and Windows. Server processes require WHATWG
Request, Response, streams, and browser-like WebSocket clients. Browser, edge, Deno,
service-worker, and CommonJS builds are unsupported; runtime entries must not expose TypeScript
source.

The export map orders types, bun, node, then default, selecting Bun before its Node-compatible
conditions. default throws a clear unsupported-runtime error when neither runtime condition is
asserted. Selected entries validate
their minimum version at startup because export conditions neither enforce versions nor prove
runtime identity.

Non-goals

The first contract excludes remote operation, TLS/proxy trust, cross-machine discovery, service
installation or a system-wide privileged broker, generic UI dispatch/control discovery or an
approval UI, exactly-once execution, and a generic resource protocol without a second non-Hunk
consumer. Hunk retains review documents,
intents, comments, highlights, navigation, reload, resources/SSE, browser-review capabilities, and
all associated UI semantics.

Ownership and package topology

Publish one package, @hunk/session-broker, with protocol/state, daemon/connection, managed-host,
and Node/Bun adapter boundaries kept as internal modules. Its only first-release entry point exports
the shared primitives, broker APIs, supervision, managed producer API, and an automatically selected
serveSessionBrokerDaemon. Hunk composes the package; the package never imports packages/hunk/src/*.

Publication requires verified npm @hunk scope ownership and trusted publishing. An approved
replacement name must not alter appId, wire identity, or runtime namespaces.

Portable server API

Both conditional entries re-export the same declarations and implement this narrower-than-native
contract:

ts
interface SessionBrokerDaemonAddress {
  hostname: string;
  port: number;
}
interface RunningSessionBrokerDaemon {
  readonly stopped: Promise<void>;
  address(): SessionBrokerDaemonAddress;
  stop(options?: { force?: boolean; drainTimeoutMs?: number }): Promise<void>;
}
interface ServeSessionBrokerDaemonOptions<Daemon> {
  daemon: Daemon;
  hostname: string;
  port: number;
  handleRequest?: (request: Request) => Response | Promise<Response | undefined> | undefined;
  notFound?: (request: Request) => Response | Promise<Response>;
  formatServeError?: (error: unknown, address: SessionBrokerDaemonAddress) => Error;
}
declare function serveSessionBrokerDaemon<Daemon>(
  options: ServeSessionBrokerDaemonOptions<Daemon>,
): Promise<RunningSessionBrokerDaemon>;

Both implementations resolve only after binding, return a non-null portable address, hide native
handles, and make concurrent stop() calls share one idempotent completion. Request hooks receive
only a WHATWG Request. The same consumer source must typecheck and pass lifecycle tests unchanged
under Node and Bun. Resolution uses export conditions, not a post-import typeof Bun branch. The
single-package convenience tradeoff permits Bun installations to contain ws, but Bun must never
import or execute it.

Process discovery/launch remains internally separate from protocol/state and the daemon engine.
Applications inject launch commands, environment, compatibility copy, and approval policy.

Build and release model

The pa