Skip to content
Draft
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/fuzzy-agents-guide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add custom React/OpenTUI dialog surfaces to the extension API and run Hunk's Agent Skill onboarding as a bundled extension.
5 changes: 5 additions & 0 deletions .changeset/fuzzy-editors-dock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Let extension commands temporarily hand Hunk's terminal to an application, resolve filesystem-attested review locations, and run Hunk's responsive open-in-editor workflow as a bundled extension.
34 changes: 24 additions & 10 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ object and registry collection (`src/extensions/runExtension.ts`):
by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously
before config resolution, so backends exist without making core import the
extension host. `default/ui/index.ts` is deliberately not part of that list:
it synchronously loads the bundled files pane through `runExtensionFactory`
only where the app resolves UI panes.
it synchronously loads the bundled files pane and editor command through
`runExtensionFactory` only where the interactive app resolves UI contributions.

Git and the built-in file navigation use the public `registerVcsAdapter` and
`registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens)
Git, built-in file navigation, and open-in-editor workflow use the public
`registerVcsAdapter`, `registerPane`, and `registerCommand` paths. The external
[Hunk Lens](https://github.com/modem-dev/hunk-lens)
extension exercises current-line pane paint through that same public contract.

Bundled extensions are implicitly trusted and stay loaded under
Expand Down Expand Up @@ -235,12 +236,14 @@ modal keys also remain outside the table and therefore outside the event.
`ctx.dialogs` is the one place extension code can interrupt the user, so its
ordering and settlement live outside React in
`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a
per-extension `dialogs` object, normalizing (and sanitizing) extension-authored
text into a request the host draws, and answering by request id so a duplicated
per-extension `dialogs` object, normalizing host-rendered prompts or retaining a
custom component request, and answering by request id so a duplicated
Enter cannot spill onto whatever was queued behind. App subscribes with
`useSyncExternalStore`, renders the current request through
`src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`;
select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so
select and input are `ModalFrame` surfaces; `open` mounts a guarded public
React/OpenTUI component inside exact clamped bounds), 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
Expand All @@ -253,8 +256,9 @@ must not be able to impersonate Hunk. The host derives the extension's trusted
bundled origin from registry metadata and omits the redundant marker only for
Hunk-owned bundled UI. `src/ui/lib/modalGeometry.ts` clamps the frame before
extension text is wrapped or windowed, so measurement and rendering use the
same terminal width; body/options yield rows to a pinned mouse-clickable action
footer on short terminals.
same terminal width. Custom components receive the remaining exact rectangle
after required attribution and own layout within it; Hunk retains Escape,
clipboard mediation, queue settlement, and render-failure containment.

Lifecycle and bus handlers receive that same attributed dialog queue plus the
same guarded live navigation commands use. `App` installs both through the
Expand All @@ -272,10 +276,20 @@ inert before shutdown begins. Session behavior requests are registry data too:
presentation view changes ephemeral without teaching `App` about an extension
id.

`src/ui/hooks/useExtensionAppController.ts` owns `ctx.openInApp`. Command-scoped
leases refuse stale handoffs, one shared lock prevents overlapping applications,
and renderer suspension always resumes in `finally` unless the renderer was
destroyed. The extension owns execution and application-specific metadata;
Hunk's bundled editor command consumes the same public callback and explicitly
refreshes after a successful edit. Dialog admission and workspace writes consult
the same ownership state so host UI cannot deadlock behind a suspended renderer.

