Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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/constrained-jsx-file-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Experiment with fixed-height React and OpenTUI row components in extension file views, with a shared live semantic paint theme, while Hunk retains review-stream geometry, windowing, hunk navigation, inline-note placement, and symbolic fallback. Advance the extension API to version 2 for the streamlined file-view contract, with checked-in TypeScript, CSS palette, and dependency-delta demos.
5 changes: 5 additions & 0 deletions .changeset/rendered-markdown-file-view.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add a streamlined experimental extension file-view contract plus an optional, user-installable Markdown preview example. File views read exact source lazily, return generic host-rendered rows, and may bind rows to exact source ranges so Hunk can place inline notes. Raw diff remains the default and the all-or-raw fallback for unresolved note bindings. The View menu can apply the active presentation to every matching file in the changeset without widening extension command controls.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,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.
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/).

### OpenTUI component

Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 45 additions & 0 deletions docs/extension-api-evaluation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Extension API field notes: Review triage

`examples/extensions/review-triage/` is a deliberately ordinary, user-installable extension built only against `hunkdiff/extension`. It provides a session-local hunk triage board: a reviewer can open a right sidebar, navigate through public hunk summaries, mark the current hunk approved/investigate/blocked with an optional rationale, and clear decisions. Its commands are ordinary **Extensions** menu entries, while lifecycle and bus events keep the board current.

Building it validated the API's central path: a third-party extension can compose a React sidebar, menu-reachable commands, host-owned modal dialogs, selection snapshots, lifecycle subscriptions, notifications, and a small inter-extension bus without imports from Hunk internals. The PTY integration test loads this exact directory rather than a string fixture.

## Findings

### Sidebar geometry and selection following are missing

The public sidebar props expose width but not pane height, viewport bounds, scroll position, or a way to scroll an item into view. The bundled file sidebar uses host-internal `ScrollBoxRenderable` viewport events and `scrollChildIntoView`; a third-party sidebar cannot reproduce its windowing or follow-selection behavior. Review triage therefore uses a simple scrollbox and compact rows, but selected hunks can fall out of view for a large review.

**Suggested addition:** expose read-only pane viewport geometry plus a narrow `actions.scrollItemIntoView(id)` capability (or a supported scrollbox ref contract). This would let extensions virtualize and retain selection visibility without exposing OpenTUI internals.

### Extensions have no safe, host-managed persistence

The triage board can only be session-local. Extension config is repository-overridable and expressly untrusted for exec-adjacent decisions; using it as writable storage would be wrong. Writing an arbitrary file from the extension is possible but creates incompatible location, lifecycle, privacy, and cleanup policies for every author. Reloading can also change a file id or hunk index, so blindly persisting the current key would misapply decisions.

**Suggested addition:** a namespaced, user-owned storage API with explicit scopes such as session and local-user/repository, plus a changeset identity available for reconciliation. Hunk should own the file location and trust semantics.

### Command handlers cannot navigate the review stream

Commands receive a selection snapshot, dialogs, and sidebar open/close controls, but no `selectFile` or `selectHunk`. A "next blocked hunk" command therefore cannot navigate directly; it would need to rely on a mounted sidebar to perform navigation, which is both indirect and unreliable on a narrow terminal. Review triage avoids shipping that misleading command and makes hunk rows clickable instead.

**Suggested addition:** place the existing guarded `selectFile` / `selectHunk` navigation methods on command context as well as sidebar actions.

### Dialogs are intentionally simple, but triage exposes their limits

The select/input sequence works well for a short status and single-line rationale. There is no structured option value (only displayed strings), validation hook, multiline input, or way to retain a dialog target if the session reloads; reload cancellation is safe and correct, but an extension has to design around it.

**Suggested addition:** retain the current simple primitives, then consider labelled `{ value, label }` select choices and a multiline input primitive. Dialog requests should still cancel on reload rather than acting on stale review state.

### The Extensions menu is command-generated, not extensible layout

Commands make the extension visible in the Extensions menu and are sufficient for this workflow. But they cannot add a custom submenu, separator, checked state, disabled state, or an entry elsewhere in the menu bar. This is an appropriate initial boundary, but it means command titles must carry more UI work than a purpose-built menu model.

**Suggested addition:** no change is required yet. If richer menu integration is added, model it as declarative command state rather than arbitrary extension renderables in chrome.

