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 +80 -19
- 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,7 +27,24 @@ Typical use cases:
|
|
|
25
27
|
- mockups
|
|
26
28
|
- calculators
|
|
27
29
|
|
|
28
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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();
|
|
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
|
|
67
|
-
|
|
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(
|
|
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.
|
|
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",
|
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;
|