Skip to content
Open
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 or navigating guided review workflows.
26 changes: 21 additions & 5 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,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 @@ -140,6 +141,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.

`ctx.dialogs` is the one place extension code can interrupt the user, so its
ordering and settlement live outside React in
`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a
Expand All @@ -155,9 +161,19 @@ 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 plus the
same guarded live navigation commands use. `App` installs both through the
per-extension event-context provider; headless or pre-mount delivery resolves
dialogs to their cancel values and refuses navigation 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
49 changes: 40 additions & 9 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,26 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `4`). Version 4 adds keyboard
modes and docked panes; API-v3 sidebar names remain as deprecated aliases.
The API generation this Hunk speaks (currently `4`). Branch on it if you want
one file to support several Hunk versions. Version 4 adds 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 @@ -1316,8 +1334,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 @@ -1437,14 +1456,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 @@ -1460,6 +1484,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 @@ -1480,8 +1509,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
32 changes: 17 additions & 15 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,21 @@ 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) |
| 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 `4`) | `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) |
| 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 `4`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand All @@ -118,7 +119,8 @@ 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), 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.selection` (a snapshot of file + hunk index),
`ctx.navigation` (live, guarded `selectFile`/`selectHunk`), `ctx.commands`
Expand Down
32 changes: 26 additions & 6 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1292,12 +1292,12 @@ export interface ExtensionInputOptions {
/**
* Ask the user questions from a command handler, one modal at a time.
*
* Every dialog is drawn by Hunk, not by the extension, and carries an
* attribution line naming the extension that raised it — a prompt cannot
* present itself as Hunk asking. Only one dialog is on screen at a time:
* Every dialog is drawn by Hunk, not by the extension. Dialogs from installed
* extensions carry an attribution line naming their source, so a third-party
* prompt cannot present itself as Hunk asking; Hunk-owned bundled extensions
* omit that redundant marker. Only one dialog is on screen at a time:
* concurrent requests queue in call order (FIFO), including across extensions,
* so a second question waits for the first to be answered rather than
* replacing it.
* so a second question waits for the first to be answered rather than replacing it.
*
* Escape always cancels, resolving the cancel value (`false`, or `null`).
* Enter accepts: the confirm action, the highlighted option, or the typed text.
Expand Down Expand Up @@ -1448,6 +1448,18 @@ export interface ExtensionWorkspace {
writeDocument(request: ExtensionWorkspaceWriteRequest): Promise<ExtensionWorkspaceWriteResult>;
}

/** Host-level behavior one extension may request for the current review session. */
export interface ExtensionSessionOptions {
/**
* Treat view-setting changes as temporary practice or presentation state.
*
* When `"transient"`, Hunk never offers to write the session's final view
* settings into the user's config on quit. Any extension requesting
* transient behavior makes the shared session transient.
*/
viewPreferences?: "default" | "transient";
}

/** What a command handler receives when its key fires. */
export interface ExtensionCommandContext extends ExtensionContext {
/** Live access to the public built-in command table. */
Expand Down Expand Up @@ -1516,11 +1528,15 @@ export interface ExtensionEventBus {
emit<Payload = unknown>(event: string, payload: Payload): void;
}

/** Context lifecycle and bus listeners receive, including live pane controls. */
/** Context lifecycle and bus listeners receive, including live host controls. */
export interface ExtensionEventContext extends ExtensionContext {
panes: ExtensionPaneControls;
/** @deprecated Use panes. */
sidebars: ExtensionSidebarControls;
/** Navigate the live review from lifecycle-driven guides and coordinators. */
readonly navigation: ExtensionReviewNavigation;
/** Ask attributed, FIFO-queued questions from lifecycle and bus handlers. */
readonly dialogs: ExtensionDialogs;
events: Pick<ExtensionEventBus, "emit">;
}

Expand Down Expand Up @@ -1557,6 +1573,8 @@ export interface ExtensionReviewNote {
export interface ExtensionEventPayloads {
startup: { cwd: string };
changeset_loaded: { changeset: ExtensionChangeset };
/** A named built-in or extension command was invoked by key, menu, or another host surface. */
command_executed: { commandId: string };
selection_changed: { fileId: string | null; hunkIndex: number | null };
/** The review stream settled on a different file. */
file_viewed: { file: ExtensionDiffFile; hunkIndex: number | null };
Expand Down Expand Up @@ -1596,6 +1614,8 @@ export type ExtensionEventHandler<Event extends ExtensionEventName = ExtensionEv
*/
export interface HunkExtensionAPI {
readonly apiVersion: HunkExtensionApiVersion;
/** Configure host-level behavior for the review session loading this extension. */
configureSession(options: ExtensionSessionOptions): void;
/** Contribute one selectable theme. */
registerTheme(theme: ExtensionThemeConfig): void;
/** Map one file extension (with or without a leading dot) to a highlight language. */
Expand Down
24 changes: 24 additions & 0 deletions src/extensions/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,24 @@ describe("extension event dispatch", () => {
expect(seen).toEqual(["first:/repo:/repo", "second"]);
});

test("reports named command execution with an immutable payload", () => {
let seen: { commandId: string } | undefined;
const { result } = createTestLoadResult([
{
extensionId: "coach",
event: "command_executed",
handler: (payload) => {
seen = payload as { commandId: string };
},
},
]);

emitExtensionEvent(result, "command_executed", { commandId: "hunk.review.nextHunk" });

expect(seen).toEqual({ commandId: "hunk.review.nextHunk" });
expect(Object.isFrozen(seen)).toBe(true);
});

test("isolates a throwing handler and keeps dispatching the rest", () => {
const seen: string[] = [];
const { result, notices } = createTestLoadResult([
Expand Down Expand Up @@ -172,6 +190,12 @@ describe("extension event dispatch", () => {
notify: () => {},
panes,
sidebars: panes,
navigation: { selectFile: () => {}, selectHunk: () => {} },
dialogs: {
confirm: async () => false,
select: async () => null,
input: async () => null,
},
events: { emit: () => {} },
};
};
Expand Down
40 changes: 40 additions & 0 deletions src/extensions/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import type {
import type { Hunk } from "@pierre/diffs";
import type {
ExtensionDiffHunk,
ExtensionDialogs,
ExtensionEventContext,
ExtensionPaneControls,
ExtensionReviewNavigation,
ExtensionVcsFileChangeType,
} from "../extension-api/types";
import { summarizeHunk } from "../core/hunkSummary";
Expand Down Expand Up @@ -308,6 +310,42 @@ function unavailablePaneControls(
};
}

/** Navigation controls used before the mounted app can safely move a review. */
function unavailableReviewNavigation(
result: ExtensionLoadResult,
extensionId: string,
): ExtensionReviewNavigation {
const unavailable = () =>
result.context.notify(
`Extension ${extensionId} cannot navigate the review before the app is ready`,
"warning",
);
return { selectFile: unavailable, selectHunk: unavailable };
}

/** Dialog controls used before the mounted app has installed its modal queue. */
function unavailableDialogs(result: ExtensionLoadResult, extensionId: string): ExtensionDialogs {
const unavailable = () =>
result.context.notify(
`Extension ${extensionId} cannot open a dialog before the app is ready`,
"warning",
);
return {
confirm: async () => {
unavailable();
return false;
},
select: async () => {
unavailable();
return null;
},
input: async () => {
unavailable();
return null;
},
};
}

/** Build the runtime event context for one owning extension. */
function createEventContext(
result: ExtensionLoadResult,
Expand All @@ -324,6 +362,8 @@ function createEventContext(
...result.context,
panes,
sidebars: panes,
navigation: unavailableReviewNavigation(result, extensionId),
dialogs: unavailableDialogs(result, extensionId),
events: {
emit(event, payload) {
emitExtensionCustomEvent(result, event, payload);
Expand Down
1 change: 1 addition & 0 deletions src/extensions/publicApiRobustness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,7 @@ describe("factories that misbehave outright", () => {
});

for (const method of [
"configureSession",
"registerTheme",
"registerFileLanguage",
"registerVcsAdapter",
Expand Down
Loading
Loading