## Non-gaps confirmed by the extension

- A React component loaded from disk can render in Hunk's tree and use hooks when it imports the host-served `react` module.
- The public hunk summaries and sidebar selection/index contract are sufficient to render and drive a hunk-level board without accessing opaque diff metadata.
- `useSyncExternalStore` is a viable bridge from detached lifecycle callbacks to sidebar rendering, including while the sidebar is closed.
- Host-rendered dialogs provide appropriate attribution and modal behavior; command registrations provide menu and keyboard access through one mechanism.
- Lifecycle events and the namespaced bus are sufficient for session-local, fire-and-forget coordination, as long as the extension treats them as observers rather than persistence or request/response channels.
15 changes: 15 additions & 0 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,21 @@ registration identity. The frozen views fill `changeType` and the public
(`src/extensions/events.ts`, deriving through `src/core/hunkSummary.ts` — the
same helper the agent session surface reports hunks with).

## File-view system

File-view registrations are selected per file but remain inside the one
host-owned review stream. `src/ui/fileViews/useFileViews.ts` bounds asynchronous
extension work and retains only immutable layouts accepted by
`src/ui/fileViews/layout.ts`; width and registration identity are part of that
accepted geometry. `src/ui/fileViews/renderPlan.ts` is the shared insertion
plan for validated extension rows and host-owned inline notes. It resolves only
unambiguous exact-source bindings and returns an explicit unresolved set, so
`DiffPane` falls the complete file back to Pierre rather than guessing or
silently dropping review data. `src/ui/fileViews/geometry.ts` measures that same
plan, and `src/ui/components/panes/FileView.tsx` windows and paints it. Extension
components can paint only their fixed validated rectangles; note cards,
scrolling, hunk bounds, and navigation remain host-owned.

## Command system

Every app-level keyboard shortcut is a named command in one dispatch table
Expand Down
122 changes: 121 additions & 1 deletion docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,7 @@ cannot mutate the registry mid-session.

### `hunk.apiVersion`

The API generation this Hunk speaks (currently `1`). Branch on it if you want
The API generation this Hunk speaks (currently `2`). Branch on it if you want
one file to support several Hunk versions.

### `hunk.registerTheme(theme)`
Expand Down Expand Up @@ -646,6 +646,115 @@ Snapshots must be immutable — replace the set instead of mutating it, so
`useSyncExternalStore` can compare references. Storing state in a hook inside
the component instead would lose it every time the pane closes and unmounts.

### `hunk.registerFileView(view)` (experimental)

A file view is an alternate **host-rendered** presentation of one file in the
same top-to-bottom review stream. It is not a whole-file React component: Hunk
owns row measurement, scrolling/windowing, hunk navigation, and fallback to
Pierre's raw diff. A constrained, experimental
[fixed-height JSX row POC](file-view-jsx-poc.md) lets individual validated rows
paint OpenTUI content without taking over that geometry. Raw is always the
default; users select a matching view from **View** for the selected file.
Rows may bind themselves to exact old/new source ranges so Hunk can insert its
own inline review-note cards without giving the extension note contents or
geometry.

