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.
13 changes: 8 additions & 5 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,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

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 Documentation exceeds line limit

This edited line exceeds the repository's 120-character limit; the same pattern occurs in website/src/content/docs/docs/extend/extension-api.md:318. Wrapping both lines keeps the documentation consistent with the project style guide.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: docs/extension-architecture.md
Line: 241

Comment:
**Documentation exceeds line limit**

This edited line exceeds the repository's 120-character limit; the same pattern occurs in `website/src/content/docs/docs/extend/extension-api.md:318`. Wrapping both lines keeps the documentation consistent with the project style guide.

**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.

Fixed in c206852. Wrapped the edited architecture paragraph and the website API documentation, including the newly documented document-size limits and clipboard normalization behavior.

Responded by OpenCode using openai/gpt-5.6-sol.

`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 @@ -254,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 Down
86 changes: 72 additions & 14 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,9 @@ new instances and run that shutdown/startup pair around the replacement.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `16`). Branch on it if you want
one file to support several Hunk versions. Version 16 adds temporary application
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`
Expand Down Expand Up @@ -1624,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 @@ -1673,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 Down
7 changes: 5 additions & 2 deletions skills/hunk-extensions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ bad or duplicate id is skipped with a startup notice.
| Coordinate with another loaded extension | `hunk.events.emit` / `hunk.events.on` |
| Read user-supplied settings | `hunk.config` (`[extension.<id>]` table) |
| Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command |
| Branch on the API generation (currently `16`) | `hunk.apiVersion` |
| Branch on the API generation (currently `17`) | `hunk.apiVersion` |

