Skip to content
Merged
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.
41 changes: 36 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 @@ -191,6 +192,15 @@ stepping's minimum-distance scroll and the top-padded position hunk, note, and
`revealLine` reveals share. Extensions name a target; they never name a scroll
position.

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

`ctx.dialogs` is the one place extension code can interrupt the user, so its
ordering and settlement live outside React in
`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a
Expand All @@ -206,9 +216,30 @@ 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. `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.

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, while `AppHost` publishes mounted
lifecycle order (`startup`, then `changeset_loaded`; reloads add
`session_reload`) only after the matching child commit. Headless or pre-mount delivery resolves
dialogs to their cancel values and refuses navigation with a warning.
`src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation,
dialog, and workspace controls to one App, extension registry, and review
generation. Soft
reload or registry retirement therefore makes old host-mediated capabilities
inert before shutdown begins. Session behavior requests are registry data too:
`resolveExtensionSessionOptions` applies the shared policy that any
`configureSession({ viewPreferences: "transient" })` request makes practice and
presentation view changes ephemeral without teaching `App` about an extension
id.

`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
76 changes: 62 additions & 14 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,10 +278,27 @@ 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 `6`). Branch on it if you want
one file to support several Hunk versions. Version 6 adds 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.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 @@ -1461,8 +1478,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 @@ -1538,9 +1556,12 @@ has moved or become unsafe.

`writeDocument` verifies the target, asks for consent through the attributed
`ctx.dialogs` queue, then verifies it again before writing. The second check
prevents deletion and symlink-swap races while the dialog is open. A successful
write starts a session reload; the write promise may settle before that reload
finishes.
prevents deletion and symlink-swap races while the dialog is open. Authority is
checked immediately before the filesystem call; once that irreversible write
starts, its actual success or failure wins even if another reload happens, and
graceful shutdown waits for it to settle. A successful write queues
reconciliation of the review then active, and the write promise may settle
before that reload finishes.

A declined prompt returns `cancelled`, an ineligible or unsafe target returns
`unavailable`, and an attempted write failure returns `failed` with a
Expand Down Expand Up @@ -1582,14 +1603,25 @@ 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.
Controls retained across a review or extension-registry replacement expire:
navigation and pane mutations warn and do nothing, dialogs resolve to their
normal cancel value, and workspace reads or not-yet-started writes return
`null`/`unavailable` instead of acting on replacement content. Once a consented
filesystem write starts, it reports its actual outcome and success reconciles
the review then active.

| Event | Payload | When |
| ---------------------- | ----------------------- | --------------------------------------------------------- |
| `startup` | `{ cwd }` | once per loaded instance, after its review UI mounts |
| `changeset_loaded` | `{ changeset }` | first load and every reload |
| `command_executed` | `{ commandId }` | 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 |
| `filter_changed` | `{ filter }` | whenever the file-filter query changes |
Expand All @@ -1601,10 +1633,22 @@ it finds something worth showing — no keypress required.
| `session_reload` | `{ changeset, reason }` | on every session reload |
| `shutdown` | `{}` | before instance replacement or exit, with a short timeout |

A newly mounted extension instance receives `startup` before its first
`changeset_loaded`; reloads then deliver `changeset_loaded` before
`session_reload` once the matching review generation has committed.

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

`command_executed` reports the stable command id after the terminal dispatcher invokes it,
whether the user reached it through a key, a menu, or `ctx.commands.execute`. 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, note-editor Ctrl-S,
and F10 menu navigation are also not commands.

`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 @@ -1618,15 +1662,19 @@ here this session", not a complete review record; present it as such.

`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` 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
31 changes: 31 additions & 0 deletions packages/session-broker/src/connection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ type TestServerMessage = SessionServerMessage<"annotate", { summary: string }>;
class TestSocket implements SessionBrokerSocketLike {
readyState = 0;
sent: string[] = [];
throwOnSend = false;
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null = null;
onerror: (() => void) | null = null;

send(data: string) {
if (this.throwOnSend) throw new Error("socket exploded");
this.sent.push(data);
}

Expand Down Expand Up @@ -106,6 +108,35 @@ describe("session broker connection", () => {
});
});

test("keeps the previous registration when replacement send throws", () => {
const socket = new TestSocket();
const registration = createRegistration();
const connection = createSessionBrokerConnection<
TestSessionInfo,
TestSessionState,
TestSocket,
TestServerMessage,
{ ok: true }
>({
url: "ws://broker.test/session",
createSocket: () => socket,
registration,
snapshot: createSnapshot(),
});
connection.start();
socket.emitOpen();
socket.throwOnSend = true;

expect(() =>
connection.replaceSession(
{ ...registration, sessionId: "session-2" },
{ ...createSnapshot(), state: { selectedIndex: 2 } },
),
).toThrow("socket exploded");
expect(connection.getRegistration()).toBe(registration);
connection.stop();
});

test("queues broker commands until the app bridge is ready", async () => {
const socket = new TestSocket();
const connection = createSessionBrokerConnection<
Expand Down
7 changes: 4 additions & 3 deletions packages/session-broker/src/connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ export class SessionBrokerConnection<
}

replaceSession(registration: SessionRegistration<Info>, snapshot: SessionSnapshot<State>) {
this.registration = registration;
this.snapshot = snapshot;
// Re-register instead of sending only a snapshot because selectors like cwd, repoRoot, and the
// session id itself live in the registration envelope.
// session id itself live in the registration envelope. Send before committing local state so
// a throwing socket keeps the previous registration and snapshot coherent.
this.send({
type: "register",
registration,
snapshot,
});
this.registration = registration;
this.snapshot = snapshot;
}

updateSnapshot(snapshot: SessionSnapshot<State>) {
Expand Down
11 changes: 10 additions & 1 deletion scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import type {
ExtensionPaneProps,
ExtensionPaneSize,
ExtensionReviewSelection,
ExtensionSessionOptions,
ExtensionVerticalPane,
ExtensionVcsAdapter,
ExtensionVcsDiffInput,
Expand All @@ -48,6 +49,8 @@ import type {
} from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
const sessionOptions: ExtensionSessionOptions = { viewPreferences: "transient" };
hunk.configureSession(sessionOptions);
const noSelection: ExtensionReviewSelection = { file: null, hunkIndex: null };
hunk.log(noSelection.file === null ? "nothing selected" : noSelection.file.path);

Expand Down Expand Up @@ -290,8 +293,14 @@ export default function (hunk: HunkExtensionAPI) {
files: changeset.files.filter((file) => !file.path.endsWith(".lock")),
}));

hunk.on("startup", (event, ctx) => {
hunk.on("startup", async (event, ctx) => {
ctx.notify(\`started in \${event.cwd}\`, "info");
if (await ctx.dialogs.confirm({ title: "Reveal the first line?" })) {
ctx.navigation.revealLine("file-id", "new", 1);
}
});
hunk.on("command_executed", ({ commandId }) => {
hunk.log(\`terminal command \${commandId}\`);
});
hunk.on("changeset_loaded", (event) => {
hunk.log(\`loaded \${event.changeset.files.length} files\`);
Expand Down
Loading
Loading