opencode-generative-ui 0.1.1 → 0.1.3

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,6 +27,13 @@ Typical use cases:
25
27
  - mockups
26
28
  - calculators
27
29
 
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
+
28
37
  ## Install
29
38
 
30
39
  ### From npm in OpenCode
@@ -138,7 +147,7 @@ bun run build
138
147
 
139
148
  ## Current limitation
140
149
 
141
- 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.
142
151
 
143
152
  ## Credits
144
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,494 @@ 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();
63
335
  }
336
+ });
337
+
338
+ viewport.addEventListener('pointercancel', (event) => {
339
+ if (event.pointerId === panPointerId) {
340
+ endPan();
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(code, 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.";
487
+ const escapedCode = escapeJS(code);
64
488
  return `<!DOCTYPE html><html><head><meta charset="utf-8">
65
489
  <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>`;
490
+ <style>
491
+ *{box-sizing:border-box}
492
+ html,body{margin:0;width:100%;height:100%;overflow:hidden}
493
+ body{font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0}
494
+ ${SVG_STYLES}
495
+ ${WIDGET_SHELL_STYLES}
496
+ </style>
497
+ </head><body class="${bodyClass}">
498
+ <div id="oc-shell" data-kind="${bodyKind}">
499
+ <div id="oc-viewport">
500
+ <div id="oc-stage">
501
+ <div id="oc-content"></div>
502
+ </div>
503
+ </div>
504
+ <div id="oc-toolbar" aria-label="Widget controls">
505
+ <button id="oc-zoom-out" type="button" title="Zoom out (-)">-</button>
506
+ <div id="oc-zoom-value">100%</div>
507
+ <button id="oc-zoom-in" type="button" title="Zoom in (+)">+</button>
508
+ <button id="oc-fit" type="button" title="Fit to viewport (F)">Fit</button>
509
+ <button id="oc-reset" type="button" title="Reset view (0)">Reset</button>
510
+ </div>
511
+ <div id="oc-hint">${hint}</div>
512
+ </div>
513
+ <script>${MORPHDOM_SOURCE}</script>
514
+ <script>
515
+ window._setContent = function(html) {
516
+ var root = document.getElementById('oc-content');
517
+ var target = document.createElement('div');
518
+ target.id = 'oc-content';
519
+ target.innerHTML = html;
520
+ morphdom(root, target, {
521
+ onBeforeElUpdated: function(from, to) {
522
+ if (from.isEqualNode(to)) return false;
523
+ return true;
524
+ },
525
+ onNodeAdded: function(node) {
526
+ if (node.nodeType === 1 && node.tagName !== 'STYLE' && node.tagName !== 'SCRIPT') {
527
+ node.style.animation = '_fadeIn 0.3s ease both';
528
+ }
529
+ return node;
530
+ }
531
+ });
532
+ if (window._ocRefresh) window._ocRefresh();
533
+ };
534
+ window._runScripts = function() {
535
+ document.querySelectorAll('#oc-content script').forEach(function(old) {
536
+ var s = document.createElement('script');
537
+ if (old.src) {
538
+ s.src = old.src;
539
+ } else {
540
+ s.textContent = old.textContent;
541
+ }
542
+ old.parentNode.replaceChild(s, old);
543
+ });
544
+ };
545
+ window._setContent('${escapedCode}');
546
+ window._runScripts();
547
+ </script>
548
+ <script>${WIDGET_SHELL_SCRIPT}</script>
549
+ </body></html>`;
68
550
  }
69
551
  function normalizeTitle(title) {
70
552
  const normalized = title.replace(/_/g, " ").trim();
@@ -137,7 +619,7 @@ const showWidget = tool({
137
619
  });
138
620
  let win;
139
621
  try {
140
- win = open(wrapHTML(code, isSVG), {
622
+ win = open(shellHTML(code, isSVG), {
141
623
  width,
142
624
  height,
143
625
  title,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-generative-ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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;