From 9729bd16a51a70fcb29820186aeaea20258622f6 Mon Sep 17 00:00:00 2001 From: Mike Clarke Date: Wed, 12 Aug 2026 08:49:12 -0700 Subject: [PATCH] feat(extensions): support guided review workflows --- .changeset/guided-extension-workflows.md | 5 ++ docs/extension-architecture.md | 26 ++++++-- docs/extensions.md | 49 +++++++++++--- skills/hunk-extensions/SKILL.md | 32 ++++----- src/extension-api/types.ts | 32 +++++++-- src/extensions/events.test.ts | 24 +++++++ src/extensions/events.ts | 40 ++++++++++++ src/extensions/publicApiRobustness.test.ts | 1 + src/extensions/runExtension.test.ts | 46 ++++++++++++- src/extensions/runExtension.ts | 19 ++++++ src/extensions/types.ts | 11 ++++ src/ui/App.tsx | 60 ++++++++++++----- src/ui/AppHost.interactions.test.tsx | 38 +++++++++-- src/ui/AppHost.keybindings.test.tsx | 51 +++++++++++++++ src/ui/components/chrome/ExtensionDialog.tsx | 55 +++++++++------- src/ui/components/panes/AgentInlineNote.tsx | 2 +- src/ui/diff/renderRows.tsx | 2 +- src/ui/hooks/useAppKeyboardShortcuts.ts | 8 ++- src/ui/lib/agentPopover.ts | 65 +------------------ src/ui/lib/extensionDialogs.test.ts | 14 ++++ src/ui/lib/extensionDialogs.ts | 18 ++++- src/ui/lib/text.ts | 64 ++++++++++++++++++ src/ui/lib/ui-lib.test.ts | 3 +- .../content/docs/docs/extend/extension-api.md | 21 +++++- 24 files changed, 532 insertions(+), 154 deletions(-) create mode 100644 .changeset/guided-extension-workflows.md diff --git a/.changeset/guided-extension-workflows.md b/.changeset/guided-extension-workflows.md new file mode 100644 index 000000000..0ec7e273e --- /dev/null +++ b/.changeset/guided-extension-workflows.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add extension APIs for transient sessions and observing or navigating guided review workflows. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index f0106beb7..76ce470ea 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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 @@ -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 @@ -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 ` 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 ` 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 diff --git a/docs/extensions.md b/docs/extensions.md index 39f017a77..7f4728e4d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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)` @@ -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 ` 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 ` 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 @@ -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 | @@ -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). @@ -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. diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index ee600094c..14d8f85b3 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -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.]` 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.]` table) | +| Branch on the API generation (currently `4`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. @@ -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` diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index e024e6fe6..da75b78a5 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -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. @@ -1448,6 +1448,18 @@ export interface ExtensionWorkspace { writeDocument(request: ExtensionWorkspaceWriteRequest): Promise; } +/** 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. */ @@ -1516,11 +1528,15 @@ export interface ExtensionEventBus { emit(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; } @@ -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 }; @@ -1596,6 +1614,8 @@ export type ExtensionEventHandler { 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([ @@ -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: () => {} }, }; }; diff --git a/src/extensions/events.ts b/src/extensions/events.ts index 1d18bcfb1..5a745ff4c 100644 --- a/src/extensions/events.ts +++ b/src/extensions/events.ts @@ -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"; @@ -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, @@ -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); diff --git a/src/extensions/publicApiRobustness.test.ts b/src/extensions/publicApiRobustness.test.ts index 5a8dbc094..bcff95a4e 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/src/extensions/publicApiRobustness.test.ts @@ -560,6 +560,7 @@ describe("factories that misbehave outright", () => { }); for (const method of [ + "configureSession", "registerTheme", "registerFileLanguage", "registerVcsAdapter", diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index 021537014..05fd0b882 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test"; import { resolveExtensionPanes } from "./apply"; import { runExtensionFactory, toInternalVcsAdapter } from "./runExtension"; -import { createEmptyExtensionRegistry, type ExtensionLoadIssue } from "./types"; +import { + createEmptyExtensionRegistry, + HUNK_EXTENSION_API_VERSION, + type ExtensionLoadIssue, +} from "./types"; /** Build the metadata one bundled-style extension would load under. */ function bundledMetadata(id: string) { @@ -12,6 +16,7 @@ describe("runExtensionFactory", () => { test("applies a synchronous factory before returning, with nothing to await", () => { const registry = createEmptyExtensionRegistry(); const issues: ExtensionLoadIssue[] = []; + let apiVersion: number | undefined; // The bundled tier depends on this: adapter resolution is synchronous, so a // static factory has to be fully applied by the time this call returns. @@ -20,11 +25,13 @@ describe("runExtensionFactory", () => { registry, issues, factory: (hunk) => { + apiVersion = hunk.apiVersion; hunk.registerFileLanguage(".demo", "demo"); }, }); expect(pending).toBeUndefined(); + expect(apiVersion).toBe(HUNK_EXTENSION_API_VERSION); expect(issues).toEqual([]); expect(registry.extensions.map((extension) => extension.id)).toEqual(["demo"]); expect(registry.fileLanguages.map((entry) => entry.extension)).toEqual(["demo"]); @@ -231,6 +238,43 @@ describe("registerPane", () => { }); }); +describe("configureSession", () => { + test("records transient view preferences under the owning extension", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + + runExtensionFactory({ + metadata: bundledMetadata("trainer"), + registry, + issues, + factory: (hunk) => hunk.configureSession({ viewPreferences: "transient" }), + }); + + expect(issues).toEqual([]); + expect(registry.sessionOptions).toEqual([ + { extensionId: "trainer", options: { viewPreferences: "transient" } }, + ]); + }); + + test("rejects unknown policy values and rolls back earlier requests", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + + runExtensionFactory({ + metadata: bundledMetadata("broken-trainer"), + registry, + issues, + factory: (hunk) => { + hunk.configureSession({ viewPreferences: "transient" }); + hunk.configureSession({ viewPreferences: "forever" } as never); + }, + }); + + expect(registry.sessionOptions).toEqual([]); + expect(issues[0]?.message).toContain('"default" or "transient"'); + }); +}); + describe("registerSidebarView", () => { test("collects a valid view tagged with the owning extension", () => { const registry = createEmptyExtensionRegistry(); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index e64409ccd..af1b365a7 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -13,6 +13,7 @@ import { type ExtensionRegistry, type ExtensionPane, type ExtensionSidebarView, + type ExtensionSessionOptions, type ExtensionFileView, type ExtensionKeyboardMode, type ExtensionThemeConfig, @@ -215,6 +216,7 @@ interface ExtensionApiHandle { /** Registration counts captured before one extension runs, for failure rollback. */ interface RegistrySnapshot { + sessionOptions: number; themes: number; fileLanguages: number; vcsAdapters: number; @@ -236,6 +238,7 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { } return { + sessionOptions: registry.sessionOptions.length, themes: registry.themes.length, fileLanguages: registry.fileLanguages.length, vcsAdapters: registry.vcsAdapters.length, @@ -257,6 +260,7 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { * not stay in the registry. Collected logs are kept as failure diagnostics. */ function rollbackRegistry(registry: ExtensionRegistry, snapshot: RegistrySnapshot) { + registry.sessionOptions.length = snapshot.sessionOptions; registry.themes.length = snapshot.themes; registry.fileLanguages.length = snapshot.fileLanguages; registry.vcsAdapters.length = snapshot.vcsAdapters; @@ -324,6 +328,21 @@ export function createExtensionApi( apiVersion: HUNK_EXTENSION_API_VERSION, config, events, + configureSession(options: ExtensionSessionOptions) { + assertOpen("configureSession"); + if (!isPlainObject(options)) { + throw new Error("configureSession requires an options object."); + } + if ( + options.viewPreferences !== undefined && + options.viewPreferences !== "default" && + options.viewPreferences !== "transient" + ) { + throw new Error('configureSession viewPreferences must be "default" or "transient".'); + } + + registry.sessionOptions.push({ extensionId: metadata.id, options: { ...options } }); + }, registerTheme(theme: ExtensionThemeConfig) { assertOpen("registerTheme"); assertNonEmptyString(theme?.id, "registerTheme requires a theme with a non-empty id."); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 062a90e57..933c3d3a2 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -12,6 +12,7 @@ import type { ExtensionKeyboardMode, ExtensionNotifyType, ExtensionPane, + ExtensionSessionOptions, ExtensionThemeConfig, } from "../extension-api/types"; import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; @@ -85,6 +86,7 @@ export type { ExtensionSidebarTheme, ExtensionSidebarView, ExtensionSidebarViewProps, + ExtensionSessionOptions, ExtensionThemeConfig, ExtensionVcsAdapter, ExtensionWorkspace, @@ -173,6 +175,12 @@ export interface RegisteredCommand { handler: ExtensionCommandHandler; } +/** One extension's host-level behavior request for the current session. */ +export interface RegisteredSessionOptions { + extensionId: string; + options: ExtensionSessionOptions; +} + export interface RegisteredEventHandler { extensionId: string; handler: ExtensionEventHandler; @@ -204,6 +212,7 @@ export type ExtensionEventHandlerMap = { /** Everything extensions registered, in load order, for the rest of the app to consume. */ export interface ExtensionRegistry { extensions: ExtensionMetadata[]; + sessionOptions: RegisteredSessionOptions[]; themes: RegisteredTheme[]; fileLanguages: RegisteredFileLanguage[]; vcsAdapters: RegisteredVcsAdapter[]; @@ -288,6 +297,7 @@ export function deriveExtensionId(entryPath: string) { export function createEmptyExtensionRegistry(): ExtensionRegistry { return { extensions: [], + sessionOptions: [], themes: [], fileLanguages: [], vcsAdapters: [], @@ -299,6 +309,7 @@ export function createEmptyExtensionRegistry(): ExtensionRegistry { eventHandlers: { startup: [], changeset_loaded: [], + command_executed: [], selection_changed: [], file_viewed: [], filter_changed: [], diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 88d9714f6..632bc5802 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -663,6 +663,21 @@ export function App({ [extensions, setPaneOpen], ); + /** Build live, guarded review navigation for one extension-owned handler. */ + const createExtensionNavigation = useCallback( + (extensionId: string) => + createGuardedReviewNavigation({ + extensionId, + getFiles: () => extensionSelectionInputsRef.current.filteredFiles, + isLive: () => appAliveForNavigationRef.current, + notify: (message, type) => extensions?.context.notify(message, type), + onSelectFile: (fileId) => extensionCommandNavigationRef.current.onSelectFile(fileId), + onSelectHunk: (fileId, hunkIndex) => + extensionCommandNavigationRef.current.onSelectHunk(fileId, hunkIndex), + }), + [extensions], + ); + /** * Reveal the sidebar area, assigned each render once the responsive layout * is known (the controls above are created before it is computed). @@ -679,7 +694,7 @@ export function App({ const { accept: acceptExtensionDialog, cancel: cancelExtensionDialog, - createDialogs: createExtensionDialogs, + createDialogs: createQueuedExtensionDialogs, inputValue: extensionDialogInputValue, moveSelection: moveExtensionDialogSelection, pickOption: setExtensionDialogSelectedIndex, @@ -688,6 +703,17 @@ export function App({ updateInput: setExtensionDialogInputValue, } = useExtensionDialogController({ reviewGeneration: bootstrap }); + /** Keep third-party dialog attribution while presenting bundled extensions as native Hunk UI. */ + const createExtensionDialogs = useCallback( + (extensionId: string) => { + const bundled = extensions?.registry.extensions.some( + (metadata) => metadata.id === extensionId && metadata.origin === "bundled", + ); + return createQueuedExtensionDialogs(extensionId, { showAttribution: !bundled }); + }, + [createQueuedExtensionDialogs, extensions], + ); + /** Build host-mediated reviewed-document read and write controls for one extension command. */ const createWorkspaceControls = useCallback( (extensionId: string): ExtensionWorkspace => { @@ -790,8 +816,8 @@ export function App({ [createExtensionDialogs], ); - // Lifecycle and bus listeners receive the same pane controls as commands, - // so an extension can react to loaded content by revealing its own pane. + // Lifecycle and bus listeners receive the same pane, navigation, and dialog + // controls as commands, so onboarding can stay entirely in the public API. if (extensions) { extensions.eventContextProvider = (extensionId): ExtensionEventContext => { const panes = createPaneControls(extensionId); @@ -800,6 +826,8 @@ export function App({ notify: (message, type) => extensions.context.notify(message, type), panes, sidebars: panes, + navigation: createExtensionNavigation(extensionId), + dialogs: createExtensionDialogs(extensionId), events: { emit(event, payload) { emitExtensionCustomEvent(extensions, event, payload); @@ -844,17 +872,7 @@ export function App({ // the same focus/jump callbacks a sidebar row click runs, so a handler // that awaits a dialog before navigating still acts on the current // review — validated, clamped, and warned exactly like sidebar actions. - navigation: createGuardedReviewNavigation({ - extensionId: registered.extensionId, - getFiles: () => extensionSelectionInputsRef.current.filteredFiles, - // Extensions outlive App remounts, so the notify sink stays valid - // even after this instance dies and `isLive` starts refusing calls. - isLive: () => appAliveForNavigationRef.current, - notify: (message, type) => extensions?.context.notify(message, type), - onSelectFile: (fileId) => extensionCommandNavigationRef.current.onSelectFile(fileId), - onSelectHunk: (fileId, hunkIndex) => - extensionCommandNavigationRef.current.onSelectHunk(fileId, hunkIndex), - }), + navigation: createExtensionNavigation(registered.extensionId), }; try { @@ -871,6 +889,7 @@ export function App({ // do not rebuild on every `[`/`]` press. [ createExtensionDialogs, + createExtensionNavigation, createFileViewControls, createKeyboardModeControls, createPaneControls, @@ -1640,8 +1659,12 @@ export function App({ /** Leave the app through the shared shutdown path, prompting before discarding view changes. */ const requestQuit = useCallback(() => { + const transientViewPreferences = extensions?.registry.sessionOptions.some( + ({ options }) => options.viewPreferences === "transient", + ); if ( !pagerMode && + !transientViewPreferences && bootstrap.input.options.promptSaveViewPreferences !== false && hasUnsavedViewPreferences ) { @@ -1653,6 +1676,7 @@ export function App({ onQuit(); }, [ bootstrap.input.options.promptSaveViewPreferences, + extensions, hasUnsavedViewPreferences, onQuit, pagerMode, @@ -1853,7 +1877,13 @@ export function App({ triggerRefreshCurrentInput, }), ...extensionAppCommands.commands, - ]; + ].map((command) => ({ + ...command, + run: (...args: Parameters) => { + command.run(...args); + emitExtensionEvent(extensions, "command_executed", { commandId: command.id }); + }, + })); extensionHostCommandsRef.current = appCommands; // Menus name commands rather than repeating them: every item's key hint and diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 1f9024a39..e82246494 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -16,6 +16,7 @@ import type { AppBootstrap, LayoutMode } from "../core/types"; import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers"; import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/AgentSkillDialog"; import { resolveTheme } from "./themes"; @@ -1172,9 +1173,6 @@ describe("App interactions", () => { }); await flush(setup); frame = setup.captureCharFrame(); - if (frame.includes("interaction coverage")) { - break; - } } expect(frame).toContain("interaction coverage"); @@ -1186,9 +1184,6 @@ describe("App interactions", () => { }); await flush(setup); frame = setup.captureCharFrame(); - if (frame.includes("this is a very")) { - break; - } } expect(frame).toContain("this is a very"); @@ -3773,6 +3768,37 @@ describe("App interactions", () => { } }); + test("transient extension sessions never offer to save practice view preferences", async () => { + const quit = mock(() => undefined); + const bootstrap = createSingleFileBootstrap(); + const extensions = createEmptyExtensionLoadResult(process.cwd()); + extensions.registry.sessionOptions.push({ + extensionId: "trainer", + options: { viewPreferences: "transient" }, + }); + bootstrap.extensions = extensions; + const setup = await testRender(, { + width: 180, + height: 24, + }); + + try { + await flush(setup); + await act(async () => { + await setup.mockInput.typeText("w"); + await setup.mockInput.typeText("q"); + }); + await flush(setup); + + expect(setup.captureCharFrame()).not.toContain("Save view preferences?"); + expect(quit).toHaveBeenCalledTimes(1); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("pager mode quits on q even after changing a view preference", async () => { const quit = mock(() => undefined); const setup = await testRender( diff --git a/src/ui/AppHost.keybindings.test.tsx b/src/ui/AppHost.keybindings.test.tsx index c8c9f3d01..aa68de14d 100644 --- a/src/ui/AppHost.keybindings.test.tsx +++ b/src/ui/AppHost.keybindings.test.tsx @@ -10,6 +10,7 @@ import { resolveConfiguredCliInput } from "../core/config"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { loadAppBootstrap } from "../core/loaders"; import type { AppBootstrap } from "../core/types"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; import { AppHost } from "./AppHost"; /** @@ -176,4 +177,54 @@ describe("user keybindings", () => { expect(quits()).toBe(1); }); }); + + test("emits command_executed after keyboard dispatch", async () => { + const repo = createTestRepo("hunk-keybindings-command-event-"); + const bootstrap = await launchWithConfig(repo, ""); + const extensions = createEmptyExtensionLoadResult(repo); + const seen: string[] = []; + extensions.registry.eventHandlers.command_executed.push({ + extensionId: "coach", + handler: ({ commandId }) => { + seen.push(commandId); + }, + }); + bootstrap.extensions = extensions; + + await withAppHost(bootstrap, async (setup) => { + await act(async () => { + await setup.mockInput.typeText("j"); + }); + await flush(setup); + expect(seen).toContain("hunk.review.stepDown"); + }); + }); + + test("emits command_executed when Tab leaves the focused file filter", async () => { + const repo = createTestRepo("hunk-keybindings-focused-command-event-"); + const bootstrap = await launchWithConfig(repo, ""); + const extensions = createEmptyExtensionLoadResult(repo); + const seen: string[] = []; + extensions.registry.eventHandlers.command_executed.push({ + extensionId: "coach", + handler: ({ commandId }) => { + seen.push(commandId); + }, + }); + bootstrap.extensions = extensions; + + await withAppHost(bootstrap, async (setup) => { + await act(async () => { + await setup.mockInput.pressTab(); + }); + await flush(setup); + seen.length = 0; + + await act(async () => { + await setup.mockInput.pressTab(); + }); + await flush(setup); + expect(seen).toEqual(["hunk.app.toggleFocusArea"]); + }); + }); }); diff --git a/src/ui/components/chrome/ExtensionDialog.tsx b/src/ui/components/chrome/ExtensionDialog.tsx index 16eb5621b..bcafbb559 100644 --- a/src/ui/components/chrome/ExtensionDialog.tsx +++ b/src/ui/components/chrome/ExtensionDialog.tsx @@ -6,7 +6,7 @@ import type { } from "../../lib/extensionDialogs"; import { extensionToastPrefix } from "../../lib/extensionNotifications"; import { listWindowStart } from "../../lib/listWindow"; -import { fitText, padText } from "../../lib/text"; +import { fitText, padText, wrapText } from "../../lib/text"; import type { AppTheme } from "../../themes"; import { ConfirmDialog, confirmDialogHeight } from "./ConfirmDialog"; import { ModalFrame } from "./ModalFrame"; @@ -15,10 +15,10 @@ import { ModalFrame } from "./ModalFrame"; * The modal surface behind `ctx.dialogs`. * * Every dialog is drawn by Hunk from host-controlled chrome, with the - * extension's own text confined to the title, body, and choices — a prompt an - * extension raises can never look like Hunk asking. The attribution row reuses - * the same `ext` marker `notify` toasts carry, so "this came from an extension" - * reads the same wherever extension output appears. + * extension's own text confined to the title, body, and choices. User-installed + * extensions receive an attribution row using the same `ext` marker `notify` + * toasts carry, so their prompts cannot look like Hunk asking. Bundled + * extensions are Hunk-owned UI and omit that redundant row. * * Keyboard handling deliberately lives in `useAppKeyboardShortcuts` beside * every other modal surface; this component owns mouse parity only. @@ -89,6 +89,9 @@ export function ExtensionDialog({ const width = dialogWidth(terminalWidth); const bodyWidth = Math.max(1, width - 4); + const wrappedBodyLines = request.bodyLines.flatMap((line) => wrapText(line, bodyWidth)); + const attributionRows = request.showAttribution ? 1 : 0; + const attributionGapRows = request.showAttribution && wrappedBodyLines.length > 0 ? 1 : 0; return ( 0 ? request.bodyLines.length + 2 : 1)} + height={confirmDialogHeight(wrappedBodyLines.length + attributionRows + attributionGapRows)} terminalHeight={terminalHeight} terminalWidth={terminalWidth} theme={theme} @@ -104,11 +107,13 @@ export function ExtensionDialog({ width={width} onClose={onCancel} > - - {attributionText(request.extensionId, bodyWidth)} - - {request.bodyLines.length > 0 ? : null} - {request.bodyLines.map((line, index) => ( + {request.showAttribution ? ( + + {attributionText(request.extensionId, bodyWidth)} + + ) : null} + {attributionGapRows > 0 ? : null} + {wrappedBodyLines.map((line, index) => ( // Body lines are positional prose, so their index is their identity. {fitText(line, bodyWidth)} @@ -139,9 +144,9 @@ function ExtensionSelectDialog({ const width = dialogWidth(terminalWidth); const bodyWidth = Math.max(1, width - 4); const modalHeight = Math.min(Math.max(11, terminalHeight - 6), 24); - // ModalFrame chrome, plus this dialog's attribution, legend, spacer, and the + // ModalFrame chrome, plus the legend, spacer, optional attribution, and the // row that reports how many options fell outside the window. - const visibleRows = Math.max(3, modalHeight - 9); + const visibleRows = Math.max(3, modalHeight - (request.showAttribution ? 9 : 8)); const start = listWindowStart(selectedIndex, request.options.length, visibleRows); const visibleOptions = request.options.slice(start, start + visibleRows); const markerWidth = 2; @@ -157,9 +162,11 @@ function ExtensionSelectDialog({ width={width} onClose={onCancel} > - - {attributionText(request.extensionId, bodyWidth)} - + {request.showAttribution ? ( + + {attributionText(request.extensionId, bodyWidth)} + + ) : null} {fitText("↑/↓ move Enter choose Esc cancel", bodyWidth)} @@ -220,8 +227,8 @@ function ExtensionInputDialog({ }) { const width = dialogWidth(terminalWidth); const bodyWidth = Math.max(1, width - 4); - // ModalFrame chrome plus attribution, spacer, field, spacer, legend. - const modalHeight = 10; + // ModalFrame chrome plus field, spacer, legend, and optional attribution + spacer. + const modalHeight = request.showAttribution ? 10 : 8; return ( - - {attributionText(request.extensionId, bodyWidth)} - - + {request.showAttribution ? ( + <> + + {attributionText(request.extensionId, bodyWidth)} + + + + ) : null} {/* The field only edits text: Enter and Escape are answered by useAppKeyboardShortcuts, where every dialog's action keys live — diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index 9a30221ba..ae72488e3 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -8,7 +8,7 @@ import { useLayoutEffect, useRef, type ReactNode } from "react"; import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; import { agentNoteBoxLayout } from "../../lib/agentNoteGeometry"; import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; -import { wrapText } from "../../lib/agentPopover"; +import { wrapText } from "../../lib/text"; import { sanitizeTerminalLine } from "../../../lib/terminalText"; import { fitText, measureTextWidth, padText } from "../../lib/text"; diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx index 8393db8cf..6638e4b33 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -25,7 +25,7 @@ import { } from "./rowStyle"; import { type PlannedReviewRow } from "./reviewRenderPlan"; import { inlineNoteTitle } from "../components/panes/AgentInlineNote"; -import { wrapText } from "../lib/agentPopover"; +import { wrapText } from "../lib/text"; import { sanitizeTerminalLine, sanitizeTerminalSpans } from "../../lib/terminalText"; import { isPrintableAsciiText, diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index b061c16eb..7853379ce 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -7,7 +7,7 @@ import type { ExtensionKeyEvent, } from "../../extensions/types"; import type { MenuId } from "../components/chrome/menu"; -import { dispatchAppCommand, type AppCommand } from "../lib/appCommands"; +import { dispatchAppCommand, executeAppCommand, type AppCommand } from "../lib/appCommands"; import type { ExtensionDialogRequest } from "../lib/extensionDialogs"; import { toExtensionKeyEvent } from "../lib/extensionKeyEvent"; import { isEscapeKey, isSaveDraftNoteKey } from "../lib/keyboard"; @@ -444,7 +444,11 @@ export function useAppKeyboardShortcuts({ // Deliberately no modifier check: Shift+Tab toggles focus exactly like // Tab, in both its CSI-u and legacy backtab encodings. if (key.name === "tab") { - toggleFocusArea(); + // Keep this text-input escape hatch on the named command path so + // extensions observe the same semantic action as a Tab from the file list. + if (!executeAppCommand(commandsRef.current, "hunk.app.toggleFocusArea")) { + toggleFocusArea(); + } return "mine"; } diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts index 06c2a7fb3..20550f976 100644 --- a/src/ui/lib/agentPopover.ts +++ b/src/ui/lib/agentPopover.ts @@ -1,73 +1,10 @@ import { sanitizeTerminalLine } from "../../lib/terminalText"; -import { fitText, measureTextWidth, sliceTextByWidth } from "./text"; +import { fitText, wrapText } from "./text"; function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -/** Wrap plain text to a fixed terminal-cell width, breaking long tokens when needed. */ -export function wrapText(text: string, width: number) { - if (width <= 0) { - return [""]; - } - - const normalized = sanitizeTerminalLine(text).trim().replace(/\s+/g, " "); - if (normalized.length === 0) { - return [""]; - } - - const words = normalized.split(" "); - const lines: string[] = []; - let current = ""; - let currentWidth = 0; - - const pushCurrent = () => { - if (current.length > 0) { - lines.push(current); - current = ""; - currentWidth = 0; - } - }; - - for (const word of words) { - const wordWidth = measureTextWidth(word); - - if (wordWidth > width) { - pushCurrent(); - let offset = 0; - while (offset < wordWidth) { - const chunk = sliceTextByWidth(word, offset, width); - if (chunk.width <= 0) { - // Width is narrower than one cluster; keep the remainder on one - // line (fitText clamps at render time) instead of dropping it. - const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER); - if (rest.text.length > 0) { - lines.push(rest.text); - } - break; - } - lines.push(chunk.text); - offset += chunk.width; - } - continue; - } - - const nextWidth = current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth; - if (nextWidth <= width) { - current = current.length === 0 ? word : `${current} ${word}`; - currentWidth = nextWidth; - continue; - } - - pushCurrent(); - current = word; - currentWidth = wordWidth; - } - - pushCurrent(); - return lines.length > 0 ? lines : [""]; -} - /** Title shown above an agent note — author name if present, otherwise "AI note", with optional "i/n" suffix. */ export function formatAgentNoteTitle(noteIndex: number, noteCount: number, author?: string) { if (author) { diff --git a/src/ui/lib/extensionDialogs.test.ts b/src/ui/lib/extensionDialogs.test.ts index ba29b1f9d..f390a7eca 100644 --- a/src/ui/lib/extensionDialogs.test.ts +++ b/src/ui/lib/extensionDialogs.test.ts @@ -80,6 +80,7 @@ describe("createExtensionDialogQueue", () => { expect(queue.current()).toMatchObject({ kind: "confirm", extensionId: "carrier", + showAttribution: true, bodyLines: ["one", "two"], confirmLabel: "ok", cancelLabel: "cancel", @@ -90,6 +91,19 @@ describe("createExtensionDialogQueue", () => { expect(queue.current()).toMatchObject({ confirmLabel: "delete", cancelLabel: "keep" }); }); + test("can omit attribution only when the host marks the dialog as native UI", () => { + const queue = createExtensionDialogQueue(); + const dialogs = queue.createDialogs("bundled-guide", { showAttribution: false }); + + void dialogs.confirm({ title: "Welcome" }); + + expect(queue.current()).toMatchObject({ + extensionId: "bundled-guide", + showAttribution: false, + title: "Welcome", + }); + }); + test("strips terminal escapes out of extension-authored text", () => { const queue = createExtensionDialogQueue(); const dialogs = queue.createDialogs("hostile"); diff --git a/src/ui/lib/extensionDialogs.ts b/src/ui/lib/extensionDialogs.ts index 7e0faf10f..57a18a078 100644 --- a/src/ui/lib/extensionDialogs.ts +++ b/src/ui/lib/extensionDialogs.ts @@ -38,6 +38,8 @@ interface ExtensionDialogRequestBase { id: number; /** The extension that raised the dialog, rendered as its attribution. */ extensionId: string; + /** Whether host chrome should identify the extension that raised the dialog. */ + showAttribution: boolean; title: string; } @@ -71,7 +73,7 @@ type ExtensionDialogResult = boolean | string | null; /** The host-side controller for every extension dialog in one session. */ export interface ExtensionDialogQueue { /** Build the `dialogs` object one extension's command handlers receive. */ - createDialogs(extensionId: string): ExtensionDialogs; + createDialogs(extensionId: string, options?: { showAttribution?: boolean }): ExtensionDialogs; /** The dialog that should be on screen, or `null` when none is. */ current(): ExtensionDialogRequest | null; /** @@ -244,7 +246,8 @@ export function createExtensionDialogQueue(): ExtensionDialogQueue { }; return { - createDialogs(extensionId: string): ExtensionDialogs { + createDialogs(extensionId: string, options = {}): ExtensionDialogs { + const showAttribution = options.showAttribution !== false; return { // Async so a validation failure rejects the returned promise instead of // throwing synchronously out of the extension's `await`. @@ -255,6 +258,7 @@ export function createExtensionDialogQueue(): ExtensionDialogQueue { kind: "confirm", id, extensionId, + showAttribution, title, bodyLines: normalizeBodyLines(options.body), confirmLabel: normalizeLabel(options.confirmLabel, DEFAULT_CONFIRM_LABEL), @@ -267,7 +271,14 @@ export function createExtensionDialogQueue(): ExtensionDialogQueue { const title = normalizeTitle("select", options?.title); const choices = normalizeOptions(options.options); return await enqueue( - (id) => ({ kind: "select", id, extensionId, title, options: choices }), + (id) => ({ + kind: "select", + id, + extensionId, + showAttribution, + title, + options: choices, + }), null, ); }, @@ -278,6 +289,7 @@ export function createExtensionDialogQueue(): ExtensionDialogQueue { kind: "input", id, extensionId, + showAttribution, title, placeholder: normalizeLabel(options.placeholder, ""), // Sanitized like every other extension-authored string, but not diff --git a/src/ui/lib/text.ts b/src/ui/lib/text.ts index 68d9a24b9..c16a3d2da 100644 --- a/src/ui/lib/text.ts +++ b/src/ui/lib/text.ts @@ -176,6 +176,70 @@ export function measureTextWidth(text: string) { return measureSanitizedTextWidth(sanitizeTerminalLine(text)); } +/** Wrap plain prose to terminal-cell width, preferring word boundaries. */ +export function wrapText(text: string, width: number) { + if (width <= 0) { + return [""]; + } + + const normalized = sanitizeTerminalLine(text).trim().replace(/\s+/g, " "); + if (normalized.length === 0) { + return [""]; + } + + const words = normalized.split(" "); + const lines: string[] = []; + let current = ""; + let currentWidth = 0; + + /** Commit the current prose row before starting another one. */ + const pushCurrent = () => { + if (current.length > 0) { + lines.push(current); + current = ""; + currentWidth = 0; + } + }; + + for (const word of words) { + const wordWidth = measureTextWidth(word); + + if (wordWidth > width) { + pushCurrent(); + let offset = 0; + while (offset < wordWidth) { + const chunk = sliceTextByWidth(word, offset, width); + if (chunk.width <= 0) { + // Width is narrower than one cluster; keep the remainder on one + // line (fitText clamps at render time) instead of dropping it. + const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER); + if (rest.text.length > 0) { + lines.push(rest.text); + } + break; + } + lines.push(chunk.text); + offset += chunk.width; + } + continue; + } + + const nextWidth = current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth; + if (nextWidth <= width) { + current = current.length === 0 ? word : `${current} ${word}`; + currentWidth = nextWidth; + continue; + } + + pushCurrent(); + current = word; + currentWidth = wordWidth; + } + + pushCurrent(); + return lines.length > 0 ? lines : [""]; +} + export interface WrappedTextChunk { text: string; width: number; diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 12abf40af..604df320a 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -11,7 +11,7 @@ import { nextMenuItemIndex, type MenuEntry, } from "../components/chrome/menu"; -import { buildAgentPopoverContent, resolveAgentPopoverPlacement, wrapText } from "./agentPopover"; +import { buildAgentPopoverContent, resolveAgentPopoverPlacement } from "./agentPopover"; import { isEscapeKey, isSaveDraftNoteKey } from "./keyboard"; import { cellRangeToCharRange, @@ -20,6 +20,7 @@ import { measureTextWidth, padText, sliceTextByWidth, + wrapText, wrapTextByWidth, } from "./text"; import { computeHunkRevealScrollTop } from "./hunkScroll"; diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index f87f9e4d6..6737bdacb 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,7 +7,20 @@ The extension factory receives one API object. Registration calls are only valid ## `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; API-v3 sidebar names remain as deprecated aliases. + +## `hunk.configureSession(options)` + +Request host behavior for the review session loading the extension. Training, +demo, and presentation extensions can make their view-setting changes temporary: + +```ts +hunk.configureSession({ viewPreferences: "transient" }); +``` + +If any loaded extension requests this, Hunk skips the save-view-preferences +prompt on quit instead of offering to write practice state into user config. +The default is `"default"`. ## `hunk.registerTheme(theme)` @@ -203,7 +216,7 @@ hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, a }); ``` -Hunk draws the dialog; your text fills the title, body, and choices, and the frame carries an `ext ` attribution line — the same marker `notify` toasts use — so a prompt cannot present itself as Hunk asking. +Hunk draws the dialog; your text fills the title, body, and choices. Dialogs from installed extensions carry an `ext ` attribution line — the same marker `notify` toasts use — so a third-party prompt cannot present itself as Hunk asking. Hunk's own bundled extensions omit that redundant marker. One dialog shows at a time; concurrent requests queue in call order, across extensions. Escape cancels (`false` or `null`), Enter accepts; confirm dialogs also answer to `y`/`n`, select dialogs to `↑`/`↓`, and everything is clickable. A session reload cancels open and queued dialogs, and a dialog pending at shutdown resolves its cancel value. @@ -235,12 +248,13 @@ 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 and receive `ctx.panes`, `cwd`, and `notify`. `ctx.sidebars` is deprecated. +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. | Event | Payload | When | | ---------------------- | ----------------------- | -------------------------------------------------------- | | `startup` | `{ cwd }` | once, after the app mounts with its first changeset | | `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 | @@ -253,6 +267,7 @@ Subscribe to a lifecycle or UI event. Handlers may be async and receive `ctx.pan | `shutdown` | `{}` | on exit, best-effort within a short timeout | - `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. +- `command_executed` reports stable command ids after invocation from a key, menu, or another host command surface. It follows remapped keys; widget-owned Escape, Enter, note-editor Ctrl-S, and F10 menu navigation are not commands. - `session_reload`'s `reason` is `"watch"`, `"daemon"` (an agent command through the session broker), 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 — an accumulated list is not a complete review record. - `shutdown` handlers get 250ms before Hunk exits anyway; treat it as best-effort flushing.