From 9729bd16a51a70fcb29820186aeaea20258622f6 Mon Sep 17 00:00:00 2001 From: Mike Clarke Date: Wed, 12 Aug 2026 08:49:12 -0700 Subject: [PATCH 1/3] 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. From 3c683a664f786707bf2363dee9ea08e7adef1e05 Mon Sep 17 00:00:00 2001 From: Mike Clarke Date: Wed, 12 Aug 2026 08:49:39 -0700 Subject: [PATCH 2/3] feat(tutor): teach Hunk inside an interactive review --- .changeset/tidy-eels-report.md | 5 + README.md | 8 +- docs/extension-architecture.md | 7 +- docs/extensions.md | 9 +- src/app/startup.test.ts | 59 ++ src/app/startup.ts | 8 + src/core/cli.test.ts | 24 + src/core/cli.ts | 28 +- src/core/config.test.ts | 16 + src/core/config.ts | 7 + src/core/loaders.test.ts | 31 + src/core/loaders.ts | 16 + src/core/types.ts | 9 +- src/core/watch.ts | 3 + src/core/watchPlan.ts | 2 + src/extensions/default/ui/tutor/index.tsx | 557 ++++++++++++++++++ src/session/app/reloadBounds.ts | 3 + src/session/broker/wire.ts | 1 + src/tutor/content.test.ts | 49 ++ src/tutor/content.ts | 331 +++++++++++ src/ui/AppHost.tutor.test.tsx | 149 +++++ src/ui/lib/extensionWorkspace.ts | 2 + test/pty/tutor.test.ts | 142 +++++ .../content/docs/docs/extend/extensions.md | 4 +- .../src/content/docs/docs/reference/cli.md | 12 + .../src/content/docs/docs/reference/config.md | 1 + .../content/docs/docs/start/quick-start.md | 11 + 27 files changed, 1483 insertions(+), 11 deletions(-) create mode 100644 .changeset/tidy-eels-report.md create mode 100644 src/extensions/default/ui/tutor/index.tsx create mode 100644 src/tutor/content.test.ts create mode 100644 src/tutor/content.ts create mode 100644 src/ui/AppHost.tutor.test.tsx create mode 100644 test/pty/tutor.test.ts diff --git a/.changeset/tidy-eels-report.md b/.changeset/tidy-eels-report.md new file mode 100644 index 000000000..a35c1da27 --- /dev/null +++ b/.changeset/tidy-eels-report.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add an interactive Hunk Tutor whose instructional diff teaches the review workflow and live keybindings. diff --git a/README.md b/README.md index a272103c5..fb9c67334 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). -**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/) - [![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main) [![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases) [![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) @@ -65,8 +63,14 @@ Requirements: ```bash hunk # show help hunk --version # print the installed version +hunk tutor # learn the interface inside a guided review ``` +New to Hunk? `hunk tutor` opens a self-contained, vimtutor-inspired tutorial whose diff is the +guide. Its bundled extension tracks the commands you actually use, follows custom keybindings, and teaches the +multi-file review stream, layouts, filtering, inline notes, agent context, menus, mouse support, +themes, and extension commands without touching your repository. + ### Working with Git Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 76ce470ea..0778683cc 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -22,9 +22,12 @@ object and registry collection (`src/extensions/runExtension.ts`): extension host. `default/ui/index.ts` is deliberately not part of that list: it synchronously loads the bundled files pane through `runExtensionFactory` only where the app resolves UI panes. + `default/ui/tutor/` is another public-API consumer; it loads only for + `hunk tutor`, after parsing establishes that the process is taking an + interactive path. -Git and the built-in file navigation use the public `registerVcsAdapter` and -`registerPane` paths. The current-line lens remains an installable example. +Git, built-in file navigation, and Tutor use the public `registerVcsAdapter` +and `registerPane` paths. The current-line lens remains an installable example. Bundled extensions are implicitly trusted and stay loaded under `--no-extensions`, which governs user extensions only. diff --git a/docs/extensions.md b/docs/extensions.md index 7f4728e4d..0957b9507 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -190,7 +190,8 @@ run without installing anything. ## Bundled extensions Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, -and so is the **built-in file-navigation pane**. They live in +as are the **built-in file-navigation pane** and the interactive guide opened +by `hunk tutor`. They live in `src/extensions/default/`, are compiled into the binary, and register through the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide documents. There is no private registration path. @@ -204,8 +205,10 @@ can do, because Git does it the same way you would. Bundled extensions differ from yours in three ways, all of them consequences of being Hunk's own code: -- They are **statically imported**, so they load synchronously, before config - resolution picks the session's VCS. +- The VCS adapters and default sidebar are **statically imported**, so they load + synchronously before config resolution picks the session's VCS. The UI-backed tutor + extension is imported only after the `tutor` command is selected, keeping headless + commands free of OpenTUI's native runtime. - They are **implicitly trusted**: no discovery, no trust prompt, and no `[extension.]` config table. - They stay loaded under `--no-extensions` and `[extensions] enabled = false`. diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index bdf35d560..29088c766 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -39,6 +39,65 @@ function createBootstrap(input: CliInput): AppBootstrap { } describe("startup planning", () => { + test("installs the bundled tutor extension for an enabled extension session", async () => { + const cliInput: CliInput = { kind: "tutor", options: {} }; + const extensionResult = createEmptyExtensionLoadResult(); + + const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], { + parseCliImpl: async () => cliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => + createTestConfigResolution(input, { + extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} }, + }), + loadStartupExtensionsImpl: async () => extensionResult, + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + + expect(plan.kind).toBe("app"); + if (plan.kind !== "app") { + throw new Error("Expected app startup plan."); + } + + expect(plan.bootstrap.extensions?.loaded.map((extension) => extension.id)).toContain( + "hunk-tutor", + ); + expect(plan.bootstrap.extensions?.registry.panes).toMatchObject([ + { extensionId: "hunk-tutor", pane: { id: "guide", replaces: "hunk:files" } }, + ]); + expect(plan.bootstrap.customThemes?.map((theme) => theme.id)).toContain("hunk-tutor"); + expect( + plan.bootstrap.changeset.files.find((file) => file.path.includes("context-and-notes"))?.agent + ?.annotations, + ).toHaveLength(2); + }); + + test("does not install the bundled tutor extension when extensions are disabled", async () => { + const cliInput: CliInput = { kind: "tutor", options: { extensions: false } }; + const extensionResult = createEmptyExtensionLoadResult(); + + const plan = await prepareStartupPlan(["bun", "hunk", "tutor", "--no-extensions"], { + parseCliImpl: async () => cliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadStartupExtensionsImpl: async () => extensionResult, + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + + expect(plan.kind).toBe("app"); + if (plan.kind !== "app") { + throw new Error("Expected app startup plan."); + } + + expect(plan.bootstrap.extensions?.loaded).toEqual([]); + expect(plan.bootstrap.extensions?.registry.panes).toEqual([]); + expect(plan.bootstrap.customThemes).toEqual([]); + }); + test("returns help output without entering app startup", async () => { let loaded = false; diff --git a/src/app/startup.ts b/src/app/startup.ts index 4b927e1e2..e1df10632 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -310,6 +310,14 @@ export async function prepareStartupPlan( configured = resolvedExtensions.configured; cliInput = configured.input; const extensionResult = resolvedExtensions.extensions; + if (cliInput.kind === "tutor" && configured.extensions.enabled) { + // UI-backed bundled extensions stay behind the interactive command path so + // headless commands never materialize OpenTUI's embedded native library. + // Unlike the core bundled extensions, Tutor also respects the session's + // extension switch so `--no-extensions` produces a plain synthetic review. + const { installBundledTutorExtension } = await import("../extensions/default/ui/tutor"); + installBundledTutorExtension(extensionResult); + } let preparedSession: SessionBootstrapResult; try { diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 39a70af36..8e9e32d6f 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -58,6 +58,7 @@ describe("parseCli", () => { expect(parsed.text).toContain("Usage:"); expect(parsed.text).toContain("hunk diff"); expect(parsed.text).toContain("hunk show"); + expect(parsed.text).toContain("hunk tutor"); expect(parsed.text).toContain("hunk skill path"); expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); @@ -151,6 +152,28 @@ describe("parseCli", () => { }); }); + test("parses the interactive tutor with normal review preferences", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "tutor", + "--mode", + "stack", + "--theme", + "github-light-default", + "--no-extensions", + ]); + + expect(parsed).toMatchObject({ + kind: "tutor", + options: { + mode: "stack", + theme: "github-light-default", + extensions: false, + }, + }); + }); + test("parses the current-line style and rejects an unknown one", async () => { const parsed = await parseCli(["bun", "hunk", "diff", "--cursor-line", "number"]); @@ -1114,6 +1137,7 @@ describe("parseCli command help text", () => { expect(await expectHelp(["patch", "--help"])).toContain("review a patch file"); expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper"); expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs"); + expect(await expectHelp(["tutor", "--help"])).toContain("interactive guided changeset"); }); test("renders the stash command overview and the stash show command help", async () => { diff --git a/src/core/cli.ts b/src/core/cli.ts index e4ccca782..05d7c73b2 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -163,6 +163,12 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + tutor: { + path: "tutor", + summary: "learn Hunk inside an interactive guided changeset", + synopsis: ["hunk tutor"], + commonReviewOptions: true, + }, "markup-render": { path: "markup render", summary: "preview experimental STML markup as terminal text", @@ -417,6 +423,7 @@ function renderCliHelp() { " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", + " hunk tutor learn Hunk in an interactive guided changeset", " hunk session inspect or control a live Hunk session", " hunk markup render ( | -) preview experimental STML note markup", " hunk markup guide print the experimental STML authoring guide", @@ -837,6 +844,23 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise

{ + const command = createCliReferenceCommand("tutor"); + let parsedOptions: Record = {}; + + command.action((options: Record) => { + parsedOptions = options; + }); + + if (tokens.includes("--help") || tokens.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, tokens); + return { kind: "tutor", options: buildCommonOptions(parsedOptions, argv) }; +} + function requireReloadableCliInput(input: ParsedCliInput): CliInput { if ( input.kind === "help" || @@ -1624,7 +1648,7 @@ export async function parseCli(argv: string[]): Promise { if ( prefixedExperimental && - !["diff", "show", "patch", "pager", "difftool", "stash"].includes(commandName) + !["diff", "show", "patch", "pager", "difftool", "stash", "tutor"].includes(commandName) ) { throw new Error("`--experimental` must be used with a Hunk review command."); } @@ -1640,6 +1664,8 @@ export async function parseCli(argv: string[]): Promise { return parsePagerCommand(rest, argv); case "difftool": return parseDifftoolCommand(rest, argv); + case "tutor": + return parseTutorCommand(rest, argv); case "stash": return parseStashCommand(rest, argv); case "session": diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 531b951ed..37c47b466 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -734,6 +734,22 @@ describe("config resolution", () => { expect(resolved.input.options.theme).toBe("github-dark-default"); }); + test("gives tutor its dedicated theme while preserving explicit theme preferences", () => { + const home = createTempDir("hunk-config-home-"); + const cwd = createTempDir("hunk-config-cwd-"); + const input: CliInput = { kind: "tutor", options: {} }; + + expect(resolveConfiguredCliInput(input, { cwd, env: { HOME: home } }).input.options.theme).toBe( + "hunk-tutor", + ); + + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), '[tutor]\ntheme = "dracula"\n'); + expect(resolveConfiguredCliInput(input, { cwd, env: { HOME: home } }).input.options.theme).toBe( + "dracula", + ); + }); + test("command-specific config sections also apply to show mode", () => { const home = createTempDir("hunk-config-home-"); mkdirSync(join(home, ".config", "hunk"), { recursive: true }); diff --git a/src/core/config.ts b/src/core/config.ts index 9c09d8723..972fc596a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -361,8 +361,14 @@ export const CONFIG_COMMAND_SECTIONS = { diff: "two-file comparisons (`hunk diff `)", patch: "patch-file reviews (`hunk patch`)", difftool: "Git difftool pair reviews (`hunk difftool`)", + tutor: "interactive tutorial reviews (`hunk tutor`)", } as const satisfies Record; +/** Command-specific defaults applied before user, repo, and CLI preference layers. */ +const CONFIG_COMMAND_DEFAULTS: Partial> = { + tutor: { theme: "hunk-tutor" }, +}; + /** Reference metadata for the root-only custom-theme tables. */ export const CONFIG_REFERENCE_CUSTOM_THEME = { table: "custom_theme", @@ -1059,6 +1065,7 @@ export function resolveConfiguredCliInput( let resolvedOptions: CommonOptions = { ...buildDefaultConfigPreferences(cwd, vcsCatalog), + ...CONFIG_COMMAND_DEFAULTS[input.kind], agentContext: input.options.agentContext, pager: input.options.pager ?? false, experimental: false, diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index a58615b99..b834e5cfd 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -184,6 +184,37 @@ afterEach(() => { }); describe("loadAppBootstrap", () => { + test("loads the bundled tutor as an ordered, multi-file synthetic changeset", async () => { + const bootstrap = await loadAppBootstrap({ kind: "tutor", options: { mode: "auto" } }); + + expect(bootstrap.changeset.title).toBe("Hunk Tutor"); + expect(bootstrap.changeset.sourceLabel).toBe("hunk tutor"); + expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([ + "00-start-here.md", + "01-moving-through-a-review.md", + "02-scrolling-and-panning.md", + "03-shaping-the-view.md", + "04-find-a-file/haystack-a.md", + "04-find-a-file/needle.md", + "04-find-a-file/haystack-b.md", + "05-context-and-notes.md", + "06-how-the-tutor-works.md", + "07-finish-and-next-steps.md", + ]); + expect( + bootstrap.changeset.files.find((file) => file.path.includes("context-and-notes"))?.metadata + .hunks, + ).toHaveLength(2); + const scrollingLesson = bootstrap.changeset.files.find((file) => + file.path.includes("scrolling-and-panning"), + ); + expect(scrollingLesson?.patch).toContain("YOU FOUND IT"); + expect(scrollingLesson?.metadata.hunks).toHaveLength(2); + expect(await scrollingLesson?.sourceFetcher?.getFullText("new")).toContain( + "YOU REVEALED THE FOLDED GUIDE", + ); + }); + test("synthesizes untracked file diffs an adapter reported by path", async () => { const dir = createTempDir("hunk-adapter-untracked-"); writeFileSync(join(dir, "note.txt"), "hello\n"); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index cb695b540..873f7b688 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -22,6 +22,7 @@ import { import type { VcsCatalog } from "./vcs/types"; import { buildFilesystemUntrackedDiffFile } from "./vcs/untracked"; import { computeWatchSignature } from "./watch"; +import { getTutorDocumentText, TUTOR_PATCH } from "../tutor/content"; import type { AppBootstrap, AgentContext, @@ -451,6 +452,18 @@ async function loadPatchChangeset( ); } +/** Build the bundled tutorial as an ordinary normalized patch changeset. */ +function loadTutorChangeset(agentContext: AgentContext | null) { + return normalizePatchChangeset(TUTOR_PATCH, "Hunk Tutor", "hunk tutor", agentContext, { + sourceFetcherBuilder: ({ path }) => ({ + cacheKey: `hunk-tutor:${path}`, + async getFullText(side) { + return getTutorDocumentText(path, side); + }, + }), + }); +} + /** Resolve CLI input into the fully loaded app bootstrap state. */ export async function loadAppBootstrap( input: CliInput, @@ -495,6 +508,9 @@ export async function loadAppBootstrap( case "difftool": changeset = await loadFileDiffChangeset(input, agentContext, cwd); break; + case "tutor": + changeset = loadTutorChangeset(agentContext); + break; } changeset = { diff --git a/src/core/types.ts b/src/core/types.ts index f19510db4..9c05a7b80 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -330,13 +330,20 @@ export interface DiffToolCommandInput { options: CommonOptions; } +/** Launch the bundled, synthetic review used by Hunk's interactive tutorial. */ +export interface TutorCommandInput { + kind: "tutor"; + options: CommonOptions; +} + export type CliInput = | VcsDiffCommandInput | VcsShowCommandInput | VcsStashShowCommandInput | FileCommandInput | PatchCommandInput - | DiffToolCommandInput; + | DiffToolCommandInput + | TutorCommandInput; export interface MarkupRenderCommandInput { kind: "markup-render"; diff --git a/src/core/watch.ts b/src/core/watch.ts index c056da5eb..f45b8f92d 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -57,6 +57,9 @@ export function computeWatchSignature(input: CliInput, context: WatchSignatureCo } parts.push(statSignature(resolveInputPath(input.file))); break; + case "tutor": + parts.push("bundled:tutor"); + break; } if (input.options.agentContext && input.options.agentContext !== "-") { diff --git a/src/core/watchPlan.ts b/src/core/watchPlan.ts index 21134f4f0..cee937f65 100644 --- a/src/core/watchPlan.ts +++ b/src/core/watchPlan.ts @@ -114,6 +114,8 @@ export function resolveWatchPlan(input: CliInput, context: WatchPlanContext): Wa } fileTargets.push({ path: input.file, source: "content" }); break; + case "tutor": + return null; case "vcs": case "show": case "stash-show": { diff --git a/src/extensions/default/ui/tutor/index.tsx b/src/extensions/default/ui/tutor/index.tsx new file mode 100644 index 000000000..4002eff53 --- /dev/null +++ b/src/extensions/default/ui/tutor/index.tsx @@ -0,0 +1,557 @@ +import { useMemo, useSyncExternalStore, type ReactNode } from "react"; +import type { + ExtensionChangeset, + ExtensionCommandContext, + ExtensionEventContext, + ExtensionPaneProps, + HunkExtensionAPI, +} from "../../../../extension-api/types"; +import { runExtensionFactory } from "../../../runExtension"; +import type { ExtensionLoadResult, ExtensionMetadata } from "../../../types"; + +const TUTOR_EXTENSION_ID = "hunk-tutor"; + +interface TutorTask { + id: string; + commandId?: string; + label: string; + literalKey?: string; +} + +interface TutorLesson { + id: string; + title: string; + subtitle: string; + targetPath: string; + tasks: readonly TutorTask[]; +} + +const TUTOR_LESSONS: readonly TutorLesson[] = [ + { + id: "orientation", + title: "01 · Move through a review", + subtitle: "Watch the highlighted row, then let each navigation step reveal its explanation.", + targetPath: "01-moving-through-a-review.md", + tasks: [ + { id: "help", commandId: "hunk.app.toggleHelp", label: "open the controls card" }, + { id: "down", commandId: "hunk.review.stepDown", label: "move down one row" }, + { id: "up", commandId: "hunk.review.stepUp", label: "move up one row" }, + { id: "next-hunk", commandId: "hunk.review.nextHunk", label: "visit the next hunk" }, + { id: "previous-hunk", commandId: "hunk.review.previousHunk", label: "return one hunk" }, + { id: "next-file", commandId: "hunk.review.nextFile", label: "visit the next file" }, + { id: "previous-file", commandId: "hunk.review.previousFile", label: "return one file" }, + { id: "top", commandId: "hunk.review.jumpToTop", label: "jump to the beginning" }, + { id: "bottom", commandId: "hunk.review.jumpToBottom", label: "jump to the end" }, + ], + }, + { + id: "momentum", + title: "02 · Cover distance", + subtitle: + "Find the labeled checkpoints, then practice moving across a line wider than the pane.", + targetPath: "02-scrolling-and-panning.md", + tasks: [ + { + id: "page-down", + commandId: "hunk.review.pageDown", + label: "page down until the PAGE CHECKPOINT appears", + }, + { id: "page-up", commandId: "hunk.review.pageUp", label: "return to the lesson heading" }, + { + id: "half-down", + commandId: "hunk.review.halfPageDown", + label: "find the HALF-PAGE CHECKPOINT", + }, + { id: "half-up", commandId: "hunk.review.halfPageUp", label: "return to the wide line" }, + { + id: "right", + commandId: "hunk.review.scrollCodeRight", + label: "pan right across the wide line (Shift is faster)", + }, + { + id: "left", + commandId: "hunk.review.scrollCodeLeft", + label: "pan left toward the line's beginning", + }, + { + id: "context", + commandId: "hunk.review.toggleHunkGap", + label: "expand the folded explanation", + }, + ], + }, + { + id: "shape", + title: "03 · Shape the view", + subtitle: "Change the presentation and read the line that explains what each choice buys you.", + targetPath: "03-shaping-the-view.md", + tasks: [ + { id: "split", commandId: "hunk.view.layoutSplit", label: "choose split diff" }, + { id: "stack", commandId: "hunk.view.layoutStack", label: "choose stacked diff" }, + { id: "auto", commandId: "hunk.view.layoutAuto", label: "restore responsive auto" }, + { id: "lines", commandId: "hunk.view.toggleLineNumbers", label: "toggle line numbers" }, + { id: "wrap", commandId: "hunk.view.toggleLineWrap", label: "wrap the long explanation" }, + { id: "metadata", commandId: "hunk.view.toggleHunkHeaders", label: "toggle source ranges" }, + { + id: "theme", + commandId: "hunk.view.openThemeSelector", + label: "choose a theme with Enter", + }, + { id: "menu", commandId: "hunk.view.toggleMenuBar", label: "hide or show the menu bar" }, + { + id: "sidebar", + commandId: "hunk.view.toggleSidebar", + label: "hide this pane; finishing the lesson brings it back", + }, + ], + }, + { + id: "focus", + title: "04 · Find a file", + subtitle: + "Filter the review to the file named needle, then clear it to restore the full guide.", + targetPath: "04-find-a-file/needle.md", + tasks: [ + { id: "focus-filter", commandId: "hunk.review.focusFilter", label: "focus the file filter" }, + { + id: "filter-text", + label: "type needle, read the isolated file, then press Escape", + literalKey: "needle → Esc", + }, + { + id: "focus-area", + commandId: "hunk.app.toggleFocusArea", + label: "switch files/filter focus", + }, + { id: "refresh", commandId: "hunk.app.refresh", label: "reload this safe synthetic review" }, + { + id: "editor", + commandId: "hunk.review.editSelectedFile", + label: "try the editor handoff; this tutorial has no real file", + }, + ], + }, + { + id: "context", + title: "05 · Review with context", + subtitle: "Agent rationale and human questions belong beside the exact lines they explain.", + targetPath: "05-context-and-notes.md", + tasks: [ + { id: "agent-notes", commandId: "hunk.view.toggleAgentNotes", label: "reveal agent notes" }, + { + id: "next-annotation", + commandId: "hunk.review.nextAnnotatedHunk", + label: "jump to the next annotated hunk", + }, + { + id: "previous-annotation", + commandId: "hunk.review.previousAnnotatedHunk", + label: "jump to the previous annotated hunk", + }, + { id: "start-note", commandId: "hunk.review.startNote", label: "start a human review note" }, + { id: "save-note", label: "type a thought and save it", literalKey: "Ctrl+S" }, + ], + }, + { + id: "extensions", + title: "06 · How the tutor works", + subtitle: "The guide itself explains how public extensions can add behavior to Hunk.", + targetPath: "06-how-the-tutor-works.md", + tasks: [{ id: "finished", label: "open the finish dialog", literalKey: "Ctrl+G" }], + }, +]; + +interface TutorSnapshot { + completed: ReadonlySet; + lastCompleted: string | null; +} + +let snapshot: TutorSnapshot = { completed: new Set(), lastCompleted: null }; +const listeners = new Set<() => void>(); +const tutorFileIds = new Map(); +let needleFilterArmed = false; + +/** Publish one immutable progress snapshot even while the tutorial pane is closed. */ +function updateSnapshot(update: (current: TutorSnapshot) => TutorSnapshot) { + const next = update(snapshot); + if (next === snapshot) { + return; + } + + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe the sidebar to progress recorded by extension event handlers. */ +function useTutorSnapshot() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +/** Navigate to the lesson's curated example when its file is currently reviewable. */ +function navigateToLesson( + lesson: TutorLesson, + ctx: ExtensionEventContext | ExtensionCommandContext, +) { + const fileId = tutorFileIds.get(lesson.targetPath); + if (fileId) { + ctx.navigation.selectFile(fileId); + } +} + +/** Mark one task complete, reveal the guide, and stage the next lesson's example. */ +function completeTask(taskId: string, ctx?: ExtensionEventContext | ExtensionCommandContext) { + if (snapshot.completed.has(taskId)) { + return; + } + + const completedBefore = new Set(snapshot.completed); + const completed = new Set(completedBefore).add(taskId); + updateSnapshot((current) => ({ ...current, completed, lastCompleted: taskId })); + + const finishedLesson = TUTOR_LESSONS.find( + (lesson) => + lesson.tasks.some((task) => task.id === taskId) && + lesson.tasks.every((task) => completed.has(task.id)) && + !lesson.tasks.every((task) => completedBefore.has(task.id)), + ); + if (finishedLesson) { + ctx?.notify(`${finishedLesson.title} complete ✨`); + ctx?.panes.open("guide"); + const nextLesson = TUTOR_LESSONS.find( + (lesson) => !lesson.tasks.every((task) => completed.has(task.id)), + ); + if (ctx && nextLesson) { + navigateToLesson(nextLesson, ctx); + } + } +} + +/** Reset progress without disturbing the user's review state. */ +function resetProgress() { + needleFilterArmed = false; + updateSnapshot(() => ({ completed: new Set(), lastCompleted: null })); +} + +/** Return every useful key label from the user's effective keymap. */ +function taskKeys(task: TutorTask, keybindings: ExtensionPaneProps["keybindings"]) { + if (task.literalKey) { + return [task.literalKey]; + } + + const keys = task.commandId ? keybindings.getKeys(task.commandId) : []; + return keys.length > 0 ? [...keys] : ["menu"]; +} + +/** Keep one compact tutor line inside its pane. */ +function fitLine(text: string, width: number) { + if (text.length <= width) { + return text; + } + + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; +} + +/** Wrap prose into deterministic sidebar-width lines without splitting words unnecessarily. */ +function wrapLines(text: string, width: number) { + const lines: string[] = []; + let current = ""; + for (const word of text.split(/\s+/).filter(Boolean)) { + const next = current.length === 0 ? word : `${current} ${word}`; + if (next.length <= width) { + current = next; + continue; + } + + if (current.length > 0) { + lines.push(current); + } + current = fitLine(word, width); + } + if (current.length > 0) { + lines.push(current); + } + return lines.length > 0 ? lines : [""]; +} + +/** Render a live, clickable lesson pane driven entirely through public pane props. */ +function TutorSidebar({ + files, + width, + theme, + keybindings, + actions, +}: ExtensionPaneProps): ReactNode { + const state = useTutorSnapshot(); + const allTasks = useMemo(() => TUTOR_LESSONS.flatMap((lesson) => lesson.tasks), []); + const completeCount = allTasks.filter((task) => state.completed.has(task.id)).length; + const activeTask = allTasks.find((task) => !state.completed.has(task.id)); + const activeLesson = + TUTOR_LESSONS.find((lesson) => !lesson.tasks.every((task) => state.completed.has(task.id))) ?? + TUTOR_LESSONS.at(-1)!; + const target = files.find((file) => file.path === activeLesson.targetPath); + const innerWidth = Math.max(8, width - 2); + const barWidth = Math.max(4, Math.min(14, innerWidth - 10)); + const filled = Math.round((completeCount / allTasks.length) * barWidth); + const lessonIndex = TUTOR_LESSONS.indexOf(activeLesson); + const taskKeysLabel = activeTask ? taskKeys(activeTask, keybindings).join(" · ") : ""; + const quitKeys = keybindings.getKeys("hunk.app.quit").join(" · ") || "menu"; + + return ( + + + + + + target && actions.selectFile(target.id)} + /> + + + {activeTask ? ( + <> + + + + {wrapLines(activeTask.label, Math.max(1, innerWidth - 2)).map((line, index) => ( + + ))} + + + {wrapLines(activeLesson.subtitle, Math.max(1, innerWidth - 2)).map((line, index) => ( + + ))} + + + + ) : ( + <> + + + + + )} + + + + + + ); +} + +/** Add curated rationale to the synthetic files without reaching into renderer metadata. */ +function annotateTutorChangeset(changeset: ExtensionChangeset): ExtensionChangeset { + if (changeset.sourceLabel !== "hunk tutor") { + return changeset; + } + + return { + ...changeset, + agentSummary: + "The tutor is ordered so each navigation step reveals the explanation for why it is useful.", + files: changeset.files.map((file) => { + const annotations = + file.path === "05-context-and-notes.md" + ? [ + { + newRange: [4, 5] as [number, number], + summary: "Agent explanations stay attached to the changed lines they describe.", + rationale: "Annotation navigation skips directly between changes with rationale.", + confidence: "high" as const, + author: "Hunk Tutor", + }, + { + newRange: [21, 23] as [number, number], + summary: "Human notes preserve the reviewer's question beside its target hunk.", + rationale: "Start a note here, write a thought, and save it without leaving Hunk.", + confidence: "high" as const, + author: "Hunk Tutor", + }, + ] + : file.path === "06-how-the-tutor-works.md" + ? [ + { + newRange: [9, 11] as [number, number], + summary: "The tutor observes named commands, not hard-coded keys.", + rationale: + "That is why this pane follows your personal [keybindings] configuration.", + confidence: "high" as const, + author: "Hunk", + }, + ] + : []; + + return annotations.length === 0 + ? file + : { + ...file, + agent: { + path: file.path, + summary: "An instructional example with rationale attached to its exact lesson.", + annotations, + }, + }; + }), + }; +} + +/** Register the interactive tutor using the same public surface third-party extensions receive. */ +export default function registerTutor(hunk: HunkExtensionAPI) { + hunk.configureSession({ viewPreferences: "transient" }); + hunk.registerTheme({ + id: "hunk-tutor", + label: "Hunk Tutor", + base: "catppuccin-mocha", + accent: "#7dd3fc", + badgeAdded: "#86efac", + badgeRemoved: "#f0abfc", + }); + hunk.registerPane({ + id: "guide", + title: "Hunk Tutor", + placement: "left", + width: { preferred: 34, min: 22 }, + defaultOpen: true, + replaces: "hunk:files", + component: TutorSidebar, + }); + hunk.registerCommand( + { id: "finish", title: "Finish Hunk Tutor…", key: "ctrl+g" }, + async (ctx) => { + const ready = await ctx.dialogs.confirm({ + title: "Finish Hunk Tutor?", + body: "Mark the tutorial complete, then use your quit key to leave. You can return anytime with `hunk tutor`.", + confirmLabel: "finish", + cancelLabel: "keep learning", + }); + if (ready) { + completeTask("finished", ctx); + ctx.notify("Hunk Tutor complete — you are ready for a real review ✓"); + } + }, + ); + hunk.registerCommand({ id: "restart", title: "Restart tutor progress" }, (ctx) => { + resetProgress(); + ctx.panes.open("guide"); + navigateToLesson(TUTOR_LESSONS[0]!, ctx); + ctx.notify("Tutor progress reset"); + }); + hunk.transformChangeset(annotateTutorChangeset); + + hunk.on("command_executed", ({ commandId }, ctx) => { + for (const task of TUTOR_LESSONS.flatMap((lesson) => lesson.tasks)) { + // Theme selection completes only after the user accepts a preview. + if (task.commandId === commandId && task.id !== "theme") { + if (task.id === "editor") { + ctx.notify("Tutorial handoff only • in a real review, this opens the file in $EDITOR"); + } + completeTask(task.id, ctx); + } + } + }); + hunk.on("filter_changed", ({ filter }, ctx) => { + if (filter.trim().toLowerCase().includes("needle")) { + needleFilterArmed = true; + return; + } + if (needleFilterArmed && filter.trim().length === 0) { + needleFilterArmed = false; + completeTask("filter-text", ctx); + } + }); + hunk.on("theme_changed", (_event, ctx) => completeTask("theme", ctx)); + hunk.on("note_created", (_event, ctx) => completeTask("save-note", ctx)); + hunk.on("changeset_loaded", ({ changeset }) => { + tutorFileIds.clear(); + for (const file of changeset.files) { + tutorFileIds.set(file.path, file.id); + } + }); + hunk.on("startup", async (_event, ctx) => { + ctx.panes.open("guide"); + navigateToLesson(TUTOR_LESSONS[0]!, ctx); + await ctx.dialogs.confirm({ + title: "Welcome to Hunk Tutor", + body: "The diff itself is the guide. Follow one step in the Tutor pane, then read the explanation that your movement or view change reveals.", + confirmLabel: "start lesson 1", + cancelLabel: "skip intro", + }); + ctx.notify("Lesson 1 is ready • open controls help"); + }); +} + +/** Install the bundled tutor into one interactive tutor session's existing registry. */ +export function installBundledTutorExtension(result: ExtensionLoadResult) { + const metadata: ExtensionMetadata = { + id: TUTOR_EXTENSION_ID, + sourcePath: "hunk:bundled/tutor", + origin: "bundled", + }; + runExtensionFactory({ + metadata, + registry: result.registry, + issues: result.issues, + factory: registerTutor, + }); + if (result.registry.extensions.includes(metadata)) { + result.loaded.push(metadata); + } +} diff --git a/src/session/app/reloadBounds.ts b/src/session/app/reloadBounds.ts index 23caaab8e..f981343e6 100644 --- a/src/session/app/reloadBounds.ts +++ b/src/session/app/reloadBounds.ts @@ -100,6 +100,8 @@ export function createSessionReloadBounds( ) : []; break; + case "tutor": + break; } return { @@ -200,6 +202,7 @@ export function validateSessionReloadWithinBounds( case "vcs": case "show": case "stash-show": + case "tutor": break; } diff --git a/src/session/broker/wire.ts b/src/session/broker/wire.ts index a6fd58ee0..e9c329c85 100644 --- a/src/session/broker/wire.ts +++ b/src/session/broker/wire.ts @@ -28,6 +28,7 @@ const REVIEW_INPUT_KINDS = new Set([ "diff", "patch", "difftool", + "tutor", ]); const EXPERIMENTAL_FEATURE_SET = new Set(EXPERIMENTAL_FEATURES); diff --git a/src/tutor/content.test.ts b/src/tutor/content.test.ts new file mode 100644 index 000000000..9567b1e03 --- /dev/null +++ b/src/tutor/content.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { getTutorDocumentText, TUTOR_PATCH, TUTOR_PATHS } from "./content"; + +describe("tutor content", () => { + test("keeps the synthetic review entirely instructional", () => { + expect(TUTOR_PATHS.every((path) => path.endsWith(".md"))).toBe(true); + expect(TUTOR_PATCH).toContain("This diff is the tutorial."); + expect(TUTOR_PATCH).toContain("You are not editing a project."); + expect(TUTOR_PATCH).toContain("Shortcuts serve one question:"); + expect(TUTOR_PATCH).not.toContain("starship"); + expect(TUTOR_PATCH).not.toContain("autopilot"); + }); + + test("keeps ordinary guide lines readable beside the tutor pane", () => { + const intentionalOverflow = ["PAN RIGHT", "WRAP THIS LONG EXPLANATION"]; + const ordinaryLines = TUTOR_PATHS.flatMap((path) => + (["old", "new"] as const).flatMap((side) => + (getTutorDocumentText(path, side) ?? "") + .split("\n") + .filter((line) => !intentionalOverflow.some((prefix) => line.startsWith(prefix))), + ), + ); + + expect(ordinaryLines.every((line) => line.length <= 36)).toBe(true); + }); + + test("puts the panning payoff beyond a normal viewport", () => { + const revealLine = TUTOR_PATCH.split("\n").find((line) => line.includes("YOU FOUND IT")); + + expect(revealLine).toBeDefined(); + expect(revealLine!.indexOf("YOU FOUND IT")).toBeGreaterThan(120); + expect(revealLine).toContain("horizontal panning reveals columns"); + expect(revealLine).toEndWith("◆ YOU FOUND IT ◆"); + }); + + test("hides an explanation inside unchanged context for the expansion lesson", () => { + const hiddenSource = getTutorDocumentText("02-scrolling-and-panning.md", "new"); + + expect(hiddenSource).toContain("YOU REVEALED THE FOLDED GUIDE"); + expect(TUTOR_PATCH).not.toContain("YOU REVEALED THE FOLDED GUIDE"); + expect(TUTOR_PATCH).toContain("The collapsed section hides a guide"); + }); + + test("makes the filter exercise describe the visible result", () => { + expect(TUTOR_PATHS).toContain("04-find-a-file/needle.md"); + expect(TUTOR_PATCH).toContain("This becomes the only visible file"); + expect(TUTOR_PATCH).toContain("Press Escape to clear the query"); + }); +}); diff --git a/src/tutor/content.ts b/src/tutor/content.ts new file mode 100644 index 000000000..68c4e8845 --- /dev/null +++ b/src/tutor/content.ts @@ -0,0 +1,331 @@ +import { createTwoFilesPatch } from "diff"; + +/** One before/after document used to build the bundled tutor's synthetic changeset. */ +interface TutorDocument { + path: string; + before: string; + after: string; +} + +/** Join source lines with the final newline real documents normally carry. */ +function lines(...source: string[]) { + return `${source.join("\n")}\n`; +} + +/** Build numbered instructional rows that make page-sized movement visible. */ +function scrollingRows(prefix: string, count: number) { + return Array.from( + { length: count }, + (_, index) => `${prefix} ${String(index + 1).padStart(2, "0")}`, + ); +} + +const NAVIGATION_BRIDGE = [ + "Tip 01 — highlight = your place.", + "Tip 02 — movement never edits.", + "Tip 03 — files form one stream.", + "Tip 04 — context frames changes.", + "Tip 05 — sidebar is an index.", + "Tip 06 — pane shows your keys.", + "Tip 07 — mouse shares this state.", + "Tip 08 — selection anchors jumps.", + "Tip 09 — big reviews reward jumps.", + "Tip 10 — next change is below.", +]; + +const HIDDEN_CONTEXT = [ + "Hidden 01 — long context folds.", + "Hidden 02 — changes stay central.", + "Hidden 03 — setup can be evidence.", + "Hidden 04 — expand it in Hunk.", + "Hidden 05 — toggle to collapse.", + "YOU REVEALED THE FOLDED GUIDE.", + "Hidden 07 — inspect dependencies.", + "Hidden 08 — the ellipsis is a clue.", + "Hidden 09 — expansion is local.", + "Hidden 10 — context can matter.", +]; + +const beforeScrollRows = scrollingRows("Practice row", 42); +const afterScrollRows = beforeScrollRows.map((_, index) => { + const row = index + 1; + if (row === 1) return "START — row steps are precise."; + if (row === 10) return "CHECKPOINT A — pages move fast."; + if (row === 20) return "PAGE CHECKPOINT — page-down works."; + if (row === 27) return "HALF-PAGE CHECKPOINT — less jump."; + if (row === 34) return "SCAN TIP — page, then row."; + if (row === 42) return "END — page-up returns."; + return `Row ${String(row).padStart(2, "0")} — distance made visible.`; +}); + +const PAN_REVEAL = + "PAN RIGHT → this sentence keeps going beyond the viewport ................................................................................ horizontal panning reveals columns that do not fit on screen. ◆ YOU FOUND IT ◆"; + +/** The curated files are themselves the guide, in the order shortcuts reveal them. */ +const TUTOR_DOCUMENTS: readonly TutorDocument[] = [ + { + path: "00-start-here.md", + before: lines("# Hunk Tutor", "", "This is a practice review."), + after: lines( + "# Hunk Tutor", + "", + "This diff is the tutorial.", + "You are not editing a project.", + "Changed lines teach Hunk.", + "Each shortcut has a purpose.", + "", + "Keep the Tutor pane open.", + "It shows one configured key.", + "Navigate, then read the reveal.", + "", + "> Lost? Open controls help.", + "> Restart from Extensions.", + ), + }, + { + path: "01-moving-through-a-review.md", + before: lines( + "# Lesson 1 — Move through a review", + "", + "## Nearby rows", + "The highlight marks your place.", + "Move around the document.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Changed blocks and files", + "A hunk groups nearby changed lines.", + "Move between changes and files.", + ), + after: lines( + "# Lesson 1 — Move through a review", + "", + "## Nearby rows", + "Open controls help for your keymap.", + "The highlight marks your place.", + "Move down once, then back up.", + "Watch the highlight follow.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Changed blocks and files", + "A hunk groups nearby changes.", + "Hunk jumps skip between groups.", + "Next-file reaches Lesson 2.", + "Previous-file returns here.", + "Top reveals Start Here.", + "Bottom previews the final page.", + ), + }, + { + path: "02-scrolling-and-panning.md", + before: lines( + "# Lesson 2 — Cover distance", + "", + "One line below is extra wide.", + "Pan across this placeholder.", + "", + ...beforeScrollRows, + "", + ...HIDDEN_CONTEXT, + "", + "## Folded context", + "Expand context when setup matters.", + ), + after: lines( + "# Lesson 2 — Cover distance", + "", + "One line below is extra wide.", + "Keep wrapping off for this hunt.", + PAN_REVEAL, + "", + ...afterScrollRows, + "", + ...HIDDEN_CONTEXT, + "", + "## Folded context", + "The collapsed section hides a guide.", + "Expand it to reveal the message.", + "Pages expose distant rows.", + "Panning exposes distant columns.", + "Expansion exposes folded context.", + ), + }, + { + path: "03-shaping-the-view.md", + before: lines( + "# Lesson 3 — View settings", + "", + "Choose a presentation.", + "Show useful chrome.", + "Try a color theme.", + ), + after: lines( + "# Lesson 3 — Shape the view", + "", + "Split: old and new side by side.", + "Stack: old above new.", + "Auto: adapts to terminal width.", + "", + "Line numbers locate source rows.", + "WRAP THIS LONG EXPLANATION → line wrapping trades horizontal scanning for extra vertical rows, which is useful when prose or code extends beyond the available pane width.", + "Hunk headers show source ranges.", + "", + "Themes change presentation only.", + "Added and removed stay distinct.", + "The menu makes features findable.", + "F10 works while the bar is hidden.", + "Hide Tutor to give the diff room.", + "It reopens after this lesson.", + ), + }, + { + path: "04-find-a-file/haystack-a.md", + before: lines("# Filtering", "", "Many files can share one review."), + after: lines( + "# Filtering: haystack A", + "", + "This file vanishes for `needle`.", + "Filtering narrows the review.", + "It does not alter the changeset.", + ), + }, + { + path: "04-find-a-file/needle.md", + before: lines("# Filtering", "", "Find one file."), + after: lines( + "# Lesson 4 — Find the signal", + "", + "Focus the filter. Type `needle`.", + "This becomes the only visible file.", + "The review now contains one match.", + "Press Escape to clear the query.", + "The complete tutorial returns.", + "", + "Focus switches files and filter.", + "It does not change selection.", + "Refresh reloads a real source.", + "This safe tutorial stays the same.", + "Editor handoff opens `$EDITOR`.", + "This tutorial has no file to edit.", + ), + }, + { + path: "04-find-a-file/haystack-b.md", + before: lines("# Filtering", "", "Many files can share one review."), + after: lines( + "# Filtering: haystack B", + "", + "This also vanishes for `needle`.", + "It returns when the query clears.", + "File order remains stable.", + ), + }, + { + path: "05-context-and-notes.md", + before: lines( + "# Lesson 5 — Context and notes", + "", + "## Agent explanations", + "A changed hunk can have rationale.", + "Read the change alone.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Human review notes", + "A reviewer can ask a question.", + "Keep the question elsewhere.", + ), + after: lines( + "# Lesson 5 — Review with context", + "", + "## Agent explanations", + "Agent notes explain why.", + "They sit beside exact lines.", + "Toggle them, then jump between.", + "You land on explained changes.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Human review notes", + "Start a note on this hunk.", + "Type a real review thought.", + "Save it beside the target code.", + "The composer owns the keyboard.", + "Save or cancel to return.", + ), + }, + { + path: "06-how-the-tutor-works.md", + before: lines("# Lesson 6 — Extensions", "", "Extensions add behavior."), + after: lines( + "# Lesson 6 — How the tutor works", + "", + "Tutor is a bundled extension.", + "It uses Hunk's public API.", + "It adds this sidebar and theme.", + "It adds commands and event handlers.", + "It transforms the guide changeset.", + "", + "Tutor listens for command names.", + "It does not hard-code keys.", + "Steps follow live `[keybindings]`.", + "Other extensions use these APIs.", + "They can add views and dialogs.", + "They can add review tools.", + "", + "Use Finish when you are ready.", + "It also lives in Extensions.", + ), + }, + { + path: "07-finish-and-next-steps.md", + before: lines("# Next", "", "Open a review."), + after: lines( + "# Finish — use Hunk on a real change", + "", + "You can move through a review.", + "You can shape and filter it.", + "You can add review context.", + "Shortcuts serve one question:", + "what changed, and why?", + "", + "Try one after leaving Tutor:", + "", + "- working tree: `hunk diff`", + "- staged: `hunk diff --staged`", + "- commit: `hunk show HEAD~1`", + "- stash: `hunk stash show`", + "- patch: `hunk patch change.diff`", + "- rationale: `--agent-context`", + "- live reload: `hunk diff --watch`", + "", + "Use the mouse when it is natural.", + "Primary actions keep key parity.", + "Open controls for a reminder.", + "Quit when the diff is understood.", + ), + }, +]; + +/** Return one complete synthetic tutorial document side for context expansion. */ +export function getTutorDocumentText(path: string, side: "old" | "new") { + const document = TUTOR_DOCUMENTS.find((candidate) => candidate.path === path); + return document?.[side === "old" ? "before" : "after"] ?? null; +} + +/** Paths in the exact narrative order Hunk should render and navigate. */ +export const TUTOR_PATHS = TUTOR_DOCUMENTS.map((document) => document.path); + +/** A deterministic multi-file patch that can be loaded without touching the user's repository. */ +export const TUTOR_PATCH = TUTOR_DOCUMENTS.map((document) => + createTwoFilesPatch( + document.path, + document.path, + document.before, + document.after, + "before", + "after", + { context: 3 }, + ), +).join("\n"); diff --git a/src/ui/AppHost.tutor.test.tsx b/src/ui/AppHost.tutor.test.tsx new file mode 100644 index 000000000..0bdd62e6b --- /dev/null +++ b/src/ui/AppHost.tutor.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { prepareStartupPlan } from "../app/startup"; +import type { HunkConfigResolution } from "../core/config"; +import { loadAppBootstrap } from "../core/loaders"; +import type { CliInput } from "../core/types"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; +import { AppHost } from "./AppHost"; + +/** Flush effects and OpenTUI rendering until the public frame reflects current state. */ +async function flush(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +describe("bundled tutor", () => { + test("renders one focused step with the user's live command binding", async () => { + const input: CliInput = { + kind: "tutor", + options: { mode: "stack", theme: "hunk-tutor" }, + }; + const configured: HunkConfigResolution = { + input, + customThemes: [], + extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, + keybindings: { "hunk.app.toggleHelp": "ctrl+h" }, + }; + const extensions = createEmptyExtensionLoadResult(); + const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], { + parseCliImpl: async () => input, + resolveRuntimeCliInputImpl: (parsed) => parsed, + resolveConfiguredCliInputImpl: () => configured, + loadStartupExtensionsImpl: async () => extensions, + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + if (plan.kind !== "app") { + throw new Error("Expected tutor startup to produce an app plan."); + } + + const setup = await testRender(, { + width: 80, + height: 24, + }); + try { + await flush(setup); + expect(setup.captureCharFrame()).toContain("HUNK TUTOR"); + expect(setup.captureCharFrame()).toContain("0/36"); + expect(setup.captureCharFrame()).toContain("Welcome to Hunk Tutor"); + expect(setup.captureCharFrame()).toContain("The diff itself is the guide"); + expect(setup.captureCharFrame()).toContain("read the explanation"); + expect(setup.captureCharFrame()).not.toContain("ext hunk-tutor"); + + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await flush(setup); + expect(setup.captureCharFrame()).toContain("NEXT STEP"); + expect(setup.captureCharFrame()).toContain("open the controls card"); + expect(setup.captureCharFrame()).toContain("ctrl+h"); + expect(setup.captureCharFrame()).not.toContain("visit the next hunk"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("treats lesson view changes as transient when quitting", async () => { + const input: CliInput = { kind: "tutor", options: { mode: "stack" } }; + const configured: HunkConfigResolution = { + input: { ...input, options: { ...input.options, promptSaveViewPreferences: true } }, + customThemes: [], + extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, + keybindings: {}, + }; + const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], { + parseCliImpl: async () => input, + resolveRuntimeCliInputImpl: (parsed) => parsed, + resolveConfiguredCliInputImpl: () => configured, + loadStartupExtensionsImpl: async () => createEmptyExtensionLoadResult(), + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + if (plan.kind !== "app") { + throw new Error("Expected tutor startup to produce an app plan."); + } + + const quit = mock(() => undefined); + const setup = await testRender(, { + width: 120, + height: 28, + }); + try { + await flush(setup); + await act(async () => { + await setup.mockInput.pressEnter(); + await setup.mockInput.typeText("l"); + 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("reveals the panning payoff at the real rightmost viewport", async () => { + const bootstrap = await loadAppBootstrap({ + kind: "tutor", + options: { mode: "stack" }, + }); + const scrollingLesson = bootstrap.changeset.files.find( + (file) => file.path === "02-scrolling-and-panning.md", + ); + if (!scrollingLesson) { + throw new Error("Expected the tutor scrolling lesson."); + } + bootstrap.changeset = { ...bootstrap.changeset, files: [scrollingLesson] }; + + const setup = await testRender(, { + width: 80, + height: 24, + }); + try { + await flush(setup); + expect(setup.captureCharFrame()).toContain("PAN RIGHT"); + expect(setup.captureCharFrame()).not.toContain("YOU FOUND IT"); + + for (let index = 0; index < 24; index += 1) { + await act(async () => { + await setup.mockInput.pressArrow("right", { shift: true }); + }); + await flush(setup); + } + + expect(setup.captureCharFrame()).toContain("YOU FOUND IT"); + expect(setup.captureCharFrame()).not.toContain("PAN RIGHT"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/lib/extensionWorkspace.ts b/src/ui/lib/extensionWorkspace.ts index ed10f2f8e..121f30c16 100644 --- a/src/ui/lib/extensionWorkspace.ts +++ b/src/ui/lib/extensionWorkspace.ts @@ -88,6 +88,8 @@ function nonWorkingTreeReview(input: CliInput): string | null { return "a stash entry"; case "patch": return "patch input"; + case "tutor": + return "the bundled tutorial"; case "diff": case "difftool": return "a file comparison"; diff --git a/test/pty/tutor.test.ts b/test/pty/tutor.test.ts new file mode 100644 index 000000000..9be9109c4 --- /dev/null +++ b/test/pty/tutor.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); + +/** Give the compiled-style startup and OpenTUI redraw loop room on slower CI workers. */ +setDefaultTimeout(30_000); + +afterEach(() => { + harness.cleanup(); +}); + +describe("PTY tutor", () => { + test("teaches the live keymap and advances one instructional step at a time", async () => { + const configHome = harness.createIsolatedConfigHome(); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + join(configHome, "hunk", "config.toml"), + '[keybindings]\n"hunk.review.stepDown" = "ctrl+n"\n', + ); + + const session = await harness.launchHunk({ + args: ["tutor", "--mode", "stack"], + cols: 180, + rows: 30, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForData({ timeout: 20_000 }); + const welcome = await harness.waitForSnapshot( + session, + (text) => text.includes("Welcome to Hunk Tutor") && text.includes("start lesson 1"), + 20_000, + ); + expect(welcome).toContain("The diff itself is the guide"); + await session.press("enter"); + await harness.ensureKeyboardIsLive(session); + const initial = await harness.waitForSnapshot( + session, + (text) => + text.includes("HUNK TUTOR") && text.includes("NEXT STEP") && text.includes("1/36"), + 20_000, + ); + expect(initial).toContain("1/36"); + expect(initial).toContain("ctrl+n"); + expect(initial).toContain("Extensions"); + expect(initial).toContain("Hunk Tutor"); + + session.sendKey(["ctrl", "n"]); + await Bun.sleep(200); + const moved = await session.text({ immediate: true }); + expect(moved).toContain("2/36"); + expect(moved).toContain("move up one row"); + + const lessonOneKeys: Array[0]> = [ + "up", + "]", + "[", + ".", + ",", + "g", + ["shift", "g"], + ]; + for (const key of lessonOneKeys) { + await session.press(key); + await Bun.sleep(120); + } + await session.waitForText(/02 · Cover distance/, { timeout: 5_000 }); + + const distanceKeys: Array[0]> = ["space", "b", "d", "u"]; + for (const key of distanceKeys) { + await session.press(key); + await Bun.sleep(120); + } + const panRight = await session.waitForText(/pan right across the wide line/i, { + timeout: 5_000, + }); + expect(panRight).toContain("13/36"); + + await session.press("right"); + const panLeft = await session.waitForText(/pan left toward/i, { + timeout: 5_000, + }); + expect(panLeft).toContain("14/36"); + + await session.press("left"); + const context = await session.waitForText(/expand the folded explanation/i, { + timeout: 5_000, + }); + expect(context).toContain("15/36"); + + await session.press(["ctrl", "g"]); + const finish = await session.waitForText(/Finish Hunk Tutor\?/, { + timeout: 5_000, + }); + expect(finish).toContain("hunk tutor"); + expect(finish).not.toContain("ext hunk-tutor"); + } finally { + session.close(); + } + }); + + test("keeps a drafted note readable in the focused 80-column layout", async () => { + const configHome = harness.createIsolatedConfigHome(); + const session = await harness.launchHunk({ + args: ["tutor", "--mode", "stack"], + cols: 80, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await harness.waitForSnapshot( + session, + (text) => text.includes("Welcome to Hunk Tutor") && text.includes("start lesson 1"), + 20_000, + ); + await session.press("enter"); + await harness.waitForSnapshot( + session, + (text) => text.includes("HUNK TUTOR") && text.includes("NEXT STEP"), + 20_000, + ); + + await session.press("c"); + const draft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + expect(draft).toContain("Write a note"); + + await session.type("Reserve math deserves a test."); + await session.waitForText(/Reserve math deserves a test\./, { timeout: 5_000 }); + await session.press(["ctrl", "s"]); + + const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + expect(saved).toContain("Reserve math deserves a test."); + } finally { + session.close(); + } + }); +}); diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 472a1252c..fba933408 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -98,9 +98,9 @@ Test the exact layout users will get with `hunk extension install /path/to/check ## Bundled extensions -Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. Bundled extensions differ from yours in three ways: +Hunk's own Git, Jujutsu, and Sapling backends, built-in file-navigation pane, and the interactive `hunk tutor` guide are themselves extensions, registered through the same public API — which is what keeps that API honest. They differ from yours in three ways: -- statically imported, so they load before config resolution picks the session's VCS +- VCS adapters and the default pane load with their host surfaces; the UI-backed tutor is imported only for `hunk tutor` - implicitly trusted, with no `[extension.]` config table - still loaded under `--no-extensions` and `[extensions] enabled = false` — those switches triage extensions _you_ installed diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 2aa4a124c..73b610433 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -133,6 +133,18 @@ Also accepts `--watch`: auto-reload when the current diff input changes. Also accepts every [common review option](#common-review-options). +## `hunk tutor` + +learn Hunk inside an interactive guided changeset + +### Usage + +```bash +hunk tutor +``` + +Also accepts every [common review option](#common-review-options). + ## `hunk markup render` preview experimental STML markup as terminal text diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index a88000629..a9f1c6aec 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -189,6 +189,7 @@ Enable moved-line coloring when the renderer supports it. | `[diff]` | two-file comparisons (`hunk diff `) | | `[patch]` | patch-file reviews (`hunk patch`) | | `[difftool]` | Git difftool pair reviews (`hunk difftool`) | +| `[tutor]` | interactive tutorial reviews (`hunk tutor`) | `[pager]` is an additional overlay for any review opened with pager-style chrome. It is applied after the matching command table in the same file. diff --git a/website/src/content/docs/docs/start/quick-start.md b/website/src/content/docs/docs/start/quick-start.md index c51f75713..aefeb1af4 100644 --- a/website/src/content/docs/docs/start/quick-start.md +++ b/website/src/content/docs/docs/start/quick-start.md @@ -13,6 +13,17 @@ From a repository: hunk diff ``` +If this is your first review, start with the vimtutor-inspired interactive guide: + +```bash +hunk tutor +``` + +It opens a safe synthetic changeset whose diff is the guide, plus a focused lesson pane that +follows your configured keybindings. Each step reveals an explanation in the review while +introducing navigation, layouts, filtering, agent notes, human review notes, themes, menus, mouse +support, and extensions. + This includes tracked changes and untracked files. Use `--exclude-untracked` when you intentionally want tracked changes only. Inside Hunk: From b5bc7b55d2954246aa937e1228beb3264beb6110 Mon Sep 17 00:00:00 2001 From: Mike Clarke Date: Sat, 15 Aug 2026 08:21:07 -0700 Subject: [PATCH 3/3] fix(tutor): keep completed spotlights visible --- src/extensions/default/ui/tutor/index.tsx | 46 +++++++++++++----- test/pty/tutor.test.ts | 58 +++++++++++++++++++++++ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/extensions/default/ui/tutor/index.tsx b/src/extensions/default/ui/tutor/index.tsx index a698eb162..c53fab0b2 100644 --- a/src/extensions/default/ui/tutor/index.tsx +++ b/src/extensions/default/ui/tutor/index.tsx @@ -352,9 +352,15 @@ export function getTutorSpotlightPlan() { /** Resolve the active task, its lesson, and its exact source-coordinate spotlight. */ function resolveActiveSpotlight(readDocument: (path: string) => string | null) { const task = findActiveTask(); - const lesson = task - ? TUTOR_LESSONS.find((candidate) => candidate.tasks.some((entry) => entry.id === task.id)) - : undefined; + return task ? resolveTaskSpotlight(task.id, readDocument) : null; +} + +/** Resolve any task by id so the latest completed payoff can remain visible. */ +function resolveTaskSpotlight(taskId: string, readDocument: (path: string) => string | null) { + const lesson = TUTOR_LESSONS.find((candidate) => + candidate.tasks.some((entry) => entry.id === taskId), + ); + const task = lesson?.tasks.find((entry) => entry.id === taskId); if (!lesson || !task) { return null; } @@ -584,7 +590,11 @@ function TutorSidebar({ ))} + @@ -686,19 +696,29 @@ export default function registerTutor(hunk: HunkExtensionAPI) { id: TUTOR_HIGHLIGHTER_ID, async highlight({ file, readDocument }) { const document = await readDocument("new"); - const active = resolveActiveSpotlight((path) => (path === file.path ? document : null)); - if (!active || active.lesson.targetPath !== file.path) { - return null; + const readCurrentDocument = (path: string) => (path === file.path ? document : null); + const active = resolveActiveSpotlight(readCurrentDocument); + const completed = snapshot.lastCompleted + ? resolveTaskSpotlight(snapshot.lastCompleted, readCurrentDocument) + : null; + const marks = []; + if (completed?.lesson.targetPath === file.path) { + marks.push({ + side: completed.spotlight.side, + line: completed.spotlight.line, + range: completed.spotlight.range, + tone: "info" as const, + }); } - - return [ - { + if (active?.lesson.targetPath === file.path) { + marks.push({ side: active.spotlight.side, line: active.spotlight.line, range: active.spotlight.range, - tone: "current", - }, - ]; + tone: "current" as const, + }); + } + return marks.length > 0 ? marks : null; }, }); hunk.registerPane({ diff --git a/test/pty/tutor.test.ts b/test/pty/tutor.test.ts index 9be9109c4..75413c115 100644 --- a/test/pty/tutor.test.ts +++ b/test/pty/tutor.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import type { Session } from "tuistory"; import { createPtyHarness } from "./harness"; const harness = createPtyHarness(); @@ -12,6 +13,14 @@ afterEach(() => { harness.cleanup(); }); +/** Find the terminal-native color run that paints one visible tutor phrase. */ +function findRenderedSpan(session: Session, phrase: string) { + return session + .getTerminalData() + .lines.flatMap((line) => line.spans) + .find((span) => span.text.includes(phrase)); +} + describe("PTY tutor", () => { test("teaches the live keymap and advances one instructional step at a time", async () => { const configHome = harness.createIsolatedConfigHome(); @@ -69,6 +78,17 @@ describe("PTY tutor", () => { await Bun.sleep(120); } await session.waitForText(/02 · Cover distance/, { timeout: 5_000 }); + const lessonTwo = await harness.waitForSnapshot( + session, + (text) => text.includes("PAGE CHECKPOINT"), + 5_000, + ); + const checkpointRow = lessonTwo + .split("\n") + .findIndex((line) => line.includes("PAGE CHECKPOINT — page-down works")); + expect(checkpointRow).toBeGreaterThan(0); + expect(checkpointRow).toBeLessThan(20); + expect(findRenderedSpan(session, "PAGE CHECKPOINT")?.bg).toBeDefined(); const distanceKeys: Array[0]> = ["space", "b", "d", "u"]; for (const key of distanceKeys) { @@ -139,4 +159,42 @@ describe("PTY tutor", () => { session.close(); } }); + + test("keeps the last payoff visible beside the next spotlight at 80 columns", async () => { + const configHome = harness.createIsolatedConfigHome(); + const session = await harness.launchHunk({ + args: ["tutor", "--mode", "stack"], + cols: 80, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/Welcome to Hunk Tutor/, { timeout: 20_000 }); + await session.press("enter"); + await harness.waitForSnapshot( + session, + (text) => text.includes("NEXT STEP") && text.includes("controls help"), + 20_000, + ); + + const firstSpotlight = findRenderedSpan(session, "controls help"); + expect(firstSpotlight?.bg).toBeDefined(); + expect(firstSpotlight?.fg).toBeDefined(); + + await session.press("?"); + await session.waitForText(/Controls help/, { timeout: 5_000 }); + await session.press("escape"); + await session.waitForText(/move down one row/, { timeout: 5_000 }); + + const completedPayoff = findRenderedSpan(session, "controls help"); + const nextSpotlight = findRenderedSpan(session, "Move down once"); + expect(completedPayoff?.bg).toBeDefined(); + expect(nextSpotlight?.bg).toBeDefined(); + expect(completedPayoff?.bg).not.toBe(nextSpotlight?.bg); + expect(nextSpotlight?.fg).not.toBe(completedPayoff?.fg); + } finally { + session.close(); + } + }); });