Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/session-keyboard-modes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extensions activate visible session-scoped keyboard modes that route keys through Hunk's public semantic commands.
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<id>]` 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

Expand Down
27 changes: 23 additions & 4 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <id>` attribution row — the toast marker — because
the title is extension-authored and a prompt must not be able to impersonate
Expand Down
106 changes: 97 additions & 9 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`

Expand Down Expand Up @@ -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)`

Expand Down Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions docs/keybindings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<extensionId>.<commandId>` 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
Expand Down
47 changes: 47 additions & 0 deletions examples/extensions/vim-navigation/README.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions examples/extensions/vim-navigation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { HunkExtensionAPI } from "hunkdiff/extension";
import { createVimNavigationState, executeVimCommand } from "./state";
Comment on lines +1 to +2

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 New files violate TypeScript style

The new Vim example and keyboard-mode implementation use two-space indentation and double-quoted strings throughout, conflicting with the repository requirement for four-space indentation and single quotes and establishing an inconsistent pattern for future extension examples.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: examples/extensions/vim-navigation/index.ts
Line: 1-2

Comment:
**New files violate TypeScript style**

The new Vim example and keyboard-mode implementation use two-space indentation and double-quoted strings throughout, conflicting with the repository requirement for four-space indentation and single quotes and establishing an inconsistent pattern for future extension examples.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No change here. Hunk's repository-local AGENTS.md makes oxfmt authoritative, and this code matches the existing two-space/double-quote format enforced by bun run format:check. The cited style rule belongs to the separate modem repository rather than modem-dev/hunk.

Responded by Pi using openai-codex/gpt-5.6-sol.

This comment was generated by Pi using gpt-5.6-sol

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right — I misattributed a style rule from the modem repository to modem-dev/hunk. Since oxfmt is the authoritative formatter here and bun run format:check passes, the two-space / double-quote style is correct and my comment was wrong.

Shall I remember this as a rule: "In modem-dev/hunk, treat oxfmt (enforced via bun run format:check) as the authoritative formatter — do not flag two-space indentation or double-quoted strings as style violations"?


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");
});
}
9 changes: 9 additions & 0 deletions examples/extensions/vim-navigation/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "hunk-vim-navigation-extension",
"private": true,
"hunk": {
"extensions": [
"./index.ts"
]
}
}
Loading
Loading