Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/guided-extension-workflows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add extension APIs for transient sessions and observing, navigating, or spotlighting guided review workflows.
5 changes: 5 additions & 0 deletions .changeset/tidy-eels-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add an interactive Hunk Tutor whose spotlighted instructional diff teaches the review workflow and live keybindings.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs).

**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/)

[![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main)
[![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases)
[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
Expand Down Expand Up @@ -65,8 +63,14 @@ Requirements:
```bash
hunk # show help
hunk --version # print the installed version
hunk tutor # learn the interface inside a guided review
```

New to Hunk? `hunk tutor` opens a self-contained, vimtutor-inspired tutorial whose diff is the
guide. Its bundled extension tracks the commands you actually use, follows custom keybindings, and teaches the
multi-file review stream, layouts, filtering, inline notes, agent context, menus, mouse support,
themes, and extension commands without touching your repository.

### Working with Git

Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text.
Expand Down
34 changes: 27 additions & 7 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ object and registry collection (`src/extensions/runExtension.ts`):
extension host. `default/ui/index.ts` is deliberately not part of that list:
it synchronously loads the bundled files pane through `runExtensionFactory`
only where the app resolves UI panes.
`default/ui/tutor/` is another public-API consumer; it loads only for
`hunk tutor`, after parsing establishes that the process is taking an
interactive path.

Git and the built-in file navigation use the public `registerVcsAdapter` and
`registerPane` paths. The current-line lens remains an installable example.
Git, built-in file navigation, and Tutor use the public `registerVcsAdapter`
and `registerPane` paths. The current-line lens remains an installable example.

Bundled extensions are implicitly trusted and stay loaded under
`--no-extensions`, which governs user extensions only.
Expand All @@ -42,8 +45,9 @@ load issue and costs only that extension. The rules themselves are stated in

## One registry, one apply path

Registrations (themes, file languages, VCS adapters, changeset transforms,
panes, commands, lifecycle/UI events, and bus listeners) collect into one
Registrations (session behavior, themes, file languages, VCS adapters,
changeset transforms, panes, commands, lifecycle/UI events, and inter-extension
bus listeners) collect into one
`ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied
through `src/extensions/apply.ts` on both startup and reload. Staged external-VCS
bootstrap retains the provisional candidate/config snapshot: a final pass that
Expand Down Expand Up @@ -180,6 +184,11 @@ chord at a time and detected by probing matchers with a synthesized event
`src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the
panes render. App reads it through a ref so the dispatch table stays stable.

After any named command runs, App emits `command_executed` with its stable id. The event is
attached around the assembled table, so keyboard dispatch, menus, and extension commands share
one observation path; widget-owned modal keys remain outside the table and therefore outside the
event.

`src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both
`ctx.navigation` and a pane's `actions`, so a jump from either surface is
validated, attributed, and reported the same way. It owns argument policy only
Expand All @@ -206,9 +215,20 @@ 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 always carries an `ext <id>` attribution row — the toast marker — because
the title is extension-authored and a prompt must not be able to impersonate
Hunk.
frame carries an `ext <id>` 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.

Lifecycle and bus handlers receive that same attributed dialog queue, the same
guarded live navigation commands use, and scoped line-highlight refresh controls.
`App` installs all three through the per-extension event-context provider;
headless or pre-mount delivery resolves dialogs to their cancel values and
refuses navigation or highlight invalidation with a warning. Session
behavior requests are registry data too: `configureSession({ viewPreferences:
"transient" })` makes practice and presentation view changes ephemeral without
teaching `App` about any particular extension id.

`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads
resolve reviewed file ids through the existing source fetcher, which retains
Expand Down
69 changes: 51 additions & 18 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ run without installing anything.
## Bundled extensions

Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension,
and so is the **built-in file-navigation pane**. They live in
as are the **built-in file-navigation pane** and the interactive guide opened
by `hunk tutor`. They live in
`src/extensions/default/`, are compiled into the binary, and register through
the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide
documents. There is no private registration path.
Expand All @@ -204,8 +205,10 @@ can do, because Git does it the same way you would.
Bundled extensions differ from yours in three ways, all of them consequences of
being Hunk's own code:

- They are **statically imported**, so they load synchronously, before config
resolution picks the session's VCS.
- The VCS adapters and default sidebar are **statically imported**, so they load
synchronously before config resolution picks the session's VCS. The UI-backed tutor
extension is imported only after the `tutor` command is selected, keeping headless
commands free of OpenTUI's native runtime.
- They are **implicitly trusted**: no discovery, no trust prompt, and no
`[extension.<id>]` config table.
- They stay loaded under `--no-extensions` and `[extensions] enabled = false`.
Expand Down Expand Up @@ -278,10 +281,26 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `5`). Version 5 adds line
highlighters and line-granular navigation (`revealLine`); version 4 added
keyboard modes and docked panes, with API-v3 sidebar names remaining as
deprecated aliases.
The API generation this Hunk speaks (currently `5`). Branch on it if you want
one file to support several Hunk versions. Version 5 adds line highlighters and
line-granular navigation (`revealLine`); version 4 added keyboard modes, docked
panes, session behavior, named-command observation, and live navigation/dialogs
in event handlers. API-v3 sidebar names remain as deprecated aliases.

### `hunk.configureSession(options)`

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

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

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

### `hunk.registerTheme(theme)`

Expand Down Expand Up @@ -1136,8 +1155,8 @@ 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.
are available to lifecycle and bus handlers and session keyboard modes through
their contexts, so a guide step or prompt 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
Expand Down Expand Up @@ -1461,8 +1480,9 @@ hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, a
```

Hunk draws the dialog, not you: your text fills the title, body, and choices,
and the frame carries an `ext <your-id>` attribution line — the same marker
`notify` toasts use — so a prompt can never present itself as Hunk asking.
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
Expand Down Expand Up @@ -1582,14 +1602,19 @@ the metadata actually parses to.

Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks
the UI waiting for one. Alongside `cwd` and `notify`, every handler receives
`ctx.panes`, the same open/close/toggle controls command handlers receive.
That means a `changeset_loaded` handler can reveal its extension's pane when
it finds something worth showing — no keypress required.
`ctx.panes`, live `ctx.navigation`, and attributed `ctx.dialogs`, the same
controls command handlers receive. `ctx.sidebars` is a deprecated alias for
`ctx.panes`. That means a `startup` handler can present
one focused welcome question and navigate to its first example, while a
`changeset_loaded` handler can reveal a pane when it finds something worth
showing — no keypress required. Dialog calls made before the mounted app is
ready resolve to their cancel value with a warning rather than opening later.

| 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 }` | whenever a named built-in or extension command runs |
| `selection_changed` | `{ fileId, hunkIndex }` | when the review selection settles (debounced ~150ms) |
| `file_viewed` | `{ file, hunkIndex }` | when selection settles on a file or a reload replaces it |
| `filter_changed` | `{ filter }` | whenever the file-filter query changes |
Expand All @@ -1605,6 +1630,11 @@ it finds something worth showing — no keypress required.
the selection many times a second, and handlers only care where the user landed.
`fileId` and `hunkIndex` are `null` when nothing is selected.

`command_executed` reports the stable command id after its handler is invoked, whether the user
reached it through a key, a menu, or another host-owned command surface. Listen for ids rather
than key chords so behavior follows the user's live `[keybindings]` table. Modal widget keys such
as Escape, Enter, note-editor Ctrl-S, and F10 menu navigation are not commands and do not emit it.

`session_reload`'s `reason` is `"watch"` (the watcher saw the source change),
`"daemon"` (an agent command through the session broker), or `"manual"` (the
refresh key, or the reload after granting extension trust).
Expand All @@ -1625,8 +1655,10 @@ The replacement instance receives `startup` after its review is mounted.
`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` controls as lifecycle handlers; delivery is fire-and-forget
and one listener's failure is reported without stopping the others. Events an
the same `ctx.panes`, `ctx.navigation`, and `ctx.dialogs` 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.

Expand Down Expand Up @@ -1676,8 +1708,9 @@ 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` and `events.emit`; command
handlers receive `panes`, `selection`, `navigation`, and `dialogs`. The deprecated
Event and bus handlers additionally receive `panes`, `highlights`, live
`navigation`, `dialogs`, and `events.emit`; command handlers receive `panes`,
`highlights`, `selection`, `navigation`, and `dialogs`. The deprecated
`sidebars` alias remains available during the API-v4 compatibility window. `notify`
shows a single unobtrusive line at the bottom of the app that clears
itself after a few seconds; queued messages appear in turn. `type` is `"info"`
Expand Down
35 changes: 19 additions & 16 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,21 +93,22 @@ bad or duplicate id is skipped with a startup notice.

## Pick the touchpoint

| To do this | Call |
| ------------------------------------------------------- | -------------------------------------------- |
| Add a selectable color theme | `hunk.registerTheme(theme)` |
| Highlight an unrecognized file extension | `hunk.registerFileLanguage(ext, lang)` |
| Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` |
| Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` |
| Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) |
| Mark character ranges inside diff lines | `hunk.registerLineHighlighter(highlighter)` |
| Interpret review keys as a temporary global mode | `hunk.registerKeyboardMode(mode)` |
| Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` |
| Hide, reorder, retitle files before review | `hunk.transformChangeset(fn)` |
| React to loads, selection, viewed files, notes, reloads | `hunk.on(event, handler)` |
| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Branch on the API generation (currently `5`) | `hunk.apiVersion` |
| To do this | Call |
| -------------------------------------------------------- | -------------------------------------------- |
| Keep demo/training view settings temporary | `hunk.configureSession(options)` |
| Add a selectable color theme | `hunk.registerTheme(theme)` |
| Highlight an unrecognized file extension | `hunk.registerFileLanguage(ext, lang)` |
| Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` |
| Add a navigation/list/status pane beside the review | `hunk.registerPane(pane)` |
| Present a file as something other than a raw diff | `hunk.registerFileView(view)` (experimental) |
| Mark character ranges inside diff lines | `hunk.registerLineHighlighter(highlighter)` |
| Interpret review keys as a temporary global mode | `hunk.registerKeyboardMode(mode)` |
| Bind a key / add an Extensions-menu entry | `hunk.registerCommand(command, handler)` |
| Hide, reorder, retitle files before review | `hunk.transformChangeset(fn)` |
| React to loads, selection, view movement, notes, reloads | `hunk.on(event, handler)` |
| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Branch on the API generation (currently `5`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand All @@ -119,7 +120,9 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's
`matches` and `layout` get no context at all. Beyond that:

- **Event and bus handlers** also get `ctx.panes` (open/close/toggle/isOpen on
any pane) and `ctx.events.emit`.
any pane), `ctx.highlights` (refresh prepared line marks), live
`ctx.navigation`, attributed `ctx.dialogs`, and `ctx.events.emit`.
`ctx.sidebars` is a deprecated alias for `ctx.panes`.
- **Command handlers** get `ctx.panes`, `ctx.fileViews` (select/toggle/isActive/
refresh/enterMode/exitMode), `ctx.highlights` (refresh prepared line marks,
whole or `{ fileId }`-scoped), `ctx.selection` (a snapshot of file + hunk index),
Expand Down
Loading