`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads
resolve reviewed file ids through the existing source fetcher, which retains
ownership of caching and size limits. Missing or unreadable sources become
`null`.
`null`. Location resolution maps reviewed file ids and source addresses onto
attested on-disk paths and lines using per-side provenance supplied by loaders
and VCS adapters plus the authoritative parsed hunk.

Writes are limited to reloadable working-tree reviews and reviewed paths inside
the review root. App supplies the current input, unfiltered changeset, and root
Expand Down
176 changes: 152 additions & 24 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,9 +280,12 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `15`). Branch on it if you want
one file to support several Hunk versions. Version 15 adds `{ side, line }` to
opted-in pane `currentLine` paint; version 14 added structured `rangeEndpoints`
The API generation this Hunk speaks (currently `17`). Branch on it if you want
one file to support several Hunk versions. Version 17 adds custom React/OpenTUI
dialog surfaces; version 16 added temporary application
handoffs and on-disk location resolution to command handlers; version 15 added
`{ side, line }` to opted-in pane
`currentLine` paint; version 14 added structured `rangeEndpoints`
to two-revision VCS diff requests; version 13 added saved-note parent identities and
committed note-edit events; version 12 adds responsive fractional pane sizing; version 11 added
the `"dim"` line-highlight tone; version 10 added generic top-level CLI commands; version 9
Expand Down Expand Up @@ -467,12 +470,13 @@ instead of a crash.
A `load` result is patch text plus how to label it. Everything else on it is
optional, and each optional field buys one thing:

| Field | What it adds |
| ---------------- | ------------------------------------------------------------------ |
| `untrackedPaths` | files your VCS calls unknown, synthesized into added-file diffs |
| `readFileSource` | exact whole-file contents, for context expansion and highlighting |
| `sourceCacheKey` | stable source-snapshot identity for highlight reuse across reloads |
| `extraFiles` | files reviewed outside the patch, including skipped placeholders |
| Field | What it adds |
| ----------------------- | ------------------------------------------------------------------ |
| `untrackedPaths` | files your VCS calls unknown, synthesized into added-file diffs |
| `readFileSource` | exact whole-file contents, for context expansion and highlighting |
| `resolveFileSourcePath` | exact filesystem provenance for application location handoff |
| `sourceCacheKey` | stable source-snapshot identity for highlight reuse across reloads |
| `extraFiles` | files reviewed outside the patch, including skipped placeholders |

`untrackedPaths` is the shorthand: list the repo-root-relative paths your VCS
reports as unknown and Hunk synthesizes the added-file diffs for you, skipping
Expand Down Expand Up @@ -585,6 +589,10 @@ async load(input, ctx) {
}
return changeType === "deleted" ? null : hgCat(newRev, path);
},
resolveFileSourcePath: ({ path, changeType, side }) => {
if (side !== "new" || changeType === "deleted" || input.range) return null;
return join(ctx.cwd, path);
},
};
}
```
Expand All @@ -604,6 +612,15 @@ stable identity and Hunk will invalidate conservatively. Leaving
`readFileSource` off is fine: Hunk falls back to the content the patch itself carries,
which renders the same diff with less context available.

`resolveFileSourcePath` is separate from source reads because a binary or skipped
file can still have a real path. Return an absolute path only when that exact
reviewed side is backed by the filesystem. Return `null` for absent sides and
for index, revision, stash, patch, merged, or other virtual sources, even when a
same-named working-tree file exists. Hunk uses this provenance for
`ctx.workspace.resolveLocation`; it never invents a checkout path for historical
content. Direct file and difftool comparisons retain their concrete input paths
independently of their display names.

#### Files outside the patch

`extraFiles` lists files to review that your `patchText` does not contain, in
Expand Down Expand Up @@ -1608,12 +1625,13 @@ your extension.

#### Asking the user

`ctx.dialogs` puts a question on screen and waits for the answer. Three shapes,
all promise-returning:
`ctx.dialogs` puts a modal surface on screen and waits for it to settle. Four
shapes, all promise-returning:

- `confirm({ title, body?, confirmLabel?, cancelLabel? })` → `true` or `false`
- `select({ title, options })` → the chosen string, or `null`
- `input({ title, placeholder?, initial? })` → the typed string, or `null`
- `open({ title, width?, height?, component })` → `void` when closed

```ts
hunk.registerCommand(
Expand Down Expand Up @@ -1657,18 +1675,74 @@ hunk.registerCommand({ id: "pick-hunk", title: "Pick a hunk", key: "ctrl+k" }, a
});
```

