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 +10 -1
- package/dist/index.js +490 -8
- package/package.json +3 -2
- package/src/glimpseui.d.ts +1 -0
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
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
|
67
|
-
|
|
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(
|
|
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.
|
|
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",
|
package/src/glimpseui.d.ts
CHANGED
|
@@ -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;
|