opencode-generative-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,8 @@ The plugin renders HTML fragments and raw SVG in a native macOS window using [Gl
9
9
 
10
10
  This project is an OpenCode-focused extraction and adaptation of the original [`Michaelliv/pi-generative-ui`](https://github.com/Michaelliv/pi-generative-ui) work for `pi`. The original repo did the reverse-engineering work and established the shape of the widget tools and guideline set.
11
11
 
12
+ The runtime now uses the same `morphdom` shell-update approach as the `pi` implementation: Glimpse loads a stable shell document, widget content is injected via `win.send()`, and DOM updates are diffed into place instead of replacing the whole page.
13
+
12
14
  ## What it does
13
15
 
14
16
  When OpenCode decides a response should be visual, the plugin can:
@@ -25,7 +27,24 @@ Typical use cases:
25
27
  - mockups
26
28
  - calculators
27
29
 
28
- ## Install from npm in OpenCode
30
+ Widget windows include built-in viewport controls:
31
+
32
+ - pinch or `Cmd/Ctrl + wheel` to zoom
33
+ - middle-mouse drag to pan
34
+ - `Space + drag` to pan with a regular mouse or trackpad
35
+ - keyboard shortcuts: `+`, `-`, `0`, `F`, and arrow keys
36
+
37
+ ## Install
38
+
39
+ ### From npm in OpenCode
40
+
41
+ OpenCode does not auto-discover this repo or install it just because it exists on GitHub.
42
+
43
+ For npm installation, all of the following must be true:
44
+
45
+ 1. This package has been published to npm
46
+ 2. The package name is listed in your OpenCode config
47
+ 3. OpenCode is restarted so it can install the plugin
29
48
 
30
49
  After publishing, add the package name to your OpenCode config:
31
50
 
@@ -36,11 +55,66 @@ After publishing, add the package name to your OpenCode config:
36
55
  }
37
56
  ```
38
57
 
39
- OpenCode will install the package with Bun on startup.
58
+ OpenCode installs npm plugins listed in `opencode.json` automatically with Bun on startup.
59
+
60
+ You do not need to run `npm install` or `bun install` inside the repo where you want to use the plugin. Just add the plugin name to your OpenCode config and restart OpenCode.
61
+
62
+ You can add it either:
63
+
64
+ 1. Globally in `~/.config/opencode/opencode.json`
65
+ 2. Per project in `opencode.json`
66
+
67
+ Example global config:
68
+
69
+ ```json
70
+ {
71
+ "$schema": "https://opencode.ai/config.json",
72
+ "plugin": ["opencode-generative-ui"]
73
+ }
74
+ ```
75
+
76
+ You can verify that OpenCode resolved the plugin with:
77
+
78
+ ```bash
79
+ opencode debug config
80
+ ```
81
+
82
+ If the package is installed correctly, you should see `opencode-generative-ui` in the resolved `plugin` list.
83
+
84
+ ### Runtime requirements
85
+
86
+ - macOS
87
+ - Bun
88
+ - OpenCode
89
+ - a working Swift/Xcode toolchain for `glimpseui`
90
+
91
+ This plugin opens widget windows through [Glimpse](https://github.com/hazat/glimpse), which builds a small native Swift binary during install.
92
+
93
+ If `glimpseui` fails to build, update your Xcode Command Line Tools or full Xcode install, then rerun:
94
+
95
+ ```bash
96
+ bun pm trust glimpseui
97
+ ```
98
+
99
+ Depending on how OpenCode installed the plugin, you may need to run that in the OpenCode cache/config directory rather than inside your app repo.
100
+
101
+ ### Local plugin development
102
+
103
+ If you are developing this plugin itself, install dependencies in this plugin repo:
104
+
105
+ ```bash
106
+ bun install
107
+ ```
108
+
109
+ If Bun blocks the native build:
110
+
111
+ ```bash
112
+ bun pm trust glimpseui
113
+ ```
40
114
 
41
- ## Install locally in OpenCode
115
+ ### Use from a local checkout before publishing
42
116
 
43
- Clone this repo, then copy the plugin entry file into your OpenCode plugin directory if you want to run it before publishing.
117
+ Clone this repo, then copy the plugin entry file into your OpenCode plugin directory if you want to run it before publishing to npm.
44
118
 
45
119
  For project-local use today:
46
120
 
@@ -53,7 +127,7 @@ For project-local use today:
53
127
 
54
128
  ## Development
55
129
 
56
- Install dependencies:
130
+ Install dependencies in this repo:
57
131
 
58
132
  ```bash
59
133
  bun install
@@ -71,22 +145,9 @@ Build publishable output:
71
145
  bun run build
72
146
  ```
73
147
 
74
- ## Runtime requirements
75
-
76
- - macOS
77
- - Bun
78
- - OpenCode
79
- - a working Swift/Xcode toolchain for `glimpseui`
80
-
81
- If `glimpseui` fails to build, update your Xcode Command Line Tools or full Xcode install, then rerun:
82
-
83
- ```bash
84
- bun pm trust glimpseui
85
- ```
86
-
87
148
  ## Current limitation
88
149
 
89
- This plugin does not yet reproduce pi's token-by-token widget streaming. Widgets render when the tool executes, not progressively during tool-argument streaming.
150
+ This plugin now uses `morphdom` for in-window DOM updates, but it still does not reproduce pi's token-by-token widget streaming. OpenCode does not currently expose the same tool-argument delta hooks, so widgets still render when the tool executes rather than progressively during argument streaming.
90
151
 
91
152
  ## Credits
92
153
 
package/dist/index.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { tool } from "@opencode-ai/plugin";
2
+ import { readFileSync } from "node:fs";
3
+ import { createRequire } from "node:module";
2
4
  import { open } from "glimpseui";
3
5
  import { AVAILABLE_MODULES, getGuidelines } from "./lib/guidelines.js";
4
6
  import { SVG_STYLES } from "./lib/svg-styles.js";
7
+ const require = createRequire(import.meta.url);
8
+ const MORPHDOM_SOURCE = readFileSync(require.resolve("morphdom/dist/morphdom-umd.min.js"), "utf8");
5
9
  const loadedModulesBySession = new Map();
6
10
  const activeWindowsBySession = new Map();
7
11
  function trackWindow(sessionID, win) {
@@ -55,16 +59,491 @@ function clearSessionState(sessionID) {
55
59
  loadedModulesBySession.delete(sessionID);
56
60
  closeSessionWindows(sessionID);
57
61
  }
58
- function wrapHTML(code, isSVG = false) {
59
- if (isSVG) {
60
- return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>${SVG_STYLES}</style></head>
61
- <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1a1a1a;color:#e0e0e0;">
62
- ${code}</body></html>`;
62
+ const WIDGET_SHELL_STYLES = `
63
+ #oc-shell {
64
+ position: relative;
65
+ width: 100vw;
66
+ height: 100vh;
67
+ overflow: hidden;
68
+ background: #1a1a1a;
69
+ color: #e0e0e0;
70
+ }
71
+
72
+ #oc-viewport {
73
+ position: absolute;
74
+ inset: 0;
75
+ overflow: hidden;
76
+ user-select: none;
77
+ }
78
+
79
+ #oc-stage {
80
+ position: absolute;
81
+ left: 0;
82
+ top: 0;
83
+ transform-origin: 0 0;
84
+ will-change: transform;
85
+ }
86
+
87
+ #oc-content {
88
+ position: relative;
89
+ }
90
+
91
+ body.oc-html #oc-content {
92
+ padding: 16px;
93
+ }
94
+
95
+ body.oc-svg #oc-content {
96
+ padding: 0;
97
+ }
98
+
99
+ body.oc-svg #oc-content > svg {
100
+ display: block;
101
+ }
102
+
103
+ #oc-toolbar {
104
+ position: absolute;
105
+ top: 12px;
106
+ right: 12px;
107
+ display: flex;
108
+ align-items: center;
109
+ gap: 8px;
110
+ padding: 8px;
111
+ border: 0.5px solid rgba(255, 255, 255, 0.18);
112
+ border-radius: 12px;
113
+ background: rgba(17, 17, 17, 0.88);
114
+ backdrop-filter: blur(10px);
115
+ z-index: 1000;
116
+ }
117
+
118
+ #oc-toolbar button {
119
+ min-width: 34px;
120
+ height: 34px;
121
+ padding: 0 10px;
122
+ border-radius: 8px;
123
+ font-size: 13px;
124
+ line-height: 1;
125
+ }
126
+
127
+ #oc-zoom-value {
128
+ min-width: 52px;
129
+ text-align: center;
130
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
131
+ font-size: 12px;
132
+ color: #a0a0a0;
133
+ }
134
+
135
+ #oc-hint {
136
+ position: absolute;
137
+ left: 12px;
138
+ bottom: 12px;
139
+ max-width: min(560px, calc(100vw - 24px));
140
+ padding: 10px 12px;
141
+ border: 0.5px solid rgba(255, 255, 255, 0.14);
142
+ border-radius: 12px;
143
+ background: rgba(17, 17, 17, 0.8);
144
+ backdrop-filter: blur(10px);
145
+ font-size: 12px;
146
+ line-height: 1.45;
147
+ color: #a0a0a0;
148
+ z-index: 1000;
149
+ pointer-events: none;
150
+ }
151
+
152
+ #oc-shell.oc-space-ready,
153
+ #oc-shell.oc-panning {
154
+ cursor: grab;
155
+ }
156
+
157
+ #oc-shell.oc-panning {
158
+ cursor: grabbing;
159
+ }
160
+
161
+ @keyframes _fadeIn {
162
+ from { opacity: 0; transform: translateY(4px); }
163
+ to { opacity: 1; transform: none; }
164
+ }
165
+ `;
166
+ const WIDGET_SHELL_SCRIPT = String.raw `(() => {
167
+ const MIN_SCALE = 0.2;
168
+ const MAX_SCALE = 4;
169
+ const ZOOM_STEP = 1.15;
170
+ const PAN_STEP = 60;
171
+
172
+ const shell = document.getElementById('oc-shell');
173
+ const viewport = document.getElementById('oc-viewport');
174
+ const stage = document.getElementById('oc-stage');
175
+ const content = document.getElementById('oc-content');
176
+ const zoomValue = document.getElementById('oc-zoom-value');
177
+ const zoomInButton = document.getElementById('oc-zoom-in');
178
+ const zoomOutButton = document.getElementById('oc-zoom-out');
179
+ const fitButton = document.getElementById('oc-fit');
180
+ const resetButton = document.getElementById('oc-reset');
181
+ const isSVG = shell?.dataset.kind === 'svg';
182
+
183
+ if (!shell || !viewport || !stage || !content || !zoomValue) return;
184
+
185
+ let scale = 1;
186
+ let tx = 0;
187
+ let ty = 0;
188
+ let defaultScale = 1;
189
+ let defaultTx = 0;
190
+ let defaultTy = 0;
191
+ let spacePressed = false;
192
+ let panning = false;
193
+ let panPointerId = null;
194
+ let panStartX = 0;
195
+ let panStartY = 0;
196
+ let panStartTx = 0;
197
+ let panStartTy = 0;
198
+ let gestureScaleStart = null;
199
+ let gestureAnchor = null;
200
+
201
+ const isEditableTarget = (target) => {
202
+ if (!(target instanceof Element)) return false;
203
+ return Boolean(target.closest('input, textarea, select, [contenteditable="true"], [contenteditable=""]'));
204
+ };
205
+
206
+ const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
207
+
208
+ const measureContent = () => {
209
+ const width = Math.max(content.scrollWidth, content.offsetWidth, content.clientWidth);
210
+ const height = Math.max(content.scrollHeight, content.offsetHeight, content.clientHeight);
211
+ return {
212
+ width: Math.max(width, 1),
213
+ height: Math.max(height, 1),
214
+ };
215
+ };
216
+
217
+ const updateZoomLabel = () => {
218
+ zoomValue.textContent = Math.round(scale * 100) + '%';
219
+ };
220
+
221
+ const render = () => {
222
+ stage.style.transform = 'translate(' + tx + 'px, ' + ty + 'px) scale(' + scale + ')';
223
+ updateZoomLabel();
224
+ };
225
+
226
+ const defaultPositionFor = (nextScale) => {
227
+ const viewportRect = viewport.getBoundingClientRect();
228
+ const size = measureContent();
229
+ const scaledWidth = size.width * nextScale;
230
+ const scaledHeight = size.height * nextScale;
231
+ const centerX = (viewportRect.width - scaledWidth) / 2;
232
+ const centerY = (viewportRect.height - scaledHeight) / 2;
233
+
234
+ return {
235
+ x: isSVG ? centerX : Math.max(24, centerX),
236
+ y: isSVG ? centerY : Math.max(24, centerY),
237
+ };
238
+ };
239
+
240
+ const setDefaultView = (nextScale) => {
241
+ scale = nextScale;
242
+ const position = defaultPositionFor(nextScale);
243
+ tx = position.x;
244
+ ty = position.y;
245
+ defaultScale = scale;
246
+ defaultTx = tx;
247
+ defaultTy = ty;
248
+ render();
249
+ };
250
+
251
+ const fitView = () => {
252
+ const viewportRect = viewport.getBoundingClientRect();
253
+ const size = measureContent();
254
+ const fitScale = clamp(
255
+ Math.min((viewportRect.width - 40) / size.width, (viewportRect.height - 40) / size.height),
256
+ MIN_SCALE,
257
+ MAX_SCALE,
258
+ );
259
+ setDefaultView(fitScale);
260
+ };
261
+
262
+ const resetView = () => {
263
+ scale = defaultScale;
264
+ tx = defaultTx;
265
+ ty = defaultTy;
266
+ render();
267
+ };
268
+
269
+ const zoomAt = (nextScale, clientX, clientY) => {
270
+ const bounded = clamp(nextScale, MIN_SCALE, MAX_SCALE);
271
+ const viewportRect = viewport.getBoundingClientRect();
272
+ const pointX = clientX - viewportRect.left;
273
+ const pointY = clientY - viewportRect.top;
274
+ const contentX = (pointX - tx) / scale;
275
+ const contentY = (pointY - ty) / scale;
276
+
277
+ scale = bounded;
278
+ tx = pointX - contentX * scale;
279
+ ty = pointY - contentY * scale;
280
+ render();
281
+ };
282
+
283
+ const zoomBy = (factor, clientX, clientY) => {
284
+ const viewportRect = viewport.getBoundingClientRect();
285
+ zoomAt(
286
+ scale * factor,
287
+ clientX ?? viewportRect.left + viewportRect.width / 2,
288
+ clientY ?? viewportRect.top + viewportRect.height / 2,
289
+ );
290
+ };
291
+
292
+ const beginPan = (pointerId, clientX, clientY) => {
293
+ panning = true;
294
+ panPointerId = pointerId;
295
+ panStartX = clientX;
296
+ panStartY = clientY;
297
+ panStartTx = tx;
298
+ panStartTy = ty;
299
+ shell.classList.add('oc-panning');
300
+ };
301
+
302
+ const endPan = () => {
303
+ panning = false;
304
+ panPointerId = null;
305
+ shell.classList.remove('oc-panning');
306
+ };
307
+
308
+ viewport.addEventListener('wheel', (event) => {
309
+ if (event.ctrlKey || event.metaKey) {
310
+ event.preventDefault();
311
+ const factor = event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP;
312
+ zoomBy(factor, event.clientX, event.clientY);
313
+ }
314
+ }, { passive: false });
315
+
316
+ viewport.addEventListener('pointerdown', (event) => {
317
+ if (event.button === 1 || (spacePressed && event.button === 0)) {
318
+ event.preventDefault();
319
+ viewport.setPointerCapture(event.pointerId);
320
+ beginPan(event.pointerId, event.clientX, event.clientY);
321
+ }
322
+ });
323
+
324
+ viewport.addEventListener('pointermove', (event) => {
325
+ if (!panning || event.pointerId !== panPointerId) return;
326
+ event.preventDefault();
327
+ tx = panStartTx + (event.clientX - panStartX);
328
+ ty = panStartTy + (event.clientY - panStartY);
329
+ render();
330
+ });
331
+
332
+ viewport.addEventListener('pointerup', (event) => {
333
+ if (event.pointerId === panPointerId) {
334
+ endPan();
335
+ }
336
+ });
337
+
338
+ viewport.addEventListener('pointercancel', (event) => {
339
+ if (event.pointerId === panPointerId) {
340
+ endPan();
63
341
  }
342
+ });
343
+
344
+ viewport.addEventListener('gesturestart', (event) => {
345
+ event.preventDefault();
346
+ gestureScaleStart = scale;
347
+ gestureAnchor = { x: event.clientX, y: event.clientY };
348
+ });
349
+
350
+ viewport.addEventListener('gesturechange', (event) => {
351
+ if (gestureScaleStart == null || !gestureAnchor) return;
352
+ event.preventDefault();
353
+ zoomAt(gestureScaleStart * event.scale, gestureAnchor.x, gestureAnchor.y);
354
+ });
355
+
356
+ viewport.addEventListener('gestureend', () => {
357
+ gestureScaleStart = null;
358
+ gestureAnchor = null;
359
+ });
360
+
361
+ window.addEventListener('keydown', (event) => {
362
+ if (event.code === 'Space' && !isEditableTarget(event.target)) {
363
+ event.preventDefault();
364
+ spacePressed = true;
365
+ shell.classList.add('oc-space-ready');
366
+ return;
367
+ }
368
+
369
+ if (isEditableTarget(event.target)) return;
370
+
371
+ const viewportRect = viewport.getBoundingClientRect();
372
+ const centerX = viewportRect.left + viewportRect.width / 2;
373
+ const centerY = viewportRect.top + viewportRect.height / 2;
374
+
375
+ if (event.key === '+' || event.key === '=') {
376
+ event.preventDefault();
377
+ zoomBy(ZOOM_STEP, centerX, centerY);
378
+ return;
379
+ }
380
+
381
+ if (event.key === '-') {
382
+ event.preventDefault();
383
+ zoomBy(1 / ZOOM_STEP, centerX, centerY);
384
+ return;
385
+ }
386
+
387
+ if (event.key === '0') {
388
+ event.preventDefault();
389
+ resetView();
390
+ return;
391
+ }
392
+
393
+ if (event.key === 'f' || event.key === 'F') {
394
+ event.preventDefault();
395
+ fitView();
396
+ return;
397
+ }
398
+
399
+ if (event.key === 'ArrowLeft') {
400
+ event.preventDefault();
401
+ tx += PAN_STEP;
402
+ render();
403
+ return;
404
+ }
405
+
406
+ if (event.key === 'ArrowRight') {
407
+ event.preventDefault();
408
+ tx -= PAN_STEP;
409
+ render();
410
+ return;
411
+ }
412
+
413
+ if (event.key === 'ArrowUp') {
414
+ event.preventDefault();
415
+ ty += PAN_STEP;
416
+ render();
417
+ return;
418
+ }
419
+
420
+ if (event.key === 'ArrowDown') {
421
+ event.preventDefault();
422
+ ty -= PAN_STEP;
423
+ render();
424
+ }
425
+ });
426
+
427
+ window.addEventListener('keyup', (event) => {
428
+ if (event.code === 'Space') {
429
+ spacePressed = false;
430
+ shell.classList.remove('oc-space-ready');
431
+ }
432
+ });
433
+
434
+ zoomInButton?.addEventListener('click', () => {
435
+ const viewportRect = viewport.getBoundingClientRect();
436
+ zoomBy(ZOOM_STEP, viewportRect.left + viewportRect.width / 2, viewportRect.top + viewportRect.height / 2);
437
+ });
438
+
439
+ zoomOutButton?.addEventListener('click', () => {
440
+ const viewportRect = viewport.getBoundingClientRect();
441
+ zoomBy(1 / ZOOM_STEP, viewportRect.left + viewportRect.width / 2, viewportRect.top + viewportRect.height / 2);
442
+ });
443
+
444
+ fitButton?.addEventListener('click', fitView);
445
+ resetButton?.addEventListener('click', resetView);
446
+
447
+ window.addEventListener('resize', () => {
448
+ if (scale === defaultScale && tx === defaultTx && ty === defaultTy) {
449
+ setDefaultView(defaultScale);
450
+ return;
451
+ }
452
+ render();
453
+ });
454
+
455
+ let hasInitialView = false;
456
+
457
+ const refreshLayout = () => {
458
+ if (!hasInitialView) {
459
+ hasInitialView = true;
460
+ if (isSVG) {
461
+ fitView();
462
+ return;
463
+ }
464
+ setDefaultView(1);
465
+ return;
466
+ }
467
+ render();
468
+ };
469
+
470
+ window._ocRefresh = refreshLayout;
471
+ updateZoomLabel();
472
+ })();`;
473
+ function escapeJS(s) {
474
+ return s
475
+ .replace(/\\/g, "\\\\")
476
+ .replace(/'/g, "\\'")
477
+ .replace(/\n/g, "\\n")
478
+ .replace(/\r/g, "\\r")
479
+ .replace(/<\/script>/gi, "<\\/script>");
480
+ }
481
+ function shellHTML(isSVG = false) {
482
+ const bodyClass = isSVG ? "oc-svg" : "oc-html";
483
+ const bodyKind = isSVG ? "svg" : "html";
484
+ const hint = isSVG
485
+ ? "Pinch or Cmd/Ctrl + wheel to zoom. Pan with middle-drag, Space + drag, or arrow keys."
486
+ : "Zoom with pinch or Cmd/Ctrl + wheel. Pan with middle-drag, Space + drag, or arrow keys without breaking widget controls.";
64
487
  return `<!DOCTYPE html><html><head><meta charset="utf-8">
65
488
  <meta name="viewport" content="width=device-width,initial-scale=1.0">
66
- <style>*{box-sizing:border-box}body{margin:0;padding:1rem;font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0}${SVG_STYLES}</style>
67
- </head><body>${code}</body></html>`;
489
+ <style>
490
+ *{box-sizing:border-box}
491
+ html,body{margin:0;width:100%;height:100%;overflow:hidden}
492
+ body{font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0}
493
+ ${SVG_STYLES}
494
+ ${WIDGET_SHELL_STYLES}
495
+ </style>
496
+ </head><body class="${bodyClass}">
497
+ <div id="oc-shell" data-kind="${bodyKind}">
498
+ <div id="oc-viewport">
499
+ <div id="oc-stage">
500
+ <div id="oc-content"></div>
501
+ </div>
502
+ </div>
503
+ <div id="oc-toolbar" aria-label="Widget controls">
504
+ <button id="oc-zoom-out" type="button" title="Zoom out (-)">-</button>
505
+ <div id="oc-zoom-value">100%</div>
506
+ <button id="oc-zoom-in" type="button" title="Zoom in (+)">+</button>
507
+ <button id="oc-fit" type="button" title="Fit to viewport (F)">Fit</button>
508
+ <button id="oc-reset" type="button" title="Reset view (0)">Reset</button>
509
+ </div>
510
+ <div id="oc-hint">${hint}</div>
511
+ </div>
512
+ <script>${MORPHDOM_SOURCE}</script>
513
+ <script>
514
+ window._setContent = function(html) {
515
+ var root = document.getElementById('oc-content');
516
+ var target = document.createElement('div');
517
+ target.id = 'oc-content';
518
+ target.innerHTML = html;
519
+ morphdom(root, target, {
520
+ onBeforeElUpdated: function(from, to) {
521
+ if (from.isEqualNode(to)) return false;
522
+ return true;
523
+ },
524
+ onNodeAdded: function(node) {
525
+ if (node.nodeType === 1 && node.tagName !== 'STYLE' && node.tagName !== 'SCRIPT') {
526
+ node.style.animation = '_fadeIn 0.3s ease both';
527
+ }
528
+ return node;
529
+ }
530
+ });
531
+ if (window._ocRefresh) window._ocRefresh();
532
+ };
533
+ window._runScripts = function() {
534
+ document.querySelectorAll('#oc-content script').forEach(function(old) {
535
+ var s = document.createElement('script');
536
+ if (old.src) {
537
+ s.src = old.src;
538
+ } else {
539
+ s.textContent = old.textContent;
540
+ }
541
+ old.parentNode.replaceChild(s, old);
542
+ });
543
+ };
544
+ </script>
545
+ <script>${WIDGET_SHELL_SCRIPT}</script>
546
+ </body></html>`;
68
547
  }
69
548
  function normalizeTitle(title) {
70
549
  const normalized = title.replace(/_/g, " ").trim();
@@ -137,7 +616,7 @@ const showWidget = tool({
137
616
  });
138
617
  let win;
139
618
  try {
140
- win = open(wrapHTML(code, isSVG), {
619
+ win = open(shellHTML(isSVG), {
141
620
  width,
142
621
  height,
143
622
  title,
@@ -161,6 +640,9 @@ const showWidget = tool({
161
640
  }
162
641
  resolve(`Widget \"${title}\" rendered and shown to the user (${width}x${height}). ${reason}`);
163
642
  };
643
+ win.on("ready", () => {
644
+ win.send(`window._setContent('${escapeJS(code)}'); window._runScripts();`);
645
+ });
164
646
  win.on("message", (data) => {
165
647
  messageData = data;
166
648
  finish("User sent data from widget.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-generative-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "OpenCode plugin that renders HTML and SVG widgets in native macOS windows using Glimpse.",
5
5
  "type": "module",
6
6
  "exports": "./dist/index.js",
@@ -40,7 +40,8 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@opencode-ai/plugin": "1.3.13",
43
- "glimpseui": "^0.3.5"
43
+ "glimpseui": "^0.3.5",
44
+ "morphdom": "^2.7.4"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^22.15.21",
@@ -12,6 +12,7 @@ declare module "glimpseui" {
12
12
  send(js: string): void;
13
13
  setHTML(html: string): void;
14
14
  close(): void;
15
+ on(event: "ready", listener: () => void): this;
15
16
  on(event: "message", listener: (data: unknown) => void): this;
16
17
  on(event: "closed", listener: () => void): this;
17
18
  on(event: "error", listener: (err: Error) => void): this;