Hunk draws the dialog, not you: your text fills the title, body, and choices,
and dialogs from installed extensions carry an `ext <your-id>` attribution line
— the same marker `notify` toasts use — so a third-party prompt can never present
itself as Hunk asking. Hunk's own bundled extensions omit that redundant marker.
`open` mounts a React/OpenTUI component in an exact host-owned rectangle, like
`registerPane` inside modal chrome. `width` and `height` request the preferred
component size (defaults `64×12`, maximum `240×100`); Hunk clamps both to the
terminal before passing the resulting dimensions, semantic theme,
`copySupported`, and guarded `actions` to the component. Escape stays
host-owned. Other keys reach the component, and `actions.close()` resolves the
promise.

```tsx
import { useKeyboard } from "@opentui/react";
import { matchesKey, type ExtensionDialogProps } from "hunkdiff/extension";

const prompt = "Review the current Hunk session. Focus on correctness.";

function AgentSetupDialog({ actions, copySupported, theme }: ExtensionDialogProps) {
const copy = () => {
actions.notify(actions.copy(prompt) ? "Copied agent prompt" : "Clipboard copy failed");
};
useKeyboard((key) => {
if (!copySupported || !matchesKey("c", key)) return;
key.preventDefault();
key.stopPropagation();
copy();
});

return (
<box style={{ width: "100%", height: "100%", flexDirection: "column" }}>
<text fg={theme.text}>{prompt}</text>
<box onMouseUp={copy}>
<text fg={copySupported ? theme.accent : theme.muted}>
{copySupported ? "Copy prompt" : "Copy unavailable"}
</text>
</box>
</box>
);
}

hunk.registerCommand({ id: "agent-setup", title: "Agent setup" }, async (ctx) => {
await ctx.dialogs.open({
title: "Agent setup",
width: 64,
height: 6,
component: AgentSetupDialog,
});
});
```

`actions.copy(text)` uses Hunk's OSC 52 integration, strips terminal control
sequences, expands tabs to four spaces, and returns whether the renderer accepted
the bounded payload (maximum 16,384 JavaScript string code units).
`actions.notify(message)` shows a short host status message, and
`actions.close()` dismisses the modal. A render failure is contained to the
component and leaves a dismissible fallback.

Component dialogs are trusted extension code, just like pane components: Hunk
cannot verify that an arbitrary surface visually discloses what it passes to
`actions.copy`. Hunk owns the frame, title, bounds, Escape handling, and an
`ext <your-id>` attribution line for installed extensions. Hunk's bundled UI
omits that redundant marker.

One dialog is on screen at a time. Concurrent requests queue in call order,
across extensions too, so a second question waits its turn instead of replacing
the first. While a dialog is up it owns the keyboard: Escape cancels (`false`,
or `null`), Enter accepts — the confirm action, the highlighted option, or the
typed text — and review shortcuts stay suppressed underneath. Confirm dialogs
also answer to `y`/`n`, select dialogs to `↑`/`↓`, and every dialog's actions
and rows are clickable.
across extensions too, so a second modal waits its turn instead of replacing
the first. While a dialog is up it owns the keyboard: Escape cancels (`false`
or `null`) or closes a component dialog, Enter accepts the confirm action,
highlighted option, or typed text, and review shortcuts stay suppressed
underneath. Component-dialog keys other than Escape reach the mounted surface.
Confirm dialogs also answer to `y`/`n`,
select dialogs to `↑`/`↓`, and every dialog's actions and rows are clickable.

Two things resolve a dialog without the user: the session moving on, and bad
arguments. A session reload — the refresh key, a watch-triggered reload, an
Expand All @@ -1679,6 +1753,47 @@ the same way, and a request made after that point cancels immediately. A blank
answer from the user, so the promise **rejects**; like any other handler
failure, that surfaces as a warning naming your extension.

