Skip to content

feat(embedded, ui): support embedding Hunk in host TUIs#309

Open
khoaHyh wants to merge 15 commits into
modem-dev:mainfrom
khoaHyh:feat/opentui-solid-export
Open

feat(embedded, ui): support embedding Hunk in host TUIs#309
khoaHyh wants to merge 15 commits into
modem-dev:mainfrom
khoaHyh:feat/opentui-solid-export

Conversation

@khoaHyh
Copy link
Copy Markdown

@khoaHyh khoaHyh commented May 14, 2026

Summary

Playing with Hunk internals for an OpenCode plugin, and turned into a real embedded API instead of a one-off custom route.

  • Add a public hunkdiff/embedded export for creating an embedded Hunk session and mounting it inside a host OpenTUI app.
  • Support worktree, staged, VCS range, show, stash show, file diff, patch, and difftool sources through the same config + loader pipeline as the CLI.
  • Scope renderer dimensions, resize events, and keyboard/paste input to the host-provided container so an embedded Hunk view can stay mounted without stealing input while inactive.
  • Keep embedded sessions connected to the normal Hunk session broker, including launching the broker from the packaged hunkdiff binary.
  • Centralize shared review command state so CLI sessions, broker commands, the UI controller, and embedded sessions use the same selected hunk, comments, notes, and snapshot behavior.

Why

The original goal was to embed hunkdiff inside OpenCode's TUI. Rather than reaching into renderer internals from the plugin, this gives host TUIs a small supported surface:

  • createEmbeddedHunkSession(...) owns source loading, reloads, snapshots, live comments, notes, and broker registration.
  • mountEmbeddedHunkApp(...) renders the existing Hunk review UI into a host OpenTUI container.

LLM use disclosure

I used gpt 5.5 & kimi k2.6 to develop this PR via opencode & pi.
I used gpt 5.5 to draft this PR description and then edited and filtered out some stuff myself.
All diffs were dogfooded with hunk inside opencode via the agent notes feature. Also used plannotator-review for the code tours feature for convenient comprehension.

@socket-security
Copy link
Copy Markdown

socket-security Bot commented May 14, 2026

No dependency changes detected. Learn more about Socket for GitHub.

👍 No dependency changes detected in pull request

@benvinegar
Copy link
Copy Markdown
Member

benvinegar commented May 14, 2026

Am I misinterpreting, but does this mean maintaining an internal solidjs fork going forward?

Edit (after more than a passing glance): Okay, I guess it's not that bad. Just wondering how we can avoid drift between the React and Solid JS exports.

@khoaHyh khoaHyh changed the title feat(opentui-solid,ui): add solidjs export feat(embedded, ui): support embedding Hunk in host TUIs May 18, 2026
@khoaHyh khoaHyh force-pushed the feat/opentui-solid-export branch from 9eb8b26 to cd00647 Compare May 18, 2026 05:02
@khoaHyh
Copy link
Copy Markdown
Author

khoaHyh commented May 22, 2026

@benvinegar I didn't vibe with the internal SolidJS fork either 🤮 and I've since played around with a different approach

it now works through src/embedded:

  • loads any supported Hunk source using same config and diff pipeline as the CLI
  • mounts Hunk’s existing React/OpenTUI app into a renderer/container
  • my plugin stays responsible for OpenCode commands, overlay lifecycle, focus, and key input scoping
  • the embedded session registers with Hunk’s daemon/session broker, so agent notes, navigation, reloads, and comments still work even when the UI is not currently visible

just a bit of clean-up to do here then it should be ready!

Screen.Recording.2026-05-22.at.1.14.30.AM-compressed-9.5mb.mp4

khoaHyh added 3 commits May 22, 2026 16:38
Move selection, live comments, user notes, and session snapshots into a
single review-command state layer shared by the UI and embedded session.
@khoaHyh khoaHyh marked this pull request as ready for review May 23, 2026 04:40
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 23, 2026

Greptile Summary

This PR introduces a first-class embedded API (hunkdiff/embedded) that lets host TUIs (e.g., OpenCode) mount Hunk review sessions inside their own OpenTUI containers. It extracts all reactive review state management into a new ReviewCommandState model in reviewCommandState.ts, so the same state machine can drive both the React UI and a headless daemon-bridge path, and adds scoped key-input and renderer-scope wrappers that keep inactive mounts quiet.

  • New src/embedded/ package: createEmbeddedHunkSession owns lifecycle and headless command dispatch; mountEmbeddedHunkApp renders AppHost into a host-provided Renderable container with properly scoped keyboard and resize event delegation.
  • ReviewCommandState extraction: all live-comment, user-note, navigation, and snapshot logic is lifted from useReviewController into pure functions in reviewCommandState.ts, used symmetrically by the React hook and the headless embedded session.
  • Bootstrap sync in AppHost: a new useEffect detects prop-level bootstrap changes (driven by the embedded session) and bumps appVersion to re-mount App with fresh state, replacing a previous onReloadSession-only update path.

