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/quick-agents-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extension lifecycle and custom-event handlers request a coalesced host review reload after external agents change reviewed files.
7 changes: 5 additions & 2 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,13 +266,16 @@ 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
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` 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
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:
Expand Down
56 changes: 44 additions & 12 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,9 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `17`). Branch on it if you want
one file to support several Hunk versions. Version 17 adds structured review metadata to delegated
The API generation this Hunk speaks (currently `18`). Branch on it if you want
one file to support several Hunk versions. Version 18 lets lifecycle and custom-event handlers
request a host-owned review reload; version 17 adds structured review metadata to delegated
patch commands and projects it into pane availability and component props; version 16 adds pane-wide
`onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine`
paint; version 14 added structured `rangeEndpoints`
Expand Down Expand Up @@ -1832,9 +1833,8 @@ 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`, 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
`ctx.panes`, live `ctx.navigation`, attributed `ctx.dialogs`, and review reload
controls. `ctx.sidebars` is a deprecated alias for `ctx.panes`. That means a `startup` handler can present
one focused welcome question and navigate to its first example, while a
`changeset_loaded` handler can reveal a pane when it finds something worth
showing — no keypress required. Dialog calls made before the mounted app is
Expand Down Expand Up @@ -1888,8 +1888,9 @@ commands and do not emit this event. Modal widget keys such as Escape, Enter, no
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).
`"daemon"` (an agent command through the session broker), `"extension"` (an
in-process extension request), or `"manual"` (the refresh key, or the reload
after granting extension trust).

`note_created` and `note_edited` cover notes authored in Hunk's own UI, in this
session. `note_edited` carries `note.draft: true` for composer changes and
Expand All @@ -1904,8 +1905,9 @@ either. Use them for incremental UI reactions only.
returns. It fires for user saves and deletes and for agent session comments.
Drafts never appear. A TUI save therefore emits both `note_created` (or
`note_edited`) and `note_changed`. Reloads that remap or drop notes do not emit
`note_changed`; listen for `session_reload` and read `ctx.review.snapshot()`
when a command needs the complete current saved-note record.
`note_changed`; listen for `session_reload` to invalidate extension-owned state,
then read `ctx.review.snapshot()` from a later command when it needs the complete
current saved-note record.

`shutdown` handlers get a short window (250ms) to finish before Hunk replaces
the extension registry or exits anyway, so make cleanup prompt and idempotent.
Expand All @@ -1918,8 +1920,8 @@ 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`, `ctx.navigation`, and `ctx.dialogs` controls as lifecycle
handlers; `ctx.sidebars` remains a deprecated pane alias. Delivery is
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
Expand All @@ -1943,6 +1945,36 @@ export default function (hunk: HunkExtensionAPI) {
Bus payloads are shallow-frozen copies when they are objects. Keep nested data
immutable if multiple extensions will read it.

### `ctx.review.requestReload()` in event handlers

Request a soft reload of the currently mounted review after an external agent,
service, or process changes its inputs. This does not require `--watch`. Hunk
reuses the current input and live view options, preserves mounted UI state and
selection where possible, serializes the work with every other reload, and
coalesces concurrent extension requests into one operation.

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

export default function (hunk: HunkExtensionAPI) {
hunk.events.on("agent:files-changed", async (_payload, ctx) => {
const result = await ctx.review.requestReload();
if (!result.ok) ctx.notify(result.detail, "warning");
});
}
```

Success resolves `{ ok: true }` after the replacement review commits and emits
`session_reload` with reason `"extension"`. Non-reloadable inputs and controls
retained from an expired review resolve `unavailable`; a reload that starts but
cannot complete resolves `failed` with a displayable `detail`. Factory-time bus
events can run before the app mounts, so their reload requests are unavailable.
Once a live request starts, it reports that operation's actual result even
though a successful reload expires the context that requested it.
Requests made from the successor's lifecycle handlers are a new generation and
can schedule a trailing reload. Check `session_reload.reason` before requesting
from that event so an extension does not create its own reload loop.

### `hunk.config`