#### Temporary applications

`ctx.openInApp(callback)` temporarily replaces Hunk with an application your
extension runs. Hunk suspends its renderer before calling you and restores the
review in `finally` after your callback returns or throws:

```ts
async function runProjectTool(metadata: { file: string | undefined; line: number | undefined }) {
// Spawn an interactive process with inherited stdio and encode metadata however the app expects.
return { exitCode: 0, metadata };
}

hunk.registerCommand({ id: "open-tool", title: "Open project tool", key: "f8" }, async (ctx) => {
const file = ctx.selection.file;
const location = file
? ctx.workspace.resolveLocation({
fileId: file.id,
...(ctx.selection.hunkIndex === null ? {} : { hunkIndex: ctx.selection.hunkIndex }),
...(ctx.selection.currentLine === null ? {} : { line: ctx.selection.currentLine }),
})
: null;
const result = await ctx.openInApp(() =>
runProjectTool({
file: location?.path,
line: location?.line,
}),
);
if (result.exitCode !== 0) ctx.notify(`Tool exited with status ${result.exitCode}`, "error");
});
```

The extension owns process execution and decides how to pass file, line, hunk,
or extension state through arguments, environment, files, or an application-specific
protocol. Hunk only owns terminal suspension and restoration. One application
may own the terminal at a time; concurrent calls and controls retained past a
review reload reject without invoking the callback. The callback's value and
error pass through unchanged. Host-presented dialogs cancel immediately and
workspace writes return `unavailable` while the callback owns the terminal, so
do not await Hunk UI from inside it. Non-interactive workspace reads and location
resolution remain available.

#### Workspace documents

`ctx.workspace` reads full documents from the current review and can replace an
Expand All @@ -1687,6 +1802,7 @@ eligible working-tree file.
| Method | Result |
| -------------------------------------- | ------------------------------------------------- |
| `readDocument(fileId, "old" \| "new")` | The reviewed source text, or `null` |
| `resolveLocation({ fileId, ... })` | Absolute on-disk `{ path, line }`, or `null` |
| `canWriteDocument(fileId)` | Whether the review and file allow writes |
| `writeDocument({ fileId, text })` | `{ ok: true }` or `{ ok: false, reason, detail }` |

Expand Down Expand Up @@ -1718,6 +1834,17 @@ returns `null` when the file or side is absent, no source is available, reading
fails, or the document exceeds Hunk's size limit. Reads never prompt. An invalid
side rejects the promise.

`resolveLocation` turns a reviewed file id and optional `hunkIndex` and
`{ side, line }` into an attested absolute path and one-based line on disk. Hunk
uses parsed hunk metadata to map old-side deletions onto a filesystem-backed new
side, so extensions can pass accurate locations to editors, debuggers, browsers,
or other applications without interpreting opaque diff metadata. Direct file
comparisons retain their concrete input paths, including the old path for a
deleted-file comparison. Index, revision, stash, patch, merged, absent, and
other virtual sides return `null` instead of borrowing a same-named checkout
file. Missing hunks and stale controls also return `null`; malformed source
addresses reject.

Writes require all of the following:

- an unstaged working-tree review (`hunk diff` with no revision range)
Expand Down Expand Up @@ -1793,9 +1920,10 @@ ready resolve to their cancel value with a warning rather than opening later.
Controls retained across a review or extension-registry replacement expire:
navigation and pane mutations warn and do nothing, dialogs resolve to their
normal cancel value, and workspace reads or not-yet-started writes return
`null`/`unavailable` instead of acting on replacement content. Once a consented
filesystem write starts, it reports its actual outcome and success reconciles
the review then active.
`null`/`unavailable` instead of acting on replacement content. A stale
`openInApp` callback rejects before taking terminal ownership.
Once a consented filesystem write starts, it reports its actual outcome and
success reconciles the review then active.

| Event | Payload | When |
| ---------------------- | ----------------------- | --------------------------------------------------------- |
Expand Down
Loading