Confidence Score: 4/5

Safe to merge for the embedded-API use case; no data loss on the happy path, and the headless–UI hand-off is well-tested.

The ReviewCommandState extraction and the embedded session lifecycle are well-structured. The headless reload path casts a CliInput to EmbeddedHunkSource without conversion, which will silently misbehave if the daemon sends a reload kind that needs special embedded handling. The removeUserNote handler in useReviewController bypasses the updater-based updateCommandState pattern, leaving an inconsistency that could produce a stale state write under React 18 batching. User notes are also silently discarded by persistSessionSnapshot on every UI snapshot flush, meaning they won't survive a panel hide/show cycle.

src/embedded/session.ts (headless reload cast and user-note persistence) and src/ui/hooks/useReviewController.ts (removeUserNote state-update pattern)

Important Files Changed

Filename Overview
src/embedded/session.ts New embedded session class; headless reload path casts CliInput to EmbeddedHunkSource unsafely, and persistSessionSnapshot drops user notes on every snapshot update from a mounted UI.
src/ui/hooks/useReviewController.ts Major refactor to delegate to ReviewCommandState; removeUserNote uses a direct ref-mutation + value setState pattern that is inconsistent with the updater-based updateCommandState, and a redundant useEffect syncs the ref after the fact.
src/hunk-session/reviewCommandState.ts New pure-function state machine for review command state; well-structured extraction from useReviewController, symmetric between UI and headless paths.
src/embedded/mount.tsx New mount helpers; scoped key-input and renderer-scope wrappers look correct; cleanup ordering in unmount() is sound.
src/ui/AppHost.tsx Adds useEffect-driven bootstrap sync for embedded re-mounts; initialSessionState threading looks correct.

Sequence Diagram

sequenceDiagram
    participant Host as Host TUI
    participant Session as EmbeddedHunkSession
    participant Mount as mountEmbeddedHunkApp
    participant AppHost as AppHost (React)
    participant Broker as SessionBrokerClient
    participant Daemon as Hunk Daemon

    Host->>Session: "createEmbeddedHunkSession({ cwd, source })"
    Session->>Broker: start() — register with local daemon
    Session-->>Host: EmbeddedHunkSession

    Host->>Mount: "mountEmbeddedHunkApp({ session, renderer, container, active })"
    Mount->>Mount: createScopedKeyInput + createEmbeddedRendererScope
    Mount->>AppHost: render AppHost with bootstrap + initialSessionState
    AppHost-->>Mount: "EmbeddedHunkMount { update, unmount }"

    Daemon->>Broker: command (comment, navigate, etc.)
    Broker->>Session: dispatchCommand(message)
    alt UI mounted
        Session->>AppHost: mountedBridge.dispatchCommand
        AppHost->>Session: hostClient.updateSnapshot
        Session->>Session: persistSessionSnapshot
        Session->>Broker: brokerClient.updateSnapshot
    else headless
        Session->>Session: headless state mutation
        Session->>Broker: brokerClient.updateSnapshot
    end

    Host->>Session: session.open(newSource)
    Session->>Session: load → setRenderSnapshot
    Session-->>AppHost: useSyncExternalStore re-render
    AppHost->>AppHost: useEffect → setAppVersion(v+1)

    Host->>Mount: mount.unmount()
    Mount->>AppHost: root.unmount()
    Mount->>Mount: dispose scoped renderer + key input
Loading

