From 802f04dacab5ce9dc933f4c8a2d3faf84e522109 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Fri, 4 Sep 2026 22:14:31 -0400 Subject: [PATCH] feat(extensions): add review reload requests --- .changeset/quick-agents-refresh.md | 5 + docs/extension-architecture.md | 7 +- docs/extensions.md | 56 +++-- skills/hunk-extensions/SKILL.md | 6 +- src/extension-api/index.ts | 2 + src/extension-api/types.ts | 35 +++- src/extensions/events.test.ts | 27 +++ src/extensions/events.ts | 22 ++ src/extensions/types.ts | 2 + .../App.extension-command-controls.test.tsx | 5 + src/ui/App.extension-runtime.test.tsx | 5 + src/ui/App.extension-trust.test.tsx | 5 + src/ui/App.tsx | 16 ++ src/ui/AppHost.extensions.test.tsx | 196 ++++++++++++++++++ src/ui/AppHost.interactions.test.tsx | 5 + src/ui/AppHost.tsx | 62 ++++++ src/ui/currentReviewRefresh.ts | 6 +- .../useCurrentReviewRefreshController.ts | 4 +- .../useExtensionEventContextProvider.test.tsx | 7 + .../hooks/useExtensionEventContextProvider.ts | 6 +- src/ui/lib/extensionReviewReload.test.ts | 32 +++ src/ui/lib/extensionReviewReload.ts | 28 +++ test/pty/extensions-integration.test.ts | 44 ++++ .../content/docs/docs/extend/extension-api.md | 31 ++- 24 files changed, 582 insertions(+), 32 deletions(-) create mode 100644 .changeset/quick-agents-refresh.md create mode 100644 src/ui/lib/extensionReviewReload.test.ts create mode 100644 src/ui/lib/extensionReviewReload.ts diff --git a/.changeset/quick-agents-refresh.md b/.changeset/quick-agents-refresh.md new file mode 100644 index 000000000..b927c0f1c --- /dev/null +++ b/.changeset/quick-agents-refresh.md @@ -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. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 8e9b6e773..9bd50888b 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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: diff --git a/docs/extensions.md b/docs/extensions.md index 4cb9e9a80..179eb1dbc 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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` @@ -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 @@ -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 @@ -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. @@ -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 @@ -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.]` config table, as a plain object. Hunk @@ -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 diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 813027c6b..f4c8ae713 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -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.]` 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. @@ -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, diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index e75d4fb32..bf6057743 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -94,6 +94,8 @@ export type { ExtensionCommandHandler, ExtensionReviewSelection, ExtensionReviewControls, + ExtensionReviewReloadControls, + ExtensionReviewReloadResult, ExtensionReviewSnapshot, ExtensionReviewSnapshotFile, ExtensionReviewSnapshotLineAddress, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 2676ab0bd..37e84fde8 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -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"; @@ -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; +} + /** One question put to the user as a modal confirm dialog. */ export interface ExtensionConfirmOptions { title: string; @@ -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; } @@ -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"; diff --git a/src/extensions/events.test.ts b/src/extensions/events.test.ts index f0ae2bad4..8957295a9 100644 --- a/src/extensions/events.test.ts +++ b/src/extensions/events.test.ts @@ -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>[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[] = []; @@ -196,6 +222,7 @@ describe("extension event dispatch", () => { select: async () => null, input: async () => null, }, + review: { requestReload: async () => ({ ok: true }) }, events: { emit: () => {} }, }; }; diff --git a/src/extensions/events.ts b/src/extensions/events.ts index 4d3bfa636..10ef48710 100644 --- a/src/extensions/events.ts +++ b/src/extensions/events.ts @@ -11,6 +11,7 @@ import type { ExtensionDialogs, ExtensionEventContext, ExtensionPaneControls, + ExtensionReviewReloadControls, ExtensionReviewNavigation, ExtensionVcsFileChangeType, } from "../extension-api/types"; @@ -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, @@ -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); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index ca5d5f312..2fd466a62 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -61,6 +61,8 @@ export type { ExtensionLineHighlighter, ExtensionReviewDescriptor, ExtensionReviewControls, + ExtensionReviewReloadControls, + ExtensionReviewReloadResult, ExtensionReviewNote, ExtensionReviewSnapshot, ExtensionReviewSnapshotFile, diff --git a/src/ui/App.extension-command-controls.test.tsx b/src/ui/App.extension-command-controls.test.tsx index cb89d00d4..a2a3ac9c7 100644 --- a/src/ui/App.extension-command-controls.test.tsx +++ b/src/ui/App.extension-command-controls.test.tsx @@ -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(); diff --git a/src/ui/App.extension-runtime.test.tsx b/src/ui/App.extension-runtime.test.tsx index cc29e5829..bdda39e6d 100644 --- a/src/ui/App.extension-runtime.test.tsx +++ b/src/ui/App.extension-runtime.test.tsx @@ -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(); diff --git a/src/ui/App.extension-trust.test.tsx b/src/ui/App.extension-trust.test.tsx index 7c922f980..bc003df9b 100644 --- a/src/ui/App.extension-trust.test.tsx +++ b/src/ui/App.extension-trust.test.tsx @@ -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(); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 4779c21d0..6ff7452df 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -15,6 +15,7 @@ import { useState, } from "react"; import type { PersistedViewPreferences } from "../core/run/config"; +import type { ExtensionReviewReloadResult } from "../extension-api/types"; import { experimentalFeatureEnabled, resolveExperimentalDiffFiles } from "../core/run/experimental"; import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP } from "../core/run/reviewGap"; import { DEFAULT_TAB_WIDTH } from "../core/run/tabWidth"; @@ -86,6 +87,7 @@ import { } from "./lib/appCommands"; import { buildAppMenus } from "./lib/appMenus"; import { buildExtensionAppCommands, extensionCommandKeyDefaults } from "./lib/extensionCommands"; +import { createExtensionReviewReloadControls } from "./lib/extensionReviewReload"; import type { CurrentLineAlignment } from "./lib/hunkScroll"; import type { LineCursor } from "./lib/lineCursors"; import { useFilePresentationController } from "./fileViews/useFilePresentationController"; @@ -137,6 +139,7 @@ export function App({ onQuit = () => process.exit(0), onRegisterWorkspaceRefreshRequest, onReloadSession, + onRequestExtensionReviewReload, onWorkspaceWriteCompleted, reviewProducer, runWorkspaceWrite, @@ -153,6 +156,10 @@ export function App({ nextInput: CliInput, options?: ReloadSessionOptions, ) => Promise; + /** Queue a coalesced reload against the review current when host execution begins. */ + onRequestExtensionReviewReload: ( + reviewGeneration: AppBootstrap, + ) => Promise; /** Reconcile the currently mounted review after a consented filesystem write succeeds. */ onWorkspaceWriteCompleted: () => void; /** The producer publishing this review's generations, when the host mounted one. */ @@ -518,10 +525,19 @@ export function App({ workspaceFileWriter, }); + const createEventReviewReloadControls = useCallback(() => { + const lease = createReviewCapabilityLease(); + return createExtensionReviewReloadControls({ + isLive: lease.isLive, + requestReload: () => onRequestExtensionReviewReload(bootstrap), + }); + }, [bootstrap, createReviewCapabilityLease, onRequestExtensionReviewReload]); + useExtensionEventContextProvider({ createDialogs: createExtensionDialogs, createNavigation: createExtensionNavigation, createPaneControls, + createReviewReloadControls: createEventReviewReloadControls, extensions, }); diff --git a/src/ui/AppHost.extensions.test.tsx b/src/ui/AppHost.extensions.test.tsx index b777021af..ed5523487 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/src/ui/AppHost.extensions.test.tsx @@ -26,6 +26,7 @@ import { resolveDetectedVcsIdWithExtensions, } from "../extensions/apply"; import { loadStartupExtensions } from "../extensions/startup"; +import { emitExtensionCustomEvent } from "../extensions/events"; import { AppHost } from "./AppHost"; /** Specialize the core loader result with extension state assigned by these tests. */ @@ -151,6 +152,20 @@ function writeProbeExtension(path: string, logPath: string, languageExtension?: ); } +/** Write an extension that records the settled result of one startup reload request. */ +function writeStartupReloadResultExtension(path: string, logPath: string) { + writeFileSync( + path, + `import { appendFileSync } from "node:fs";\n` + + `export default function (hunk) {\n` + + ` hunk.on("startup", async (_event, ctx) => {\n` + + ` const result = await ctx.review.requestReload();\n` + + ` appendFileSync(${JSON.stringify(logPath)}, result.ok ? "ok\\n" : result.reason + ":" + result.detail + "\\n");\n` + + ` });\n` + + `}\n`, + ); +} + /** Write a probe whose replacement transform waits until the test releases its commit gate. */ function writeDelayedReplacementExtension(path: string, logPath: string, releasePath: string) { mkdirSync(join(path, ".."), { recursive: true }); @@ -850,6 +865,65 @@ describe("reload keeps launch extension authority", () => { }); describe("mounted lifecycle ordering", () => { + test("reports unavailable when the mounted review cannot reload its input", async () => { + const root = createTempDir("hunk-apphost-extension-unavailable-reload-"); + const logPath = join(root, "extension-reload.log"); + const extPath = join(root, "extension-reload.ts"); + writeStartupReloadResultExtension(extPath, logPath); + useTempConfigHome(); + + const bootstrap = await loadAppBootstrap( + { + kind: "patch", + text: "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-before\n+after\n", + options: { mode: "stack", extensionPaths: [extPath] }, + }, + { cwd: root }, + ); + bootstrap.extensions = await loadStartupExtensions({ + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + cwd: root, + cliExtensionPaths: [extPath], + }); + + await withAppHost(bootstrap, async (setup) => { + await flushUntil(setup, () => existsSync(logPath), "the unavailable reload result"); + expect(readProbeLog(logPath)).toEqual([ + "unavailable:The current review cannot be reloaded from its original input.", + ]); + }); + }); + + test("reports failed when a started extension reload cannot commit", async () => { + const repo = createTestRepo("hunk-apphost-extension-failed-reload-"); + const logPath = join(repo, "extension-reload.log"); + const extPath = join(repo, "extension-reload.ts"); + writeStartupReloadResultExtension(extPath, logPath); + useTempConfigHome(); + + const bootstrap = await loadAppBootstrap( + { kind: "vcs", staged: false, options: { mode: "stack", extensionPaths: [extPath] } }, + { cwd: repo }, + ); + bootstrap.extensions = await loadStartupExtensions({ + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + cwd: repo, + cliExtensionPaths: [extPath], + }); + const broker = createTestBrokerClient({ replaceSessionError: new Error("broker exploded") }); + + await withAppHost( + bootstrap, + async (setup) => { + await flushUntil(setup, () => existsSync(logPath), "the failed reload result"); + expect(readProbeLog(logPath)).toEqual([ + "failed:Failed to reload the current review: broker exploded", + ]); + }, + broker.client, + ); + }); + test("delivers same-runtime reload events after the new review commits", async () => { const repo = createTestRepo("hunk-apphost-lifecycle-order-"); const logPath = join(repo, "lifecycle.log"); @@ -914,6 +988,128 @@ describe("mounted lifecycle ordering", () => { broker.client, ); }); + + test("lets a custom extension event reload externally changed review input", async () => { + const repo = createTestRepo("hunk-apphost-extension-request-reload-"); + const reviewedPath = join(repo, "sub", "a.txt"); + const logPath = join(repo, "extension-reload.log"); + const extPath = join(repo, "extension-reload.ts"); + writeFileSync( + extPath, + `import { appendFileSync, writeFileSync } from "node:fs";\n` + + `export default function (hunk) {\n` + + ` const log = (line) => appendFileSync(${JSON.stringify(logPath)}, line + "\\n");\n` + + ` let retainedReview;\n` + + ` let successorRequested = false;\n` + + ` hunk.events.on("reload-probe:changed", async (_payload, ctx) => {\n` + + ` retainedReview = ctx.review;\n` + + ` const first = ctx.review.requestReload();\n` + + ` const second = ctx.review.requestReload();\n` + + ` log("coalesced:" + String(first === second));\n` + + ` const result = await first;\n` + + ` log(result.ok ? "result:ok" : "result:" + result.reason);\n` + + ` });\n` + + ` hunk.on("startup", () => {\n` + + ` writeFileSync(${JSON.stringify(reviewedPath)}, "one\\ntwo\\nthree\\n");\n` + + ` hunk.events.emit("reload-probe:changed", {});\n` + + ` });\n` + + ` hunk.on("session_reload", async ({ reason }, ctx) => {\n` + + ` log("reload:" + reason);\n` + + ` if (reason !== "extension" || successorRequested) return;\n` + + ` successorRequested = true;\n` + + ` const stale = await retainedReview.requestReload();\n` + + ` log(stale.ok ? "stale:ok" : "stale:" + stale.reason);\n` + + ` writeFileSync(${JSON.stringify(reviewedPath)}, "one\\ntwo\\nthree\\nfour\\n");\n` + + ` const trailing = await ctx.review.requestReload();\n` + + ` log(trailing.ok ? "trailing:ok" : "trailing:" + trailing.reason);\n` + + ` });\n` + + `}\n`, + ); + useTempConfigHome(); + + const bootstrap = await loadAppBootstrap( + { kind: "vcs", staged: false, options: { mode: "stack", extensionPaths: [extPath] } }, + { cwd: repo }, + ); + bootstrap.extensions = await loadStartupExtensions({ + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + cwd: repo, + cliExtensionPaths: [extPath], + }); + + await withAppHost(bootstrap, async (setup) => { + await flushUntil( + setup, + () => readProbeLog(logPath).includes("trailing:ok"), + "the successor extension reload to commit", + ); + + const events = readProbeLog(logPath); + expect(events).toContain("coalesced:true"); + expect(events).toContain("stale:unavailable"); + expect(events).toContain("result:ok"); + expect(events).toContain("trailing:ok"); + expect(events.filter((event) => event === "reload:extension")).toHaveLength(2); + expect(setup.captureCharFrame()).toContain("four"); + }); + }); + + test("reloads the review current when a queued extension request begins", async () => { + const repo = createTestRepo("hunk-apphost-extension-current-reload-"); + const stagedPath = join(repo, "staged.txt"); + writeFileSync(stagedPath, "before\n"); + execSync("git add staged.txt", { cwd: repo, stdio: "ignore" }); + execSync("git commit -m staged-base", { cwd: repo, stdio: "ignore" }); + writeFileSync(stagedPath, "before\nafter\n"); + execSync("git add staged.txt", { cwd: repo, stdio: "ignore" }); + + const logPath = join(repo, "extension-current-reload.log"); + const extPath = join(repo, "extension-current-reload.ts"); + writeFileSync( + extPath, + `import { appendFileSync } from "node:fs";\n` + + `export default function (hunk) {\n` + + ` const log = (line) => appendFileSync(${JSON.stringify(logPath)}, line + "\\n");\n` + + ` hunk.events.on("reload-probe:queued", async (_payload, ctx) => {\n` + + ` const result = await ctx.review.requestReload();\n` + + ` log(result.ok ? "result:ok" : "result:" + result.reason);\n` + + ` });\n` + + ` hunk.on("session_reload", ({ reason }) => log("reload:" + reason));\n` + + `}\n`, + ); + useTempConfigHome(); + + const bootstrap = await loadAppBootstrap( + { kind: "vcs", staged: false, options: { mode: "stack", extensionPaths: [extPath] } }, + { cwd: repo }, + ); + bootstrap.extensions = await loadStartupExtensions({ + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + cwd: repo, + cliExtensionPaths: [extPath], + }); + const broker = createTestBrokerClient(); + + await withAppHost( + bootstrap, + async (setup) => { + const daemonReload = broker.reload({ kind: "vcs", staged: true, options: {} }, repo); + emitExtensionCustomEvent(bootstrap.extensions!, "reload-probe:queued", {}); + await flushUntil( + setup, + () => readProbeLog(logPath).includes("result:ok"), + "the queued extension reload to settle", + ); + await daemonReload; + + expect(readProbeLog(logPath)).toEqual(["reload:daemon", "reload:extension", "result:ok"]); + const frame = setup.captureCharFrame(); + expect(frame).toContain("staged.txt"); + expect(frame).not.toContain("a.txt"); + }, + broker.client, + ); + }); }); describe("file_viewed events", () => { diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index e7c707496..527699080 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1118,6 +1118,11 @@ describe("App interactions", () => { onReloadSession={async () => { throw new Error("Theme event test does not reload the session."); }} + onRequestExtensionReviewReload={async () => ({ + ok: false, + reason: "unavailable", + detail: "The test host does not reload reviews.", + })} onWorkspaceWriteCompleted={() => {}} runWorkspaceWrite={async (write) => { await write(); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 246c3088c..9c83727c2 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -10,6 +10,7 @@ import { resolveRuntimeCliInput } from "../core/process/terminal"; import type { StartupNotice } from "../core/process/startupNotice"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput } from "../core/run/commandInputs"; +import type { ExtensionReviewReloadResult } from "../extension-api/types"; import type { ExtensionLoadResult } from "../extensions/types"; import { createUnknownVcsNotice, @@ -48,6 +49,12 @@ function reloadRefusedDuringShutdown() { return new Error("The review session is shutting down and cannot reload."); } +/** Describe a host reload failure without assuming it is an Error instance. */ +function describeExtensionReloadFailure(error: unknown) { + const detail = error instanceof Error ? error.message || error.name : String(error); + return `Failed to reload the current review: ${detail}`; +} + /** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ export function AppHost({ bootstrap, @@ -133,6 +140,10 @@ export function AppHost({ const extensionsCwdRef = useRef(sessionFileBounds.defaultCwd); const initialExtensionStartupPendingRef = useRef(true); const reloadTailRef = useRef>(Promise.resolve()); + const pendingExtensionReloadRef = useRef<{ + reviewGeneration: AppBootstrap; + promise: Promise; + } | null>(null); const quitRequestedRef = useRef(false); const pendingExtensionReplacementRef = useRef(undefined); const pendingExtensionRetirementsRef = useRef>>(new Set()); @@ -509,6 +520,56 @@ export function AppHost({ }); }, [enqueueReload, performReloadSession]); + /** Coalesce extension requests and resolve their descriptor at the front of the host queue. */ + const requestExtensionReviewReload = useCallback( + (reviewGeneration: AppBootstrap): Promise => { + const existing = pendingExtensionReloadRef.current; + if (existing?.reviewGeneration === reviewGeneration) return existing.promise; + + const promise = enqueueReload(async () => { + if (quitRequestedRef.current) { + return { + ok: false, + reason: "unavailable", + detail: "The review session is shutting down and cannot reload.", + } as const; + } + const request = workspaceRefreshRequestRef.current; + if (!request) { + return { + ok: false, + reason: "unavailable", + detail: "The current review cannot be reloaded from its original input.", + } as const; + } + + try { + await performReloadSession(request.nextInput, { + reason: "extension", + resetApp: false, + sourcePath: request.sourcePath, + }); + return { ok: true } as const; + } catch (error) { + return { + ok: false, + reason: "failed", + detail: describeExtensionReloadFailure(error), + } as const; + } + }); + const pending = { reviewGeneration, promise }; + pendingExtensionReloadRef.current = pending; + void promise.finally(() => { + if (pendingExtensionReloadRef.current === pending) { + pendingExtensionReloadRef.current = null; + } + }); + return promise; + }, + [enqueueReload, performReloadSession], + ); + /** Revoke all extension authority, finish started writes, then leave. */ const quitAfterShutdownEvent = useCallback(() => { if (quitRequestedRef.current) return; @@ -554,6 +615,7 @@ export function AppHost({ onQuit={quitAfterShutdownEvent} onRegisterWorkspaceRefreshRequest={registerWorkspaceRefreshRequest} onReloadSession={reloadSession} + onRequestExtensionReviewReload={requestExtensionReviewReload} onWorkspaceWriteCompleted={reloadAfterWorkspaceWrite} reviewProducer={producer} runWorkspaceWrite={runWorkspaceWrite} diff --git a/src/ui/currentReviewRefresh.ts b/src/ui/currentReviewRefresh.ts index debdf5ba9..2265b0703 100644 --- a/src/ui/currentReviewRefresh.ts +++ b/src/ui/currentReviewRefresh.ts @@ -1,8 +1,8 @@ /** * Describes how the currently mounted review can be rebuilt from its original input. * - * Manual refresh, watch mode, editor return, extension trust reloads, and completed workspace - * writes all reuse this descriptor. It reapplies live view options so a soft reload does not + * Manual refresh, watch mode, editor return, extension trust reloads, external extension requests, + * and completed workspace writes all reuse this descriptor. It reapplies live view options so a soft reload does not * fall back to launch-time settings, and it supplies a source path only for VCS-backed reviews. * * Stdin-backed inputs remain non-reloadable because refreshing must not attempt to reread @@ -37,7 +37,7 @@ export interface CurrentReviewReloadOptions extends CurrentReviewRefreshOptions sourcePath?: string; } -/** Current mounted review descriptor AppHost dereferences after a completed workspace write. */ +/** Current mounted review descriptor AppHost dereferences for deferred host reloads. */ export interface WorkspaceRefreshRequest { nextInput: CliInput; sourcePath?: string; diff --git a/src/ui/hooks/useCurrentReviewRefreshController.ts b/src/ui/hooks/useCurrentReviewRefreshController.ts index 05c78046a..fcead646c 100644 --- a/src/ui/hooks/useCurrentReviewRefreshController.ts +++ b/src/ui/hooks/useCurrentReviewRefreshController.ts @@ -1,8 +1,8 @@ /** * Coordinates every in-session refresh of the currently mounted review. * - * Watch changes, manual commands, editor return, extension trust grants, and completed workspace - * writes converge on the same reloadable review descriptor. This hook derives and registers that + * Watch changes, manual commands, editor return, extension trust grants, external extension + * requests, and completed workspace writes converge on the same reloadable review descriptor. This hook derives and registers that * descriptor, connects watch notifications to refreshes, and exposes stable refresh callbacks to * App. * diff --git a/src/ui/hooks/useExtensionEventContextProvider.test.tsx b/src/ui/hooks/useExtensionEventContextProvider.test.tsx index ec3e51304..53213a1c5 100644 --- a/src/ui/hooks/useExtensionEventContextProvider.test.tsx +++ b/src/ui/hooks/useExtensionEventContextProvider.test.tsx @@ -4,6 +4,7 @@ import { act, StrictMode, useLayoutEffect, useState } from "react"; import type { ExtensionDialogs, ExtensionPaneControls, + ExtensionReviewReloadControls, ExtensionReviewNavigation, } from "../../extension-api/types"; import { createEmptyExtensionLoadResult } from "../../extensions/types"; @@ -12,9 +13,11 @@ import { useExtensionEventContextProvider } from "./useExtensionEventContextProv const dialogs = {} as ExtensionDialogs; const navigation = {} as ExtensionReviewNavigation; const panes = {} as ExtensionPaneControls; +const review = {} as ExtensionReviewReloadControls; const createDialogs = () => dialogs; const createNavigation = () => navigation; const createPaneControls = () => panes; +const createReviewReloadControls = () => review; /** Flush layout and passive work in an OpenTUI hook harness. */ async function settle(setup: Awaited>) { @@ -35,6 +38,7 @@ describe("useExtensionEventContextProvider", () => { createDialogs, createNavigation, createPaneControls, + createReviewReloadControls, extensions, }); useLayoutEffect(() => { @@ -59,6 +63,7 @@ describe("useExtensionEventContextProvider", () => { navigation, panes, sidebars: panes, + review, }); await act(async () => setup.renderer.destroy()); @@ -77,6 +82,7 @@ describe("useExtensionEventContextProvider", () => { createDialogs, createNavigation, createPaneControls, + createReviewReloadControls, extensions, }); return {extensions.context.cwd}; @@ -104,6 +110,7 @@ describe("useExtensionEventContextProvider", () => { createDialogs, createNavigation, createPaneControls, + createReviewReloadControls, extensions, }); return {marker}; diff --git a/src/ui/hooks/useExtensionEventContextProvider.ts b/src/ui/hooks/useExtensionEventContextProvider.ts index 52a8f653f..45359e4b3 100644 --- a/src/ui/hooks/useExtensionEventContextProvider.ts +++ b/src/ui/hooks/useExtensionEventContextProvider.ts @@ -12,6 +12,7 @@ import type { ExtensionDialogs, ExtensionEventContext, ExtensionPaneControls, + ExtensionReviewReloadControls, ExtensionReviewNavigation, } from "../../extension-api/types"; import { emitExtensionCustomEvent } from "../../extensions/events"; @@ -22,11 +23,13 @@ export function useExtensionEventContextProvider({ createDialogs, createNavigation, createPaneControls, + createReviewReloadControls, extensions, }: { createDialogs: (extensionId: string) => ExtensionDialogs; createNavigation: (extensionId: string) => ExtensionReviewNavigation; createPaneControls: (extensionId: string) => ExtensionPaneControls; + createReviewReloadControls: () => ExtensionReviewReloadControls; extensions?: ExtensionLoadResult; }) { useLayoutEffect(() => { @@ -41,6 +44,7 @@ export function useExtensionEventContextProvider({ sidebars: panes, navigation: createNavigation(extensionId), dialogs: createDialogs(extensionId), + review: createReviewReloadControls(), events: { emit(event, payload) { emitExtensionCustomEvent(extensions, event, payload); @@ -57,5 +61,5 @@ export function useExtensionEventContextProvider({ delete extensions.eventContextProvider; } }; - }, [createDialogs, createNavigation, createPaneControls, extensions]); + }, [createDialogs, createNavigation, createPaneControls, createReviewReloadControls, extensions]); } diff --git a/src/ui/lib/extensionReviewReload.test.ts b/src/ui/lib/extensionReviewReload.test.ts new file mode 100644 index 000000000..4ceae1cdf --- /dev/null +++ b/src/ui/lib/extensionReviewReload.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, mock, test } from "bun:test"; +import { createExtensionReviewReloadControls } from "./extensionReviewReload"; + +describe("createExtensionReviewReloadControls", () => { + test("forwards a live request and preserves the host result", async () => { + const result = { ok: true } as const; + const requestReload = mock(async () => result); + const controls = createExtensionReviewReloadControls({ + isLive: () => true, + requestReload, + }); + + await expect(controls.requestReload()).resolves.toBe(result); + expect(requestReload).toHaveBeenCalledTimes(1); + expect(Object.isFrozen(controls)).toBe(true); + }); + + test("refuses an expired review without starting host work", async () => { + const requestReload = mock(async () => ({ ok: true }) as const); + const controls = createExtensionReviewReloadControls({ + isLive: () => false, + requestReload, + }); + + await expect(controls.requestReload()).resolves.toEqual({ + ok: false, + reason: "unavailable", + detail: "This review reload request is no longer current.", + }); + expect(requestReload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/lib/extensionReviewReload.ts b/src/ui/lib/extensionReviewReload.ts new file mode 100644 index 000000000..d9fb5f4ec --- /dev/null +++ b/src/ui/lib/extensionReviewReload.ts @@ -0,0 +1,28 @@ +/** Builds review-reload controls that expire with their originating review generation. */ + +import type { + ExtensionReviewReloadControls, + ExtensionReviewReloadResult, +} from "../../extension-api/types"; + +const EXPIRED_RELOAD_RESULT: ExtensionReviewReloadResult = Object.freeze({ + ok: false, + reason: "unavailable", + detail: "This review reload request is no longer current.", +}); + +/** Guard one host-owned reload operation behind a review-generation capability lease. */ +export function createExtensionReviewReloadControls({ + isLive, + requestReload, +}: { + isLive: () => boolean; + requestReload: () => Promise; +}): ExtensionReviewReloadControls { + return Object.freeze({ + requestReload() { + if (!isLive()) return Promise.resolve(EXPIRED_RELOAD_RESULT); + return requestReload(); + }, + }); +} diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index f48fb62e3..8ba5bac2c 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -21,6 +21,24 @@ const GITHUB_PR_EXTENSION_ENTRY = resolve( fileURLToPath(new URL("../../examples/extensions/github-pr/index.ts", import.meta.url)), ); +/** An external-event workflow that changes a reviewed file and requests a host reload. */ +const REVIEW_RELOAD_EXTENSION_SOURCE = ` +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; + +export default function (hunk) { + hunk.events.on("reload-fixture:changed", async (_payload, ctx) => { + const result = await ctx.review.requestReload(); + if (!result.ok) ctx.notify(result.detail, "error"); + }); + + hunk.on("startup", (_event, ctx) => { + writeFileSync(join(ctx.cwd, "alpha.ts"), "export const agentReloaded = 3;\\n"); + hunk.events.emit("reload-fixture:changed", {}); + }); +} +`; + /** Give PTY-backed startup, reloads, and redraws headroom on slower CI machines. */ setDefaultTimeout(30_000); @@ -314,6 +332,32 @@ const DIALOG_EXTENSION_SOURCE = `export default function (hunk) { `; describe("PTY extensions", () => { + test("an extension event reloads agent changes without watch mode", async () => { + const configHome = harness.createIsolatedConfigHome(); + const fixture = harness.createRepoExtensionFixture(REVIEW_RELOAD_EXTENSION_SOURCE); + const session = await harness.launchHunk({ + args: [ + "--extension", + join(fixture.dir, ".hunk", "extensions", "fixture.ts"), + "diff", + "--mode", + "stack", + ], + cwd: fixture.dir, + cols: 140, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + const frame = await session.waitForText(/agentReloaded/, { timeout: 20_000 }); + expect(frame).toContain("alpha.ts"); + expect(frame).not.toContain("alphaValue"); + } finally { + session.close(); + } + }); + test("shows delegated change-request info above the review beside the files pane", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(DELEGATED_REVIEW_EXTENSION_SOURCE); diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 1e55114a9..b9cc4b47c 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,8 +7,8 @@ The extension factory receives one API object. Registration calls are only valid ## `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 two-revision @@ -390,7 +390,7 @@ Writes require a reloadable, unstaged working-tree review and a writable reviewe ## `hunk.on(event, handler)` -Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Every handler receives `ctx.panes`, live `ctx.navigation`, and attributed `ctx.dialogs` alongside `cwd` and `notify`, so a `startup` handler can present one focused welcome dialog and navigate to its first example without a keypress. `ctx.sidebars` is deprecated. Controls retained across a review or extension-registry replacement expire instead of controlling the replacement UI; workspace reads and writes that have not started return `null`/`unavailable`. Once a consented filesystem write starts, it reports its actual outcome, graceful shutdown waits for it, and success reconciles the review then active. +Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks the UI waiting for one. Every handler receives `ctx.panes`, live `ctx.navigation`, attributed `ctx.dialogs`, and `ctx.review.requestReload()` alongside `cwd` and `notify`, so a `startup` handler can present one focused welcome dialog and navigate to its first example without a keypress. `ctx.sidebars` is deprecated. Controls retained across a review or extension-registry replacement expire instead of controlling the replacement UI; workspace reads and writes that have not started return `null`/`unavailable`. Once a consented filesystem write starts, it reports its actual outcome, graceful shutdown waits for it, and success reconciles the review then active. | Event | Payload | When | | ---------------------- | ----------------------- | -------------------------------------------------------- | @@ -414,9 +414,9 @@ Subscribe to a lifecycle or UI event. Handlers may be async; Hunk never blocks t - `selection_changed` is trailing-debounced: holding `[`/`]` retargets many times a second, and handlers only care where the user landed. `fileId` and `hunkIndex` are `null` when nothing is selected. - `hunk_viewed` fires when the settled `(file, hunk)` pair changes, including `[`/`]` inside one file. Current-line movement within a hunk does not emit it. `file_viewed` still fires only when the selected file object changes. - `command_executed` reports stable command ids after terminal dispatch from a key, menu, or `ctx.commands.execute`. Detached async extension work may still be running; the event observes the accepted action rather than promise settlement. It follows remapped keys; browser/session review intents and widget-owned Escape, Enter, note-editor Ctrl-S, and F10 menu navigation are not terminal commands. -- `session_reload`'s `reason` is `"watch"`, `"daemon"` (an agent command through the session broker), or `"manual"`. +- `session_reload`'s `reason` is `"watch"`, `"daemon"` (an agent command through the session broker), `"extension"` (an in-process extension request), or `"manual"`. - `note_created` and `note_edited` cover notes authored in Hunk's own UI this session. Agent session comments do not emit them, and a reload may remap or drop notes. Use them for incremental reactions. -- `note_changed` is store-backed: `kind` is `"created"`, `"updated"`, or `"removed"`, and `note` matches `ctx.review.snapshot()`. It includes agent session comments and user deletes; drafts never appear. Reloads that remap notes do not emit it — use `session_reload` plus `ctx.review.snapshot()` for the complete current record. +- `note_changed` is store-backed: `kind` is `"created"`, `"updated"`, or `"removed"`, and `note` matches the snapshot shape command handlers read through `ctx.review.snapshot()`. It includes agent session comments and user deletes; drafts never appear. Reloads that remap notes do not emit it — use `session_reload` to invalidate extension-owned state and read the complete current record from a later command snapshot. - `shutdown` handlers get 250ms before Hunk exits anyway; treat it as best-effort flushing. UI authority has already been revoked, so shutdown is for releasing extension-owned resources rather than navigation or dialogs. ## `hunk.events` @@ -440,6 +440,25 @@ 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 after an external agent, service, or process changes the current review's inputs. Hunk preserves mounted UI state and selection where possible, serializes the request with every other reload, and coalesces concurrent extension requests. This does not require `--watch`. + +```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 commits and emits `session_reload` with reason `"extension"`. Non-reloadable inputs and expired controls resolve `unavailable`; started reloads that fail resolve `failed` with a displayable `detail`. Factory-time bus events run before live controls mount, so they cannot request a reload. + +Requests made from the successor's lifecycle handlers 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, and repo config overrides user config key by key. @@ -458,7 +477,7 @@ const patterns = (hunk.config.patterns as string[] | undefined) ?? ["*.lock"]; ## `ctx.notify(message, type?)` -Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `panes` and `events.emit`, command handlers add `commands`, `panes`, `fileViews`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely. +Every handler and transform receives a context with `cwd` and `notify`; event and bus handlers add `panes`, `review.requestReload`, and `events.emit`, command handlers add `commands`, `panes`, `fileViews`, `selection`, `navigation`, and `dialogs`. `notify` shows one transient line at the bottom of the app; `type` is `"info"` (default), `"warning"`, or `"error"`. Messages raised before the UI mounts are buffered, so a `startup` handler can notify safely. ## `hunk.log(message)`