Your extension's own `[extension.<id>]` config table, as a plain object. Hunk
Expand Down Expand Up @@ -1971,7 +2003,7 @@ 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
Event and bus handlers additionally receive `panes`, `review.requestReload`, and `events.emit`; command
handlers receive `panes`, `selection`, `navigation`, and `dialogs`. The deprecated
`sidebars` alias remains available during the API-v4 compatibility window. `notify`
shows a single unobtrusive line at the bottom of the app that clears
Expand Down
6 changes: 4 additions & 2 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,10 @@ bad or duplicate id is skipped with a startup notice.
| 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` |
| Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command |
| Branch on the API generation (currently `15`) | `hunk.apiVersion` |
| Branch on the API generation (currently `18`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand Down Expand Up @@ -149,7 +150,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), live `ctx.navigation`, attributed `ctx.dialogs`, and
any pane), live `ctx.navigation`, attributed `ctx.dialogs`, review reloads through
`ctx.review.requestReload()`, 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,
Expand Down
2 changes: 2 additions & 0 deletions src/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ export type {
ExtensionCommandHandler,
ExtensionReviewSelection,
ExtensionReviewControls,
ExtensionReviewReloadControls,
ExtensionReviewReloadResult,
ExtensionReviewSnapshot,
ExtensionReviewSnapshotFile,
ExtensionReviewSnapshotLineAddress,
Expand Down
35 changes: 31 additions & 4 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading
* older extensions without guessing at their expectations.
*/
export const HUNK_EXTENSION_API_VERSION = 17;
export const HUNK_EXTENSION_API_VERSION = 18;
export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION;

export type ExtensionNotifyType = "info" | "warning" | "error";
Expand Down Expand Up @@ -1648,6 +1648,31 @@ export interface ExtensionReviewControls {
snapshot(): ExtensionReviewSnapshot | null;
}

/** How an extension-requested reload of the mounted review settled. */
export type ExtensionReviewReloadResult =
| { readonly ok: true }
| {
readonly ok: false;
readonly reason: "unavailable" | "failed";
readonly detail: string;
};

/** Ask Hunk to rebuild the currently mounted review after external work changes its inputs. */
export interface ExtensionReviewReloadControls {
/**
* Request the same soft reload as Hunk's refresh command without requiring watch mode.
*
* Hunk preserves mounted UI state, reapplies live view options, serializes the operation with
* every other reload, and coalesces concurrent extension requests. A successful replacement
* emits `session_reload` with reason `"extension"`.
*
* Resolves `"unavailable"` when the current input cannot be rebuilt or these controls expired
* on reload/teardown. A reload that starts and then fails resolves `"failed"` with a displayable
* detail. The result reports the shared host operation even when another extension requested it.
*/
requestReload(): Promise<ExtensionReviewReloadResult>;
}

/** One question put to the user as a modal confirm dialog. */
export interface ExtensionConfirmOptions {
title: string;
Expand Down Expand Up @@ -1934,6 +1959,8 @@ export interface ExtensionEventContext extends ExtensionContext {
readonly navigation: ExtensionReviewNavigation;
/** Ask attributed, FIFO-queued questions from lifecycle and bus handlers. */
readonly dialogs: ExtensionDialogs;
/** Request a host-owned reload after an external service changes the reviewed inputs. */
readonly review: ExtensionReviewReloadControls;
events: Pick<ExtensionEventBus, "emit">;
}

Expand All @@ -1945,10 +1972,10 @@ export interface ExtensionEventContext extends ExtensionContext {
* Why a session reload happened.
*
* `watch` is a file/VCS change Hunk noticed itself, `daemon` is an agent
* command routed through the session broker, and `manual` is a user action
* (the refresh key, or reloading after granting repo-extension trust).
* command routed through the session broker, `extension` is an in-process extension request,
* and `manual` is a user action (the refresh key, or reloading after granting repo-extension trust).
*/
export type SessionReloadReason = "watch" | "daemon" | "manual";
export type SessionReloadReason = "watch" | "daemon" | "extension" | "manual";

/** Payload delivered with each lifecycle event, keyed by event name. */
export type ExtensionLayoutMode = "auto" | "split" | "stack";
Expand Down
27 changes: 27 additions & 0 deletions src/extensions/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,32 @@ describe("extension event dispatch", () => {
expect(notices).toEqual(["Extension slow failed handling changeset_loaded • async failure"]);
});

test("refuses review reloads before the mounted app installs live controls", async () => {
let reloadResult:
| Awaited<
ReturnType<Parameters<ExtensionEventHandler<"startup">>[1]["review"]["requestReload"]>
>
| undefined;
const { result, notices } = createTestLoadResult([
{
extensionId: "agent",
event: "startup",
handler: async (_payload, ctx) => {
reloadResult = await ctx.review.requestReload();
},
},
]);

await emitExtensionEventBounded(result, "startup", { cwd: "/repo" });

expect(reloadResult).toEqual({
ok: false,
reason: "unavailable",
detail: "The review is not ready to reload.",
});
expect(notices).toEqual(["Extension agent cannot reload the review before the app is ready"]);
});

test("does not wait on async handlers when emitting fire-and-forget", async () => {
let resolveHandler = () => {};
const finished: string[] = [];
Expand Down Expand Up @@ -196,6 +222,7 @@ describe("extension event dispatch", () => {
select: async () => null,
input: async () => null,
},
review: { requestReload: async () => ({ ok: true }) },
events: { emit: () => {} },
};
};
Expand Down
22 changes: 22 additions & 0 deletions src/extensions/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ExtensionDialogs,
ExtensionEventContext,
ExtensionPaneControls,
ExtensionReviewReloadControls,
ExtensionReviewNavigation,
ExtensionVcsFileChangeType,
} from "../extension-api/types";
Expand Down Expand Up @@ -346,6 +347,26 @@ function unavailableDialogs(result: ExtensionLoadResult, extensionId: string): E
};
}

/** Review reload controls used before the mounted app can rebuild its current input. */
function unavailableReviewReloadControls(
result: ExtensionLoadResult,
extensionId: string,
): ExtensionReviewReloadControls {
return {
requestReload: async () => {
result.context.notify(
`Extension ${extensionId} cannot reload the review before the app is ready`,
"warning",
);
return {
ok: false,
reason: "unavailable",
detail: "The review is not ready to reload.",
};
},
};
}

/** Build the runtime event context for one owning extension. */
function createEventContext(
result: ExtensionLoadResult,
Expand All @@ -364,6 +385,7 @@ function createEventContext(
sidebars: panes,
navigation: unavailableReviewNavigation(result, extensionId),
dialogs: unavailableDialogs(result, extensionId),
review: unavailableReviewReloadControls(result, extensionId),
events: {
emit(event, payload) {
emitExtensionCustomEvent(result, event, payload);
Expand Down
2 changes: 2 additions & 0 deletions src/extensions/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export type {
ExtensionLineHighlighter,
ExtensionReviewDescriptor,
ExtensionReviewControls,
ExtensionReviewReloadControls,
ExtensionReviewReloadResult,
ExtensionReviewNote,
ExtensionReviewSnapshot,
ExtensionReviewSnapshotFile,
Expand Down
5 changes: 5 additions & 0 deletions src/ui/App.extension-command-controls.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ describe("extension command control authority", () => {
fileCount: bootstrap.changeset.files.length,
selectedHunkIndex: 0,
})}
onRequestExtensionReviewReload={async () => ({
ok: false,
reason: "unavailable",
detail: "The test host does not reload reviews.",
})}
onWorkspaceWriteCompleted={() => {}}
runWorkspaceWrite={async (write) => {
await write();
Expand Down
5 changes: 5 additions & 0 deletions src/ui/App.extension-runtime.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ function TestApp({ bootstrap }: { bootstrap: AppBootstrap }) {
fileCount: bootstrap.changeset.files.length,
selectedHunkIndex: 0,
})}
onRequestExtensionReviewReload={async () => ({
ok: false,
reason: "unavailable",
detail: "The test host does not reload reviews.",
})}
onWorkspaceWriteCompleted={() => {}}
runWorkspaceWrite={async (write) => {
await write();
Expand Down
5 changes: 5 additions & 0 deletions src/ui/App.extension-trust.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,11 @@ function createTrustHarness(initial: AppBootstrap) {
fileCount: bootstrap.changeset.files.length,
selectedHunkIndex: 0,
})}
onRequestExtensionReviewReload={async () => ({
ok: false,
reason: "unavailable",
detail: "The test host does not reload reviews.",
})}
onWorkspaceWriteCompleted={() => {}}
runWorkspaceWrite={async (write) => {
await write();
Expand Down
Loading
Loading