The installable
[`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/)
uses this contract for a parsed Markdown preview. It is intentionally not bundled
or loaded by default; copy the folder into `~/.config/hunk/extensions/`, install
its dependency there, and its View entry and `F8` command become available.

```ts
import type { HunkExtensionAPI } from "hunkdiff/extension";

export default function (hunk: HunkExtensionAPI) {
hunk.registerFileView({
id: "plain-markdown",
title: "Plain Markdown",
matches: (file) => file.path.endsWith(".md"),
async layout(input) {
const document = await input.readDocument("new");
if (!document || document.length > 100_000) return null;

const sourceLines = (document.endsWith("\n") ? document.slice(0, -1) : document).split("\n");
const rows = sourceLines.map((text, index) => ({
id: `line:${index + 1}`,
spans: [{ text: text || " " }],
sourceRanges: [{ side: "new" as const, range: [index + 1, index + 1] as const }],
}));
if (rows.length === 0) return null;

return {
rows,
hunkRows: (input.file.hunks ?? []).map((hunk) => ({
startRow: Math.max(0, (hunk.newRange?.[0] ?? 1) - 1),
endRow: Math.min(rows.length - 1, (hunk.newRange?.[1] ?? 1) - 1),
})),
};
},
});
}
```

`layout` receives one readonly input containing `file`, `width`, `signal`,
`changes`, and `readDocument`. `input.file` is the same frozen public
`ExtensionDiffFile` sidebars receive. `input.changes` exposes typed added and
removed ranges without Pierre metadata;
complete old/new hunk ranges remain available through `input.file.hunks`.
`readDocument("old" | "new")` is lazy and cached by Hunk; it resolves exact
text or `null` when that side is absent, unavailable, too large, or fails to
load. Never treat `null` as an exception: return `null` from `layout` to keep
raw diff active.

Layouts use an omitted tone for ordinary text and generic symbolic tones
(`muted`, `accent`, `accent-muted`, `syntax`, `added`, `removed`) plus optional
terminal attributes (`bold`, `italic`, `underline`, `strikethrough`). Hunk
resolves those primitives only while painting, so the host does not learn the
extension's content format and measurement remains theme-independent. Every
parsed hunk needs one in-bounds, inclusive `hunkRows` entry at the same array
position as `input.file.hunks`.

A row's optional `sourceRanges` contains inclusive, one-based exact-source
bindings such as `{ side: "new", range: [12, 18] }`. Hunk reads only the bound
source sides, verifies every range is in bounds, rejects overlapping ranges on
the same side across rows, and requires each bound row to belong to exactly one
`hunkRows` extent. One source line and one bound row therefore resolve to one
presentation/hunk target. Inline notes anchor by their existing preferred-side start
line and are inserted before the bound row. Placement is **all-or-raw** per
file: if any visible note is range-less or unbound, Hunk temporarily renders
the complete raw diff rather than guessing or dropping review data. The stored
presentation selection returns when the note layer is hidden or the mapping
becomes resolvable. Draft note editing remains raw-only.

Invalid, oversized, cancelled, or throwing layouts are isolated with one
warning per concrete extension registration and fall back to raw diff. Rapid width changes are coalesced, and Hunk never paints
geometry measured for a stale width. An experimental custom row keeps symbolic
fallback spans and declares its fixed painter
atomically as `component: { height, render }`. Painter props include the same
curated semantic `theme` palette as custom sidebars. It updates live at paint
time without entering `layout` or changing deterministic geometry. If painting
fails, the fallback spans are clipped to that same declared height rather than
changing stream geometry. Custom rows are non-focusable
paint surfaces: registered commands are their supported keyboard path. A
cooperatively delivered, handled left-button mouse-up may act and stop
propagation, while wheel, drag, and unhandled input remain host-owned. Hunk
makes no portal, renderer, focus, or input-delivery guarantee; see the linked
JSX POC for state lifetime, clipping, and error boundaries. The opt-in
[`jsx-file-view-gallery`](../examples/extensions/jsx-file-view-gallery/) runs
fixed JSX rows against checked-in TypeScript, CSS, and package dependency diffs.

A command handler can control the selected file's view through
`ctx.fileViews.select("view-id")`, `toggle("view-id")`, and
`isActive("view-id")`; pass `null` to `select` to restore raw rendering.
Bare ids address the calling extension; use `"other-extension:view-id"` to
address another registered view. The public command API remains current-file
only. When the current file already uses an alternate presentation, **View →
Apply “…” to all matching files** applies it to every file in the complete
changeset that passes that view's `matches` function, including files hidden by
the current filter. Nonmatches retain their existing choices, and host
constraints such as an active draft may temporarily keep a selected file raw.

### `hunk.registerCommand(command, handler)`

Register a named command, optionally bound to a key. Commands are not a
Expand Down Expand Up @@ -956,6 +1065,17 @@ to the terminal, because the TUI owns the screen.

## A complete example

The examples directory contains two user-installable folder extensions:

- [`examples/extensions/review-triage/`](../examples/extensions/review-triage/)
is a session-local hunk triage board combining a sidebar, commands, dialogs,
lifecycle listeners, and the extension event bus. Its API evaluation and
follow-up opportunities are recorded in
[Extension API field notes](extension-api-evaluation.md).
- [`examples/extensions/rendered-markdown/`](../examples/extensions/rendered-markdown/)
parses Markdown into generic host-owned file-view rows. Its README shows how
to run it from the checkout or copy it into the global extensions directory.

Collapse lockfiles and generated output out of every review, and say how many
files were hidden.

Expand Down
Loading
Loading