Comments Outside Diff (2)

  1. src/ui/hooks/useReviewController.ts, line 474-478 (link)

    P2 removeUserNote uses a different state-update pattern from every other handler

    All other mutations go through updateCommandState(updater), which updates commandStateRef.current inside the React state setter (during reconciliation, with the latest committed value as input). removeUserNote instead reads commandStateRef.current synchronously, writes back to it, and then calls setCommandState(value) — a non-updater form. Under React 18 automatic batching, if updateCommandState and removeUserNote are both queued in the same flush, the non-updater setCommandState(value) will overwrite the result of the preceding updater call because value was computed from the ref before that updater ran. Switching to updateCommandState((current) => removeSavedUserReviewNote(current, noteId)) keeps the ref and state in sync via the setter callback.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/ui/hooks/useReviewController.ts
    Line: 474-478
    
    Comment:
    **`removeUserNote` uses a different state-update pattern from every other handler**
    
    All other mutations go through `updateCommandState(updater)`, which updates `commandStateRef.current` inside the React state setter (during reconciliation, with the latest committed value as input). `removeUserNote` instead reads `commandStateRef.current` synchronously, writes back to it, and then calls `setCommandState(value)` — a non-updater form. Under React 18 automatic batching, if `updateCommandState` and `removeUserNote` are both queued in the same flush, the non-updater `setCommandState(value)` will overwrite the result of the preceding updater call because `value` was computed from the ref before that updater ran. Switching to `updateCommandState((current) => removeSavedUserReviewNote(current, noteId))` keeps the ref and state in sync via the setter callback.
    
    How can I resolve this? If you propose a fix, please make it concise.
  2. src/ui/hooks/useReviewController.ts, line 425-427 (link)

    P2 Redundant commandStateRef sync via useEffect

    updateCommandState already writes commandStateRef.current = next inside the setCommandState updater callback (which runs synchronously during reconciliation), so the ref is always current before any effects fire. The useEffect here re-assigns the same value one asynchronous phase later and adds no safety net — it just creates the misleading impression that the ref might lag behind state between render and commit. Removing it would make the two update sites (updateCommandState and applyCommandStateTransition) the single authoritative owners of the ref.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/ui/hooks/useReviewController.ts
    Line: 425-427
    
    Comment:
    **Redundant `commandStateRef` sync via `useEffect`**
    
    `updateCommandState` already writes `commandStateRef.current = next` inside the `setCommandState` updater callback (which runs synchronously during reconciliation), so the ref is always current before any effects fire. The `useEffect` here re-assigns the same value one asynchronous phase later and adds no safety net — it just creates the misleading impression that the ref might lag behind state between render and commit. Removing it would make the two update sites (`updateCommandState` and `applyCommandStateTransition`) the single authoritative owners of the ref.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
src/embedded/session.ts:243-258
**Unsafe `CliInput → EmbeddedHunkSource` cast in headless reload path**

`createHunkSessionBridge` delivers `nextInput` as a `CliInput` (the daemon's internal type), but it is cast to `EmbeddedHunkSource` before being passed to `load()`. `load()` then stores it as `this.source` (typed `EmbeddedHunkSource`) when `updateSource: true`. If the daemon ever sends a reload with a `CliInput` kind that maps differently in the embedded context — or if a new `CliInput` variant is added — the cast will silently produce a mistyped `this.source` that breaks subsequent `open()` identity comparisons and `embeddedSourceToCliInput` conversions. A safer approach would be to map the `CliInput` back through an explicit converter rather than asserting the type.

### Issue 2 of 4
src/embedded/session.ts:297-307
**User notes are silently dropped on session remount**

`persistSessionSnapshot` rebuilds `reviewState` via `createReviewCommandState`, which always initialises `userNotesByFileId: {}`. `HunkSessionState` carries user notes only as `SessionReviewNoteSummary` entries inside `reviewNotes`, but `createReviewCommandState` only rehydrates `liveComments` — it has no path to reconstruct `userNotesByFileId`. The practical consequence: any notes a user created while the UI was mounted will not appear if the mount is removed and re-added (e.g., the user switches panels and back). Whether this is intentional or a known gap worth a comment would help future maintainers.

### Issue 3 of 4
src/ui/hooks/useReviewController.ts:474-478
**`removeUserNote` uses a different state-update pattern from every other handler**

All other mutations go through `updateCommandState(updater)`, which updates `commandStateRef.current` inside the React state setter (during reconciliation, with the latest committed value as input). `removeUserNote` instead reads `commandStateRef.current` synchronously, writes back to it, and then calls `setCommandState(value)` — a non-updater form. Under React 18 automatic batching, if `updateCommandState` and `removeUserNote` are both queued in the same flush, the non-updater `setCommandState(value)` will overwrite the result of the preceding updater call because `value` was computed from the ref before that updater ran. Switching to `updateCommandState((current) => removeSavedUserReviewNote(current, noteId))` keeps the ref and state in sync via the setter callback.

### Issue 4 of 4
src/ui/hooks/useReviewController.ts:425-427
**Redundant `commandStateRef` sync via `useEffect`**

`updateCommandState` already writes `commandStateRef.current = next` inside the `setCommandState` updater callback (which runs synchronously during reconciliation), so the ref is always current before any effects fire. The `useEffect` here re-assigns the same value one asynchronous phase later and adds no safety net — it just creates the misleading impression that the ref might lag behind state between render and commit. Removing it would make the two update sites (`updateCommandState` and `applyCommandStateTransition`) the single authoritative owners of the ref.

Reviews (1): Last reviewed commit: "Merge branch 'main' into feat/opentui-so..." | Re-trigger Greptile

Comment thread src/embedded/session.ts
Comment thread src/embedded/session.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants