Skip to content

refactor: rewrite generative-ui extension — display-only widgets, cross-platform, typed protocol - #5

Merged
Michaelliv merged 9 commits into
mainfrom
refactor/widget-runtime
Jun 3, 2026
Merged

refactor: rewrite generative-ui extension — display-only widgets, cross-platform, typed protocol#5
Michaelliv merged 9 commits into
mainfrom
refactor/widget-runtime

Conversation

@Michaelliv

Copy link
Copy Markdown
Owner

Full rewrite of the generative-ui extension. Started as "the SVG export menu PR was kinda gnarly, can we do better?" and grew into a complete restructure.

What this does

For the user:

  • Display-only widgets that render fast and don't block the agent. execute() returns the moment the final HTML lands — no more 2-minute "waiting for interaction" timeout.
  • macOS + Linux + Windows. Drops the "os": ["darwin"] lock.
  • Hover any <svg> in a widget → floating menu with Copy to clipboard / Download file. Backed by a typed RPC layer that talks to per-OS shims.
  • Chart.js / D3 / mermaid actually work now — external script tags are awaited sequentially so inline scripts don't run before their CDN deps load.
  • SVG palette retuned. Calmer outlines, crisper edges, less "marker pen on construction paper".

For the codebase:

  • index.ts: 632 → ~250 lines. Just tool registration + a thin streaming bridge.
  • Page-side runtime is real TypeScript bundled by esbuild into a single IIFE inlined into the shell HTML. No more 140-line IIFEs assembled as strings inside Node.
  • Typed JSON protocol both directions, discriminated unions, narrow type guards.
  • Platform abstraction (platform/{darwin,linux,win32}.ts) so clipboard + save-dialog are one-call interfaces.
  • 34-test vitest suite + cross-platform CI matrix (macOS/Linux/Windows × Node 20/22) that builds the runtime, verifies the committed bundle is in sync, and runs the full suite on every cell.

Highlights from the journey

I asked for honest critique three times mid-PR. Each pass turned up real issues:

  • First pass killed a window-global anti-pattern I'd accidentally reintroduced, fixed timer/listener leaks in awaitInteraction, dropped wrong <\!-- escape, tightened types.
  • Second pass dropped widget→agent interaction entirely (we weren't actually using it; it added 80+ lines of state machinery) and updated the LLM-facing tool description to stop promising callbacks that no longer exist.
  • Third pass fixed a real script-loading race (Chart.js was undefined when widgets tried to use it), tightened abort-signal handling, switched tool args to inferred Static<TParams> types.

Commits

688146e refactor: display-only widgets + SVG palette tune + script-load fix + final polish
a1b2092 ci: add cross-platform matrix workflow + win32 tests + polish
9fc314e build: regenerate runtime.bundle.ts after pristine pass
c1657e0 refactor: pristine pass — kill window globals, fix leaks, tighten types
2e66b94 test: full coverage — protocol, rpc, session, platform, integration
d9f829f feat(host): WidgetSession + RPC + platform shim + slim index.ts
490fdf3 feat(runtime): scaffold page-side runtime + esbuild bundle
d3c30ab chore: migrate @mariozechner/* peer deps → @earendil-works/*
d1f6c1d chore: upgrade glimpseui ^0.3.5 → ^0.8.1

Behavior changes worth flagging

  • window.glimpse.send(data) from widgets is now a no-op. The LLM is explicitly told not to emit it. Existing widgets that used it will silently lose their callbacks. The new contract: widgets are display-only; in-widget interactivity (sliders updating charts, hovers, canvas animations) still works fine.
  • Deps migrated: @mariozechner/pi-*@earendil-works/pi-*, @sinclair/typeboxtypebox (the framework switched packages).
  • Glimpse: ^0.3.5^0.8.1 (the cross-platform release).
  • Postinstall: runs npm run build:runtime on install. The committed runtime.bundle.ts means end users don't need esbuild present; CI verifies the bundle stays in sync.

Testing

npm test runs:

  • 7 protocol guard tests
  • 6 RPC routing tests
  • 8 session lifecycle tests
  • 11 platform shim tests (darwin / linux / win32, mocked child_process)
  • 2 integration tests that open a real Glimpse window (skipped automatically when the native binary isn't present)

34/34 green locally. CI matrix in .github/workflows/test.yml runs the same suite on macOS, Linux, and Windows.

Michael Livshits added 9 commits June 2, 2026 17:10
- protocol.ts: shared discriminated-union message types (host ↔ page)
- runtime/bridge.ts: deliver hook + typed on()/send() + page→host RPC
- runtime/morph.ts: morphdom-based applyHTML + runScripts (final-only)
- runtime/features/svg-saver.ts: hover menu, calls svg.copy / svg.save RPC
- runtime/index.ts: boot — install bridge, subscribe to content, mount features
- build.mjs: esbuild bundles runtime → IIFE → inlined into shell HTML →
  runtime.bundle.ts exports RUNTIME_HTML/RUNTIME_VERSION (committed)
- tsconfig.json, devDeps (esbuild, morphdom, vitest, typescript, @types/node)

No behavior change yet — index.ts still consumes the legacy code path.
Host-side rewrite lands in chunk 2.
Host-side rewrite. index.ts shrinks from 632 to ~220 lines and the
streaming / execute() handoff collapses into one owner.

- protocol/rpc.ts: typed message bridge attached per window. One
  win.on('message') listener routes 'rpc-call' to handlers, wraps
  user payloads (window.glimpse.send({...})) into 'user-message'.
- session.ts: WidgetSession owns one window's lifetime. onChunk()
  debounces, onComplete() finalizes, awaitInteraction() races
  message/closed/error/abort/timeout. Ready handshake is a promise.
- platform/: darwin (osascript+pbcopy), linux (wl-copy/xclip/xsel +
  zenity/kdialog), win32 (PowerShell). Drops 'os: darwin' lock.
- features/svg-saver.ts: registers svg.copy / svg.save RPC methods,
  delegates to platform shim. ~40 lines.
- runtime/bridge.ts: wraps window.glimpse.send so user payloads get
  the 'user-message' envelope; protocol messages pass through.
- index.ts: tool registration only. Streaming path creates a session
  on toolcall_start, feeds chunks, hands off to execute() by content
  index. No escapeJS. No __glimpse_svg_action magic key. No WeakSet
  dedupe. No setSvgSaverReady triple-call. No win.info guessing.

Also switches Type import to 'typebox' (the new package pi-ai/pi-coding
-agent use). The old @sinclair/typebox peer dep was incompatible — its
TSchema and typebox's TSchema are nominally distinct, which collapsed
Static<TParams> to unknown[].
- tests/fake-window.ts: in-memory glimpse window stand-in. Captures
  win.send() eval payloads and decodes the __glimpseUI.deliver(JSON)
  back into structured messages so assertions stay readable.
- tests/protocol.test.ts (7): isHostToPage / isPageToHost guards
- tests/rpc.test.ts (7): attach() handler routing, error wrapping,
  unknown method response, user-message dispatch, malformed-message
  drops, attach() idempotence, </script> escaping in push()
- tests/session.test.ts (11): ready-handshake, debounce, MIN_CHUNK,
  duplicate suppression, onComplete cancels debounce + emits final,
  post-complete chunks ignored, awaitInteraction message/closed/
  error/abort/timeout, first-terminator-wins race.
- tests/platform.test.ts (7): mocked child_process. darwin pbcopy +
  osascript argv; linux wl-copy/xclip selection by env, missing-tool
  error, zenity invocation; the getPlatform() factory.
- tests/integration.test.ts (2): opens a real glimpse window (skipped
  via describe.skip when the native binary is absent and FORCE_INTEGRATION
  is unset). Streams a chunk, finalizes, asserts the user-message
  round-trip. Second case wraps __glimpseUI.deliver to surface an RPC
  result back, exercising the full host↔page loop.

vitest.config.ts added (node env, 10s timeout).
Architectural fixes:
- glimpse-window.ts: one structural type (GlimpseWindowLike + Opener) shared
  by session, rpc, and tests. Drops 'as never' / 'as unknown as object' casts
  throughout production code.
- runtime/features/svg-saver subscribes to bridge 'content' events directly
  and owns its own ready bit. Kills the __glimpseUiSvgSetReady global that
  was the same anti-pattern the original code shipped (runtime entry knowing
  about a specific feature by name via a window global).
- runtime/index.ts no longer references any feature; just installs bridge,
  morph loop, then features (each manages itself).

Correctness fixes:
- session.awaitInteraction: clears its setTimeout on win and removes its
  abort listener when another terminator wins. No more leaked timers/handles.
- session.awaitInteraction throws if called twice (tested).
- Page-side rpc() now has a configurable timeout (30s default) so orphaned
  calls don't hang forever in the pending Map.
- rpc.jsLiteral: dropped the bogus <\!-- escape (no-op in JS strings) and
  switched </script> to \u003C/script (decodes back to original on eval).
- Host-side svg-saver is sole owner of filename sanitization; the client
  just sends suggestedName as a hint.
- features/svg-saver: trigger no longer has a click handler conflict; the
  toggle works whether the menu is open or closed.

UX fixes (followed user testing):
- SVG menu auto-dismisses ~900ms after a successful Copy/Save (skips
  dismiss on Save-dialog cancellation — they're likely about to retry).
- Click outside menu, or Escape, dismisses immediately.
- Trigger and menu hover/transit decoupled: 120ms grace lets the cursor
  cross the trigger↔menu gap without collapsing the dropdown. The earlier
  bug where Download became unclickable is gone.
- Trigger visibility (host) and menu visibility are independent state
  machines; SVG hover only drives the trigger, not the menu.
- Dropped the 4px blue outline on the trigger; kept the subtle background
  darken when the menu is open.

Platform cleanup:
- darwin: spawn + execFile imported once.
- win32: single runPS(ps, stdin?) helper; copyText no longer duplicates
  the PowerShell invocation; stale 'env to avoid quoting' comment removed.
- platform/index: 'unsupported' branch returns a Platform with name:
  'unsupported' instead of an unsound cast on process.platform.

Dead code removed:
- setHTML in GlimpseWindowLike / FakeWindow.
- WidgetSession.onUserMessage public method.

Tests:
- FakeWindow implements GlimpseWindowLike directly; session.test drops
  'as never'.
- Added: awaitInteraction throws on second call; timer cleanup on
  first-terminator-wins; abort listener removal.
- rpc test verifies </script> survives the round-trip via eval+JSON.parse.
- Integration tests pass {hidden: true} and wrap in try/finally so failed
  assertions don't leak windows; second test uses the public
  __glimpseUI.rpc API instead of monkey-patching deliver.

README and CHANGELOG updated to reflect the new architecture, the
cross-platform support (macOS / Linux / Windows via Glimpse 0.8), and
the public widget RPC surface.
Reflects all the host↔page changes above plus the SVG menu UX work in
runtime/features/svg-saver.ts. Hash 8a9f3b16e91a, 19.9 KB.

Run `npm run build:runtime` to regenerate.
CI workflow (.github/workflows/test.yml):
- Matrix: macos-latest, ubuntu-latest, windows-latest × node 20, 22.
- Typecheck, build runtime, verify the committed bundle stays in sync
  with sources (catches forgotten 'npm run build:runtime' commits), then
  run the full vitest suite on every platform.
- macOS does a full 'npm ci' so glimpseui's postinstall compiles the
  Swift binary and the real-window integration tests run.
- Linux + Windows use '--ignore-scripts' (avoids requiring Rust+GTK or
  .NET 8 SDK in the runner). The integration tests skip themselves when
  the native binary is absent, so unit + platform tests still execute.

.gitattributes:
- Force LF line endings on all text files so the runtime.bundle.ts hash
  is identical across Windows / Linux / macOS — otherwise the
  bundle-sync check above would false-positive on Windows.

win32 platform tests (tests/platform.test.ts, +4 tests):
- copyText: powershell.exe is invoked with -EncodedCommand, the encoded
  command decodes to the expected PS, and the SVG payload reaches
  proc.stdin.
- chooseSavePath: PS contains SaveFileDialog + JSON-quoted filename +
  OverwritePrompt, trimmed stdout is returned as the chosen path.
- chooseSavePath returns null on empty (cancelled) stdout.
- GLIMPSE_PS_PATH env override is honored.

The execFile mock was extended to also record stdin for callers that
write to proc.stdin synchronously after the call (the win32 copyText
pattern). Existing darwin/linux tests are unaffected because they check
the right array (__execFileCalls vs __spawnCalls).

Polish:
- guidelines.ts: MODULE_SECTIONS is now 'as const satisfies …', exports
  a Module union type. AVAILABLE_MODULES is 'readonly Module[]' instead
  of 'string[]'. index.ts drops the 'as readonly string[]' and 'as
  string[]' casts; renderCall now takes 'readonly string[]'.
- runtime/features/svg-saver: trigger cursor is 'pointer' (was 'default')
  now that the trigger has a real click handler.
- platform/win32: PSExecutable env var renamed to GLIMPSE_PS_PATH
  (conventional naming, namespaced).
- tests/integration: drops the last 'open as never' by importing the
  Opener type and casting the dynamic glimpse import to '{ open: Opener }'.
… final polish

show_widget no longer waits for or surfaces user interactions
─────────────────────────────────────────────────────────────
- execute() resolves the moment the final HTML is delivered. No more
  'Widget still open (timed out waiting for interaction)' two minutes
  after the user already moved on.
- The window stays open; the user closes it when done.
- WidgetSession loses ~60 lines: no awaitInteraction, no SessionResult
  union, no timeout, no interactionStarted guard, no terminator-race.
- Host-side rpc.ts drops onUserMessage and the user-message branch.
- runtime/bridge.ts drops wrapGlimpse (was envelope-wrapping user
  glimpse.send payloads). Widget code can still call glimpse.send(...);
  the payload reaches native and is then dropped on the floor by design.
- PageToHost collapses to RpcCall.

Tool description now tells the truth
────────────────────────────────────
The previous description and promptGuidelines promised the LLM that
'window.glimpse.send(data) sends JSON data back to the agent' — but
after this commit it doesn't. Updated both to say widgets are
display-only and that glimpse.send / sendPrompt patterns are no-ops.
The LLM will stop emitting dead interactivity.

External script loading fix
───────────────────────────
runScripts in runtime/morph.ts now AWAITS the load event for each
external <script src=…> before processing the next one. Previously the
naive loop inserted the CDN script and immediately the inline script
that used it — running Chart.js initialization before Chart was
defined → blank canvas. Sequential loading fixes Chart.js, D3,
mermaid, anything that's 'CDN then init'.

SVG colour styling tuned
────────────────────────
Each c-* ramp now uses the 400-stop as the stroke (mid-luminance)
instead of the 200-stop (high-contrast outline) at 1px stroke-width
instead of 0.5px. Result: calmer outlines, crisper edges, less
'marker pen on construction paper'. --t neutral (used for .arr / .leader)
moved from #707070 to #b4b2a9 so arrows and leader lines are visible
without screaming.

Abort handling tightened
────────────────────────
- signal.aborted is checked at execute() entry and again after we own a
  session (covers the race between the two checks).
- The abort listener is wired BEFORE the async session.onComplete(code)
  await so an abort that arrives during the streaming flush still
  closes the window.

Static type plumbing
────────────────────
- Tool schemas (ReadMeParams, ShowWidgetParams) extracted as module
  constants, paired with explicit Details interfaces and passed as
  type params to registerTool<typeof Schema, Details>. renderCall
  and renderResult drop their hand-rolled arg types; TypeScript infers
  the proper Static<TParams> from the schema.

Misc polish
───────────
- session.ts: window-error events now logged (were silently swallowed).
- session.ts: MIN_CHUNK_BYTES has a doc comment explaining the threshold.
- protocol.ts: PageToHost / HostToPage members extracted as named
  interfaces (ContentMessage, RpcOk, RpcErr, RpcCall) so the discriminated
  union reads as one type per member instead of a wall of inline literals.
- rpc.ts: stale doc claiming we 'forward user-message payloads to
  registered listeners' removed.
- README + CHANGELOG updated to reflect the display-only contract.
@Michaelliv
Michaelliv merged commit f86b878 into main Jun 3, 2026
6 checks passed
@Michaelliv
Michaelliv deleted the refactor/widget-runtime branch June 3, 2026 08:30
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.

1 participant