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.
- 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:
curl -fsSL https://hunk.dev/install.sh | shWindows users can install with npm or mise. Other installation methods are also available:
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 viamodem-dev/tap, be sure to uninstall it first withbrew 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+):
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 releaseOn 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
hunk # show help
hunk --version # print the installed versionWorking with Git
Use familiar Git commands to open changes in Hunk:
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 snapshothunk 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:
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/hunkPublic 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
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 repositoryUse 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
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 stdinWorking with agents
- Install the review skill once:
npx skills add modem-dev/hunk -g --skill hunk-review. - Open Hunk in another terminal with
hunk difforhunk show. - Ask your agent to use the hunk-review skill against the live Hunk session.
- Press
ain 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.tomlin 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:
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 = falseSee 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 ownhunk diffworking-tree loader. If you opengit diffthroughhunk pager, Git still decides the patch contents, so untracked files will not appear there.
git config --global core.pager "hunk pager"Or in your Git config:
[core]
pager = hunk pagerIf you want to keep Git's default pager and add opt-in aliases instead:
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:
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"Sapling pager integration
To use Hunk as Sapling's pager, run sl config -u and update:
[pager]
pager = hunk pagerExtensions (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:
// ~/.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 ahunk-extension GitHub topic marks community ones:
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.
Sponsor
Sponsored by Modem.
License
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:
bun run changesetSelect hunkdiff and choose the semver bump that matches the shipped CLI/package change:
patchfor fixes and small behavior changesminorfor new user-facing featuresmajorfor 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:
bun run changeset -- --emptyRelease prep runs:
bun run release:versionCHANGELOG.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:
bun run bench -- --samples 3 --out benchmarks/results/local.jsonRun optional competitor comparisons when the tools are installed:
bun run bench -- --samples 3 --include-competitors --out benchmarks/results/local-with-competitors.jsonInclude 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:
bun run bench -- --samples 1 --include-huge --out benchmarks/results/local-with-huge.jsonGenerate the committed release benchmark snapshot during release prep:
bun run bench:release
bun run bench:release:compareRun focused scripts while iterating:
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:competitorsScripts
startup-first-frame.ts— launcheshunk diffin 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 reportsstartup_first_color_ms(spawn to the first keyword-colored cell) andstartup_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). SetHUNK_BENCHMARK_EXECUTABLEto 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 normalizedDiffFileconstruction 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 ofmemory.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 equivalentstring-widthreference paths, verifying identical width checksums.huge-stream.ts— opt-in huge tier (--include-hugeorHUNK_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 againstgit diff --no-ext-diff,delta,difftastic, anddiff-so-fancywhen installed.large-stream-fixture.tsandlib/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:
{
"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.tsandinteraction-latency.ts; the huge tier (1,000 files × 300 lines + one 50,000-line file) backshuge-stream.tsand 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 5locally when validating borderline changes. - Set
HUNK_BENCHMARK_TRACE=1on 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
extensions/cli-tools/demonstrates the minimal generic CLI exit and delegation contracts.extensions/review-triage/adds a session-local hunk triage sidebar.extensions/review-note-navigator/inventories every saved ReviewStore note and navigates to visible authoritative anchors.extensions/review-snapshot-export/exports stable file identities and every saved ReviewStore note with a stale-work guard.extensions/rendered-markdown/adds an optional parsed Markdown file presentation.extensions/inline-edit/edits the file under review in place, composing a file-view mode, layout refresh, and host-mediated workspace writes.extensions/jsx-file-view/is the smallest hook-using fixed-row JSX proof of concept.extensions/jsx-file-view-gallery/runs three constrained-JSX presentations against checked-in TypeScript, CSS, andpackage.jsondiffs: an impact atlas, real color swatches, and highlighted dependency versions.
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.patchfiles, so you can open them without creating a temporary repo. - The agent demo also includes an
agent-context.jsonsidecar 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.tsxif you want the same demo driven by raw unified diff text instead ofbefore/aftercontents. - 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
- Add Hunk to your nix flake inputs like such:
{
inputs = {
hunk = {
url = "github:modem-dev/hunk";
inputs.nixpkgs.follows = "nixpkgs";
};
}
}- Use in NixOS
environment.systemPackages:
{
environment.systemPackages = [
inputs.hunk.packages.${pkgs.stdenv.hostPlatform.system}.hunk
];
}Or in Home Manager home.packages:
{
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.
- Add the module to your Home Manager configuration:
{
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:
nix run github:modem-dev/hunk -- --helpUpdating Hunk
Flake users update Hunk by updating their own pinned flake.lock input:
nix flake lock --update-input hunkSupported 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:
{
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:
nix build .#hunkMaintainer dependency updates
When JavaScript or Bun dependencies change, regenerate the Nix dependency lockfile and commit it with the dependency change:
bun run nix:update-lockThis 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 rootpackage.json when one exists.
benchmarks/— release and daemon benchmark toolingbuild/— source builds and local binary installationdev/— manual development probes and fixturesgenerate/— checked-in documentation, skill, changelog, and theme generationlaunch-video/— launch-video capture and compositionpackaging/— package assembly, validation, publication, and install checksquality/— repository architecture, package-contract, and example conformance testsrelease/— Changesets and release-channel validationtest/— default test-suite and compatibility runnerswebsite/— 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
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 checksPlacement
- 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/, andtest/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:
bun run build:bin
HUNK_TEST_BINARY="$PWD/dist/hunk" bun test test/cli/npm-extension-install.test.tsHUNK_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:
bun test test/pty/shell-completion-integration.test.tsSet 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:
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:
bun run bench:releaseThis writes:
benchmarks/release/bench-x.y.z.jsonThen compare it against the latest lower stable release snapshot:
bun run bench:release:compareCommit 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
hunk diff --files examples/1-hello-diff/before.ts examples/1-hello-diff/after.tsWhat 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
hunk patch examples/2-mini-app-refactor/change.patchWhat 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
hunk patch examples/3-agent-review-demo/change.patch \
--agent-context examples/3-agent-review-demo/agent-context.jsonWhat 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
hunk diff --files examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsxWhat 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
hunk diff --files examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts --pagerWhat to look for
- enough changed content to exceed a normal terminal viewport
↑and↓for line-by-line movementPageUp,PageDown,Home, andEndfor 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
hunk patch examples/6-readme-screenshot/change.patch \
--agent-context examples/6-readme-screenshot/agent-context.json \
--mode split \
--theme github-dark-defaultScreenshot 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
bun run examples/7-opentui-component/from-files.tsx
bun run examples/7-opentui-component/from-patch.tsxWhat it shows
- embedding
HunkDiffViewinside a normal OpenTUI app shell - building
diff.metadatawithparseDiffFromFile - 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
bun run examples/8-opentui-primitives/primitives-demo.tsxWhat it shows
createHunkDiffFilesFromPatchfor turning unified diff text into public Hunk file modelsHunkFileNavfor a standalone file listHunkReviewStreamfor a multi-file review stream without Hunk's menu bar or global shortcutsHunkDiffFileHeaderandHunkDiffBodyfor 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:
hunk patch examples/9-agent-markup-notes/change.patch \
--agent-context examples/9-agent-markup-notes/agent-context.jsonPress a to reveal the agent notes for the selected hunk.
The same markup works for live comments from an agent driving a session:
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>' \
--focusLearn and iterate from the CLI:
hunk markup guide # authoring guide with copy-paste patterns
echo '<badge color="success">OK</badge> ready' | \
hunk markup render - --width 56 # preview before publishingTags: 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 inpackages/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.
- 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:
curl -fsSL https://hunk.dev/install.sh | shWindows users can install with npm or mise. Other installation methods are also available:
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 viamodem-dev/tap, be sure to uninstall it first withbrew 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+):
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 releaseOn 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
hunk # show help
hunk --version # print the installed versionWorking with Git
Use familiar Git commands to open changes in Hunk:
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 snapshothunk 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:
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/hunkPublic 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
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 repositoryUse 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
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 stdinWorking with agents
- Install the review skill once:
npx skills add modem-dev/hunk -g --skill hunk-review. - Open Hunk in another terminal with
hunk difforhunk show. - Ask your agent to use the hunk-review skill against the live Hunk session.
- Press
ain 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.tomlin 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:
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 = falseSee 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 ownhunk diffworking-tree loader. If you opengit diffthroughhunk pager, Git still decides the patch contents, so untracked files will not appear there.
git config --global core.pager "hunk pager"Or in your Git config:
[core]
pager = hunk pagerIf you want to keep Git's default pager and add opt-in aliases instead:
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:
[ui]
pager = ["hunk", "pager"]
diff-formatter = ":git"Sapling pager integration
To use Hunk as Sapling's pager, run sl config -u and update:
[pager]
pager = hunk pagerExtensions (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:
// ~/.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 ahunk-extension GitHub topic marks community ones:
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.
Sponsor
Sponsored by Modem.
License
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
stoppedpromise 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:
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.
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, andrepoRoot - 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
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
SessionBrokerDaemonthrough 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:
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
SessionBrokerraw session registrySessionBrokerDaemonruntime-neutral daemon engineSessionBrokerConnectionruntime-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/wslistener 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
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
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 appauthorizer are supplied. The includedSessionBrokerAuthenticator 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
import { serveSessionBrokerDaemon } from "@hunk/session-broker-bun";
const server = serveSessionBrokerDaemon({
daemon,
hostname: "127.0.0.1",
port: 47657,
});Node
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.
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 ordinarySessionBrokerLifecycleClock 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, useconnection.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
snapshotupdates - heartbeats
command-resultreplies- 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, singletonappRevision, caller authenticator, and authorizer, it additionally serves:
GET /broker/capabilitiesPOST /broker
Request body shapes:
{ 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) —composeStoryboardrenders the
plan in headless Chromium againstsrc/stage.html(or a custom stage) and
writes PNG frames plus an ffmpeg concat list.playwright-coreis resolved
from the caller's work directory so its version can match the driven
Chromium build.@hunk/term-video/timeline(Node ≥ 22) —renderTimelinesteps a free-form
motion page frame by frame instead of planning stage states. The page owns
all of its motion and exposestimelineReady,TIMELINE_DURATION, and a
purerenderAt(t), so any frame renders on its own for review stills.frameTimesandencodeArgsare 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 inskills/hunk-launch-video/SKILL.md.
promo/ is the house style for release videos: engine.js andstage.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 ashttps://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:
{ "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 andcurrentVersion 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
npm install
bun test
npm run typecheck
npm run devDeployment
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:
npx wrangler secret put GITHUB_TOKENwrangler deploy publishes the Worker and its every-minute cron trigger to the configuredupdates.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:
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/latestThe 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:
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 reviewstatus 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:
bun run packages/hunk/src/main.tsx -- diff \
--extension ./examples/extensions/code-document-file-view \
path/to/before.ts path/to/after.tsChoose 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
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inline-editInstall it globally
Copy the whole folder — it has no dependencies:
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
writeDocumentreloads the session, the reload exits the mode,onExitends 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
detailsentence 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
docs/extensions.md→ Interactive file viewsdocs/extensions.md→ Reading and writing a reviewed filedocs/extensions.md→hunk.registerFileView(view)
Overview (`examples/extensions/jsx-file-view-gallery/README.md`)
JSX file-view gallery
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.
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.ts2. 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.
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.css3. 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.
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.jsonMixed five-file review
To see all three preview types retained together between ordinary raw diffs—and enough content to exercise stream scrolling—run:
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.tsFollow 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
nullwhen 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:
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/jsx-file-viewChoose 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:
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/pane-layoutPress 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:
bun run packages/hunk/src/main.tsx -- diff \
--extension ./examples/extensions/rendered-markdown \
before.md after.mdInstall it globally
Copy the whole folder, then install its local dependency:
mkdir -p ~/.config/hunk/extensions
cp -R examples/extensions/rendered-markdown ~/.config/hunk/extensions/
cd ~/.config/hunk/extensions/rendered-markdown
bun installHunk 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:
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-note-navigatorSave 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:
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-exportAdd 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:
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triageOr 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
registerPanerenders public file/hunk summaries and navigates with pane actions.registerCommandsupplies the Extensions-menu items and user-remappable defaults;ctx.commands.executedelegates the dedicated centering action to Hunk's public semantic command.dialogs.select,dialogs.input, anddialogs.confirmimplement the review decision and clear flows.- Lifecycle handlers track changeset loads, reloads, selection, viewed hunks, Hunk notes, filters, and pending watch reloads through a
useSyncExternalStorebridge. hunk.eventspublishes decisions and listens forreview-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
bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/vim-navigationPress 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
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/kvmand/dev/net/tun.
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-vmFor 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:
bun run vm:shell
bun run vm:shell -- --with-hunkThe 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:
bun run test:install-vm:cleanThe 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.
Overview (`examples/extensions/jsx-file-view-gallery/mixed-review/README.md`)
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:
bun run ./examples/extensions/jsx-file-view-gallery/mixed-review/run.tsRaw diff is deliberately the default. To build the mixed stream:
- Click
package.jsonin the sidebar and press F8. - Click
src/invoice.tsand press F8. - Click
styles/theme.cssand press F8. - Click
README.mdto 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:
- tag: meta
attrs:
property: og:image
content: "https://hunk.dev/changelog/og/index.png" - tag: meta
attrs:
property: og:image:alt
content: "Hunk changelog — 24 release series · March 2026 – October 2026" - tag: meta
attrs:
name: twitter:image
content: "https://hunk.dev/changelog/og/index.png"
[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?
- Install Hunk.
- Open your working tree with
hunk diff. - Follow the quick start to learn the review stream and navigation model.
Browse the docs
Common tasks
- Review current work or a commit
- Configure Hunk as a Git pager or difftool
- Review a live session with a coding agent
- Choose a theme
- Remap a keyboard shortcut
- Build a custom file preview
- Look up a command
- Troubleshoot terminal behavior
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
Recommended workflow: steer a live Hunk window
- Open Hunk in one terminal with a normal review command such as
hunk difforhunk show. - Load the Hunk review skill:
packages/hunk/skills/hunk-review/SKILL.md. - Ask the agent to use the skill and review the current session.
A good generic prompt is:
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:
hunk session list
hunk session get --repo .
hunk session review --repo . --jsonlistshows the active Hunk windowsget --repo .confirms which live session matches the current reporeview --jsonreturns 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:
hunk session review --repo . --include-patch --jsonMove the live window to the right place
Use navigate to jump to the file or hunk you want the user to see:
hunk session navigate --repo . --file src/App.tsx --hunk 2
hunk session navigate --repo . --next-commentTo jump to an exact comment returned by the JSON list, copy its commentId into --comment:
hunk session comment list --repo . --json
hunk session navigate --repo . --comment <comment-id> --jsonUse reload when you want the already-open Hunk window to show a different diff or commit:
hunk session reload --repo . -- diff
hunk session reload --repo . -- show HEAD~1 -- README.mdNotes:
- always include
--before the nested Hunk command inreload --hunkis 1-based--next-commentand--prev-commentare handy when an agent is walking the user through existing notes
Add comments
For one note, use comment add:
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:
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:
printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"},{"replyTo":"user:123","summary":"Addressed"}]}' \
| hunk session comment apply --repo . --stdinEach comment apply item requires summary and either:
replyToby itself to inherit an existing note's anchor, orfilePathwith exactly one target such ashunk,hunkNumber,oldLine, ornewLine
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:
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` notesAgents 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-pathtargets the live Hunk window by its current working directory--sourcechanges where the replacementdifforshowcommand 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.
hunk diff --agent-context notes.json
hunk patch change.patch --agent-context notes.jsonFor 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:
hunk --experimental diff --agent-context notes.json
# Equivalent: hunk diff --experimental --agent-context notes.jsonNormal 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-patchwhen the raw patch is actually needed - use
comment addfor one-off notes andcomment applyfor batches - prefer
--repoover--session-pathunless 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 (Hunkas 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 inwebsite/public/brand/downloads/ and website/public/brand/hunk-brand.zip after editing them:
# Requires rsvg-convert (librsvg) and zip on PATH.
cd website
bun run generate:brandEvery 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:
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.pngRules
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-monoand 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.02emletter-spacing. Checkmarketing.cssandstarlight.cssfor the other weights in use. - Eyebrows (like
TERMINAL DIFFS) are 13px, uppercase,0.15emletter-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.
Related branding
- Modem ("Built by") uses
website/public/modem-light.svgin 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 insession-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), andpackages/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 intopackages/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)
- Review store:
state / actions / reducer / store / intents / selectorsinpackages/hunk/src/core/review/, withuseReviewController/App/AppHostrefactored onto it in the
same PR. Behavior-neutral; existing PTY integration tests must pass untouched. - Review document projection + diff geometry:
document / identity / sourceIdentity / anchors / contentManifest / notes / expansion / reconcile / jsonStreamplus the geometry
primitives, adopted by the terminal — including switching the terminal's collapsed-gap math
toreviewGapAddress(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. - Navigation intents + command catalog:
selection/move/selection/select-fileand
the shared navigation/reveal/note selectors; the agent runtime'snavigateSessiondeleted
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 intounknown-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 fromReviewIntent (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)
- Read-only mirror:
apiClient/mirror/pierreDocument/ review stream rendering a
snapshot with Pierre — built on the shared geometry/selector/ordering primitives from day
one (nosideRange, no local acceptance rules, no baresplit("\n")). No actions, no note
editing. - 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/--tailscaleCLI wiring. The review URL is
always recoverable from the terminal, and the opener preserves URL fragments on every
platform (norundll32). 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 onReviewFileV1instead of recomputed, empty-diff reason, default hunk note target, and STML
tag roles so both renderers share one tag vocabulary. - Navigation semantics (
core/reviewintents/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 fromReviewIntentinstead of
hand-restated, and gains theexpandedLineProoffield 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
ReviewIntentand 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'shunk sessioncommands 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.
- Semantic — lowers to a
- 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. browserCmd+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:
- 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. - Conformance harness (built in Phase 1 PR 2, grows every phase — not deferred to Phase 5):
one golden fixture corpus undertest/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. - 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. - Behavior invariance: adoption phases must not change what users see — Phase 1 passes
the existing PTY integration suite untouched, Phases 2–3 the existingtest/session/
suite. A needed test edit in these phases is a red flag reviewed as a behavior change, not
a test update. - 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
fetchreader, notEventSource. The capability is
presented in a header, whichEventSourcecannot set, so Phase 5's client consumes the
stream withfetchand 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-IDwith 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'spierreNoteAnchor, and D4'sparseCanonicalReviewFile. 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
corepackages/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 becauseintents.tsvalidates againstreviewGapAddress.
Fix: terminal callsreviewGapAddress; delete its local math.
Repaid (Phase 1 PR 2):reviewLeadingGap/reviewGapAddressincore/review/expansion.ts;pierre.tscopies deleted; fixturespure-insertion-hunkandpure-deletion-hunkintest/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, sopure-deletion-hunkpins 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.tstrailingCollapsedLines,
producerpackages/hunk/src/app/session/registration.ts(~:131-139, booleanhasTrailingContext), coreexpansion.ts. The browser can offer a "Trailing context" button whose expansion core then
rejects (gap-not-found). Fix: onereviewTrailingGap(file)in core.
Repaid (Phase 1 PR 2):reviewTrailingGapincore/review/expansion.ts;trailingCollapsedLines/trailingCollapsedRangesdeleted frompierre.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.tshunkLineRange,core/review/anchors.tsreviewHunkRange(correct), webpierreDocument.tssideRange(uses changed-line countsdeletionLines/additionLinesas
span lengths — wrong for hunks with leading context, misplacing notes in the browser), andReviewStream.tsxre-filtering against manifest ranges. Fix:reviewHunkRangeeverywhere;
collapsehunkLineRangeinto it.
Repaid (Phase 1 PR 2):reviewHunkRange/reviewHunkRangesincore/review/geometry.ts;hunkLineRangedeleted and all six terminal/session sites converted; fixturehunk-with-leading-context. - A4. Source-line splitting for expanded context — 3 implementations, browser skips
normalization. TerminalexpandCollapsedRows.tssliceLinesand coreanchors.tsnormalizedReviewSourceLinesagree (CRLF-normalize, strip one trailing newline); webReviewStream.tsxdoes a baresplit("\n")—\rglyphs, phantom EOF line, and context
digests that can mismatch on reload. (sourceBackedHighlight.tssplitSourceLinesis a
legitimate fourth variant — Pierre's highlighter needs retained newlines; comment it.) Fix:
export and adoptnormalizedReviewSourceLines.
Repaid (Phase 1 PR 2):normalizedReviewSourceLinesincore/review/geometry.ts;expandCollapsedRows.tssliceLinesdeleted;splitSourceLinescarries the comment saying why
it is legitimately different; fixturescrlf-sourceandsource-without-trailing-newline. - A5. Expansion side policy
deleted ? "old" : "new"— 3 copies. Coreintents.ts
(authoritative), terminaldiffSectionRowPlan.ts(recomputed instead of readinggap.side),
webpierreDocument.tsfallback ordering. Fix: threadgap.side; exportreviewExpansionSide(file).
Repaid (Phase 1 PR 2):reviewExpansionSideincore/review/expansion.ts; both terminal
recomputations (diffSectionRowPlan.ts,useReviewController.ts) deleted. - A6. Hunk content-index rebasing — 2 copies, opposite
isPartialconclusions. WebpierreDocument.tsisolatePierreHunkvs terminalsourceBackedHighlight.ts(~:108-199).
Fix: onerebaseReviewHunk(hunk, origins)in core.
Repaid (Phase 1 PR 2, terminal site):rebaseReviewHunkincore/review/geometry.ts, adopted
bysourceBackedHighlight.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.tsreconstructssplitLineCount/unifiedLineCountby reducing over hunks; terminal reads Pierre's
authoritative values. Mis-sizes browser virtualization when the parser counts rows outside
hunk spans. Fix: carry both onReviewFileV1.
Repaid (Phase 1 PR 2, model side):splitLineCount/unifiedLineCountcarried onReviewFileV1bycore/review/document.ts; the browser consumes them in Phase 5. - A8. Empty-diff explanation — 3 variants with different precedence. Terminal
plannedRowText.tsdiffMessage(rename-pure first), webReviewStream.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):reviewEmptyDiffReasonincore/review/document.tswith 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.tsandstaticDiffPager.tskeep their own wording and share the reason; fixturebinary-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.tshandles the full vocabulary; webReviewNote.tsxhandles a subset
(everything else flattens to ``) and accepts a<tag>alias the terminal lacks. Fix:stmlTagRole(tag)incore/review/stml.ts; both renderers switch on roles.
Repaid (Phase 1 PR 2, terminal site):core/review/stml.tsowns the tag-to-role table;ui/lib/stml/layout.tsswitches on roles andparse.tsderives its void/raw-text tags from
them. The parser itself stays inui/lib/stmlfor now — it sanitizes throughlib/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.tsfirstCommentTargetForHunk(first added line, else first deleted);
webApp.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):reviewDefaultHunkLineTargetincore/review/geometry.ts;firstCommentTargetForHunkdeleted and both terminal callers converted; every geometry fixture
pins the target,pure-deletion-hunkandhunk-with-leading-contextadversarially. - 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 explicitlanguage. Fix: side-effect import inpackages/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 runtimereviewSessionRuntime.tsnavigateSession(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
(terminalDiffFilemodel vsintersectingHunkIndices). Fix:selection/moveintent
(scope: hunk|file|annotated-hunk|annotated-file, delta, wrap policy) planned over shared
selectors; deleteui/lib/hunks.tsand the runtime walk.
Repaid (Phase 1 PR 3, core and terminal sites):planReviewSelectionMoveincore/review/navigation.ts, reached through theselection/moveintent.ui/lib/hunks.tsis
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. Fixtureannotated-hunk-multi-step-carry
intest/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.
moveToFileclamps;moveToAnnotatedFilewraps
(ui/lib/reviewState.ts). Encode per-scope wrap policy in theselection/moveintent.
Repaid (Phase 1 PR 3):REVIEW_SELECTION_WRAP_POLICYnames the policy per scope —
hunk/file/annotated-hunk clamp, annotated-file wraps — andfindNextAnnotatedFileis 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). Fixturescope-wrap-and-clamp. - B3. File-jump semantics — hard-coded identically in both clients. "Hunk 0 + file-top
reveal" in terminalApp.tsx/useReviewController.tsand webApp.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-fileowns "first hunk"
(REVIEW_FILE_JUMP_HUNK_INDEX) withREVIEW_FILE_JUMP_REVEALas its default reveal, and the
forward-cross-file alignment rule moved into the hunk-move planner, where the crossing is
known. The terminal'sselectFilelowers to the intent and no longer takes a hunk index.
Fixturescope-wrap-and-clamppins both reveals. The browser's copy lands in Phase 5. - B4. Selection fallback after reload/filter — 2 divergent answers. Terminal
resolveSelectedFilereturns undefined (renders "no file"); webvalidSelectionandtreeSource.resetsilently fall back tofiles[0]. Core permitsfileKey: null. Fix:selectNormalizedSelection/selectFallbackFileKeyselectors; delete both client fallbacks.
Repaid (Phase 1 PR 3, core and terminal sites): both selectors live incore/review/selectors.ts, andresolveSelectedFileis 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, sofileKey: nullis reached exactly when nothing is visible at all. Fixturesselection-outliving-its-fileandselection-with-nothing-visible. The browser'svalidSelection/treeSource.resetfallbacks close in Phase 5. - B5. Filter matching — 3 matchers. Core
reviewFileMatchesFilter(path, previousPath,
agentSummary), terminalfilterReviewFiles(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):reviewFileMatchesFilterincore/review/selectors.tsis the only matcher, andfilterReviewFilesis 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.tsxrecomputes the
hunk target line (newRange ? "new" : "old"), duplicating corecanonicalLineForHunkwhich
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):reviewCanonicalHunkLineincore/review/geometry.ts
(preferred side first, backed sides only), behindselectRevealTarget. Fixturepure-deletion-reveal-targetpins 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 inDiffPane.tsxrow scanning; web never readsreveal.scrollToNote. Fix:selectActiveRevealNoteId(state)in core.
Repaid (Phase 1 PR 3, core site):selectActiveRevealNoteIdnames the policy — an active
draft in the selected hunk first, else the note anchored earliest in it, arrival order breaking
ties — which is whatDiffPane'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 afterpierreDocumentaccepted them — notes silently disappear in the browser. Fix: group byownerHunkIndexvia a sharedselectNotesByHunk.
Repaid (Phase 1 PR 3, core site):selectNotesByHunkgroups byreviewNoteOwnerHunkIndex,
reading the ownershipresolveReviewNoteAnchordecided instead of re-testing containment. The
terminal site stays open deliberately:DiffPanegroups 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.tsreviewNoteVisibleByPolicy(web path) vsnotes.tsalwaysShowReviewNote
(terminal path). Collapse to one predicate over{source}.
Repaid (Phase 1 PR 3): onereviewNoteVisibleByPolicyover{source}incore/review/state.ts, beside the other stored-note policies;alwaysShowReviewNoteis
deleted, andDiffPanecalls the predicate over the normalized source. - B10. Selected-line semantics — browser structurally weaker. Terminal maps rendered rows
to semantic side/line withexpandedLineProofand separates "anchor" from "reveal"; web
sends raw lines withrevealforced on every click, and the wireselection/set-line/notes/create-useractions have noexpandedLineProoffield 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):ReviewExpandedLineClaimincore/review/expansion.tsstates what a caller claims — the gap, the side, the line, and the
identity of the source it expanded — andresolveReviewExpandedLineis the one thing that
decides whether the claim holds. The wire carries it asexpandedLineProofon the two
actions that can name a line (notes/start-draft's target, andnotes/create-user's
precondition on the draft it is saving), refusing evidence that accompanies no line, andpackages/hunk/src/app/session/reviewCommands.tschecks it before planning. Where the resulting note hangs
is deliberately not decided there: it goes throughreviewLineAnchor's fallback owner
exactly as a terminal note does, whichreviewCommands.test.tspins by asserting an empty
intersection set and the declared owner. Fixturesstart-draft-on-an-expanded-line,create-user-note-at-an-expanded-line, andstart-draft-with-a-proof-about-nothingintest/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/anchorintent that never bumps reveal tokens).
Repaid (Phase 1 PR 3, core and terminal sites):REVIEW_VIEWPORT_ANCHOR_REVEALincore/review/state.ts, and theselection/anchorintent that carries it. The terminal's
viewport-centered hunk selection and its line-cursor anchoring both publish through it, and the
localpreserveViewportselection 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 inregistration.ts, validation list inwire.ts, remapped
field-by-field inreviewSessionRuntime.ts; forgotten fields/actions are silently
unreachable from the browser. Fix: derive the wire type and both lists from oneHUNK_REVIEW_ACTION_TYPESsource; validate-and-narrow rather than restate.
Repaid (Phase 3):REVIEW_INTENT_TYPESincore/review/intents.tsis the vocabulary,
made total in both directions by type assertions — a member added toReviewIntentand not
listed fails to typecheck, and a listed name that is not an intent fails too.HUNK_REVIEW_ACTION_TYPESis 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 —HunkReviewActionV1isReviewIntentwith the two wire-only fields added to
the members that need them — andtoReviewIntentstrips 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.tsis 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 intest/review-conformance/wireFixtures.ts, registered as thereview wire protocolconsumer.
Repaid (note editing and replies):notes/update-draftnow carries remote composer text,
whilenotes/start-edit,notes/start-reply, andnotes/update-usershare 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+1revisions — a contract the
server does not guarantee, since initial snapshots andLast-Event-IDreplay can skip),
brokerstate.ts(accepts equal revisions for width-only refreshes), producerreviewSessionRuntime.ts(equality required except selection actions),wire.tsparse-time
triple-equality, plus a fifth copy in webApp.tsxcompensating for the first being
stricter than the broker. Fix:core/review/generationOrder.tsexporting the invariant andclassifySnapshot/classifyState→accepted|stale|gap, consumed by all five sites.
Repaid (Phase 2, producer site):core/review/generationOrder.tsstates 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 — andclassifyReviewPublication
answersaccepted | stale | gap.assertReviewPublicationAdvanceis 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+1rule 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 intest/review-conformance/orderingFixtures.tsand 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'sReviewMirror
(packages/hunk/src/session/broker/reviewMirror.ts) holds one publication per session and orders every
arriving one with a singleclassifyReviewPublicationcall —acceptedadvances the
revision,gapreplaces the generation and retires everything derived from the old one,staleis 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.tsmakes the same one
call for an action'sexpectedStateRevision, 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 thebroker review mirrorordering 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.tsrange
loop; brokerstate.tsmaterializing and pre-sized loops (which already disagree on
progress/eof rules); SSE reassembly inmirror.ts. Three in-flight dedupe key formats;
digest case normalized on a different operand in each. Fix:core/review/resourceAssembly.ts(ChunkAssemblerwith 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.tsowns resource addressing, the
chunk bound both ends validate against, and the failure vocabulary;packages/hunk/src/app/review/resourceStore.tsproduces 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 unboundedPromise.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.tsis the reader half —
oneReviewChunkAssemblerthat 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 aReviewDigestFnthe 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 isreviewResourceCache.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'sapiClientrange 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 inbrowserReviewServer.tsand
re-declared/regex-parsed inmirror.ts/apiClient.ts; client bounds (12 MiB / 1024 chunks)
are unlinked from server bounds and only coincidentally compatible. Fix:packages/hunk/src/session/reviewEventProtocol.tsowning names, envelopes, id grammar, and bounds derived
fromMAX_BROWSER_REVIEW_SNAPSHOT_BYTES.
Repaid (Phase 4, server side):packages/hunk/src/session/reviewEventProtocol.tsowns 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_BYTESis the
protocol's envelope bound,REVIEW_EVENT_CHUNK_BYTESis the shared resource chunk size,
andMAX_REVIEW_EVENT_CHUNKSis 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.tsimports all of it and declares none of it; the
browser client imports the same module unchanged in Phase 5, whichscripts/quality/source-boundaries.test.tskeeps 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 sharedReviewChunkAssemblerrather than a fourth reassembly loop (C2's rule applied here); and
only the frame that completes an event carries anid, so aLast-Event-IDcan never
name a position inside a half-delivered payload. Fixturespublication-exactly-one-windowandpublication-one-byte-over-a-windowintest/review-conformance/eventFixtures.tspin 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),
webApp.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: onecreateReconnectSchedulerin@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
bodyandmarkupseparately
againstMAX_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: onereviewNoteWithinSizeLimitused by wire, broker, producer, and both
composers.
Repaid (Phase 2, core and producer sites):core/review/noteSize.tsmeasures the whole
note in the unit a transport pays — its serialized bytes, through the platform-freeutf8ByteLength— andMAX_REVIEW_NOTE_BYTESsits beside it. Fixturestest/review-conformance/noteSize.tspin 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):isTransportableReviewNoteinpackages/hunk/src/session/reviewProtocol.ts
isreviewNoteWithinSizeLimitand 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, soevery-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: oneisBlankReviewNoteBodypredicate.
Repaid (Phase 1 PR 2, core and terminal sites): the predicate lives incore/review/intents.ts, andtest/review-conformance/noteBodies.tspins 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.tssibling over Pierre geometry,
brokerwire.tsre-derivation that omits the fallback branch (a legal expanded-gap note can
get a whole registration rejected), webpierreNoteAnchorwithout 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, byresolveReviewNoteAnchorat 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 inpackages/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. WebpierreNoteAnchorcloses in Phase 5. - D4. Canonical-file ↔ manifest consistency — 3 checks, 3 field lists. Producer
registration.ts(authoritative ~17 fields), webparseCanonicalReviewFile(12, and
comparesflags/sourceResourceIdsvia key-order-sensitiveJSON.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-independentassertCanonicalFileMatchesManifestin core; producer self-check reuses it.
(contentManifest.tsis legitimately different — a parity-test snapshot, not a validator.)
Repaid (Phase 2, core and producer sites):core/review/canonicalFile.tscarries 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.
isReviewSha256Digestexists but is bypassed by five
inline regexes with case-sensitivity drift; rawcreateHash("sha256")at seven sites
instead ofreviewDigest;hasExactKeysprivate while the pattern is inlined ~10×;
action-envelope parsing duplicated betweenreviewProtocol.tsandbrowserReviewServer.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 fromreviewProtocol/brokerWireParsers; import constants everywhere, and derive coupled bounds
from each other. Also name the intentionally stricterresolution === "active"filter
(isActiveStoredReviewNote) besideisRenderableStoredReviewNote, and comment whyparseReviewStatenotes intentionally skip manifest-geometry matching.
Repaid (Phase 2, core and producer sites):core/review/validation.tsowns the digest
vocabulary and the exact-key check the producer validates with and the wire protocol will.isReviewSha256Digestaccepts only the canonical lowercase form — the case-insensitive
variant is what let a writer and a reader disagree — withnormalizeReviewDigestfor values
arriving from outside andreviewDigestsEqualnormalizing both operands. Hashing itself is
an injectedReviewDigestFnrather than inlinecreateHashcalls; 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 incore/review/resources.tsthat 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 intocore/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 ofREVIEW_SELECTION_WRAP_POLICYrather 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 isisReviewSha256Digest. 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 — soscripts/quality/review-vocabulary.test.tsasserts 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. NamingisActiveStoredReviewNotebesideisRenderableStoredReviewNoteand commentingparseReviewStateare 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.tsformatSidebarStat(zero-hiding,
truncation marker) vs web inline+${additions} −${deletions}intreeSource.ts. One
shared formatter. - E2. Theme. Web hardcodes two standalone palettes disconnected from
packages/hunk/src/ui/themesand
theAppThememapping; 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 inbrowser-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 (terminalKeyEventmatchers), 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.tscarries id, title, category, default
chords, resolution locus, extension visibility, and menu-closing behavior for all 44 built-ins.ui/lib/appCommands.tsbuilds its dispatch table from it — the handler map is keyed byAppCommandId, so a catalogued command with no terminal handler fails to typecheck — and
menus and help keep reading identity through that table. Parity is asserted inappCommands.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 outsidecore/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 toReviewIntents (the Phase 1 store refactor is the same work); the browser fires them
through the existing apply-action path, and the agent runtime'shunk sessionsurface
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), andlowerAppCommandToReviewIntentis the one constructor turning
a command plus a repeat count into aReviewIntent. 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 2expansion/toggleintent — and are listed by name inSEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECT, so the gap is a decision rather than an oversight.
Closed (Phase 2): both landed.notes/start-drafttakes caller-owned draft identity and
an optional client-measured line, falling back to the shared whole-hunk default;expansion/toggleresolves throughreviewGapAddressand reports the side, ranges, and
source identity a caller needs to fill the gap. Which gap the command reaches isselectReviewGapForSelectionin core, replacing the terminal'sselectGapForKeyboardToggle.SEMANTIC_COMMANDS_WITHOUT_REVIEW_EFFECTis now empty, and every semantic command lowers to
an intent a remote client could fire.
Repaid (terminal adoption):useTerminalReview.tscallslowerAppCommandToReviewIntent, 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 (browserCmd+Wetc.).
Repaid (Phase 1 PR 3, host site):builtinCommandKeyDefaultsreads 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 —showAgentNotesand 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 opaqueclientId, a coarsekind(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) incore/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.tsserializes 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.tsis deleted. A
primitive with no consumers cannot be checked against what its consumers need, and theno-dead-modulesrule 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.tssingle-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 inpackages/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.tsgives every code a
statement and a remedy, in the agent surface's own pattern. Totality is mechanical rather
than reviewed: the catalog is aRecordoverHunkReviewClientErrorCodeV1, 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, andreviewErrorCatalog.test.tsstates 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 arehunk sessionCLI 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.tsshould 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 inCHANGELOG.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.mdtwin of every page, and an explicit crawler allow-list; release history
was the largest gap in that corpus. Changelog pages joinllms-full.txtautomatically and are
excluded fromllms-small.txt, which stays scoped to learning Hunk. - Release videos get a permanent home.
skills/hunk-launch-videorenders 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.
/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-mdAstro 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 whenbun 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,
andcheck:changeloggates CI on Vercel's shallow, tagless
checkout without touching Git. - Publication state is derived from dates, not from position. An undated stable version renders
asUnreleased; an undated prerelease remains off every surface until its tag exists. Neither can
advance the index'sLatestmarker, 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.astrocarries the nav item;DocsHeader.astroderives the active item from the
route, because Starlight renders the changelog and the docs through the same shell.DocsMarkdownContent.astromarks changelog articles withdata-changelog, which is what letsstarlight.cssstyle 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-separatoranchor 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.jsonfor 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
videofield is implemented end to end, includingVideoObjectschema,
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.mddoes not carry authors, so this needs a second input. - The in-app update notice.
packages/hunk/src/core/process/updateNotice.tstells users a new version exists without
linking what changed. Appendinghunk.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
Sidebar geometry and selection following were missing
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 guardedselectFile, 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
reactmodule. - The public hunk summaries and sidebar selection/index contract are sufficient to render and drive a hunk-level board without accessing opaque diff metadata.
useSyncExternalStoreis 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 inpackages/hunk/src/extensions/discovery.tsandpackages/hunk/src/extensions/trust.ts. - Bundled extensions are compiled into the binary. The private
packages/hunk-{git,jj,sapling}provider workspaces are statically imported bypackages/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.tsis deliberately not part of
that list: the UI loads its bundled files pane, delegated review-info panes, and content search
registrations throughrunExtensionFactory, once per process. The privatepackages/hunk-gh
workspace registershunk ghthroughdefault/core/index.tsinto 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 publicregisterVcsAdapter 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 inpackages/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 anExtensionRegistry (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); andextensions/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 aReviewDocumentV1 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 bypackages/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, soctx.fileViews.refresh bumps an invalidation epoch owned bypackages/hunk/src/ui/fileViews/useFilePresentationController.ts and modeled inpackages/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, andfileViewLayoutEpoch 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, soDiffPane 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 bypackages/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 bypackages/hunk/src/ui/highlights/useLineHighlightsController.ts behindctx.highlights.refresh, using the shared scoped-epoch policy inpackages/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 inpackages/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, whilepackages/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, sopackages/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 samerevealLine 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 sharedpackages/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. ExtensionregisterCommand entries join the same table viapackages/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 frompackages/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 bothctx.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, andrevealLine reveals share. Extensions name a target; they never name a scroll
position.
After any named command dispatches in the terminal host, App emitscommand_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 inpackages/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 withuseSyncExternalStore, renders the current request throughpackages/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), oneStatusLine component that owns the focused input and the badge's
click-to-exit, and extensionControls.ts, which mints the per-extensionstatusLine 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 addsession_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 anyconfigureSession({ 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 becomenull.
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 callsrefreshCurrentInput. 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 inpackages/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 throughexecuteAppCommand. 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 bypackages/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 sharedsrc/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:
tsexport 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.tsflag for development and/reloadfor hot reload.One capability-granting API object, not global hooks. Everything an
extension can do goes throughExtensionAPI/ExtensionContext:
registration (registerCommand,registerShortcut,registerTool,registerFlag, renderers), lifecycle + pipeline events (session_start,turn_start/end,tool_callwith block/mutate power,contextfiltering,inputinterception), 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_callcan
block or rewrite a tool invocation;message_endcan replace a message;inputcan consume user input entirely. This is what lets extensions change
behavior, not just decorate it.Packages bundle extensions + skills + prompts + themes and are installed
withpi install. Notably, Hunk already participates in this ecosystem from
the other side: ourpackage.jsondeclares"pi": { "skills": ["./skills"] }
so pi users get thehunk-reviewskill 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 wayhunk session commentdoes — 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 blameage, 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 viacreateHunkSessionBridge(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'sregisterCustomTheme), 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, inbuildAppMenus, and in a
hardcodedHelpDialogsections 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
throughparseCli's closed fast path, while an unknown top-level token loads
enabled/trusted extensions and resolvesregisterCliCommandregistrations.
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.tsunion +
version bump,brokerServer.tsswitch + supported-actions list,bridge.ts,
sessioncli.ts,core/cli.tsparser). A registry keyed by action name
would collapse that — and is a prerequisite for extension session commands. MenuIdis 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
// ~/.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/*.tsand*/index.ts(global, follows
our existing XDG path logic inpackages/hunk/src/core/run/paths.ts),.hunk/extensions/
(repo-local, trust-gated, same posture as pi's project trust), explicit[extensions] paths = [...]inconfig.toml, and a--extension
dev flag.--no-extensionsfor 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. anote_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
- Phase 0 — loader spike. Prove that a compiled
bun build --compile
binary canimport()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 existingstartupNoticeschannel — and is skipped, never crashes review),--no-extensions. - 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. - Phase 2 — the action registry. Refactor core actions, then open
registerActionwith keys + menu + help integration,ctx.reviewfaçade,ctx.uiprompts. This unlocks the whole open-in-X / copy-as / send-to-agent
family — the highest-demand tier. - 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 sohunk session <ext-cmd>
fails cleanly against a session that lacks the extension. - 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 anextending-piguidance extension; our equivalent
is ahunk-extension-authoringskill — same trick we already use forhunk-review).
7. Open questions
- Compiled-binary loading. The installed
hunkis abun build --compile
snapshot. Runtimeimport()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 withBun.Transpilerand
evaluate, or require plain.js/.mjsfor the binary whilebun runkeeps
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.execmust 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 optionalpackage.json manifest; no build step is required.
// ~/.config/hunk/extensions/hello.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.on("startup", (_event, ctx) => {
ctx.notify("Hello from my extension");
});
}The extension API is experimental. Everything below works today, but the
hunkdiff/extensionsurface may change in breaking ways between minor
releases while it stabilizes against real third-party extensions. Breaking
changes will be called out in release notes, andhunk.apiVersionidentifies
the surface an extension was written against.
Writing one with a coding agent? hunk skill path hunk-extensions prints a
bundled skill that maps the touchpoints below for agents, the wayhunk 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:
~/.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.tsThe 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:
{
"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 asHUNK_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, andhunk, 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:
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@nextManaged 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 ashunk extension update npm:@acme/hunk-word-diff orhunk 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:
hunk-word-diff/
package.json # name, version, description, hunk field
index.ts # or entries declared in "hunk": {"extensions": [...]}
README.mdTo publish one:
- Give
package.jsona realname,version, anddescription, declare
entries under thehunkfield, and state"hunk": {"apiVersion": N}if you
rely on recent API surface (see the manifest). - Keep it dependency-light. Declared
dependenciesare installed withbun installat install time when the user hasbunon PATH; without it
they get a warning and instructions.react,@opentui/*, andhunkdiff/extensioncome from the host at runtime and belong indevDependencies(types only), neverdependencies. - Tag releases (
v1.2.0) so users can pin with@v1.2.0instead of tracking
your default branch. - Push the repository to any git host and add the
hunk-extensionGitHub
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
againstwebsite/src/data/extensions.tsalso 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 privatepackages/hunk-{git,jj,sapling} workspaces and are statically imported bypackages/hunk/src/extensions/default/vcs/index.ts. The GitHub command implementation lives inpackages/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 samehunk.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-extensionsand[extensions] enabled = false.
Those switches exist to triage extensions you installed. Independently selectable bundled
capabilities use host-owned selection ids; the GitHub command extension ishunk.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 idsgit, 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:
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 fromstartup, 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 eventualshutdown 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-wideonActivate; 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.
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 plusctx.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. Itskind 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, ormerged) and boolean draft; commits may carry a parseable authoredAt date-time. Exit results and delegation to any built-in other thanpatch 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 abortctx.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:
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 changesBoth 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 withhunk --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.
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:
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:
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 anExtensionVcsStatusSnapshotwithschemaVersion: 1.context.cwdis 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 inspectedrepositoryId; a row matching
the current worktree's id or path must match both. These checks also apply to embeddedsiblings: { state: "ready", value }snapshots. Return an unavailable/error row, not zero
changes, when a sibling cannot be read. The initial snapshot can publishsiblings: { state: "loading" }before this slower scan completes.reviewActionssupports only the semantic idsstaged(index comparison) andunstaged
(working-copy comparison), each at most once; ids are not opaque. Labels are provider-owned.
A provider without an index offers onlyunstaged, with a suitable label such as "Working
copy changes", and omitspaths[].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 exclusiverange/rangeEndpoints
variants but excludes pathspecs. The boundary explicitly rejects apathspecsfield, even an
empty list orundefined, rather than silently stripping it.cwdmust retain the inspected
worktree path andinput.stagedmust 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.
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:
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 indisplayRevision 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. SetdetectionPriority explicitly to outrank a shipped backend; it is your machine.
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, returningstring | Promise<string>. Hunk awaits it and reloads when it changes. Prefer
async I/O and honorctx.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.
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. LeavingwatchPlan 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 withreadFileSource and Hunk will expand context past the hunk, highlight against
the real file, and word-diff accurately.
async load(input, ctx) {
// Pin the revisions while the operation loads, then close over them: by the
// time Hunk asks for a file, nothing can have moved underneath it.
const [oldRev, newRev] = await resolveHgRevisions(input, ctx.cwd);
return {
repoRoot: ctx.cwd,
sourceLabel: ctx.cwd,
title: "Mercurial working copy",
patchText: await runHgDiff(ctx.cwd),
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. LeavingreadFileSource 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:
extraFiles: [
{
kind: "patch",
path: "notes.md",
patchText: await hgDiffOneFile("notes.md"),
isUntracked: true,
},
];A skipped entry is a file Hunk should list but not render. Reviewing a
multi-hundred-megabyte generated file costs more than it is worth, so report the
file and why instead of producing a diff nothing will read:
extraFiles: [
{
kind: "skipped",
path: "dist/bundle.js",
reason: "too-large",
changeType: "change",
stats: { additions: 100_001, deletions: 0 },
statsTruncated: true,
},
];readFileSource covers the patch entries too; a skipped entry has no content to
read, so it never gets a source reader.
untrackedPaths remains the shorthand for the common case: list the paths your
VCS calls unknown and Hunk synthesizes the added-file diffs from the working
copy, skipping binaries and files too large to render. Use extraFiles instead
only when your VCS renders those files better than a plain read would.
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 — whatgit 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.
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:
// ~/.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 whenfraction 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 asavailable, 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 readreview 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. Thehunk.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 ofhunk.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), andactions.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 readtheme.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:
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 publicExtensionDiffHunk summaries (index, the @@ header, inclusive old/new
line spans) in render order, selectedHunkIndex reports the same index, andactions.selectHunk(fileId, hunkIndex) accepts it. That is everything a hunk
checklist, a per-hunk progress view, or an agent-annotation navigator needs —
match an annotation's oldRange/newRange against the summaries' spans to
find its hunk — without touching the opaque metadata.
A component that owns a key event should ask the injected keybindings
manager about a command id, rather than hard-coding the command's default
chord. Like Pi's injected KeybindingsManager, this keeps local component
behavior synchronized with the user's remaps and unbindings:
import type { ExtensionKeyEvent, 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:
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 thatidprop
into view.scrollTopandviewport.heightread the current scroll offset and
the scrollbox's live viewport rows. A read before the first layout pass
reports0, so viewport-dependent code belongs behind the events below
rather than a bare mount effect.verticalScrollBar.on("change", handler),viewport.on("layout-changed", handler), andviewport.on("resized", handler)report scrolling and pane resizes;
unsubscribe with the matching.offin your effect's cleanup.
That is enough to window a long list yourself: the built-in 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.
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, souseSyncExternalStore 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.
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 andctx.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 installableexamples/extensions/rendered-markdown/
uses this contract for a parsed Markdown preview. It is intentionally not bundled
or loaded by default; copy the folder into ~/.config/hunk/extensions/, install
its dependency there, and its View entry and F8 command become available.
import type { HunkExtensionAPI } from "hunkdiff/extension";
export default function (hunk: HunkExtensionAPI) {
hunk.registerFileView({
id: "plain-markdown",
title: "Plain Markdown",
matches: (file) => file.path.endsWith(".md"),
async layout(input) {
const document = await input.readDocument("new");
if (!document || document.length > 100_000) return null;
const sourceLines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n");
const rows = sourceLines.map((text, index) => ({
id: `line:${index + 1}`,
spans: [{ text: text || " " }],
sourceRanges: [{ side: "new" as const, range: [index + 1, index + 1] as const }],
}));
if (rows.length === 0) return null;
return {
rows,
hunkRows: (input.file.hunks ?? []).map((hunk) => ({
startRow: Math.max(0, (hunk.newRange?.[0] ?? 1) - 1),
endRow: Math.min(rows.length - 1, (hunk.newRange?.[1] ?? 1) - 1),
})),
};
},
});
}layout receives one readonly input containing file, width, signal,changes, and readDocument. input.file is the same frozen publicExtensionDiffFile 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.
/* Detailed source-code truncated for AI context efficiency. */ExtensionFileViewCodeDocument has a layout-local unique id, complete text, and optionallanguage. 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 orsyntax 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 onehunkRows 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-injsx-file-view-gallery runs
fixed JSX rows against checked-in TypeScript, CSS, and package dependency diffs. The focusedcode-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 throughctx.fileViews.select("view-id"), toggle("view-id"), andisActive("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:
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:
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.
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.
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 andRegExp.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:
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:
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 oronExit 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-freevim-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.
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:
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,
callctx.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 returnfalse.
To toggle rendered visibility without touching other panes:tsif (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:
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, ornull 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 ornull. 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:
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 malformedexecute ids, options, and counts throw as extension programming errors. The public ids are the built-ins listed in
keybindings, including the unboundhunk.review.alignCurrentLineTop, hunk.review.alignCurrentLineCenter, andhunk.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 returnfalse.
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. Seehunk.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 samectx.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:
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 withctx.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.
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, andruntimeId only for navigation inside that exact generation. Thereview-snapshot-export
example writes the complete value as JSON and demonstrates this stale-work check. Thereview-note-navigator example composes
the complete note inventory, authoritative anchors, a selector dialog, and guarded navigation
to currently visible files.
Navigating the review
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.
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? })→trueorfalseselect({ title, options })→ the chosen string, ornullinput({ title, placeholder?, initial? })→ the typed string, ornull
hunk.registerCommand(
{ id: "reformat", title: "Reformat the selected file", key: "ctrl+r" },
async (ctx) => {
const file = ctx.selection.file;
if (!file) {
return;
}
const proceed = await ctx.dialogs.confirm({
title: `Reformat ${file.path}?`,
body: "The file is rewritten in place.",
confirmLabel: "reformat",
});
ctx.notify(proceed ? `Reformatting ${file.path}` : "Left it alone");
},
);select is the natural fit for acting on part of the selection — here, asking
which hunk of the selected file to jump to, then navigating there:
hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, async (ctx) => {
const file = ctx.selection.file;
const hunks = file?.hunks ?? [];
if (!file || hunks.length === 0) {
ctx.notify("Nothing to pick from", "warning");
return;
}
const labels = hunks.map((hunk) => hunk.header || `hunk ${hunk.index + 1}`);
const picked = await ctx.dialogs.select({ title: "Which hunk?", options: labels });
// `navigation` is live, so the jump is valid even after awaiting the dialog.
if (picked !== null) {
ctx.navigation.selectHunk(file.id, labels.indexOf(picked));
}
});Hunk draws the dialog, not you: your text fills the title, body, and choices,
and 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 blanktitle, 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:
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 inhunk 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 diffwith 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 attributedctx.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 returnsunavailable, 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.
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 receivesctx.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 achangeset_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 returnnull/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 firstchangeset_loaded; reloads then deliver changeset_loaded beforesession_reload once the matching review generation has committed.
Starting with extension API v23, layout_changed adds canonicalMode andcanonicalLayout. 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 andExtensionCanonicalResolvedLayout. 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, orctx.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 andnote.draft: false for an identity-preserving saved-note edit. Replies include their directparentId. 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 (ornote_edited) and note_changed. Reloads that remap or drop notes do not emitnote_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.
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.
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 emitssession_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 meanshunk.configmust never be trusted for
exec-adjacent decisions such as binary paths, shell commands, or module
loading. Validate those against something the user controls.
# ~/.config/hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "dist/**"]const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"];`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 deprecatedsidebars 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 astartup 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:
pane-layoutfor all four placements.- Hunk Lens for opaque selected-row
paint (hunk extension install modem-dev/hunk-lens). review-triagefor panes, commands,
dialogs, lifecycle events, and the event bus.review-snapshot-exportfor
authoritative saved-note export and generation/revision stale-work checks.examples/extensions/rendered-markdown/
parses Markdown into generic host-owned file-view rows. Its README shows how
to run it from the checkout or copy it into the global extensions directory.examples/extensions/code-document-file-view/
maps complete old/new code documents and partial UTF-16 slices into symbolic rows while Hunk owns
syntax colors and fallback.
Collapse lockfiles and generated output out of every review, and say how many
files were hidden.
// ~/.config/hunk/extensions/collapse-generated.ts
import type { HunkExtensionAPI } from "hunkdiff/extension";
/** Match one path against a `*`-only glob, anchored at both ends. */
function matchesPattern(path: string, pattern: string) {
const source = pattern
.split("*")
.map((part) => part.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&"))
.join(".*");
return new RegExp(`^${source}$`).test(path);
}
export default function (hunk: HunkExtensionAPI) {
const patterns = (hunk.config.patterns as string[] | undefined) ?? [
"*.lock",
"*-lock.json",
"dist/*",
];
hunk.transformChangeset((changeset, ctx) => {
const kept = changeset.files.filter(
(file) => !patterns.some((pattern) => matchesPattern(file.path, pattern)),
);
const hidden = changeset.files.length - kept.length;
if (hidden > 0) {
ctx.notify(`Collapsed ${hidden} generated ${hidden === 1 ? "file" : "files"}`);
}
return { ...changeset, files: kept };
});
}Configure it without touching the code:
# .hunk/config.toml
[extension.collapse-generated]
patterns = ["*.lock", "bun.lockb", "generated/*"]Try it against the working tree without installing it:
hunk diff --extension ./collapse-generated.tsCLI flags and config reference
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# ~/.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 }.rendermust 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, fixedheight,selected, zero-basedrowIndex, and the shared semanticthemepalette 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.renderinside a fixedcomponent.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:
[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 tooEvery 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.findhands/to the filter whilen/Nstill 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 dohunk.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, andhunk.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 forhunk.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.
[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.jsonafter 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.
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 entrypointOther workspaces are bounded units rather than one tier in that stack:
packages/hunk-{git,jj,sapling}implement private bundled providers throughhunkdiff/extensionand 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-videoowns 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/opentuiimportspackages/hunk/src/uiinternals: it is a packaging facade whose job is re-export.packages/hunk/src/hunk-reviewimportspackages/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 inui/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-modulesflags any module underpackages/hunk/src/that no entry point reaches
(main.tsx,highlightWorkerEntry.ts, theopentuiandextension-apifacades, and the
skill generator). It usesreachable: falserather thanorphan, 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'sTEST_ONLY_MODULESallowlist
with the reason. That allowlist is shrink-only, like the baseline.core-leaves-stay-below-bootstrapfreezes 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 wascore-leaves-never-reimport-typesand guardedcore/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 bychangeset-internals-stay-in-module:
- Public:
model(theChangeset/DiffFile/SidecarContextshapes),loaders
(every input source, plus the app bootstrap built around one),diffFile,fileSource,fileLanguage,binary,diffPaths,hunkHeader,hunkSummary. - Interior:
fromPatchturns patch text into the model and is reached throughloaders;fileLanguageLookupis the only reader/writer of Pierre's process-global extension table
and the import that drags in the diff engine;sidecarreads--agent-contextas 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 namescore/run/commandInputs.ts in place of the old root path. Only commandInputs is a
types-leaf — config, experimental, and inputReload import core/types legally, sincecore/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 wholeSession*CommandInput family with SessionSelectorInput / SessionCommandOutput /SessionCommentApplyItemInput, MarkupRenderCommandInput, MarkupGuideCommandInput, theExtension*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 ontocommandInputs 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 fromprocess/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, besideDiffSideandCommentTargetInput: 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 namesAgentAnnotation, 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 throughcore/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 intochangeset/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 topackages/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 intocore/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.tsgave its changeset model tocore/changeset.ts(since phase 1,core/changeset/model.ts) and its command-input model tocore/commandInputs.ts(since phase
2,core/run/commandInputs.ts; re-exported fromcore/typesso import sites kept
working, until phase 4 melted that shell intocore/bootstrap.tsand moved the sites onto the
declaring modules); the diff row model moved toui/diff/diffRowModel.ts; the worker's
compact encoder was retyped structurally (HighlightedHastLines);HunkSessionBrokerClient
moved beside the client class it aliases;CopySelectedRowRangemoved intoui/lib/diffSpatial.ts;extensions/notifications.tsnow importsExtensionNotifyType
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 (includinghunk session *fromsession/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
insidepackages/hunk/src/sessionimported 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-freecore/review/seam) is its tier.ui/lib/reviewState.tsresolves 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/uiimports 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 consumepackages/hunk/src/ui, and still may never touchpackages/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:
- Give
packages/hunk/src/corean interior. Done (phases 0–4, see Module interiors). Every group is a
module directory —review/,vcs/,theme/,watch/,patch/,changeset/,history/,install/,run/,process/— andcore/*root containsbootstrap.ts,reviewDigest.ts,reviewDescriptor.ts, andliveComments.ts, with no grab-bag re-export shell. What remains is per-file public
surfaces for the modules that never got one:changesethaschangeset-internals-stay-in-moduleandreviewhasreview-reducer-is-module-internal,
whilerun,process,theme,vcs,watch, andpatchare still public in full
because every file in them has an outside importer today. Two named follow-ups: moveloadAppBootstrapout ofcore/changeset/loaders.tsintopackages/hunk/src/app(it is composition, and
it is the one exceptioncore-leaves-stay-below-bootstraphas to carve out), and splitcore/run/config.ts, whose readers reach it for three unrelated reasons — the
resolvedHunkConfigResolution, the persisted view preferences, and the extension/keybinding
tables. The review seam's named modules (document,geometry,state, …) stay public;
their helpers become internal. - Tighten the adapter allowlist.
ui-couples-to-session-via-adaptersnames its current files
inUI_SESSION_ADAPTERSinside.dependency-cruiser.cjs. As session coupling consolidates intouseTerminalReview/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.
hunk open README.md
hunk open ~/project
hunk open hypr/ --theme nord
hunk open file.ts --wrap --tab-width 4Controls
- 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.
[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
npm i hunkdiff @pierre/[email protected] @opentui/core@^0.5.6 @opentui/react@^0.5.6 reacthunkdiff 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
import { createCliRenderer } from "@opentui/core";
import { createRoot } from "@opentui/react";
import { HunkDiffView, createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
const metadata = parseDiffFromFile(
{
cacheKey: "before",
contents: "export const value = 1;\n",
name: "example.ts",
},
{
cacheKey: "after",
contents: "export const value = 2;\nexport const added = true;\n",
name: "example.ts",
},
{ context: 3 },
true,
);
const renderer = await createCliRenderer({
useAlternateScreen: true,
useMouse: true,
exitOnCtrlC: true,
});
const root = createRoot(renderer);
root.render(
<HunkDiffView
diff={createHunkDiffFile({
id: "example",
metadata,
language: "typescript",
path: "example.ts",
})}
layout="split"
width={88}
theme="midnight"
/>,
);In a real app, derive width from your layout or useTerminalDimensions().
Convenience vs primitives
`HunkDiffView`
HunkDiffView renders one file and can own an OpenTUI scrollbox:
<HunkDiffView diff={file} width={88} 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:
<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:
<HunkDiffFileHeader file={file} width={88} onSelect={() => selectFile(file.id)} />`HunkReviewStream`
HunkReviewStream renders a top-to-bottom multi-file review stream without Hunk's app shell, chrome, keybindings, or scroll owner:
<scrollbox width="100%" height="100%" scrollY>
<HunkReviewStream
files={files}
width={terminal.width}
layout="split"
selection={{ fileId, hunkIndex }}
onSelectionChange={({ fileId, hunkIndex }) => {
setFileId(fileId);
setHunkIndex(hunkIndex);
}}
/>
</scrollbox>Use it when you want Hunk's main review stream but your own navigation, chrome, scrolling, and keybindings.
`HunkFileNav`
HunkFileNav renders Hunk's file navigation list as an optional primitive. It does not render borders, outer padding, or a scrollbox; host apps own surrounding chrome and scrolling.
<scrollbox width={32} height="100%" scrollY>
<HunkFileNav
files={files}
selectedFileId={fileId}
width={32}
onSelectFile={(nextFileId) => setFileId(nextFileId)}
/>
</scrollbox>Building file inputs
The public file model is intentionally higher-level than Hunk's internal renderer rows. Row models are not exported.
type HunkDiffFileInput = {
id: string;
metadata: FileDiffMetadata;
language?: string;
path?: string;
previousPath?: string;
patch?: string;
stats?: { additions: number; deletions: number };
isBinary?: boolean;
isTooLarge?: boolean;
isUntracked?: boolean;
statsTruncated?: boolean;
};
type HunkDiffFile = Omit<HunkDiffFileInput, "stats"> & {
stats: { additions: number; deletions: number };
};Components accept HunkDiffFileInput directly. Use createHunkDiffFile(...) when you want a normalized HunkDiffFile with paths and stats filled in once:
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
const file = createHunkDiffFile({
id: "example",
metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
path: "example.ts",
language: "typescript",
});From before/after contents
Use parseDiffFromFile(...) when you already have the old and new file contents.
import { createHunkDiffFile, parseDiffFromFile } from "hunkdiff/opentui";
const file = createHunkDiffFile({
id: "example",
metadata: parseDiffFromFile(beforeFile, afterFile, { context: 3 }, true),
});From unified diff text
Use createHunkDiffFilesFromPatch(...) for a quick multi-file patch path:
import { createHunkDiffFilesFromPatch } from "hunkdiff/opentui";
const files = createHunkDiffFilesFromPatch(patchText, "example:patch");If you need direct access to Pierre's parser, parsePatchFiles(...) is still re-exported.
Common props
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
parseDiffFromFileparsePatchFilesFileDiffMetadatacreateHunkDiffFilecreateHunkDiffFilesFromPatchcountHunkDiffStatsHUNK_DIFF_THEME_NAMESHunkDiffThemeNameCanonicalHunkDiffLayoutHunkDiffLayoutHunkDiffFileHunkDiffFileInputHunkDiffStatsHunkDiffSelection- 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
- Runnable demo overview:
examples/README.md - Component demos:
examples/7-opentui-component/README.md
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]:
[extensions]
enabled = true
disabled = ["hunk.gh", "hunk.search"]
paths = ["~/dev/my-extension"]Keep the existing master switch:
[extensions]
enabled = falseenabled = 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:
[extension.review-triage]
some_setting = trueStart 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:
effective disabled = user disabled ∪ repository disabled ∪ CLI disablesA 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:
effective enabled override = explicit CLI enablesWithin CLI arguments, the last enable/disable occurrence for one identity wins.
CLI overrides
Add repeatable global options:
hunk --disable-extension hunk.gh gh pr 123
hunk diff --disable-extension hunk.search
hunk diff --enable-extension hunk.searchMultiple identities can be selected:
hunk diff \
--disable-extension hunk.search \
--disable-extension review-triageKeep --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:
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 --repoThe 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:
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:
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.ghBare 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:
No enabled VCS provider recognizes this repository.
Disabled matching provider: hunk.vcs.jj
Enable it with:
hunk --enable-extension hunk.vcs.jj diffDo 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:
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:
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:
- Resolve user and repository configuration.
- Parse ordered CLI extension overrides.
- Resolve bundled selection decisions.
- Run only enabled bundled factories in their existing lifecycle owners.
- Discover user candidates when the existing master switch permits it.
- Resolve candidate IDs and filter disabled candidates before module import or trust prompting.
- Trust-gate and import only remaining user candidates.
- 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 ghpatches live until the session owning the enabled factory shuts down; - disabling
hunk.ghon 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
- Add
disabled: readonly string[]to resolved extension configuration. - Parse exact non-empty IDs from user and repository
[extensions]tables. - Add ordered CLI override representation to command inputs.
- Implement and unit-test the pure selection policy.
- 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
disabledis absent.
Phase 2: bundled definitions and `hunk.gh`
- Give the bundled GitHub definition selection ID
hunk.gh. - Keep its current registration namespace and session-owned resource lifetime.
- Filter it before
createGitHubPrExtensionruns. - Retain static ownership metadata for
ghdiagnostics. - Add the disabled-command error and one-run recovery suggestion.
Acceptance:
[extensions] disabled = ["hunk.gh"]preventsghregistration;hunk gh ...reports disabled, not unknown;--enable-extension hunk.ghrestores it for one invocation;--no-extensionsalone does not disable it;- no GitHub temporary directory or network request is created while disabled.
Phase 3: user extensions
- Apply exact-ID selection after discovery identifies a candidate and before import.
- Ensure explicit
--extensionpaths obey the same policy. - Suppress repo trust prompts when every repo candidate is disabled.
- 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-extensionwhen persistently disabled; - enabled candidates retain existing ordering and collision behavior.
Phase 4: bundled UI and VCS
- Add selection identities to the bundled VCS definitions.
- Filter providers before catalog construction and project-root detection.
- Add actionable diagnostics when only disabled providers match.
- Add selection identities to independently optional bundled UI factories.
- 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
--vcsreports 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
- Extend
extension listwith bundled and effective-state views. - Add safe user-config mutations for
extension enable/disable. - Add explicit
--repomutation scope only if TOML preservation is reliable. - 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.mdfor configuration, flags, management commands, and trust behavior;docs/extension-architecture.mdfor 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 ghexamples 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 --helpreports disabled;- config-disabled
hunk.ghplus CLI enable reaches bundled help; - disabled explicit user extension never evaluates its module;
- disabled repo extension never prompts for trust;
- enabled
hunk ghdelegation 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:
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:checkRun 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 inhunk 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 WHATWGRequest, 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 selectedserveSessionBrokerDaemon. 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:
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