Registration is only valid while the factory runs — Hunk seals the API object
afterwards.
Expand Down Expand Up @@ -160,13 +160,16 @@ transform — gets `ctx.cwd` and `ctx.notify(message, type?)`. A file view's
(`isEnabled`/`execute` for public semantic `hunk.*` commands),
`ctx.keyboardModes` (enter/exit/probe this extension's session modes), `ctx.review`
(deeply immutable snapshots of stable files and complete saved store notes),
`ctx.dialogs` (`confirm`/`select`/`input`, queued and attributed),
`ctx.dialogs` (`confirm`/`select`/`input` plus `open` for a custom OpenTUI component,
queued and attributed),
`ctx.openInApp` (temporary terminal ownership around extension-run applications),
and `ctx.workspace` (`readDocument`, `resolveLocation`, `canWriteDocument`,
`writeDocument` with consent).
- **Pane components** get frozen `files`, selection, placement, exact dimensions,
optional `currentLine` paint (with `{ side, line }` when opted in), semantic `theme`, resolved `keybindings`, and
guarded navigation/notification `actions`.
- **Dialog components** get exact clamped dimensions, semantic `theme`, clipboard
availability, and guarded `close`/`copy`/`notify` actions. Escape remains host-owned.
- **File-view `layout`** gets `file`, `width`, `signal`, `changes`, and a lazy
`readDocument(side)`.
- **File-view `mode` handlers** get `ctx.file` and `ctx.fileViews`. `onKey`,
Expand Down
4 changes: 4 additions & 0 deletions src/extension-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,10 @@ export type {
ExtensionReviewSnapshotNote,
ExtensionReviewSnapshotNoteAnchor,
ExtensionConfirmOptions,
ExtensionDialogActions,
ExtensionDialogComponent,
ExtensionDialogOptions,
ExtensionDialogProps,
ExtensionDialogs,
ExtensionInputOptions,
ExtensionSelectOptions,
Expand Down
64 changes: 55 additions & 9 deletions src/extension-api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 16;
export const HUNK_EXTENSION_API_VERSION = 17;
export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION;

export type ExtensionNotifyType = "info" | "warning" | "error";
Expand Down Expand Up @@ -1631,27 +1631,71 @@ export interface ExtensionInputOptions {
initial?: string;
}

/** Actions available while an extension-owned dialog component is mounted. */
export interface ExtensionDialogActions {
/** Close this dialog and resolve its `open` promise. */
close(): void;
/**
* Copy terminal-safe text through Hunk's OSC 52 integration.
*
* Hunk strips terminal control sequences, expands tabs to four spaces, and
* rejects empty or oversized payloads. Returns false when copying is
* unsupported, refused, or no longer belongs to the mounted dialog.
*/
copy(text: string): boolean;
/** Show one short host status message while this dialog remains current. */
notify(message: string): void;
}

/** Everything an extension-owned dialog component receives. */
export interface ExtensionDialogProps {
/** Exact host-owned component width after terminal clamping. */
readonly width: number;
/** Exact host-owned component height after terminal clamping and attribution. */
readonly height: number;
readonly theme: ExtensionPaintTheme;
/** Whether Hunk's renderer currently supports clipboard writes. */
readonly copySupported: boolean;
readonly actions: ExtensionDialogActions;
}

/** A React/OpenTUI component mounted inside a host-owned modal frame. */
export type ExtensionDialogComponent = (props: ExtensionDialogProps) => unknown;

/** One extension-owned modal surface opened from a command or event handler. */
export interface ExtensionDialogOptions {
title: string;
/** Preferred component width in terminal cells. Defaults to 64; maximum 240. */
width?: number;
/** Preferred component height in terminal rows. Defaults to 12; maximum 100. */
height?: number;
component: ExtensionDialogComponent;
}

/**
* Ask the user questions from a command handler, one modal at a time.
* Present modal interactions from a command handler, one at a time.
*
* Every dialog is drawn by Hunk, not by the extension. Dialogs from installed
* extensions carry an attribution line naming their source, so a third-party
* prompt cannot present itself as Hunk asking; Hunk-owned bundled extensions
* Hunk draws every frame and every confirm/select/input surface; `open` mounts
* extension-owned content inside that frame. Dialogs from installed extensions
* carry an attribution line naming their source; Hunk-owned bundled extensions
* omit that redundant marker. Only one dialog is on screen at a time:
* concurrent requests queue in call order (FIFO), including across extensions,
* so a second question waits for the first to be answered rather than replacing it.
*
* Escape always cancels, resolving the cancel value (`false`, or `null`).
* Enter accepts: the confirm action, the highlighted option, or the typed text.
* Escape always dismisses, resolving the cancel value (`false`, `null`, or
* `undefined`). Enter accepts: the confirm action, the highlighted option, or
* the typed text. Open component dialogs remain mounted until they call
* `actions.close()` or the user presses Escape.
* A session reload — the refresh key, a watch-triggered reload, an agent
* command — cancels open and queued dialogs the same way: the review they
* asked about is being replaced. A dialog raised while the app is tearing
* down resolves its cancel value immediately, so a handler awaiting one is
* never left hanging.
*
* Bad arguments are a programming error rather than a user answer, so they
* reject instead of resolving: a missing or blank `title`, or a `select` with
* no options. Because a dialog call is only useful awaited, the rejection
* reject instead of resolving: a missing or blank `title`, a `select` with no
* options, or invalid component-dialog dimensions. Because a dialog
* call is only useful awaited, the rejection
* surfaces through the same path as any other handler failure — a warning toast
* naming the extension.
*/
Expand All @@ -1662,6 +1706,8 @@ export interface ExtensionDialogs {
select(options: ExtensionSelectOptions): Promise<string | null>;
/** Resolves the submitted text, or null on cancel/escape. */
input(options: ExtensionInputOptions): Promise<string | null>;
/** Mount an extension-owned React/OpenTUI surface inside a host-owned modal. */
open(options: ExtensionDialogOptions): Promise<void>;
}

/** One whole-document replacement an extension asks the host to write. */
Expand Down
40 changes: 40 additions & 0 deletions src/extensions/default/ui/agentSkill/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, mock, test } from "bun:test";
import type { ExtensionCommandContext } from "hunkdiff/extension";
import { getBundledUIRegistry } from "..";
import { AgentSkillDialog, BUNDLED_AGENT_SKILL_COMMAND_FULL_ID } from ".";

/** Return the agent-skill registration from the process-static bundled UI registry. */
function getBundledAgentSkillCommand() {
const registered = getBundledUIRegistry().commands.find(
({ extensionId, command }) =>
`${extensionId}.${command.id}` === BUNDLED_AGENT_SKILL_COMMAND_FULL_ID,
);
if (!registered) throw new Error("Bundled agent skill command is missing.");
return registered;
}

describe("bundled agent skill extension", () => {
test("registers the shared Hunk command identity without owning its host menu shell", () => {
const registered = getBundledAgentSkillCommand();

expect(registered.extensionId).toBe("hunk");
expect(registered.command).toEqual({
id: "app.openAgentSkill",
title: "Show setup guidance for reviewing with an agent",
});
});

test("opens its onboarding through the public component dialog", async () => {
const open = mock(async () => {});
const context = { dialogs: { open } } as unknown as ExtensionCommandContext;

await getBundledAgentSkillCommand().handler(context);

expect(open).toHaveBeenCalledWith({
title: "Agent skill",
width: 80,
height: 9,
component: AgentSkillDialog,
});
});
});
Loading
Loading