diff --git a/.changeset/session-keyboard-modes.md b/.changeset/session-keyboard-modes.md new file mode 100644 index 00000000..9d54edab --- /dev/null +++ b/.changeset/session-keyboard-modes.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Let extensions activate visible session-scoped keyboard modes that route keys through Hunk's public semantic commands. diff --git a/README.md b/README.md index ddaf6c02..5ac36b10 100644 --- a/README.md +++ b/README.md @@ -231,8 +231,9 @@ export default function (hunk: HunkExtensionAPI) { See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, and the `[extensions]` / `[extension.]` config reference. Installable examples -include [review triage](examples/extensions/review-triage/) and an optional -[rendered Markdown file view](examples/extensions/rendered-markdown/). +include [review triage](examples/extensions/review-triage/), an optional +[rendered Markdown file view](examples/extensions/rendered-markdown/), and a +[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands. ### OpenTUI component diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 2e6e6904..d58e5361 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -105,9 +105,27 @@ scrolling, hunk bounds, and navigation remain host-owned. containment. The presentation controller stores the active mode and funnels all exit paths through one teardown, including re-entrant handoffs. -Keyboard routing checks modes after focused inputs and before app commands. -`"handled"` and `"exit"` consume the key; `"pass"` continues normal routing. -Escape remains host-owned. +Keyboard routing checks file-view modes after focused inputs and before session +keyboard modes and app commands. `"handled"` and `"exit"` consume the key; +`"pass"` continues normal routing. Escape remains host-owned. + +Session-wide modes registered through `registerKeyboardMode` are resolved with +the same extension ownership and first-registration rules as other surfaces. +`src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session +mode, with eager ref state for input chunks, registry-generation authority, +contained synchronous lifecycle callbacks, and one teardown used by Escape, +status, menu, reload, and unmount. Mode controls are activation-scoped; +`onEnter` and `onExit` cannot change ownership, while `onKey` may deliberately +replace its activation without letting the outgoing callback defeat recovery or +manipulate the replacement. +`src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot +used by both session and file-view mode delivery, so OpenTUI events and their +consumption methods never cross the extension boundary. Their shared +`src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, +rejects thenables without leaving unhandled rejections, and normalizes key +results; each mode module supplies only its context and attributed warnings. A +focused file-view mode may overlap and temporarily outrank a session mode; +leaving it resumes the session mode rather than destroying unrelated state. ## Command system @@ -140,7 +158,8 @@ select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so every pending and queued dialog resolves its cancel value instead of leaving a handler awaiting forever. Key precedence in `useAppKeyboardShortcuts` places dialogs below Hunk's own app-critical prompts (repo trust, save-on-quit) and -above menus, help, the theme selector, and the command table: an extension may +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 diff --git a/docs/extensions.md b/docs/extensions.md index e4a4cb1a..29b28ba0 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -192,8 +192,9 @@ cannot mutate the registry mid-session. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `3`). Branch on it if you want -one file to support several Hunk versions. +The API generation this Hunk speaks (currently `4`). Branch on it if you want +one file to support several Hunk versions. Version 4 adds session-scoped +keyboard modes; version 3 added public semantic command execution. ### `hunk.registerTheme(theme)` @@ -938,20 +939,104 @@ hunk.registerCommand({ id: "outline-keys", title: "Outline keys", key: "f9" }, ( ``` `enterMode(viewId)` selects the view and starts its mode, returning `false` if it -cannot. Only one mode runs at a time. `exitMode()` stops it; +cannot. Only one file-view mode runs at a time. `exitMode()` stops it; `isModeActive(viewId)` checks it. `onKey` must return synchronously: - `"handled"` consumes the key. -- `"pass"` leaves it for Hunk's commands and scrolling. +- `"pass"` continues through any active session keyboard mode, then Hunk's + commands and focused scrolling. - `"exit"` consumes the key and stops the mode. -Escape always exits and never reaches `onKey`. Hunk also exits when the selected -file, active presentation, extensions, or review session changes. `onEnter` and -`onExit` are optional lifecycle callbacks, and `onExit` runs exactly once per -activation. A failing `onEnter` or `onKey` exits the mode; any callback failure -warns without breaking the review. +When the file-view mode is the highest-priority input owner, Escape exits it and +never reaches `onKey`. Hunk also exits when the selected file, active presentation, +extensions, or review session changes. Optional `onEnter` and `onExit` lifecycle +callbacks must also return synchronously, and `onExit` runs exactly once per +activation. A failing or asynchronous `onEnter` or `onKey` exits the mode; any +callback failure warns without breaking the review. + +### Session keyboard modes + +Register a session-wide mode when an extension needs to interpret review keys +without replacing a pane or exposing renderer internals. Registration is inert; +a command deliberately enters the mode through its own scoped controls: + +```ts +let pending = ""; + +hunk.registerKeyboardMode({ + id: "normal", + title: "Vim navigation", + onEnter: () => { + pending = ""; + }, + onExit: () => { + pending = ""; + }, + onKey: (key, ctx) => { + if (key.sequence === "g") { + if (pending === "g") { + pending = ""; + ctx.commands.execute("hunk.review.jumpToTop"); + } else { + pending = "g"; + } + return "handled"; + } + + pending = ""; + if (key.sequence !== "j") return "pass"; + ctx.commands.execute("hunk.review.stepDown"); + return "handled"; + }, +}); + +hunk.registerCommand({ id: "vim", title: "Toggle Vim navigation", key: "ctrl+v" }, (ctx) => { + if (ctx.keyboardModes.isActive("normal")) { + ctx.keyboardModes.exitMode(); + } else { + ctx.keyboardModes.enterMode("normal"); + } +}); +``` + +`ctx.keyboardModes.enterMode(id)` resolves only a mode registered by the same +extension. `exitMode()` and `isActive(id?)` likewise act only on that extension's +active mode, so one extension cannot inspect or stop another. Entering a mode +replaces the previous session mode and runs its `onExit` first. While `onEnter` or +`onExit` runs, `enterMode()` and `exitMode()` return `false`; lifecycle callbacks +reset extension-owned state but cannot change keyboard ownership. Only one session +keyboard mode runs at a time. + +`onKey` returns synchronously: + +- `"handled"` consumes the key. +- `"pass"` continues through ordinary Hunk commands and focused scrolling. +- `"exit"` consumes the key and leaves the mode. + +The context is intentionally small: `cwd`, `notify`, live public `commands`, and +activation-scoped `keyboardModes`. Keys are frozen plain snapshots, not OpenTUI +events. Async/throwing callbacks are contained and exit safely. When the session +mode is the highest-priority active input owner, host-owned Escape exits without +reaching `onKey`; the status badge and a host-owned **Extensions** menu item are +clickable exits too. Controls handed to a mode are activation-scoped: after that +activation exits, retained callbacks cannot inspect, stop, or replace a later mode. +An active `onKey` may deliberately enter another mode from the same extension; its +outgoing lifecycle callback cannot supersede that replacement. + +Dialogs, menus, focused filter/note inputs, and interactive file-view modes run +before a session mode. A file-view mode may temporarily overlap it: the first +Escape leaves the focused file-view mode, and the second leaves the resumed +session mode. Ordinary content soft reloads preserve a session mode, while an +extension reload, registry closure, or App teardown exits it exactly once. + +Multi-key grammar and numeric prefixes belong to the extension. Resolve a count, +then call `ctx.commands.execute(id, { count })` once so the host applies movement +atomically. See the dependency-free +[`vim-navigation`](../examples/extensions/vim-navigation/) example for `j`/`k`, +`gg`/`G`, hunk movement, alignment, capped counts, Ctrl chords, and a focused +`:` command line composed from a registered command plus `ctx.dialogs.input()`. ### `hunk.registerCommand(command, handler)` @@ -1071,6 +1156,9 @@ state, so they remain valid after an `await` or an ordinary content soft reload extension registry. Controls retained across an extension-registry reload or App remount return `false`. +`ctx.keyboardModes` enters, exits, or probes the command's own registered +session keyboard modes. See [Session keyboard modes](#session-keyboard-modes). + `ctx.navigation` moves the review stream: `selectFile(fileId)` and `selectHunk(fileId, hunkIndex)`, the same guarded navigation a sidebar's `actions` carry, routed through the same review controller — the stream diff --git a/docs/keybindings.md b/docs/keybindings.md index 7938dae9..04f7b80d 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -97,9 +97,19 @@ present, so remapping something changes what they advertise. Unbinding a menu command keeps its menu item and simply stops showing a key. Extension commands are named `.` and remap the same way -(see [docs/extensions.md](extensions.md)). Keys that belong to a dialog, +(see [docs/extensions.md](extensions.md)). An explicitly activated extension +keyboard mode is a routing layer rather than a second command table: it may +consume a key, pass it to these resolved bindings, or consume it and exit. Its +multi-key grammar and counts are extension-owned, but resolved actions should +invoke these same public `hunk.*` commands. + +Routing precedence is host prompts and dialogs, menus/overlays, focused text +inputs, an interactive file-view mode, a session extension keyboard mode, then +the command table and focused review widget. Keys that belong to a dialog, menu, or focused text input — `Esc`, `Enter`, `Ctrl-S` while writing a note — -are part of those widgets rather than commands, and are not remappable. +are part of those widgets rather than commands, and are not remappable. Escape +is also the reserved exit from each active extension mode, so an extension +cannot trap the keyboard. `[keybindings]` is read from your user config only — never from a repository's `.hunk/config.toml`. Which keys do what is a property of your keyboard and your diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md new file mode 100644 index 00000000..074c0bc4 --- /dev/null +++ b/examples/extensions/vim-navigation/README.md @@ -0,0 +1,47 @@ +# Vim navigation extension + +A small Vim-style normal mode for Hunk's whole review stream. It demonstrates session keyboard modes and public semantic command execution without accessing scroll boxes, renderer objects, or viewport coordinates. + +This example is **not bundled or loaded by Hunk**. Install it explicitly if you want it. + +## Try it from this checkout + +```bash +bun run src/main.tsx -- diff --extension ./examples/extensions/vim-navigation +``` + +Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press `Esc` to leave. + +## Install it globally + +```bash +mkdir -p ~/.config/hunk/extensions +cp -R examples/extensions/vim-navigation ~/.config/hunk/extensions/ +``` + +## Keys + +| Key | Action | +| ------------------- | ----------------------------------------------------------- | +| `j` / `k` | Move the current review line down/up | +| `[` / `]` | Move to the previous/next hunk | +| `gg` / `G` | Jump to the start/end of the review | +| `zt` / `zz` / `zb` | Align the current line at the top/center/bottom | +| `Ctrl-D` / `Ctrl-U` | Move down/up by half pages | +| positive digits | Prefix the next relative motion, for example `5j` or `3]` | +| `:` | Open the host-rendered Vim command line | +| `Esc` | Exit the mode (host-owned; the extension never receives it) | +| everything else | Pass through to normal Hunk routing | + +Counts are parsed by the extension and capped at 10,000. Once a normal-mode sequence resolves, the extension calls `ctx.commands.execute(id, { count })` exactly once, so Hunk applies movement atomically. A bare `0` passes to Hunk's normal layout shortcut; `0` can extend a count that already began with `1`–`9`. + +Pressing `:` passes the key to the example's registered command, which opens `ctx.dialogs.input()`. That focused host dialog captures typed keys ahead of the still-active session mode until Enter submits or Escape cancels. The deliberately small Ex-style command set is: + +| Command | Action | +| --------- | ------------------------------- | +| `:top` | Jump to the start of the review | +| `:bottom` | Jump to the end of the review | + +Unsupported commands produce an attributed warning. Absolute source-line commands such as Vim's `:100` are intentionally absent because Hunk does not expose source-line targeting as a public semantic command; relative counted movement such as `100j` remains available in normal mode. + +The example enables Hunk's host-owned current-line marker on entry so the `z*` alignment commands have a target. It resets all pending prefix/count state on entry and exit. Invalid continuations clear pending state and pass the current key back to Hunk. diff --git a/examples/extensions/vim-navigation/index.ts b/examples/extensions/vim-navigation/index.ts new file mode 100644 index 00000000..7f84a7a2 --- /dev/null +++ b/examples/extensions/vim-navigation/index.ts @@ -0,0 +1,52 @@ +import type { HunkExtensionAPI } from "hunkdiff/extension"; +import { createVimNavigationState, executeVimCommand } from "./state"; + +export default function (hunk: HunkExtensionAPI) { + let navigation = createVimNavigationState({ execute: () => false }); + + hunk.registerKeyboardMode({ + id: "normal", + title: "Vim navigation", + onEnter(ctx) { + navigation = createVimNavigationState(ctx.commands); + // Alignment commands need a current-line target, so make the host-owned marker visible. + ctx.commands.execute("hunk.view.cursorLineRow"); + }, + onExit() { + navigation.reset(); + }, + onKey(key) { + return navigation.handleKey(key); + }, + }); + + hunk.registerCommand( + { id: "command-line", title: "Open Vim command line", key: ":" }, + async (ctx) => { + if (!ctx.keyboardModes.isActive("normal")) { + ctx.notify("Enter Vim navigation before opening its command line", "info"); + return; + } + + const input = await ctx.dialogs.input({ + title: "Vim command (:)", + placeholder: "top or bottom", + }); + if (input === null || !ctx.keyboardModes.isActive("normal")) return; + + const result = executeVimCommand(input, ctx.commands); + if (result === "unknown") { + ctx.notify(`Unknown Vim command "${input.trim()}"`, "warning"); + } + }, + ); + + hunk.registerCommand({ id: "toggle", title: "Toggle Vim navigation", key: "f6" }, (ctx) => { + if (ctx.keyboardModes.isActive("normal")) { + ctx.keyboardModes.exitMode(); + return; + } + + ctx.keyboardModes.enterMode("normal"); + }); +} diff --git a/examples/extensions/vim-navigation/package.json b/examples/extensions/vim-navigation/package.json new file mode 100644 index 00000000..2b192853 --- /dev/null +++ b/examples/extensions/vim-navigation/package.json @@ -0,0 +1,9 @@ +{ + "name": "hunk-vim-navigation-extension", + "private": true, + "hunk": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/examples/extensions/vim-navigation/state.ts b/examples/extensions/vim-navigation/state.ts new file mode 100644 index 00000000..ac3b9114 --- /dev/null +++ b/examples/extensions/vim-navigation/state.ts @@ -0,0 +1,154 @@ +export interface VimNavigationKey { + name?: string; + sequence?: string; + ctrl?: boolean; + meta?: boolean; + option?: boolean; + shift?: boolean; +} + +export type VimNavigationResult = "handled" | "pass"; +export type VimCommandResult = "handled" | "empty" | "unknown"; + +export interface VimNavigationCommands { + execute(commandId: string, options?: { count?: number }): boolean; +} + +const MAX_COUNT = 10_000; + +const RELATIVE_COMMANDS: Readonly> = { + j: "hunk.review.stepDown", + k: "hunk.review.stepUp", + "[": "hunk.review.previousHunk", + "]": "hunk.review.nextHunk", +}; + +const ALIGNMENT_COMMANDS: Readonly> = { + t: "hunk.review.alignCurrentLineTop", + z: "hunk.review.alignCurrentLineCenter", + b: "hunk.review.alignCurrentLineBottom", +}; + +const CONTROL_COMMANDS: Readonly> = { + d: "hunk.review.halfPageDown", + u: "hunk.review.halfPageUp", +}; + +/** Execute one small Ex-style command using only public semantic commands. */ +export function executeVimCommand( + input: string, + commands: VimNavigationCommands, +): VimCommandResult { + const command = input.trim().replace(/^:/, "").trim(); + if (command.length === 0) return "empty"; + + if (command === "top") { + commands.execute("hunk.review.jumpToTop"); + return "handled"; + } + if (command === "bottom") { + commands.execute("hunk.review.jumpToBottom"); + return "handled"; + } + return "unknown"; +} + +/** Build the small Vim-normal grammar independently from Hunk's key router. */ +export function createVimNavigationState(commands: VimNavigationCommands) { + let countText = ""; + let prefix: "g" | "z" | null = null; + + /** Clear every partially entered sequence. */ + const reset = () => { + countText = ""; + prefix = null; + }; + + /** Resolve the positive count, already saturated to the host's public maximum. */ + const count = () => (countText.length > 0 ? Number(countText) : 1); + + /** Execute one relative semantic command atomically. */ + const executeRelative = (commandId: string) => { + const magnitude = count(); + reset(); + commands.execute(commandId, { count: magnitude }); + return "handled" as const; + }; + + /** Interpret one key and either claim it or return it to Hunk unchanged. */ + const handleKey = (key: VimNavigationKey): VimNavigationResult => { + const text = key.sequence || key.name || ""; + const shiftedG = text === "G" || (key.name === "g" && key.shift === true); + + if (key.ctrl) { + const commandId = + !key.meta && !key.option && !key.shift + ? CONTROL_COMMANDS[key.name || key.sequence || ""] + : undefined; + if (commandId) return executeRelative(commandId); + reset(); + return "pass"; + } + if (key.meta || key.option) { + reset(); + return "pass"; + } + + if (/^[0-9]$/.test(text)) { + // A bare zero is a Hunk layout command, not a positive Vim count. + if (text === "0" && countText.length === 0) { + reset(); + return "pass"; + } + if (prefix !== null) { + reset(); + return "pass"; + } + + const next = Math.min(MAX_COUNT, Number(`${countText}${text}`)); + countText = String(next); + return "handled"; + } + + if (prefix === "g") { + const matches = text === "g" && !shiftedG; + reset(); + if (!matches) return "pass"; + commands.execute("hunk.review.jumpToTop"); + return "handled"; + } + + if (prefix === "z") { + const commandId = ALIGNMENT_COMMANDS[text]; + reset(); + if (!commandId) return "pass"; + commands.execute(commandId); + return "handled"; + } + + // The registered `:` command opens a host dialog after this mode passes the key onward. + if (text === ":") { + reset(); + return "pass"; + } + + const relative = RELATIVE_COMMANDS[text]; + if (relative) return executeRelative(relative); + + if (shiftedG) { + reset(); + commands.execute("hunk.review.jumpToBottom"); + return "handled"; + } + + if (text === "g" || text === "z") { + prefix = text; + return "handled"; + } + + reset(); + return "pass"; + }; + + return { handleKey, reset }; +} diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index cdd0ba63..2a64192a 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -28,6 +28,8 @@ import type { ExtensionFileViewRow, ExtensionFileViewRowComponentProps, ExtensionFileViewSourceRange, + ExtensionKeyboardModeControls, + ExtensionKeyboardModeKeyResult, ExtensionPaintTheme, ExtensionReviewSelection, ExtensionVcsAdapter, @@ -99,8 +101,18 @@ export default function (hunk: HunkExtensionAPI) { }; }, }); + hunk.registerKeyboardMode({ + id: "review-keys", + title: "Review keys", + onKey(key, ctx): ExtensionKeyboardModeKeyResult { + if (key.name !== "j") return "pass"; + ctx.commands.execute("hunk.review.stepDown"); + return "handled"; + }, + }); hunk.registerCommand({ id: "raw-view", title: "Raw view" }, (ctx) => { const commandControls: ExtensionCommandControls = ctx.commands; + const modeControls: ExtensionKeyboardModeControls = ctx.keyboardModes; const execution: ExtensionCommandExecutionOptions = { count: 2 }; if (commandControls.isEnabled("hunk.review.nextHunk")) { const executed: boolean = commandControls.execute("hunk.review.nextHunk", execution); @@ -116,6 +128,10 @@ export default function (hunk: HunkExtensionAPI) { hunk.log(entered ? "mode running" : "mode refused"); } ctx.fileViews.exitMode(); + if (!modeControls.isActive("review-keys")) { + modeControls.enterMode("review-keys"); + } + modeControls.exitMode(); }); hunk.registerCommand({ id: "rewrite", title: "Rewrite the selection" }, async (ctx) => { diff --git a/scripts/vim-navigation-extension.test.ts b/scripts/vim-navigation-extension.test.ts new file mode 100644 index 00000000..ae85f0e3 --- /dev/null +++ b/scripts/vim-navigation-extension.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { + createVimNavigationState, + executeVimCommand, +} from "../examples/extensions/vim-navigation/state"; + +/** Record semantic executions made by the example grammar. */ +function recordingState() { + const calls: Array<{ id: string; options?: { count?: number } }> = []; + const commands = { + execute(id: string, options?: { count?: number }) { + calls.push({ id, options }); + return true; + }, + }; + return { calls, commands, state: createVimNavigationState(commands) }; +} + +const MAPPINGS = [ + { keys: "j", id: "hunk.review.stepDown", options: { count: 1 } }, + { keys: "k", id: "hunk.review.stepUp", options: { count: 1 } }, + { keys: "[", id: "hunk.review.previousHunk", options: { count: 1 } }, + { keys: "]", id: "hunk.review.nextHunk", options: { count: 1 } }, + { keys: "gg", id: "hunk.review.jumpToTop", options: undefined }, + { keys: "G", id: "hunk.review.jumpToBottom", options: undefined }, + { keys: "zt", id: "hunk.review.alignCurrentLineTop", options: undefined }, + { keys: "zz", id: "hunk.review.alignCurrentLineCenter", options: undefined }, + { keys: "zb", id: "hunk.review.alignCurrentLineBottom", options: undefined }, +] as const; + +describe("vim navigation example state", () => { + for (const mapping of MAPPINGS) { + test(`maps ${mapping.keys} to one ${mapping.id} execution`, () => { + const { calls, state } = recordingState(); + + for (const key of mapping.keys) { + expect(state.handleKey({ sequence: key })).toBe("handled"); + } + + expect(calls).toEqual([{ id: mapping.id, options: mapping.options }]); + }); + } + + test("maps counted Ctrl-D and Ctrl-U through public half-page commands", () => { + const { calls, state } = recordingState(); + + expect(state.handleKey({ sequence: "3" })).toBe("handled"); + expect(state.handleKey({ ctrl: true, name: "d" })).toBe("handled"); + expect(state.handleKey({ ctrl: true, name: "u" })).toBe("handled"); + + expect(calls).toEqual([ + { id: "hunk.review.halfPageDown", options: { count: 3 } }, + { id: "hunk.review.halfPageUp", options: { count: 1 } }, + ]); + }); + + test("resolves a numeric relative motion into one atomic command call", () => { + const { calls, state } = recordingState(); + + expect(state.handleKey({ sequence: "3" })).toBe("handled"); + expect(state.handleKey({ sequence: "0" })).toBe("handled"); + expect(state.handleKey({ sequence: "]" })).toBe("handled"); + + expect(calls).toEqual([{ id: "hunk.review.nextHunk", options: { count: 30 } }]); + }); + + test("caps long counts at the host maximum", () => { + const { calls, state } = recordingState(); + for (const digit of "999999999") state.handleKey({ sequence: digit }); + state.handleKey({ sequence: "j" }); + + expect(calls).toEqual([{ id: "hunk.review.stepDown", options: { count: 10_000 } }]); + }); + + test("passes colon to the registered command line and ignores unsupported modifiers", () => { + const { calls, state } = recordingState(); + + expect(state.handleKey({ sequence: "4" })).toBe("handled"); + expect(state.handleKey({ sequence: ":" })).toBe("pass"); + expect(state.handleKey({ meta: true, name: "j" })).toBe("pass"); + expect(state.handleKey({ option: true, name: "k" })).toBe("pass"); + expect(state.handleKey({ ctrl: true, option: true, name: "d" })).toBe("pass"); + expect(state.handleKey({ ctrl: true, shift: true, name: "u" })).toBe("pass"); + expect(state.handleKey({ sequence: "j" })).toBe("handled"); + + expect(calls).toEqual([{ id: "hunk.review.stepDown", options: { count: 1 } }]); + }); + + test("executes the illustrative Ex commands and reports unsupported input", () => { + const { calls, commands } = recordingState(); + + expect(executeVimCommand("top", commands)).toBe("handled"); + expect(executeVimCommand(":bottom", commands)).toBe("handled"); + expect(executeVimCommand(" ", commands)).toBe("empty"); + expect(executeVimCommand("100", commands)).toBe("unknown"); + + expect(calls).toEqual([ + { id: "hunk.review.jumpToTop", options: undefined }, + { id: "hunk.review.jumpToBottom", options: undefined }, + ]); + }); + + test("passes a bare zero and clears counts and invalid pending sequences", () => { + const { calls, state } = recordingState(); + + expect(state.handleKey({ sequence: "0" })).toBe("pass"); + expect(state.handleKey({ sequence: "4" })).toBe("handled"); + expect(state.handleKey({ sequence: "x" })).toBe("pass"); + expect(state.handleKey({ sequence: "j" })).toBe("handled"); + expect(state.handleKey({ sequence: "z" })).toBe("handled"); + expect(state.handleKey({ sequence: "x" })).toBe("pass"); + expect(state.handleKey({ sequence: "k" })).toBe("handled"); + + expect(calls).toEqual([ + { id: "hunk.review.stepDown", options: { count: 1 } }, + { id: "hunk.review.stepUp", options: { count: 1 } }, + ]); + }); + + test("reset clears a pending count and sequence", () => { + const { calls, state } = recordingState(); + + state.handleKey({ sequence: "8" }); + state.handleKey({ sequence: "g" }); + state.reset(); + state.handleKey({ sequence: "j" }); + + expect(calls).toEqual([{ id: "hunk.review.stepDown", options: { count: 1 } }]); + }); +}); diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 8731a90d..378f9f37 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -75,8 +75,8 @@ dependency-free. The **id** is the file stem, or the folder name for a folder extension — unless its manifest declares several entries, in which case each entry is its own extension named by its own stem (numeric suffix on collision). The id is the -namespace it owns: commands are `.`, sidebar views -`:`, config `[extension.]`. Ids match +namespace it owns: commands are `.`, sidebar views and keyboard +modes are `:`, config `[extension.]`. Ids match `/^[A-Za-z0-9][A-Za-z0-9_-]*$/`; `hunk`, `git`, `jj`, and `sl` are reserved. A bad or duplicate id is skipped with a startup notice. @@ -89,12 +89,13 @@ bad or duplicate id is skipped with a startup notice. | Support another VCS (`git`/`jj`/`sl` are reserved) | `hunk.registerVcsAdapter(adapter)` | | Add a navigation/list/status pane beside the review | `hunk.registerSidebarView(view)` | | 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 `3`) | `hunk.apiVersion` | +| Branch on the API generation (currently `4`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. @@ -110,7 +111,8 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's - **Command handlers** get `ctx.sidebars`, `ctx.fileViews` (select/toggle/isActive/ refresh/enterMode/exitMode), `ctx.selection` (a snapshot of file + hunk index), `ctx.navigation` (live, guarded `selectFile`/`selectHunk`), `ctx.commands` - (`isEnabled`/`execute` for public semantic `hunk.*` commands), `ctx.dialogs` + (`isEnabled`/`execute` for public semantic `hunk.*` commands), + `ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed), and `ctx.workspace` (`readDocument`, `canWriteDocument`, `writeDocument` with consent). - **Sidebar components** get props: `files` (frozen, filtered, review order, each @@ -120,10 +122,18 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's (`selectFile`, `selectHunk`, `notify`). - **File-view `layout`** gets `file`, `width`, `signal`, `changes`, and a lazy `readDocument(side)`. -- **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey` must - answer **synchronously** — its return value (`"handled"`/`"pass"`/`"exit"`) is - the routing decision, so kick off async work and report it later through - `notify` or `refresh`. Escape is host-owned and never reaches `onKey`. +- **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey`, + `onEnter`, and `onExit` must answer **synchronously** — `onKey`'s return value + (`"handled"`/`"pass"`/`"exit"`) is the routing decision, so kick off async work + and report it later through `notify` or `refresh`. A passed key reaches any + active session keyboard mode before ordinary Hunk routing. Escape is host-owned + and never reaches `onKey`. +- **Session keyboard-mode handlers** get only `ctx.commands` and activation-scoped + `ctx.keyboardModes` beyond the standard context. Those controls become inert on + exit, and lifecycle callbacks cannot change keyboard ownership. Keys are frozen + snapshots; dialogs, focused inputs, and file-view modes outrank them. When the + session mode owns input, Escape exits it; the status badge and Extensions menu + are unconditional host-owned exits. Event payloads, sidebar props, and a command's selection all hand you frozen `ExtensionDiffFile` / `ExtensionDiffHunk` views. A changeset transform is the @@ -165,6 +175,11 @@ Most extension bugs are one of these: - **Chords are defaults.** Users remap by command id in `[keybindings]`; built-ins win conflicts, refused one chord at a time. Bind the character shift produces (`"!"`, not `"shift+1"`). +- **Keyboard modes are grammar, not behavior.** Keep pending sequences and + numeric prefixes in the extension, then call one public `ctx.commands.execute` + after resolving an action. `vim-navigation` demonstrates counts, Ctrl chords, + and a `:` key passed to a registered command whose host input dialog temporarily + outranks the still-active mode. - **`ctx.commands` invokes Hunk, not other extensions.** Probe with `isEnabled("hunk.review.nextHunk")`, then call `execute(id, { count })` for an explicitly public built-in. Counts are positive whole numbers up to 10,000, diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index b9a94424..42363206 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -56,6 +56,10 @@ export type { ExtensionFileViewSourceRange, ExtensionFileViewSpan, ExtensionKeyEvent, + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, + ExtensionKeyboardModeControls, + ExtensionKeyboardModeKeyResult, ExtensionCustomEventHandler, ExtensionEventBus, ExtensionEventContext, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 148a1fce..70903f98 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 3; +export const HUNK_EXTENSION_API_VERSION = 4; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -232,6 +232,47 @@ export interface ExtensionKeyEvent { shift?: boolean; } +/* -------------------------------------------------------------------------- */ +/* Session keyboard modes */ +/* -------------------------------------------------------------------------- */ + +/** What a session keyboard mode did with one key. */ +export type ExtensionKeyboardModeKeyResult = "handled" | "pass" | "exit"; + +/** Renderer-free capabilities available while a session keyboard mode runs. */ +export interface ExtensionKeyboardModeContext extends ExtensionContext { + /** Live access to explicitly public built-in Hunk commands. */ + readonly commands: ExtensionCommandControls; + /** + * Controls scoped to this extension and activation. + * + * They become inert when the activation exits, so retained callbacks cannot inspect, stop, or + * replace a later mode. A deliberate replacement may be entered while `onKey` is running; + * ownership changes return `false` while `onEnter` or `onExit` is running. + */ + readonly keyboardModes: ExtensionKeyboardModeControls; +} + +/** + * One deliberately activated, session-scoped keyboard interpretation. + * + * Modes receive keys after host modal/focused surfaces and interactive file + * views, but before ordinary app commands. They are synchronous because their + * return value decides ownership of the current terminal key. + */ +export interface ExtensionKeyboardMode { + /** Identifies the mode within its extension; `:` globally. */ + id: string; + /** Human-readable label shown while the mode is active. */ + title: string; + /** Decide whether to consume, pass, or consume-and-exit for one key. */ + onKey(key: ExtensionKeyEvent, ctx: ExtensionKeyboardModeContext): ExtensionKeyboardModeKeyResult; + /** Runs once before the first key reaches the mode. Must return synchronously; cannot change ownership. */ + onEnter?(ctx: ExtensionKeyboardModeContext): void; + /** Runs exactly once on every exit path. Must return synchronously; cannot change ownership. */ + onExit?(ctx: ExtensionKeyboardModeContext): void; +} + /* -------------------------------------------------------------------------- */ /* File views */ /* -------------------------------------------------------------------------- */ @@ -350,31 +391,31 @@ export interface ExtensionFileViewModeContext extends ExtensionContext { * `fileViews.enterMode`, never on its own — during which keys reach `onKey` * before Hunk's command table. Modes are session-scoped: nothing persists. * - * Only one mode is active at a time, app-wide, and the host guarantees a way - * out: Escape always exits, and every exit path runs `onExit` exactly once. + * Only one file-view mode is active at a time. When it is the highest-priority + * input owner, Escape exits it; every exit path runs `onExit` exactly once. */ export interface ExtensionFileViewMode { /** * Decide what happens to one key, synchronously. * * The return value *is* the routing decision, so it cannot be awaited: - * `"handled"` consumes the key, `"pass"` declines it (the key then flows on - * to the command table and scrolling exactly as if no mode were active), and - * `"exit"` consumes the key and leaves the mode. Start async work here and + * `"handled"` consumes the key, `"pass"` declines it (routing then continues + * through any active session keyboard mode, the command table, and focused + * scrolling), and `"exit"` consumes the key and leaves the mode. Start async work here and * report it afterwards through `ctx.notify` or `ctx.fileViews.refresh`. * * Every key the app's modal surfaces do not claim arrives — including plain * printable characters, which would otherwise run whatever command is bound - * to them. Escape is the one exception: it is host-owned and exits the mode - * without ever reaching this handler. + * to them. When this mode owns input, Escape is the one exception: it is + * host-owned and exits the mode without ever reaching this handler. * * A throw is contained: Hunk warns naming the extension, exits the mode, and * the review keeps working. */ onKey(key: ExtensionKeyEvent, ctx: ExtensionFileViewModeContext): ExtensionFileViewModeKeyResult; - /** Runs once when the mode is entered, before any key reaches `onKey`. */ + /** Runs once when the mode is entered, before any key reaches `onKey`. Must return synchronously. */ onEnter?(ctx: ExtensionFileViewModeContext): void; - /** Runs on every exit — key result, Escape, host auto-exit, or a contained throw. */ + /** Runs synchronously on every exit — key result, Escape, host auto-exit, or a contained throw. */ onExit?(ctx: ExtensionFileViewModeContext): void; } @@ -1020,6 +1061,19 @@ export interface ExtensionCommandControls { execute(commandId: string, options?: ExtensionCommandExecutionOptions): boolean; } +/** + * Enter, leave, and inspect this extension's registered session keyboard modes. + * Ownership-changing calls return `false` during `onEnter` and `onExit`. + */ +export interface ExtensionKeyboardModeControls { + /** Enter one owned mode from a command or `onKey`, replacing the active session mode. */ + enterMode(modeId: string): boolean; + /** Leave this extension's active mode from a command or `onKey`. */ + exitMode(): boolean; + /** Report whether this extension owns the active mode, optionally requiring one local id. */ + isActive(modeId?: string): boolean; +} + /** Open, close, and inspect sidebar views from a command handler. */ export interface ExtensionSidebarControls { /** @@ -1091,8 +1145,9 @@ export interface ExtensionFileViewControls { * resolves, and a layout that declines or fails falls back to raw diff. * * While the mode is active, keys the app's modal surfaces do not claim reach - * `onKey` before Hunk's command table. Escape is host-owned: it exits the - * mode and never reaches the handler, so there is always a way out. + * `onKey` before Hunk's command table. When the mode is the highest-priority + * input owner, Escape is host-owned: it exits the mode and never reaches the + * handler, so there is always a way out. * * Hunk also exits the mode by itself when the review moves out from under it * — the selected file changes, the view stops being that file's presentation @@ -1109,8 +1164,8 @@ export interface ExtensionFileViewControls { /** * Leave the active mode, whichever view owns it. * - * Global rather than per-view, because only one mode is active at a time, - * and idempotent: calling it with no mode active does nothing. + * Global across file views because only one file-view mode is active at a + * time, and idempotent: calling it with no file-view mode active does nothing. */ exitMode(): void; /** Report whether this view's mode is the one currently active. */ @@ -1332,6 +1387,8 @@ export interface ExtensionWorkspace { export interface ExtensionCommandContext extends ExtensionContext { /** Live access to the public built-in command table. */ readonly commands: ExtensionCommandControls; + /** Session keyboard modes registered by this command's owning extension. */ + readonly keyboardModes: ExtensionKeyboardModeControls; sidebars: ExtensionSidebarControls; /** Host-owned selection controls for alternate file presentations. */ fileViews: ExtensionFileViewControls; @@ -1492,6 +1549,13 @@ export interface HunkExtensionAPI { * row component contract may paint React/OpenTUI content inside clipped host geometry. */ registerFileView(view: ExtensionFileView): void; + /** + * Register one session-scoped keyboard interpretation. + * + * Registration alone changes nothing; a command deliberately enters it + * through `ctx.keyboardModes.enterMode()`. + */ + registerKeyboardMode(mode: ExtensionKeyboardMode): void; /** * Register one named command, optionally bound to a key, * diff --git a/src/extensions/apply.test.ts b/src/extensions/apply.test.ts index cab8c482..dd11ba1d 100644 --- a/src/extensions/apply.test.ts +++ b/src/extensions/apply.test.ts @@ -18,6 +18,7 @@ import { resolveDetectedVcsIdWithExtensions, resolveExtensionCommands, resolveExtensionFileViews, + resolveExtensionKeyboardModes, resolveExtensionSidebarViews, resolveExtensionVcsAdapters, resolveSessionVcsId, @@ -195,6 +196,31 @@ describe("extension file views", () => { }); }); +describe("extension keyboard modes", () => { + test("keeps the first duplicate qualified identity", () => { + const result = createEmptyExtensionLoadResult(); + const normal = { id: "normal", title: "Normal", onKey: () => "handled" as const }; + result.registry.keyboardModes.push( + { extensionId: "vim", mode: normal }, + { extensionId: "other", mode: normal }, + { extensionId: "vim", mode: { ...normal, title: "Later" } }, + ); + + const { modes, issues } = resolveExtensionKeyboardModes(result.registry); + + expect(modes.map((entry) => `${entry.extensionId}:${entry.mode.id}`)).toEqual([ + "vim:normal", + "other:normal", + ]); + expect(issues).toEqual([ + { + extensionId: "vim", + message: 'Skipped duplicate keyboard mode "vim:normal" from extension vim', + }, + ]); + }); +}); + describe("extension commands", () => { test("keeps every distinct command and reports duplicate ids", () => { const result = createEmptyExtensionLoadResult(); diff --git a/src/extensions/apply.ts b/src/extensions/apply.ts index 19214110..8e12ef36 100644 --- a/src/extensions/apply.ts +++ b/src/extensions/apply.ts @@ -10,6 +10,7 @@ import type { ExtensionRegistry, RegisteredCommand, RegisteredFileView, + RegisteredKeyboardMode, RegisteredSidebarView, } from "./types"; @@ -198,6 +199,42 @@ export function resolveExtensionFileViews(registry: ExtensionRegistry): Resolved return { views, issues }; } +/** Derive the key one session keyboard mode is addressed by everywhere in the app. */ +export function keyboardModeKey(registered: RegisteredKeyboardMode) { + return qualifiedViewKey(registered.extensionId, registered.mode.id); +} + +/** The session keyboard modes one session offers, plus duplicate diagnostics. */ +export interface ResolvedExtensionKeyboardModes { + modes: RegisteredKeyboardMode[]; + issues: ExtensionApplyIssue[]; +} + +/** Resolve session keyboard-mode identities with first registration winning. */ +export function resolveExtensionKeyboardModes( + registry: ExtensionRegistry, +): ResolvedExtensionKeyboardModes { + const modes: RegisteredKeyboardMode[] = []; + const issues: ExtensionApplyIssue[] = []; + const claimed = new Set(); + + for (const registered of registry.keyboardModes) { + const key = keyboardModeKey(registered); + if (claimed.has(key)) { + issues.push({ + extensionId: registered.extensionId, + message: `Skipped duplicate keyboard mode "${key}" from extension ${registered.extensionId}`, + }); + continue; + } + + claimed.add(key); + modes.push(registered); + } + + return { modes, issues }; +} + /** The commands one session offers, plus the registrations skipped as duplicates. */ export interface ResolvedExtensionCommands { commands: RegisteredCommand[]; @@ -263,6 +300,7 @@ export function applyExtensionRegistrations( // other refusal. const sidebars = resolveExtensionSidebarViews(result.registry); const fileViews = resolveExtensionFileViews(result.registry); + const keyboardModes = resolveExtensionKeyboardModes(result.registry); const commands = resolveExtensionCommands(result.registry); return { vcsAdapters: vcs.adapters, @@ -271,6 +309,7 @@ export function applyExtensionRegistrations( ...vcs.issues, ...sidebars.issues, ...fileViews.issues, + ...keyboardModes.issues, ...commands.issues, ], }; diff --git a/src/extensions/index.ts b/src/extensions/index.ts index fa2c26a9..8d26882f 100644 --- a/src/extensions/index.ts +++ b/src/extensions/index.ts @@ -6,12 +6,14 @@ export { reportExtensionApplyIssues, resolveDetectedVcsIdWithExtensions, resolveExtensionCommands, + resolveExtensionKeyboardModes, resolveExtensionSidebarViews, resolveExtensionVcsAdapters, sidebarViewKey, type AppliedExtensionRegistrations, type ExtensionApplyIssue, type ResolvedExtensionCommands, + type ResolvedExtensionKeyboardModes, type ResolvedExtensionSidebarViews, } from "./apply"; export { @@ -68,6 +70,10 @@ export type { ExtensionEventName, ExtensionEventPayloads, ExtensionFactory, + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, + ExtensionKeyboardModeControls, + ExtensionKeyboardModeKeyResult, ExtensionLoadIssue, ExtensionLoadResult, ExtensionLogEntry, @@ -83,6 +89,7 @@ export type { RegisteredCommand, RegisteredEventHandler, RegisteredFileLanguage, + RegisteredKeyboardMode, RegisteredSidebarView, RegisteredTheme, RegisteredVcsAdapter, diff --git a/src/extensions/runExtension.test.ts b/src/extensions/runExtension.test.ts index d5328cad..9213cbab 100644 --- a/src/extensions/runExtension.test.ts +++ b/src/extensions/runExtension.test.ts @@ -257,6 +257,70 @@ describe("registerFileView", () => { }); }); +describe("registerKeyboardMode", () => { + test("collects a valid mode under its owning extension", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + const mode = { id: "normal", title: "Vim normal", onKey: () => "handled" as const }; + + runExtensionFactory({ + metadata: bundledMetadata("vim"), + registry, + issues, + factory: (hunk) => hunk.registerKeyboardMode(mode), + }); + + expect(issues).toEqual([]); + expect(registry.keyboardModes).toEqual([{ extensionId: "vim", mode }]); + }); + + test("validates the complete synchronous callback shape", () => { + const cases = [ + [{ title: "Missing id", onKey: () => "handled" }, "non-empty id"], + [{ id: "normal", onKey: () => "handled" }, "non-empty title"], + [{ id: "normal", title: "Normal" }, "onKey() function"], + [ + { id: "normal", title: "Normal", onKey: () => "handled", onEnter: true }, + "onEnter must be a function", + ], + [ + { id: "normal", title: "Normal", onKey: () => "handled", onExit: true }, + "onExit must be a function", + ], + ] as const; + + for (const [candidate, expected] of cases) { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("broken-mode"), + registry, + issues, + factory: (hunk) => hunk.registerKeyboardMode(candidate as never), + }); + expect(registry.keyboardModes).toEqual([]); + expect(issues[0]?.message).toContain(expected); + } + }); + + test("rolls a registered mode back when its factory later throws", () => { + const registry = createEmptyExtensionRegistry(); + const issues: ExtensionLoadIssue[] = []; + runExtensionFactory({ + metadata: bundledMetadata("half-mode"), + registry, + issues, + factory: (hunk) => { + hunk.registerKeyboardMode({ id: "normal", title: "Normal", onKey: () => "pass" }); + throw new Error("after mode"); + }, + }); + + expect(registry.keyboardModes).toEqual([]); + expect(issues[0]?.message).toBe("after mode"); + }); +}); + describe("hunk.events", () => { test("registers a bus listener under its owning extension", () => { const registry = createEmptyExtensionRegistry(); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 3a20c7be..5ede4faf 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -13,6 +13,7 @@ import { type ExtensionRegistry, type ExtensionSidebarView, type ExtensionFileView, + type ExtensionKeyboardMode, type ExtensionThemeConfig, type ExtensionVcsAdapter, type HunkExtensionAPI, @@ -218,6 +219,7 @@ interface RegistrySnapshot { changesetTransforms: number; sidebarViews: number; fileViews: number; + keyboardModes: number; commands: number; eventHandlers: Record; customEventHandlers: number; @@ -238,6 +240,7 @@ function snapshotRegistry(registry: ExtensionRegistry): RegistrySnapshot { changesetTransforms: registry.changesetTransforms.length, sidebarViews: registry.sidebarViews.length, fileViews: registry.fileViews.length, + keyboardModes: registry.keyboardModes.length, commands: registry.commands.length, eventHandlers, customEventHandlers: registry.customEventHandlers.length, @@ -258,6 +261,7 @@ function rollbackRegistry(registry: ExtensionRegistry, snapshot: RegistrySnapsho registry.changesetTransforms.length = snapshot.changesetTransforms; registry.sidebarViews.length = snapshot.sidebarViews; registry.fileViews.length = snapshot.fileViews; + registry.keyboardModes.length = snapshot.keyboardModes; registry.commands.length = snapshot.commands; registry.customEventHandlers.length = snapshot.customEventHandlers; registry.pendingCustomEvents.length = snapshot.pendingCustomEvents; @@ -384,6 +388,25 @@ export function createExtensionApi( registry.fileViews.push({ extensionId: metadata.id, view }); }, + registerKeyboardMode(mode: ExtensionKeyboardMode) { + assertOpen("registerKeyboardMode"); + assertNonEmptyString(mode?.id, "registerKeyboardMode requires a mode with a non-empty id."); + assertNonEmptyString( + mode?.title, + "registerKeyboardMode requires a mode with a non-empty title.", + ); + if (typeof mode.onKey !== "function") { + throw new Error("registerKeyboardMode requires an onKey() function."); + } + if (mode.onEnter !== undefined && typeof mode.onEnter !== "function") { + throw new Error("registerKeyboardMode onEnter must be a function when provided."); + } + if (mode.onExit !== undefined && typeof mode.onExit !== "function") { + throw new Error("registerKeyboardMode onExit must be a function when provided."); + } + + registry.keyboardModes.push({ extensionId: metadata.id, mode }); + }, registerCommand(command: ExtensionCommand, handler: ExtensionCommandHandler) { assertOpen("registerCommand"); assertNonEmptyString(command?.id, "registerCommand requires a command with a non-empty id."); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index dfecf5f6..dca65350 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -9,6 +9,7 @@ import type { ExtensionEventHandler, ExtensionEventName, ExtensionFileView, + ExtensionKeyboardMode, ExtensionNotifyType, ExtensionSidebarView, ExtensionThemeConfig, @@ -56,6 +57,10 @@ export type { ExtensionFactory, ExtensionInputOptions, ExtensionKeyEvent, + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, + ExtensionKeyboardModeControls, + ExtensionKeyboardModeKeyResult, ExtensionReviewNote, ExtensionNotifyType, ExtensionPaintTheme, @@ -137,6 +142,12 @@ export interface RegisteredFileView { view: ExtensionFileView; } +/** One session-scoped keyboard mode registered by an extension. */ +export interface RegisteredKeyboardMode { + extensionId: string; + mode: ExtensionKeyboardMode; +} + export interface RegisteredCommand { extensionId: string; command: ExtensionCommand; @@ -180,6 +191,7 @@ export interface ExtensionRegistry { changesetTransforms: RegisteredChangesetTransform[]; sidebarViews: RegisteredSidebarView[]; fileViews: RegisteredFileView[]; + keyboardModes: RegisteredKeyboardMode[]; commands: RegisteredCommand[]; eventHandlers: ExtensionEventHandlerMap; customEventHandlers: RegisteredCustomEventHandler[]; @@ -254,6 +266,7 @@ export function createEmptyExtensionRegistry(): ExtensionRegistry { changesetTransforms: [], sidebarViews: [], fileViews: [], + keyboardModes: [], commands: [], eventHandlers: { startup: [], diff --git a/src/ui/App.tsx b/src/ui/App.tsx index ea5664e1..e375416d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -33,7 +33,11 @@ import type { } from "../core/types"; import { canReloadInput } from "../core/inputReload"; import { sanitizeTerminalLine } from "../lib/terminalText"; -import { resolveExtensionCommands, resolveExtensionFileViews } from "../extensions/apply"; +import { + resolveExtensionCommands, + resolveExtensionFileViews, + resolveExtensionKeyboardModes, +} from "../extensions/apply"; import { emitExtensionCustomEvent, emitExtensionEvent, @@ -94,6 +98,7 @@ import type { LineCursor } from "./lib/lineCursors"; import { buildExtensionReviewSelection } from "./lib/extensionSelection"; import { useFilePresentationController } from "./fileViews/useFilePresentationController"; import { useFilePresentationRendering } from "./fileViews/useFilePresentationRendering"; +import { useKeyboardModeController } from "./keyboardModes/useKeyboardModeController"; import { createExtensionSidebarKeybindings, resolveCommandKeys } from "./lib/keymap"; import { buildSessionSidebarViews, @@ -372,6 +377,10 @@ export function App({ () => (extensions ? resolveExtensionFileViews(extensions.registry).views : []), [extensions], ); + const sessionKeyboardModes = useMemo( + () => (extensions ? resolveExtensionKeyboardModes(extensions.registry).modes : []), + [extensions], + ); // The one conversion of the visible review files into the frozen views every // extension surface sees: sidebar props and command-handler selection both // read from this list, so they can never describe the review differently. @@ -492,10 +501,25 @@ export function App({ sessionNoticeTimeoutRef.current = null; }, 4000); }, []); - const notifyFileViewMode = useCallback( + const notifyExtensionMode = useCallback( (message: string, type?: ExtensionNotifyType) => extensions?.context.notify(message, type), [extensions], ); + const { + activeModeTitle: keyboardModeTitle, + createControls: createKeyboardModeControls, + exitMode: exitKeyboardMode, + isModeActive: isKeyboardModeActive, + modeStatusHint: keyboardModeHint, + sendModeKey: sendKeyboardModeKey, + } = useKeyboardModeController({ + commands: extensionCommandControls, + cwd: extensions?.context.cwd ?? process.cwd(), + modes: sessionKeyboardModes, + notify: notifyExtensionMode, + registry: extensions?.registry, + showNotice: showSessionNotice, + }); const { applyBulkTarget: applyFilePresentationToAllMatching, @@ -519,7 +543,7 @@ export function App({ getExtensionSelection, showNotice: showSessionNotice, cwd: extensions?.context.cwd ?? process.cwd(), - notify: notifyFileViewMode, + notify: notifyExtensionMode, reviewGeneration: bootstrap, }); @@ -786,6 +810,7 @@ export function App({ const ctx: ExtensionCommandContext = { cwd: extensions?.context.cwd ?? process.cwd(), commands: extensionCommandControls, + keyboardModes: createKeyboardModeControls(registered.extensionId, extensions?.registry), notify: (message, type) => extensions?.context.notify(message, type), sidebars: createSidebarControls(registered.extensionId), fileViews: createFileViewControls(registered.extensionId), @@ -833,6 +858,7 @@ export function App({ [ createExtensionDialogs, createFileViewControls, + createKeyboardModeControls, createSidebarControls, extensionCommandControls, createWorkspaceControls, @@ -1771,6 +1797,14 @@ export function App({ fileViewApplyAllLabel: selectedFileViewBulkTarget ? `Apply “${selectedFileViewBulkTarget.title}” to all matching files` : undefined, + keyboardModeExitEntry: keyboardModeTitle + ? { + kind: "item", + label: `Exit ${keyboardModeTitle}`, + commandId: "hunk.extensions.exitKeyboardMode", + action: exitKeyboardMode, + } + : undefined, copyDecorations, layoutMode, renderSidebar, @@ -1819,6 +1853,9 @@ export function App({ isFileViewModeActive, exitFileViewMode, sendFileViewModeKey, + isKeyboardModeActive, + exitKeyboardMode, + sendKeyboardModeKey, focusArea, moveMenuItem, moveThemeSelector, @@ -2121,10 +2158,17 @@ export function App({ {focusArea === "filter" || Boolean(review.filter) || - Boolean(sessionNoticeText ?? transientNoticeText ?? noticeText ?? fileViewModeHint) ? ( + Boolean( + sessionNoticeText ?? + transientNoticeText ?? + noticeText ?? + fileViewModeHint ?? + keyboardModeHint, + ) ? ( ) : null} diff --git a/src/ui/AppHost.keyboard-modes.test.tsx b/src/ui/AppHost.keyboard-modes.test.tsx new file mode 100644 index 00000000..a918f9a4 --- /dev/null +++ b/src/ui/AppHost.keyboard-modes.test.tsx @@ -0,0 +1,279 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { KeyEvent, type ParsedKey } from "@opentui/core"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { loadStartupExtensions } from "../extensions/startup"; +import { AppHost } from "./AppHost"; + +const tempDirs: string[] = []; +setDefaultTimeout(20_000); + +afterEach(() => { + for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +/** Build a real extension using both session and file-view keyboard modes. */ +function createKeyboardModeExtension() { + const root = mkdtempSync(join(tmpdir(), "hunk-keyboard-mode-")); + tempDirs.push(root); + const extension = join(root, "keyboard-probe"); + mkdirSync(extension, { recursive: true }); + writeFileSync( + join(extension, "package.json"), + JSON.stringify({ name: "keyboard-probe", private: true, hunk: { extensions: ["./index.ts"] } }), + ); + writeFileSync( + join(extension, "index.ts"), + `export default function (hunk) { + hunk.registerKeyboardMode({ + id: "normal", + title: "Probe normal", + onEnter: (ctx) => ctx.notify("SESSION ENTER"), + onExit: (ctx) => { + ctx.notify("SESSION EXIT"); + ctx.notify("SESSION REENTER " + ctx.keyboardModes.enterMode("normal")); + }, + onKey: (key, ctx) => { + const pressed = key.sequence || key.name; + ctx.notify("SESSION KEY " + pressed); + if (pressed === "j" || pressed === "?") return "handled"; + if (pressed === "x") return "exit"; + return "pass"; + }, + }); + hunk.registerFileView({ + id: "focused", + title: "Focused", + matches: () => true, + layout: ({ file }) => ({ + rows: [{ id: "focused", spans: [{ text: "FOCUSED VIEW" }] }], + hunkRows: (file.hunks ?? []).map(() => ({ startRow: 0, endRow: 0 })), + }), + mode: { + onEnter: (ctx) => ctx.notify("FILE ENTER"), + onExit: (ctx) => ctx.notify("FILE EXIT"), + onKey: (key, ctx) => { + const pressed = key.sequence || key.name; + ctx.notify("FILE KEY " + pressed); + return pressed === "?" ? "handled" : "pass"; + }, + }, + }); + hunk.registerCommand({ id: "session", title: "Toggle probe mode", key: "f8" }, (ctx) => { + if (ctx.keyboardModes.isActive("normal")) ctx.keyboardModes.exitMode(); + else ctx.keyboardModes.enterMode("normal"); + }); + hunk.registerCommand({ id: "file", title: "Enter focused view", key: "f9" }, (ctx) => + ctx.fileViews.enterMode("focused"), + ); + hunk.registerCommand({ id: "passed", title: "Passed command", key: "p" }, (ctx) => + ctx.notify("COMMAND P"), + ); +} +`, + ); + return { extension, root }; +} + +/** Boot AppHost with one real extension and capture its notices. */ +async function renderWithExtension() { + const { extension, root } = createKeyboardModeExtension(); + const extensions = await loadStartupExtensions({ + cliExtensionPaths: [extension], + cwd: root, + env: { XDG_CONFIG_HOME: root } as NodeJS.ProcessEnv, + extensions: { enabled: true, extensionConfigs: {}, paths: [], repoPaths: [] }, + }); + expect(extensions.issues).toEqual([]); + const notices: string[] = []; + const notify = extensions.context.notify; + extensions.context.notify = (message, type) => { + notices.push(String(message)); + notify(message, type); + }; + const bootstrap = createTestVcsAppBootstrap({ + changesetId: "keyboard-mode", + files: [createTestDiffFile({ id: "alpha", path: "alpha.ts" })], + initialMode: "stack", + inputMode: "stack", + }); + bootstrap.extensions = extensions; + const setup = await testRender( {}} />, { + width: 120, + height: 24, + }); + return { bootstrap, extensions, notices, setup }; +} + +/** Render until one frame satisfies a predicate. */ +async function waitForFrame( + setup: Awaited>, + predicate: (frame: string) => boolean, +) { + for (let attempt = 0; attempt < 80; attempt += 1) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(10); + }); + const frame = setup.captureCharFrame(); + if (predicate(frame)) return frame; + } + throw new Error(`Timed out waiting for frame:\n${setup.captureCharFrame()}`); +} + +/** Publish one key synchronously, used for same-input-flush coverage. */ +function testKeyEvent(fields: Partial) { + return new KeyEvent({ + name: "", + sequence: "", + raw: "", + ctrl: false, + meta: false, + option: false, + shift: false, + number: false, + eventType: "press", + source: "raw", + ...fields, + }); +} + +describe("AppHost session keyboard modes", () => { + test("routes handled/pass keys and keeps modal and focused input precedence", async () => { + const { notices, setup } = await renderWithExtension(); + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await waitForFrame(setup, (frame) => frame.includes("Probe normal")); + + await act(async () => setup.mockInput.typeText("j")); + expect(notices).toContain("SESSION KEY j"); + await act(async () => setup.mockInput.typeText("p")); + expect(notices).toContain("SESSION KEY p"); + expect(notices).toContain("COMMAND P"); + + // Menus outrank the session mode and remain a host-owned escape path. + await act(async () => setup.mockInput.pressKey("F10")); + await waitForFrame(setup, (frame) => frame.includes("Reload")); + expect(notices).not.toContain("SESSION KEY f10"); + await act(async () => setup.mockInput.pressKey("F10")); + await waitForFrame(setup, (frame) => !frame.includes("Reload")); + expect(setup.captureCharFrame()).toContain("Probe normal"); + + // The mode passes `/`; once the filter owns focus, its text never reaches the mode. + await act(async () => setup.mockInput.typeText("/")); + await waitForFrame(setup, (frame) => frame.includes("filter:")); + const jCount = notices.filter((notice) => notice === "SESSION KEY j").length; + await act(async () => setup.mockInput.typeText("j")); + expect(notices.filter((notice) => notice === "SESSION KEY j")).toHaveLength(jCount); + await act(async () => setup.mockInput.pressTab()); + await act(async () => setup.mockInput.typeText("j")); + expect(notices.filter((notice) => notice === "SESSION KEY j")).toHaveLength(jCount + 1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("routes open-menu accelerators before extension keyboard modes", async () => { + const { notices, setup } = await renderWithExtension(); + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await act(async () => setup.mockInput.pressKey("F9")); + await waitForFrame( + setup, + (frame) => frame.includes("FOCUSED VIEW") && frame.includes("Probe normal"), + ); + + await act(async () => setup.mockInput.pressKey("F10")); + await waitForFrame(setup, (frame) => frame.includes("Reload")); + await act(async () => setup.mockInput.typeText("?")); + const help = await waitForFrame(setup, (frame) => frame.includes("Controls help")); + + expect(help).not.toContain("Reload"); + expect(notices).not.toContain("FILE KEY ?"); + expect(notices).not.toContain("SESSION KEY ?"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("gives a focused file-view mode first Escape and the session mode the second", async () => { + const { notices, setup } = await renderWithExtension(); + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await act(async () => setup.mockInput.pressKey("F9")); + await waitForFrame( + setup, + (frame) => frame.includes("FOCUSED VIEW") && frame.includes("Probe normal"), + ); + + await act(async () => { + setup.renderer.keyInput.emit( + "keypress", + testKeyEvent({ name: "escape", sequence: "\u001b", raw: "\u001b" }), + ); + }); + expect(notices).toContain("FILE EXIT"); + expect(notices).not.toContain("SESSION EXIT"); + expect(setup.captureCharFrame()).toContain("Probe normal"); + + await act(async () => { + setup.renderer.keyInput.emit( + "keypress", + testKeyEvent({ name: "escape", sequence: "\u001b", raw: "\u001b" }), + ); + }); + await waitForFrame(setup, (frame) => !frame.includes("Probe normal")); + expect(notices).toContain("SESSION EXIT"); + expect(notices).toContain("SESSION REENTER false"); + expect(notices).not.toContain("FILE KEY escape"); + expect(notices).not.toContain("SESSION KEY escape"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("updates ownership eagerly for two Escapes in one input flush", async () => { + const { notices, setup } = await renderWithExtension(); + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await act(async () => setup.mockInput.pressKey("F9")); + await waitForFrame(setup, (frame) => frame.includes("FOCUSED VIEW")); + + await act(async () => { + const escape = { name: "escape", sequence: "\u001b", raw: "\u001b" }; + setup.renderer.keyInput.emit("keypress", testKeyEvent(escape)); + setup.renderer.keyInput.emit("keypress", testKeyEvent(escape)); + }); + expect(notices.filter((notice) => notice === "FILE EXIT")).toHaveLength(1); + expect(notices.filter((notice) => notice === "SESSION EXIT")).toHaveLength(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("retires closed registry authority before delivering another key", async () => { + const { extensions, notices, setup } = await renderWithExtension(); + try { + await waitForFrame(setup, (frame) => frame.includes("alpha.ts")); + await act(async () => setup.mockInput.pressKey("F8")); + await waitForFrame(setup, (frame) => frame.includes("Probe normal")); + + extensions.registry.eventBusPhase = "closed"; + const before = notices.filter((notice) => notice === "SESSION KEY j").length; + await act(async () => setup.mockInput.typeText("j")); + expect(notices.filter((notice) => notice === "SESSION KEY j")).toHaveLength(before); + expect(notices.filter((notice) => notice === "SESSION EXIT")).toHaveLength(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/components/chrome/StatusBar.tsx b/src/ui/components/chrome/StatusBar.tsx index a5bda04f..f05fa32d 100644 --- a/src/ui/components/chrome/StatusBar.tsx +++ b/src/ui/components/chrome/StatusBar.tsx @@ -1,26 +1,36 @@ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import stringWidth from "string-width"; import { isEscapeKey } from "../../lib/keyboard"; import type { AppTheme } from "../../themes"; -/** Render the active file filter input or current filter summary. */ +/** Render the active file filter, transient notice, and persistent keyboard-mode badge. */ export function StatusBar({ filter, filterFocused, + modeText, noticeText, terminalWidth, theme, onCloseMenu, onFilterInput, onFilterSubmit, + onExitMode, }: { filter: string; filterFocused: boolean; + modeText?: string; noticeText?: string; terminalWidth: number; theme: AppTheme; onCloseMenu: () => void; onFilterInput: (value: string) => void; onFilterSubmit: () => void; + onExitMode?: () => void; }) { + const modeWidth = modeText + ? Math.min(stringWidth(modeText) + 2, Math.max(6, Math.floor(terminalWidth / 2))) + : 0; + return ( - {filterFocused ? ( - <> - filter: - - - - { - if (!isEscapeKey(key)) { - return; - } + + {filterFocused ? ( + <> + filter: + + + + { + if (!isEscapeKey(key)) { + return; + } - key.preventDefault(); - key.stopPropagation(); + key.preventDefault(); + key.stopPropagation(); - if (filter.length > 0) { - onFilterInput(""); - return; - } + if (filter.length > 0) { + onFilterInput(""); + return; + } - onFilterSubmit(); - }} - /> - - ) : filter.length > 0 ? ( - {`filter=${filter}`} - ) : ( - {noticeText ?? ""} - )} + onFilterSubmit(); + }} + /> + + ) : filter.length > 0 ? ( + {`filter=${filter}`} + ) : ( + {noticeText ?? ""} + )} + + {modeText ? ( + { + event.stopPropagation(); + onExitMode?.(); + }} + > + {` ${modeText} `} + + ) : null} ); } diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index d1040d35..138ae595 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -2795,6 +2795,78 @@ describe("UI components", () => { expect(frame).toContain("Update available: 9.9.9"); }); + test("StatusBar keeps the keyboard-mode badge visible beside notices and filter input", async () => { + const theme = resolveTheme("github-dark-default", null); + const noticeFrame = await captureFrame( + {}} + onFilterInput={() => {}} + onFilterSubmit={() => {}} + onExitMode={() => {}} + />, + 80, + 3, + ); + const filterFrame = await captureFrame( + {}} + onFilterInput={() => {}} + onFilterSubmit={() => {}} + onExitMode={() => {}} + />, + 80, + 3, + ); + + expect(noticeFrame).toContain("Update available"); + expect(noticeFrame).toContain("Vim navigation"); + expect(filterFrame).toContain("filter:"); + expect(filterFrame).toContain("beta"); + expect(filterFrame).toContain("Vim navigation"); + }); + + test("StatusBar mode badge uses the host exit callback and stops the outer click", () => { + const theme = resolveTheme("github-dark-default", null); + let exits = 0; + let stopped = 0; + const element = StatusBar({ + filter: "", + filterFocused: false, + modeText: "Vim navigation", + terminalWidth: 80, + theme, + onCloseMenu: () => {}, + onFilterInput: () => {}, + onFilterSubmit: () => {}, + onExitMode: () => { + exits += 1; + }, + }) as unknown as { + props: { + children: readonly [unknown, { props: { onMouseUp: (event: unknown) => void } }]; + }; + }; + + element.props.children[1].props.onMouseUp({ + stopPropagation() { + stopped += 1; + }, + }); + expect(exits).toBe(1); + expect(stopped).toBe(1); + }); + test("StatusBar keeps filter input precedence over a notice", async () => { const theme = resolveTheme("github-dark-default", null); const frame = await captureFrame( diff --git a/src/ui/fileViews/mode.test.ts b/src/ui/fileViews/mode.test.ts index a2f0484d..784a2fe6 100644 --- a/src/ui/fileViews/mode.test.ts +++ b/src/ui/fileViews/mode.test.ts @@ -201,7 +201,7 @@ describe("file-view mode state", () => { ); }); - test("runs lifecycle callbacks and contains a throwing one as a warning", () => { + test("runs lifecycle callbacks and contains throws and thenables as warnings", async () => { const warnings: string[] = []; const entered: string[] = []; const active = createTestActiveMode({ @@ -227,12 +227,30 @@ describe("file-view mode state", () => { expect(runFileViewModeLifecycle(broken, "onExit", (message) => warnings.push(message))).toBe( false, ); + const asyncEntry = createTestActiveMode({ + onEnter: (() => Promise.reject(new Error("late entry rejection"))) as never, + onKey: () => "handled", + }); + expect( + runFileViewModeLifecycle(asyncEntry, "onEnter", (message) => warnings.push(message)), + ).toBe(false); + const asyncExit = createTestActiveMode({ + onExit: (() => Promise.reject(new Error("late exit rejection"))) as never, + onKey: () => "handled", + }); + expect(runFileViewModeLifecycle(asyncExit, "onExit", (message) => warnings.push(message))).toBe( + false, + ); + await Promise.resolve(); + expect(warnings).toEqual([ 'Extension preview file view "rendered" mode failed onExit • teardown exploded', + 'Extension preview file view "rendered" mode failed onEnter • onEnter must return synchronously', + 'Extension preview file view "rendered" mode failed onExit • onExit must return synchronously', ]); }); - test("normalizes key answers and turns a throwing handler into an exit", () => { + test("normalizes key answers and turns throws and thenables into an exit", async () => { const warnings: string[] = []; const seen: string[] = []; const push = (message: string) => warnings.push(message); @@ -260,8 +278,15 @@ describe("file-view mode state", () => { }, }); expect(deliverFileViewModeKey(broken, { name: "j" }, push)).toBe("exit"); + const asyncKey = createTestActiveMode({ + onKey: (() => Promise.reject(new Error("late key rejection"))) as never, + }); + expect(deliverFileViewModeKey(asyncKey, { name: "j" }, push)).toBe("exit"); + await Promise.resolve(); + expect(warnings).toEqual([ 'Extension preview file view "rendered" mode failed onKey • key exploded', + 'Extension preview file view "rendered" mode failed onKey • onKey must return synchronously', ]); }); }); diff --git a/src/ui/fileViews/mode.ts b/src/ui/fileViews/mode.ts index f8eccdf4..f3626650 100644 --- a/src/ui/fileViews/mode.ts +++ b/src/ui/fileViews/mode.ts @@ -6,13 +6,12 @@ import type { ExtensionKeyEvent, } from "../../extension-api/types"; import type { RegisteredFileView } from "../../extensions/types"; +import { + deliverSynchronousExtensionModeKey, + runSynchronousExtensionModeLifecycle, +} from "../lib/synchronousExtensionCallback"; import { registeredFileViewKey, resolveFileViewSelectionTarget } from "./state"; -/** Read an error's message without assuming extensions throw `Error` instances. */ -function describeError(error: unknown) { - return error instanceof Error ? error.message || error.name : String(error); -} - /** * The one interactive file-view mode a session can have running. * @@ -169,10 +168,10 @@ export function fileViewModeStatusHint(active: ActiveFileViewMode): string { } /** Attribute one mode failure to the extension and the action that raised it. */ -function formatFileViewModeFailure(active: ActiveFileViewMode, action: string, error: unknown) { +function formatFileViewModeFailure(active: ActiveFileViewMode, action: string, detail: string) { return ( `Extension ${active.extensionId} file view "${active.viewId}" mode ` + - `failed ${action} • ${describeError(error)}` + `failed ${action} • ${detail}` ); } @@ -189,15 +188,12 @@ export function runFileViewModeLifecycle( notify: (message: string) => void, ): boolean { const callback = active.mode[phase]; - if (!callback) return true; - - try { - callback.call(active.mode, active.ctx); - return true; - } catch (error) { - notify(formatFileViewModeFailure(active, phase, error)); - return false; - } + return runSynchronousExtensionModeLifecycle( + callback ? () => callback.call(active.mode, active.ctx) : undefined, + phase, + (action, detail) => formatFileViewModeFailure(active, action, detail), + notify, + ); } /** @@ -214,13 +210,9 @@ export function deliverFileViewModeKey( key: ExtensionKeyEvent, notify: (message: string) => void, ): ExtensionFileViewModeKeyResult { - let result: ExtensionFileViewModeKeyResult; - try { - result = active.mode.onKey.call(active.mode, key, active.ctx); - } catch (error) { - notify(formatFileViewModeFailure(active, "onKey", error)); - return "exit"; - } - - return result === "handled" || result === "exit" ? result : "pass"; + return deliverSynchronousExtensionModeKey( + () => active.mode.onKey.call(active.mode, key, active.ctx), + (action, detail) => formatFileViewModeFailure(active, action, detail), + notify, + ); } diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 4302481a..b061c16e 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -1,10 +1,15 @@ import type { KeyEvent } from "@opentui/core"; import { useKeyboard } from "@opentui/react"; import { useRef } from "react"; -import type { ExtensionFileViewModeKeyResult } from "../../extensions/types"; +import type { + ExtensionFileViewModeKeyResult, + ExtensionKeyboardModeKeyResult, + ExtensionKeyEvent, +} from "../../extensions/types"; import type { MenuId } from "../components/chrome/menu"; import { dispatchAppCommand, type AppCommand } from "../lib/appCommands"; import type { ExtensionDialogRequest } from "../lib/extensionDialogs"; +import { toExtensionKeyEvent } from "../lib/extensionKeyEvent"; import { isEscapeKey, isSaveDraftNoteKey } from "../lib/keyboard"; import { routeKeyOwnership, type KeyOwner } from "../lib/keyRouting"; @@ -41,8 +46,14 @@ export interface UseAppKeyboardShortcutsOptions { isFileViewModeActive: () => boolean; /** Leave that mode, running its `onExit`. Idempotent. */ exitFileViewMode: () => void; - /** Offer one key to the active mode and report what it decided. */ - sendFileViewModeKey: (key: KeyEvent) => ExtensionFileViewModeKeyResult; + /** Offer one key to the active file-view mode and report what it decided. */ + sendFileViewModeKey: (key: ExtensionKeyEvent) => ExtensionFileViewModeKeyResult; + /** Whether a session-scoped extension keyboard mode currently owns review keys. */ + isKeyboardModeActive: () => boolean; + /** Leave the active session keyboard mode. */ + exitKeyboardMode: () => void; + /** Offer one key to the active session keyboard mode. */ + sendKeyboardModeKey: (key: ExtensionKeyEvent) => ExtensionKeyboardModeKeyResult; focusArea: FocusArea; moveMenuItem: (delta: number) => void; moveThemeSelector: (delta: number) => void; @@ -100,6 +111,9 @@ export function useAppKeyboardShortcuts({ isFileViewModeActive, exitFileViewMode, sendFileViewModeKey, + isKeyboardModeActive, + exitKeyboardMode, + sendKeyboardModeKey, focusArea, moveMenuItem, moveThemeSelector, @@ -130,6 +144,9 @@ export function useAppKeyboardShortcuts({ const isFileViewModeActiveRef = useRef(isFileViewModeActive); const exitFileViewModeRef = useRef(exitFileViewMode); const sendFileViewModeKeyRef = useRef(sendFileViewModeKey); + const isKeyboardModeActiveRef = useRef(isKeyboardModeActive); + const exitKeyboardModeRef = useRef(exitKeyboardMode); + const sendKeyboardModeKeyRef = useRef(sendKeyboardModeKey); // These three close over live dialog state (the highlighted option, the typed // text), so they are read through refs rather than captured once. const acceptExtensionDialogRef = useRef(acceptExtensionDialog); @@ -148,6 +165,9 @@ export function useAppKeyboardShortcuts({ isFileViewModeActiveRef.current = isFileViewModeActive; exitFileViewModeRef.current = exitFileViewMode; sendFileViewModeKeyRef.current = sendFileViewModeKey; + isKeyboardModeActiveRef.current = isKeyboardModeActive; + exitKeyboardModeRef.current = exitKeyboardMode; + sendKeyboardModeKeyRef.current = sendKeyboardModeKey; acceptExtensionDialogRef.current = acceptExtensionDialog; cancelExtensionDialogRef.current = cancelExtensionDialog; moveExtensionDialogSelectionRef.current = moveExtensionDialogSelection; @@ -487,7 +507,7 @@ export function useAppKeyboardShortcuts({ return "mine"; } - const result = sendFileViewModeKeyRef.current(key); + const result = sendFileViewModeKeyRef.current(toExtensionKeyEvent(key)); if (result === "pass") { return "notMine"; } @@ -499,11 +519,40 @@ export function useAppKeyboardShortcuts({ return "mine"; }; + /** Route review-level keys through the one active session extension mode. */ + const handleKeyboardModeShortcut = (key: KeyEvent): KeyOwner => { + if (!isKeyboardModeActiveRef.current()) { + return "notMine"; + } + + // The host reserves Escape as a guaranteed way out of third-party routing. + if (isEscapeKey(key)) { + exitKeyboardModeRef.current(); + return "mine"; + } + + const result = sendKeyboardModeKeyRef.current(toExtensionKeyEvent(key)); + if (result === "pass") return "notMine"; + if (result === "exit") exitKeyboardModeRef.current(); + return "mine"; + }; + + /** Dispatch one command shortcut and honor its menu-closing policy. */ + const dispatchCommandShortcut = (key: KeyEvent) => { + // Dispatch consumes on match (preventDefault inside the loop), so a key + // that runs a command never doubles as a scroll-box or input key. + const matched = dispatchAppCommand(commandsRef.current, key); + if (matched?.closesMenu) { + closeMenu(); + } + return matched !== undefined; + }; + useKeyboard((key: KeyEvent) => { - // Precedence is the array order: app-critical prompts, extension dialogs, - // then menus and overlays, then focused text inputs, then an active file - // view mode, and finally the command table below. - const owned = routeKeyOwnership( + // Route through the active menu first. Its navigation keys stay host-owned, + // while an advertised accelerator gets one direct trip to the command table + // before focused inputs or extension modes can claim it. + const surfaceOwned = routeKeyOwnership( [ handleExtensionTrustPromptShortcut, handleSaveConfigPromptShortcut, @@ -512,21 +561,23 @@ export function useAppKeyboardShortcuts({ handleDialogShortcut, handleThemeSelectorShortcut, handleMenuShortcut, - handleFocusedInputShortcut, - handleFileViewModeShortcut, ], key, consumeKey, ); - if (owned) { - return; - } + if (surfaceOwned) return; - // Dispatch consumes on match (preventDefault inside the loop), so a key - // that runs a command never doubles as a scroll-box or input key. - const matched = dispatchAppCommand(commandsRef.current, key); - if (matched?.closesMenu) { - closeMenu(); - } + if (activeMenuIdRef.current && dispatchCommandShortcut(key)) return; + + // Without an open-menu command match, focused inputs and extension modes + // keep their ordinary precedence ahead of the command table. + const reviewOwned = routeKeyOwnership( + [handleFocusedInputShortcut, handleFileViewModeShortcut, handleKeyboardModeShortcut], + key, + consumeKey, + ); + if (reviewOwned) return; + + dispatchCommandShortcut(key); }); } diff --git a/src/ui/keyboardModes/mode.test.ts b/src/ui/keyboardModes/mode.test.ts new file mode 100644 index 00000000..627423e2 --- /dev/null +++ b/src/ui/keyboardModes/mode.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, test } from "bun:test"; +import type { + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, +} from "../../extension-api/types"; +import { createEmptyExtensionRegistry, type RegisteredKeyboardMode } from "../../extensions/types"; +import { + deliverSessionKeyboardModeKey, + runSessionKeyboardModeLifecycle, + sessionKeyboardModeStatusHint, + sessionKeyboardModeStillValid, + type ActiveSessionKeyboardMode, +} from "./mode"; + +/** Build one active record around a mode callback. */ +function activeFor(mode: ExtensionKeyboardMode): ActiveSessionKeyboardMode { + const registry = createEmptyExtensionRegistry(); + registry.eventBusPhase = "ready"; + const registered: RegisteredKeyboardMode = { extensionId: "vim", mode }; + registry.keyboardModes.push(registered); + return { + extensionId: "vim", + modeId: mode.id, + registered, + mode, + registry, + ctx: { + cwd: "/repo", + notify: () => {}, + commands: {} as ExtensionKeyboardModeContext["commands"], + keyboardModes: {} as ExtensionKeyboardModeContext["keyboardModes"], + }, + }; +} + +describe("session keyboard mode helpers", () => { + test("ties validity to registry authority and registration identity", () => { + const active = activeFor({ id: "normal", title: "Vim normal", onKey: () => "handled" }); + + expect(sessionKeyboardModeStillValid(active, active.registry, [active.registered])).toBe(true); + active.registry.eventBusPhase = "closed"; + expect(sessionKeyboardModeStillValid(active, active.registry, [active.registered])).toBe(false); + active.registry.eventBusPhase = "ready"; + expect( + sessionKeyboardModeStillValid(active, createEmptyExtensionRegistry(), [active.registered]), + ).toBe(false); + expect(sessionKeyboardModeStillValid(active, active.registry, [])).toBe(false); + }); + + test("formats a persistent attributed, terminal-safe status hint", () => { + const active = activeFor({ + id: "normal", + title: "Vim\u001b]0;spoof\u0007 normal", + onKey: () => "handled", + }); + expect(sessionKeyboardModeStatusHint(active)).toBe("Vim normal — ext vim:normal — Esc exits"); + }); + + test("normalizes invalid results, contains throws, and refuses async onKey", async () => { + const warnings: string[] = []; + const warn = (message: string) => warnings.push(message); + + expect( + deliverSessionKeyboardModeKey( + activeFor({ id: "normal", title: "Normal", onKey: () => undefined as never }), + { name: "j" }, + warn, + ), + ).toBe("pass"); + expect( + deliverSessionKeyboardModeKey( + activeFor({ + id: "normal", + title: "Normal", + onKey: (() => Promise.resolve("handled")) as never, + }), + { name: "j" }, + warn, + ), + ).toBe("exit"); + expect( + deliverSessionKeyboardModeKey( + activeFor({ + id: "normal", + title: "Normal", + onKey: () => { + throw new Error("boom"); + }, + }), + { name: "j" }, + warn, + ), + ).toBe("exit"); + await Promise.resolve(); + + expect(warnings).toEqual([ + 'Extension vim keyboard mode "normal" failed onKey • onKey must return synchronously', + 'Extension vim keyboard mode "normal" failed onKey • boom', + ]); + }); + + test("contains lifecycle throws and async callbacks", async () => { + const warnings: string[] = []; + const warn = (message: string) => warnings.push(message); + const asyncEntry = activeFor({ + id: "normal", + title: "Normal", + onEnter: (() => Promise.resolve()) as never, + onKey: () => "handled", + }); + expect(runSessionKeyboardModeLifecycle(asyncEntry, "onEnter", warn)).toBe(false); + + const brokenExit = activeFor({ + id: "normal", + title: "Normal", + onExit: () => { + throw new Error("teardown"); + }, + onKey: () => "handled", + }); + expect(runSessionKeyboardModeLifecycle(brokenExit, "onExit", warn)).toBe(false); + await Promise.resolve(); + + expect(warnings).toEqual([ + 'Extension vim keyboard mode "normal" failed onEnter • onEnter must return synchronously', + 'Extension vim keyboard mode "normal" failed onExit • teardown', + ]); + }); +}); diff --git a/src/ui/keyboardModes/mode.ts b/src/ui/keyboardModes/mode.ts new file mode 100644 index 00000000..64b1017c --- /dev/null +++ b/src/ui/keyboardModes/mode.ts @@ -0,0 +1,84 @@ +import type { + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, + ExtensionKeyboardModeKeyResult, + ExtensionKeyEvent, +} from "../../extension-api/types"; +import type { ExtensionRegistry, RegisteredKeyboardMode } from "../../extensions/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { + deliverSynchronousExtensionModeKey, + runSynchronousExtensionModeLifecycle, +} from "../lib/synchronousExtensionCallback"; + +/** Everything the host retains while one session keyboard mode is active. */ +export interface ActiveSessionKeyboardMode { + readonly extensionId: string; + readonly modeId: string; + readonly registered: RegisteredKeyboardMode; + readonly mode: ExtensionKeyboardMode; + readonly ctx: ExtensionKeyboardModeContext; + readonly registry: ExtensionRegistry; +} + +/** Attribute one contained callback failure to its extension and mode. */ +function formatKeyboardModeFailure( + active: ActiveSessionKeyboardMode, + action: string, + detail: string, +) { + return `Extension ${active.extensionId} keyboard mode "${active.modeId}" failed ${action} • ${detail}`; +} + +/** Report whether an activation still belongs to the live extension registry. */ +export function sessionKeyboardModeStillValid( + active: ActiveSessionKeyboardMode, + registry: ExtensionRegistry | undefined, + modes: readonly RegisteredKeyboardMode[], +): boolean { + return ( + active.registry === registry && + active.registry.eventBusPhase !== "closed" && + modes.includes(active.registered) + ); +} + +/** Return the terminal-safe human label for one extension-authored mode title. */ +export function sessionKeyboardModeDisplayTitle(active: ActiveSessionKeyboardMode): string { + const title = sanitizeTerminalLine(active.mode.title).trim(); + return title || sanitizeTerminalLine(`${active.extensionId}:${active.modeId}`); +} + +/** Build the persistent status label for one active session mode. */ +export function sessionKeyboardModeStatusHint(active: ActiveSessionKeyboardMode): string { + const owner = sanitizeTerminalLine(`${active.extensionId}:${active.modeId}`); + return `${sessionKeyboardModeDisplayTitle(active)} — ext ${owner} — Esc exits`; +} + +/** Run one lifecycle callback synchronously with extension failure containment. */ +export function runSessionKeyboardModeLifecycle( + active: ActiveSessionKeyboardMode, + phase: "onEnter" | "onExit", + notify: (message: string) => void, +): boolean { + const callback = active.mode[phase]; + return runSynchronousExtensionModeLifecycle( + callback ? () => callback.call(active.mode, active.ctx) : undefined, + phase, + (action, detail) => formatKeyboardModeFailure(active, action, detail), + notify, + ); +} + +/** Deliver one frozen public key snapshot and normalize the extension's routing answer. */ +export function deliverSessionKeyboardModeKey( + active: ActiveSessionKeyboardMode, + key: ExtensionKeyEvent, + notify: (message: string) => void, +): ExtensionKeyboardModeKeyResult { + return deliverSynchronousExtensionModeKey( + () => active.mode.onKey.call(active.mode, key, active.ctx), + (action, detail) => formatKeyboardModeFailure(active, action, detail), + notify, + ); +} diff --git a/src/ui/keyboardModes/useKeyboardModeController.test.tsx b/src/ui/keyboardModes/useKeyboardModeController.test.tsx new file mode 100644 index 00000000..455f2c3f --- /dev/null +++ b/src/ui/keyboardModes/useKeyboardModeController.test.tsx @@ -0,0 +1,330 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, useCallback, useState } from "react"; +import type { + ExtensionCommandControls, + ExtensionKeyboardMode, + ExtensionKeyboardModeContext, +} from "../../extension-api/types"; +import { + createEmptyExtensionRegistry, + type ExtensionRegistry, + type RegisteredKeyboardMode, +} from "../../extensions/types"; +import { useKeyboardModeController } from "./useKeyboardModeController"; + +interface HarnessState { + registry: ExtensionRegistry; + modes: RegisteredKeyboardMode[]; +} + +/** Register one test mode under an extension id. */ +function registered( + extensionId: string, + id: string, + callbacks: Partial = {}, +): RegisteredKeyboardMode { + return { + extensionId, + mode: { + id, + title: `${extensionId} ${id}`, + onKey: () => "handled", + ...callbacks, + }, + }; +} + +/** Create a ready registry carrying exactly the given registrations. */ +function registryWith(modes: RegisteredKeyboardMode[]) { + const registry = createEmptyExtensionRegistry(); + registry.eventBusPhase = "ready"; + registry.keyboardModes.push(...modes); + return registry; +} + +/** Mount the controller with replaceable registry authority. */ +async function renderController(initial: HarnessState) { + let controller!: ReturnType; + let update!: (next: Partial) => void; + const notices: string[] = []; + const commands: ExtensionCommandControls = { + isEnabled: () => true, + execute: () => true, + }; + + function Harness() { + const [state, setState] = useState(initial); + update = (next) => setState((current) => ({ ...current, ...next })); + const showNotice = useCallback((message: string) => notices.push(message), []); + controller = useKeyboardModeController({ + commands, + cwd: "/repo", + modes: state.modes, + notify: (message) => notices.push(message), + registry: state.registry, + showNotice, + }); + return null; + } + + const setup = await testRender(, { width: 40, height: 4 }); + await act(async () => setup.renderOnce()); + return { + setup, + notices, + controller: () => controller, + update: (next: Partial) => update(next), + }; +} + +describe("useKeyboardModeController", () => { + test("scopes observation and exit while allowing a later extension to replace the mode", async () => { + const events: string[] = []; + const alpha = registered("alpha", "normal", { + onEnter: () => events.push("enter alpha"), + onExit: () => events.push("exit alpha"), + }); + const beta = registered("beta", "normal", { + onEnter: () => events.push("enter beta"), + }); + const registry = registryWith([alpha, beta]); + const harness = await renderController({ registry, modes: [alpha, beta] }); + + try { + const alphaControls = harness.controller().createControls("alpha", registry); + const betaControls = harness.controller().createControls("beta", registry); + await act(async () => expect(alphaControls.enterMode("normal")).toBe(true)); + expect(alphaControls.isActive()).toBe(true); + expect(betaControls.isActive()).toBe(false); + expect(betaControls.exitMode()).toBe(false); + + await act(async () => expect(betaControls.enterMode("normal")).toBe(true)); + expect(alphaControls.isActive()).toBe(false); + expect(betaControls.isActive("normal")).toBe(true); + expect(events).toEqual(["enter alpha", "exit alpha", "enter beta"]); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("retires closed registry authority synchronously and makes retained controls inert", async () => { + let exits = 0; + const mode = registered("vim", "normal", { onExit: () => (exits += 1) }); + const registry = registryWith([mode]); + const harness = await renderController({ registry, modes: [mode] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("normal")).toBe(true)); + registry.eventBusPhase = "closed"; + // No React render announces closure: the live routing probe must still see it immediately. + let active = true; + await act(async () => { + active = harness.controller().isModeActive(); + }); + expect(active).toBe(false); + expect(exits).toBe(1); + expect(controls.isActive()).toBe(false); + expect(controls.enterMode("normal")).toBe(false); + expect(controls.exitMode()).toBe(false); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("keeps lifecycle callbacks from changing keyboard ownership", async () => { + const attempts: boolean[] = []; + const attemptOwnershipChange = (ctx: ExtensionKeyboardModeContext) => { + attempts.push(ctx.keyboardModes.enterMode("gamma"), ctx.keyboardModes.exitMode()); + }; + const alpha = registered("vim", "alpha", { + onEnter: attemptOwnershipChange, + onExit: attemptOwnershipChange, + }); + const beta = registered("vim", "beta"); + const gamma = registered("vim", "gamma"); + const registry = registryWith([alpha, beta, gamma]); + const harness = await renderController({ registry, modes: [alpha, beta, gamma] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("alpha")).toBe(true)); + await act(async () => expect(controls.enterMode("beta")).toBe(true)); + expect(attempts).toEqual([false, false, false, false]); + expect(controls.isActive("beta")).toBe(true); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("cleans up a failed onEnter and permits a later command entry", async () => { + let failedExits = 0; + const failed = registered("vim", "failed", { + onEnter: () => { + throw new Error("entry failed"); + }, + onExit: () => (failedExits += 1), + }); + const healthy = registered("vim", "healthy"); + const registry = registryWith([failed, healthy]); + const harness = await renderController({ registry, modes: [failed, healthy] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("failed")).toBe(false)); + expect(failedExits).toBe(1); + expect(controls.isActive()).toBe(false); + expect(harness.notices).toContain( + 'Extension vim keyboard mode "failed" failed onEnter • entry failed', + ); + + await act(async () => expect(controls.enterMode("healthy")).toBe(true)); + expect(controls.isActive("healthy")).toBe(true); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("keeps an onKey replacement when its predecessor returns exit", async () => { + let alphaExits = 0; + const alpha = registered("vim", "alpha", { + onExit: () => (alphaExits += 1), + onKey: (_key, ctx) => { + expect(ctx.keyboardModes.enterMode("beta")).toBe(true); + return "exit"; + }, + }); + const beta = registered("vim", "beta"); + const registry = registryWith([alpha, beta]); + const harness = await renderController({ registry, modes: [alpha, beta] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("alpha")).toBe(true)); + let result = "pass"; + await act(async () => { + result = harness.controller().sendModeKey({ name: "x" }); + }); + expect(result).toBe("handled"); + expect(alphaExits).toBe(1); + expect(controls.isActive("beta")).toBe(true); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("host exit invalidates onExit controls synchronously and for deferred work", async () => { + const attempts: boolean[] = []; + const gamma = registered("vim", "gamma"); + const alpha = registered("vim", "alpha", { + onExit: (ctx) => { + attempts.push(ctx.keyboardModes.isActive("alpha")); + attempts.push(ctx.keyboardModes.exitMode()); + attempts.push(ctx.keyboardModes.enterMode("gamma")); + queueMicrotask(() => attempts.push(ctx.keyboardModes.enterMode("gamma"))); + }, + }); + const registry = registryWith([alpha, gamma]); + const harness = await renderController({ registry, modes: [alpha, gamma] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("alpha")).toBe(true)); + await act(async () => harness.controller().exitMode()); + await Promise.resolve(); + + expect(attempts).toEqual([false, false, false, false]); + expect(controls.isActive()).toBe(false); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("old mode contexts cannot inspect or exit a same-extension replacement", async () => { + let oldContext!: Parameters>[0]; + const alpha = registered("vim", "alpha", { onEnter: (ctx) => (oldContext = ctx) }); + const beta = registered("vim", "beta"); + const registry = registryWith([alpha, beta]); + const harness = await renderController({ registry, modes: [alpha, beta] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("alpha")).toBe(true)); + await act(async () => expect(controls.enterMode("beta")).toBe(true)); + + expect(oldContext.keyboardModes.isActive()).toBe(false); + expect(oldContext.keyboardModes.exitMode()).toBe(false); + expect(oldContext.keyboardModes.enterMode("alpha")).toBe(false); + expect(controls.isActive("beta")).toBe(true); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("keeps a mode across same-registry content updates and exits on registry identity replacement", async () => { + let exits = 0; + const mode = registered("vim", "normal", { onExit: () => (exits += 1) }); + const registry = registryWith([mode]); + const harness = await renderController({ registry, modes: [mode] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("normal")).toBe(true)); + await act(async () => harness.update({ modes: [mode] })); + expect(controls.isActive("normal")).toBe(true); + + const replacementMode = registered("vim", "normal"); + const replacementRegistry = registryWith([replacementMode]); + await act(async () => + harness.update({ registry: replacementRegistry, modes: [replacementMode] }), + ); + expect(harness.controller().isModeActive()).toBe(false); + expect(exits).toBe(1); + expect(controls.isActive()).toBe(false); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); + + test("unmount exits once and makes retained command controls inert", async () => { + let exits = 0; + const mode = registered("vim", "normal", { onExit: () => (exits += 1) }); + const registry = registryWith([mode]); + const harness = await renderController({ registry, modes: [mode] }); + const controls = harness.controller().createControls("vim", registry); + + await act(async () => expect(controls.enterMode("normal")).toBe(true)); + await act(async () => harness.setup.renderer.destroy()); + + expect(exits).toBe(1); + expect(controls.isActive()).toBe(false); + expect(controls.exitMode()).toBe(false); + expect(controls.enterMode("normal")).toBe(false); + }); + + test("contains a throwing key handler and exits it through the controller", async () => { + let exits = 0; + const mode = registered("vim", "normal", { + onKey: () => { + throw new Error("key failed"); + }, + onExit: () => (exits += 1), + }); + const registry = registryWith([mode]); + const harness = await renderController({ registry, modes: [mode] }); + const controls = harness.controller().createControls("vim", registry); + + try { + await act(async () => expect(controls.enterMode("normal")).toBe(true)); + expect(harness.controller().sendModeKey({ name: "j" })).toBe("exit"); + await act(async () => harness.controller().exitMode()); + expect(exits).toBe(1); + expect(harness.notices).toContain( + 'Extension vim keyboard mode "normal" failed onKey • key failed', + ); + } finally { + await act(async () => harness.setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/keyboardModes/useKeyboardModeController.ts b/src/ui/keyboardModes/useKeyboardModeController.ts new file mode 100644 index 00000000..41ba8733 --- /dev/null +++ b/src/ui/keyboardModes/useKeyboardModeController.ts @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { + ExtensionCommandControls, + ExtensionContext, + ExtensionKeyboardModeContext, + ExtensionKeyboardModeControls, + ExtensionKeyboardModeKeyResult, + ExtensionKeyEvent, + ExtensionNotifyType, +} from "../../extension-api/types"; +import type { ExtensionRegistry, RegisteredKeyboardMode } from "../../extensions/types"; +import { + deliverSessionKeyboardModeKey, + runSessionKeyboardModeLifecycle, + sessionKeyboardModeDisplayTitle, + sessionKeyboardModeStatusHint, + sessionKeyboardModeStillValid, + type ActiveSessionKeyboardMode, +} from "./mode"; + +/** Activation identity retained only for the lifetime of one mode context. */ +type KeyboardModeControlScope = { active: ActiveSessionKeyboardMode | null }; + +export interface KeyboardModeController { + /** Build controls restricted to one extension and registry generation. */ + createControls: ( + extensionId: string, + owningRegistry: ExtensionRegistry | undefined, + ) => ExtensionKeyboardModeControls; + /** Whether a live session mode owns review-level keys. */ + isModeActive: () => boolean; + /** Persistent status text for the active mode. */ + modeStatusHint: string | null; + /** Human-readable active title for host menu affordances. */ + activeModeTitle: string | null; + /** Host-owned teardown used by Escape, status, menus, and unmount. */ + exitMode: () => void; + /** Offer one frozen public key snapshot to the active mode. */ + sendModeKey: (key: ExtensionKeyEvent) => ExtensionKeyboardModeKeyResult; +} + +/** Own the single extension keyboard mode active across one mounted review session. */ +export function useKeyboardModeController({ + commands, + cwd, + modes, + notify, + registry, + showNotice, +}: { + commands: ExtensionCommandControls; + cwd: string; + modes: readonly RegisteredKeyboardMode[]; + notify: ExtensionContext["notify"]; + registry: ExtensionRegistry | undefined; + showNotice: (message: string) => void; +}): KeyboardModeController { + const modesRef = useRef(modes); + modesRef.current = modes; + const registryRef = useRef(registry); + registryRef.current = registry; + const cwdRef = useRef(cwd); + cwdRef.current = cwd; + const notifyRef = useRef(notify); + notifyRef.current = notify; + const commandsRef = useRef(commands); + commandsRef.current = commands; + const aliveRef = useRef(true); + const lifecycleDepthRef = useRef(0); + + // The ref changes eagerly so several keys delivered in one input flush see entry and exit. + const [activeMode, setActiveModeState] = useState(null); + const activeModeRef = useRef(null); + const setActiveMode = useCallback((next: ActiveSessionKeyboardMode | null) => { + activeModeRef.current = next; + setActiveModeState(next); + }, []); + const warnMode = useCallback((message: string) => notifyRef.current(message, "warning"), []); + + /** Run lifecycle code while ownership-changing controls are intentionally inert. */ + const runModeLifecycle = useCallback( + (active: ActiveSessionKeyboardMode, phase: "onEnter" | "onExit") => { + lifecycleDepthRef.current += 1; + try { + return runSessionKeyboardModeLifecycle(active, phase, warnMode); + } finally { + lifecycleDepthRef.current -= 1; + } + }, + [warnMode], + ); + + /** + * Tear down the active mode exactly once before running extension lifecycle code. + * + * Lifecycle callbacks may observe their scoped state but cannot change keyboard ownership. This + * keeps every replacement on the explicit command/onKey path and makes host teardown final. + */ + const teardownMode = useCallback(() => { + const active = activeModeRef.current; + if (!active) return; + setActiveMode(null); + runModeLifecycle(active, "onExit"); + }, [runModeLifecycle, setActiveMode]); + const exitMode = teardownMode; + const exitModeRef = useRef(exitMode); + exitModeRef.current = exitMode; + + /** Retire stale registry authority before answering a control or key-routing probe. */ + const getLiveActiveMode = useCallback(() => { + const active = activeModeRef.current; + if (active && !sessionKeyboardModeStillValid(active, registryRef.current, modesRef.current)) { + exitModeRef.current(); + return null; + } + return active; + }, []); + + const createControlsRef = useRef< + ( + extensionId: string, + owningRegistry: ExtensionRegistry | undefined, + scope?: KeyboardModeControlScope, + ) => ExtensionKeyboardModeControls + >(() => { + throw new Error("Keyboard mode controls are not ready"); + }); + + /** Start one registration after tearing down the previous session mode. */ + const beginMode = useCallback( + ( + extensionId: string, + owningRegistry: ExtensionRegistry, + registered: RegisteredKeyboardMode, + ) => { + exitMode(); + + const scope: KeyboardModeControlScope = { active: null }; + const keyboardModes = createControlsRef.current(extensionId, owningRegistry, scope); + const ctx: ExtensionKeyboardModeContext = Object.freeze({ + cwd: cwdRef.current, + notify: (message: string, type?: ExtensionNotifyType) => notifyRef.current(message, type), + commands: commandsRef.current, + keyboardModes, + }); + const active: ActiveSessionKeyboardMode = { + ctx, + extensionId, + mode: registered.mode, + modeId: registered.mode.id, + registered, + registry: owningRegistry, + }; + scope.active = active; + setActiveMode(active); + if (!runModeLifecycle(active, "onEnter")) { + if (activeModeRef.current === active) exitMode(); + return false; + } + + return activeModeRef.current === active; + }, + [exitMode, runModeLifecycle, setActiveMode], + ); + + /** Build live controls without capturing mode selection or active state. */ + const createControls = useCallback( + ( + extensionId: string, + owningRegistry: ExtensionRegistry | undefined, + scope?: KeyboardModeControlScope, + ): ExtensionKeyboardModeControls => { + const hasAuthority = () => + aliveRef.current && + owningRegistry !== undefined && + owningRegistry.eventBusPhase !== "closed" && + registryRef.current === owningRegistry; + const resolve = (modeId: string) => + modesRef.current.find( + (registered) => registered.extensionId === extensionId && registered.mode.id === modeId, + ); + const canChangeOwnership = () => + hasAuthority() && + lifecycleDepthRef.current === 0 && + (!scope || activeModeRef.current === scope.active); + + const controls: ExtensionKeyboardModeControls = { + enterMode(modeId: string) { + if (!owningRegistry || !canChangeOwnership()) return false; + if (typeof modeId !== "string" || modeId.trim().length === 0) { + showNotice(`Extension ${extensionId} targeted an invalid keyboard mode id`); + return false; + } + const registered = resolve(modeId); + if (!registered) { + showNotice(`Extension ${extensionId} targeted unknown keyboard mode "${modeId}"`); + return false; + } + return beginMode(extensionId, owningRegistry, registered); + }, + exitMode() { + if (!canChangeOwnership()) return false; + const active = getLiveActiveMode(); + if (!active || active.extensionId !== extensionId || (scope && active !== scope.active)) { + return false; + } + exitMode(); + return true; + }, + isActive(modeId?: string) { + if (!hasAuthority()) return false; + if (modeId !== undefined && (typeof modeId !== "string" || modeId.length === 0)) { + return false; + } + const active = getLiveActiveMode(); + return Boolean( + active && + active.extensionId === extensionId && + (!scope || active === scope.active) && + (modeId === undefined || active.modeId === modeId), + ); + }, + }; + return Object.freeze(controls); + }, + [beginMode, exitMode, getLiveActiveMode, showNotice], + ); + createControlsRef.current = createControls; + + /** Report synchronous ownership, retiring closed registry authority first. */ + const isModeActive = useCallback(() => getLiveActiveMode() !== null, [getLiveActiveMode]); + + /** Deliver a key without capturing a stale activation. */ + const sendModeKey = useCallback( + (key: ExtensionKeyEvent): ExtensionKeyboardModeKeyResult => { + const active = getLiveActiveMode(); + if (!active) return "pass"; + const result = deliverSessionKeyboardModeKey(active, key, warnMode); + // A handler can enter a replacement. Its predecessor's exit answer cannot tear it down. + return result === "exit" && activeModeRef.current !== active ? "handled" : result; + }, + [getLiveActiveMode, warnMode], + ); + + useEffect(() => { + aliveRef.current = true; + return () => { + aliveRef.current = false; + exitModeRef.current(); + }; + }, []); + + // Render-driven reconciliation handles replacement registrations even without a following key. + useEffect(() => { + getLiveActiveMode(); + }, [getLiveActiveMode, modes, registry]); + + return { + createControls, + isModeActive, + modeStatusHint: activeMode ? sessionKeyboardModeStatusHint(activeMode) : null, + activeModeTitle: activeMode ? sessionKeyboardModeDisplayTitle(activeMode) : null, + exitMode, + sendModeKey, + }; +} diff --git a/src/ui/lib/appMenus.test.ts b/src/ui/lib/appMenus.test.ts index 4a519871..77e40243 100644 --- a/src/ui/lib/appMenus.test.ts +++ b/src/ui/lib/appMenus.test.ts @@ -250,13 +250,32 @@ describe("the Extensions menu", () => { return buildAppMenus({ commands, extensionCommands, ...MENU_STATE }); } - test("is absent when no extension registered a command", () => { + test("is absent when no extension command or active keyboard mode needs it", () => { const { commands } = createTestCommands(); expect(buildAppMenus({ commands, ...MENU_STATE }).extensions).toBeUndefined(); expect(menusWithExtensions([]).extensions).toBeUndefined(); }); + test("offers the host-owned keyboard-mode exit even without extension commands", () => { + const { commands } = createTestCommands(); + const exits: string[] = []; + const menus = buildAppMenus({ + commands, + ...MENU_STATE, + keyboardModeExitEntry: { + kind: "item", + label: "Exit Vim navigation", + commandId: "hunk.extensions.exitKeyboardMode", + action: () => exits.push("exit"), + }, + }); + + expect(items(menus.extensions).map((item) => item.label)).toEqual(["Exit Vim navigation"]); + entry(menus, "extensions", "Exit Vim navigation").action(); + expect(exits).toEqual(["exit"]); + }); + test("lists every registered command with its title and current key", () => { const menus = menusWithExtensions([ registeredCommand("notes", "sync", "Sync notes", "y"), diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 8af8998b..fbba7fcb 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -34,6 +34,8 @@ export interface BuildAppMenusOptions { extensionCommands?: readonly AppCommand[]; /** Host-owned per-file presentation choices appended to View. */ fileViewEntries?: readonly MenuEntry[]; + /** Host-owned escape hatch shown while a session keyboard mode is active. */ + keyboardModeExitEntry?: MenuEntry; /** Live label for the stable host command that applies the selected presentation changeset-wide. */ fileViewApplyAllLabel?: string; copyDecorations: boolean; @@ -125,6 +127,7 @@ export function buildAppMenus({ extensionCommands = [], fileViewEntries = [], fileViewApplyAllLabel, + keyboardModeExitEntry, copyDecorations, cursorLine, layoutMode, @@ -211,7 +214,15 @@ export function buildAppMenus({ specs.view.push(SEPARATOR); } - const extensions = toExtensionMenuEntries(commands, extensionCommands); + const extensionCommandEntries = toExtensionMenuEntries(commands, extensionCommands); + const extensions = keyboardModeExitEntry + ? [ + keyboardModeExitEntry, + ...(extensionCommandEntries.length > 0 + ? [{ kind: "separator" as const }, ...extensionCommandEntries] + : []), + ] + : extensionCommandEntries; const applyAllEntries = fileViewApplyAllLabel ? toMenuEntries(commands, [ { diff --git a/src/ui/lib/extensionKeyEvent.test.ts b/src/ui/lib/extensionKeyEvent.test.ts new file mode 100644 index 00000000..3c58d1c6 --- /dev/null +++ b/src/ui/lib/extensionKeyEvent.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; +import type { KeyEvent } from "@opentui/core"; +import { toExtensionKeyEvent } from "./extensionKeyEvent"; + +describe("toExtensionKeyEvent", () => { + test("returns a frozen method-free snapshot instead of the host event", () => { + const host = { + name: "g", + sequence: "G", + ctrl: false, + meta: false, + option: true, + shift: true, + preventDefault() {}, + stopPropagation() {}, + } as unknown as KeyEvent; + + const snapshot = toExtensionKeyEvent(host); + + expect(snapshot).toEqual({ + name: "g", + sequence: "G", + ctrl: false, + meta: false, + option: true, + shift: true, + }); + expect(snapshot).not.toBe(host); + expect(Object.isFrozen(snapshot)).toBe(true); + expect("preventDefault" in snapshot).toBe(false); + expect("stopPropagation" in snapshot).toBe(false); + }); +}); diff --git a/src/ui/lib/extensionKeyEvent.ts b/src/ui/lib/extensionKeyEvent.ts new file mode 100644 index 00000000..a13983d3 --- /dev/null +++ b/src/ui/lib/extensionKeyEvent.ts @@ -0,0 +1,14 @@ +import type { KeyEvent } from "@opentui/core"; +import type { ExtensionKeyEvent } from "../../extension-api/types"; + +/** Copy a host key into the frozen, method-free shape published to extensions. */ +export function toExtensionKeyEvent(key: KeyEvent): ExtensionKeyEvent { + return Object.freeze({ + name: key.name, + sequence: key.sequence, + ctrl: key.ctrl, + meta: key.meta, + option: key.option, + shift: key.shift, + }); +} diff --git a/src/ui/lib/synchronousExtensionCallback.ts b/src/ui/lib/synchronousExtensionCallback.ts new file mode 100644 index 00000000..fa80f8a7 --- /dev/null +++ b/src/ui/lib/synchronousExtensionCallback.ts @@ -0,0 +1,75 @@ +/** The three outcomes of invoking an extension callback that must finish synchronously. */ +export type SynchronousExtensionCallbackResult = + | { readonly kind: "returned"; readonly value: Value } + | { readonly kind: "thenable" } + | { readonly kind: "threw"; readonly error: unknown }; + +/** The routing decisions shared by interactive extension mode flavors. */ +type SynchronousExtensionModeKeyResult = "handled" | "pass" | "exit"; + +/** Read an error without assuming extension code threw an Error instance. */ +function describeError(error: unknown) { + return error instanceof Error ? error.message || error.name : String(error); +} + +/** Report whether an untyped extension returned promise-like work. */ +function isThenable(value: unknown): value is PromiseLike { + return ( + (typeof value === "object" || typeof value === "function") && + value !== null && + typeof (value as PromiseLike).then === "function" + ); +} + +/** + * Invoke one callback whose return value decides the current key or lifecycle transition. + * + * A returned thenable is rejected by the contract, but its rejection is still observed so broken + * third-party code cannot create an unhandled rejection after the host has safely moved on. + */ +export function callExtensionSynchronously( + callback: () => Value, +): SynchronousExtensionCallbackResult { + try { + const value = callback(); + if (!isThenable(value)) return { kind: "returned", value }; + void Promise.resolve(value).catch(() => {}); + return { kind: "thenable" }; + } catch (error) { + return { kind: "threw", error }; + } +} + +/** Run one extension mode lifecycle callback through the shared synchronous contract. */ +export function runSynchronousExtensionModeLifecycle( + callback: (() => unknown) | undefined, + phase: "onEnter" | "onExit", + formatFailure: (action: string, detail: string) => string, + notify: (message: string) => void, +): boolean { + if (!callback) return true; + + const result = callExtensionSynchronously(callback); + if (result.kind === "returned") return true; + const detail = + result.kind === "thenable" ? `${phase} must return synchronously` : describeError(result.error); + notify(formatFailure(phase, detail)); + return false; +} + +/** Deliver one extension mode key through the shared synchronous routing contract. */ +export function deliverSynchronousExtensionModeKey( + callback: () => unknown, + formatFailure: (action: string, detail: string) => string, + notify: (message: string) => void, +): SynchronousExtensionModeKeyResult { + const result = callExtensionSynchronously(callback); + if (result.kind === "returned") { + return result.value === "handled" || result.value === "exit" ? result.value : "pass"; + } + + const detail = + result.kind === "thenable" ? "onKey must return synchronously" : describeError(result.error); + notify(formatFailure("onKey", detail)); + return "exit"; +} diff --git a/test/pty/extensions-integration.test.ts b/test/pty/extensions-integration.test.ts index 4b423e71..b292a97d 100644 --- a/test/pty/extensions-integration.test.ts +++ b/test/pty/extensions-integration.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { createPtyHarness } from "./harness"; +import { createPtyHarness, lineIndexOf } from "./harness"; const harness = createPtyHarness(); const REVIEW_TRIAGE_EXTENSION = resolve( fileURLToPath(new URL("../../examples/extensions/review-triage", import.meta.url)), ); +const VIM_NAVIGATION_EXTENSION = resolve( + fileURLToPath(new URL("../../examples/extensions/vim-navigation", import.meta.url)), +); /** Give PTY-backed startup, reloads, and redraws headroom on slower CI machines. */ setDefaultTimeout(30_000); @@ -399,6 +402,151 @@ describe("PTY extensions", () => { } }); + test("the real Vim navigation example routes counts, command-line input, and Ctrl chords", async () => { + const configHome = harness.createIsolatedConfigHome(); + // Enough changed rows that top/bottom navigation has an observable viewport effect. + const fixture = harness.createPinnedHeaderRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "stack", "--extension", VIM_NAVIGATION_EXTENSION], + cwd: fixture.dir, + cols: 140, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await harness.waitForSnapshot( + session, + (text) => text.includes("first.ts") && text.includes("Extensions"), + 20_000, + ); + await harness.ensureKeyboardIsLive(session); + await session.press("f6"); + await session.waitForText(/Vim navigation.*Esc exits/, { timeout: 20_000 }); + + // The host contributes a mouse-accessible exit independently of the extension command. + await session.clickAt(33, 0); + await session.waitForText(/Exit Vim navigation/, { timeout: 20_000 }); + await session.click(/Exit Vim navigation/); + await harness.waitForSnapshot( + session, + (text) => !/Vim navigation.*Esc exits/.test(text), + 20_000, + ); + await session.press("f6"); + await session.waitForText(/Vim navigation.*Esc exits/, { timeout: 20_000 }); + + // A passed `c` exposes the host-owned current line through note placement, + // giving counted movement and alignment observable terminal effects. + await session.press("c"); + const initialDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); + const initialDraftRow = lineIndexOf(initialDraft, "Draft note"); + await session.press("escape"); + await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 20_000); + + await session.press("1"); + await session.press("0"); + await session.press("j"); + await session.press("c"); + const countedDraft = await session.waitForText(/Draft note/, { timeout: 20_000 }); + expect(lineIndexOf(countedDraft, "Draft note")).toBeGreaterThan(initialDraftRow); + await session.press("escape"); + await harness.waitForSnapshot(session, (text) => !text.includes("Draft note"), 20_000); + + await session.press("z"); + await session.press("t"); + const topAligned = await session.text({ immediate: true }); + const topAlignedRow = lineIndexOf(topAligned, "export const line11 = 11;"); + + await session.press("z"); + await session.press("z"); + const centered = await session.text({ immediate: true }); + expect(lineIndexOf(centered, "export const line11 = 11;")).toBeGreaterThan(topAlignedRow); + + // `:` passes into the registered command, whose focused host dialog owns even mode keys. + await session.press(":"); + await session.waitForText(/Vim command \(:\)/, { timeout: 20_000 }); + await session.type("j-owned"); + await session.waitForText(/j-owned/, { timeout: 20_000 }); + await session.press("escape"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Vim command (:)") && /Vim navigation.*Esc exits/.test(text), + 20_000, + ); + + await session.press(":"); + await session.waitForText(/Vim command \(:\)/, { timeout: 20_000 }); + await session.type("bottom"); + await session.press("enter"); + const commandBottom = await harness.waitForSnapshot( + session, + (text) => text.includes("second.ts") && !text.includes("first.ts"), + 20_000, + ); + expect(commandBottom).toContain("second.ts"); + + await session.press(":"); + await session.waitForText(/Vim command \(:\)/, { timeout: 20_000 }); + await session.type("top"); + await session.press("enter"); + const commandTop = await harness.waitForSnapshot( + session, + (text) => + text.includes("first.ts") && + text.includes("export const line01 = 1;") && + !text.includes("second.ts"), + 20_000, + ); + expect(commandTop).toContain("first.ts"); + + await session.press(["ctrl", "d"]); + const controlDown = await harness.waitForSnapshot( + session, + (text) => text.includes("first.ts") && !text.includes("export const line01 = 1;"), + 20_000, + ); + expect(controlDown).toContain("first.ts"); + await session.press(["ctrl", "u"]); + await harness.waitForSnapshot( + session, + (text) => text.includes("export const line01 = 1;"), + 20_000, + ); + + // Both normal-mode absolute forms visibly move between the two long files. + await session.press(["shift", "g"]); + const bottom = await harness.waitForSnapshot( + session, + (text) => text.includes("second.ts") && !text.includes("first.ts"), + 20_000, + ); + expect(bottom).toContain("second.ts"); + + await session.press("g"); + await session.press("g"); + const top = await harness.waitForSnapshot( + session, + (text) => text.includes("first.ts") && !text.includes("second.ts"), + 20_000, + ); + expect(top).toContain("first.ts"); + const active = await session.waitForText(/Vim navigation.*Esc exits/, { + timeout: 20_000, + }); + expect(active).toContain("Vim navigation"); + + await session.press("escape"); + await harness.waitForSnapshot( + session, + (text) => !/Vim navigation.*Esc exits/.test(text), + 20_000, + ); + } finally { + session.close(); + } + }); + test("a startup handler's notify renders as a toast and clears itself", async () => { const configHome = harness.createIsolatedConfigHome(); const fixture = harness.createRepoExtensionFixture(NOTIFY_EXTENSION_SOURCE); diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 9cc5b7cc..1f848842 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -1,13 +1,13 @@ --- title: Extension API -description: Register themes, file previews, transforms, commands, dialogs, and events through the extension API object. +description: Register themes, file previews, keyboard modes, transforms, commands, dialogs, and events through the extension API object. --- The extension factory receives one API object. Registration calls are only valid while the factory is running; Hunk seals the object afterwards so a deferred callback cannot mutate the registry mid-session. This page indexes the whole object; larger registration calls are documented in depth on their own pages and summarized in place below. ## `hunk.apiVersion` -The API generation this Hunk speaks (currently `3`). Branch on it if you want one file to support several Hunk versions. Version 3 adds live execution of public Hunk commands from extension command handlers. +The API generation this Hunk speaks (currently `4`). Branch on it if you want one file to support several Hunk versions. Version 4 adds session-scoped keyboard modes; version 3 added live execution of public Hunk commands from extension command handlers. ## `hunk.registerTheme(theme)` @@ -73,6 +73,31 @@ Each file carries an opaque `metadata` field — the parsed diff the renderer dr You never need `metadata` to know a file's hunks: the read-only views Hunk hands outward (event payloads, sidebar props, a command's selection) carry a `hunks` list of public summaries — `index`, the `@@` header, and the inclusive old/new line spans, in render order. Like `changeType`, it is derived at that boundary; a transform neither receives nor produces it. +## `hunk.registerKeyboardMode(mode)` + +Register a session-wide, deliberately activated keyboard interpretation. Modes receive frozen plain key snapshots after dialogs, menus, focused inputs, and interactive file views, but before Hunk's ordinary command table. + +```ts +hunk.registerKeyboardMode({ + id: "normal", + title: "Vim navigation", + onKey(key, ctx) { + if (key.sequence !== "j") return "pass"; + ctx.commands.execute("hunk.review.stepDown"); + return "handled"; + }, +}); + +hunk.registerCommand({ id: "vim", title: "Toggle Vim navigation", key: "ctrl+v" }, (ctx) => { + if (ctx.keyboardModes.isActive("normal")) ctx.keyboardModes.exitMode(); + else ctx.keyboardModes.enterMode("normal"); +}); +``` + +`onKey` returns `"handled"`, `"pass"`, or `"exit"` synchronously. Optional `onEnter`/`onExit` callbacks reset extension-owned state such as counts and pending sequences; while either lifecycle callback runs, `enterMode()` and `exitMode()` return `false`. The context exposes only `cwd`, `notify`, public `commands`, and activation-scoped `keyboardModes` controls. Those controls become inert on exit, so retained callbacks cannot replace a later mode. When the session mode is the highest-priority input owner, host-owned Escape exits it; the persistent status badge and Extensions-menu exit are clickable too. + +One session mode runs at a time. Entering another runs the outgoing `onExit` first. Focused dialogs and file-view modes temporarily outrank, rather than destroy, a session mode. Content soft reloads preserve it; extension reload, registry closure, and App teardown retire it. See the complete [authoring guide](https://github.com/modem-dev/hunk/blob/main/docs/extensions.md#session-keyboard-modes) and [`vim-navigation` example](https://github.com/modem-dev/hunk/tree/main/examples/extensions/vim-navigation), which includes counts, Ctrl chords, and a focused `:` command line. + ## `hunk.registerCommand(command, handler)` Register a named command, optionally bound to a key. Commands are the same mechanism Hunk's own shortcuts dispatch through — one table, one loop, built-ins first. @@ -96,6 +121,7 @@ Registered commands are also listed in the menu bar's **Extensions** menu under The handler fires when the key is pressed outside modal UI (dialogs, menus, and focused text inputs own their keys). It receives the standard context plus: - `ctx.commands.isEnabled(commandId)` / `execute(commandId, { count? })` — probes or invokes an explicitly public built-in `hunk.*` command through the same live table as keyboard and menu actions. Relative movement applies counts atomically; extension-owned and cross-extension commands return `false`. +- `ctx.keyboardModes.enterMode(id)` / `exitMode()` / `isActive(id?)` — controls only keyboard modes registered by this command's owning extension. - `ctx.sidebars.open(viewId)` / `close(viewId)` / `toggle(viewId)` / `isOpen(viewId)` — a bare id names your own view, `"files"` the built-in file navigation, `":"` any registered view. Opening also reveals a hidden sidebar area. - `ctx.fileViews.select(viewId)` / `toggle(viewId)` / `isActive(viewId)` — controls a matching [file preview](/docs/extend/file-previews/) for the current file; `select(null)` restores raw diff. - `ctx.fileViews.refresh(viewId, options?)` — marks that view's prepared layouts stale so a stateful view re-derives; every file presenting it re-lays out, keeping its current rows visible until the replacement resolves. Pass `{ fileId }` to scope the invalidation to one reviewed file's presentation of the view. diff --git a/website/src/content/docs/docs/extend/file-previews.md b/website/src/content/docs/docs/extend/file-previews.md index d3594fea..928e2891 100644 --- a/website/src/content/docs/docs/extend/file-previews.md +++ b/website/src/content/docs/docs/extend/file-previews.md @@ -187,11 +187,11 @@ mode: { }, ``` -Start it from a command with `ctx.fileViews.enterMode("preview")`. Entering also selects the preview and returns whether the mode started. Only one mode runs at a time; use `exitMode()` to stop it and `isModeActive("preview")` to check it. +Start it from a command with `ctx.fileViews.enterMode("preview")`. Entering also selects the preview and returns whether the mode started. Only one interactive preview mode runs at a time; use `exitMode()` to stop it and `isModeActive("preview")` to check it. It may overlap a session keyboard mode, but receives keys first until it exits. -`onKey` returns `"handled"` to consume a key, `"pass"` to continue normal Hunk routing, or `"exit"` to consume the key and stop. It must return synchronously. Escape is reserved by Hunk and always exits. +`onKey` returns `"handled"` to consume a key, `"pass"` to continue through any active session keyboard mode and then normal Hunk routing, or `"exit"` to consume the key and stop. It must return synchronously. When the file-view mode is the highest-priority input owner, Escape is reserved by Hunk and exits it. -Modes also exit when their file, presentation, extension, or review session changes. Optional `onEnter` and `onExit` callbacks track that lifecycle; `onExit` runs exactly once per activation. A failing `onEnter` or `onKey` exits the mode, and any callback failure warns without breaking the review. +Modes also exit when their file, presentation, extension, or review session changes. Optional `onEnter` and `onExit` callbacks track that lifecycle and must return synchronously; `onExit` runs exactly once per activation. A failing or asynchronous `onEnter` or `onKey` exits the mode, and any callback failure warns without breaking the review. ## Validation and fallback