opencode-generative-ui 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tushar
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,98 @@
1
+ # opencode-generative-ui
2
+
3
+ OpenCode plugin that recreates Claude-style generative UI widgets with two custom tools:
4
+
5
+ - `visualize_read_me`
6
+ - `show_widget`
7
+
8
+ The plugin renders HTML fragments and raw SVG in a native macOS window using [Glimpse](https://github.com/hazat/glimpse).
9
+
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
+
12
+ ## What it does
13
+
14
+ When OpenCode decides a response should be visual, the plugin can:
15
+
16
+ 1. Load Claude-style widget design guidance with `visualize_read_me`
17
+ 2. Render the resulting widget with `show_widget`
18
+ 3. Return any `window.glimpse.send(data)` interaction payload back to the model
19
+
20
+ Typical use cases:
21
+
22
+ - interactive explainers
23
+ - charts and dashboards
24
+ - SVG diagrams
25
+ - mockups
26
+ - calculators
27
+
28
+ ## Install from npm in OpenCode
29
+
30
+ After publishing, add the package name to your OpenCode config:
31
+
32
+ ```json
33
+ {
34
+ "$schema": "https://opencode.ai/config.json",
35
+ "plugin": ["opencode-generative-ui"]
36
+ }
37
+ ```
38
+
39
+ OpenCode will install the package with Bun on startup.
40
+
41
+ ## Install locally in OpenCode
42
+
43
+ Clone this repo, then copy the plugin entry file into your OpenCode plugin directory if you want to run it before publishing.
44
+
45
+ For project-local use today:
46
+
47
+ 1. Copy `src/index.ts` to `.opencode/plugins/generative-ui.ts`
48
+ 2. Copy `src/lib/` into `.opencode/plugins/lib/`
49
+ 3. Copy `glimpseui.d.ts` into `.opencode/`
50
+ 4. Copy this `package.json` into `.opencode/package.json` or merge the dependencies into your existing one
51
+ 5. Run `bun install`
52
+ 6. If Bun blocks lifecycle scripts, run `bun pm trust glimpseui`
53
+
54
+ ## Development
55
+
56
+ Install dependencies:
57
+
58
+ ```bash
59
+ bun install
60
+ ```
61
+
62
+ Typecheck:
63
+
64
+ ```bash
65
+ bun run typecheck
66
+ ```
67
+
68
+ Build publishable output:
69
+
70
+ ```bash
71
+ bun run build
72
+ ```
73
+
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
+ ## Current limitation
88
+
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.
90
+
91
+ ## Credits
92
+
93
+ - Original reverse-engineering and `pi` implementation: [`Michaelliv/pi-generative-ui`](https://github.com/Michaelliv/pi-generative-ui)
94
+ - Native widget window runtime: [Glimpse](https://github.com/hazat/glimpse)
95
+
96
+ ## License
97
+
98
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ import { tool } from "@opencode-ai/plugin";
2
+ import { open } from "glimpseui";
3
+ import { AVAILABLE_MODULES, getGuidelines } from "./lib/guidelines.js";
4
+ import { SVG_STYLES } from "./lib/svg-styles.js";
5
+ const loadedModulesBySession = new Map();
6
+ const activeWindowsBySession = new Map();
7
+ function trackWindow(sessionID, win) {
8
+ let windows = activeWindowsBySession.get(sessionID);
9
+ if (!windows) {
10
+ windows = new Set();
11
+ activeWindowsBySession.set(sessionID, windows);
12
+ }
13
+ windows.add(win);
14
+ }
15
+ function untrackWindow(sessionID, win) {
16
+ const windows = activeWindowsBySession.get(sessionID);
17
+ if (!windows)
18
+ return;
19
+ windows.delete(win);
20
+ if (windows.size === 0) {
21
+ activeWindowsBySession.delete(sessionID);
22
+ }
23
+ }
24
+ function closeSessionWindows(sessionID) {
25
+ const windows = activeWindowsBySession.get(sessionID);
26
+ if (!windows)
27
+ return;
28
+ for (const win of windows) {
29
+ try {
30
+ win.close();
31
+ }
32
+ catch { }
33
+ }
34
+ activeWindowsBySession.delete(sessionID);
35
+ }
36
+ function closeAllWindows() {
37
+ for (const sessionID of Array.from(activeWindowsBySession.keys())) {
38
+ closeSessionWindows(sessionID);
39
+ }
40
+ }
41
+ function rememberModules(sessionID, modules) {
42
+ let seen = loadedModulesBySession.get(sessionID);
43
+ if (!seen) {
44
+ seen = new Set();
45
+ loadedModulesBySession.set(sessionID, seen);
46
+ }
47
+ for (const moduleName of modules) {
48
+ seen.add(moduleName);
49
+ }
50
+ }
51
+ function hasLoadedGuidelines(sessionID) {
52
+ return (loadedModulesBySession.get(sessionID)?.size ?? 0) > 0;
53
+ }
54
+ function clearSessionState(sessionID) {
55
+ loadedModulesBySession.delete(sessionID);
56
+ closeSessionWindows(sessionID);
57
+ }
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>`;
63
+ }
64
+ return `<!DOCTYPE html><html><head><meta charset="utf-8">
65
+ <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>`;
68
+ }
69
+ function normalizeTitle(title) {
70
+ const normalized = title.replace(/_/g, " ").trim();
71
+ return normalized || "Widget";
72
+ }
73
+ function getGlimpseSetupHint(err) {
74
+ const message = err instanceof Error ? err.message : String(err);
75
+ return [
76
+ `Failed to open the Glimpse widget window: ${message}`,
77
+ "Ensure plugin dependencies are installed.",
78
+ "If Bun blocked lifecycle scripts, run `bun pm trust glimpseui` in the plugin directory or OpenCode config directory.",
79
+ "If the native build still fails, update your Xcode Command Line Tools / Swift toolchain or point GLIMPSE_BINARY_PATH at an existing Glimpse binary.",
80
+ ].join(" ");
81
+ }
82
+ const visualizeReadMe = tool({
83
+ description: "Returns design guidelines for show_widget (CSS patterns, colors, typography, layout rules, examples). " +
84
+ "Call once before your first show_widget call. Do not mention this call to the user.",
85
+ args: {
86
+ modules: tool.schema
87
+ .array(tool.schema.string())
88
+ .min(1)
89
+ .describe(`Which module(s) to load. Pick from: ${AVAILABLE_MODULES.join(", ")}.`),
90
+ },
91
+ async execute(args, context) {
92
+ const invalid = args.modules.filter((moduleName) => !AVAILABLE_MODULES.includes(moduleName));
93
+ if (invalid.length > 0) {
94
+ throw new Error(`Unknown module(s): ${invalid.join(", ")}. Available modules: ${AVAILABLE_MODULES.join(", ")}.`);
95
+ }
96
+ rememberModules(context.sessionID, args.modules);
97
+ context.metadata({
98
+ title: "Read Guidelines",
99
+ metadata: { modules: args.modules },
100
+ });
101
+ return getGuidelines(args.modules);
102
+ },
103
+ });
104
+ const showWidget = tool({
105
+ description: "Show visual content in a native window using HTML or SVG. Use for charts, diagrams, dashboards, calculators, mockups, and interactive explainers. " +
106
+ "Always call visualize_read_me first in the current session.",
107
+ args: {
108
+ i_have_seen_read_me: tool.schema
109
+ .boolean()
110
+ .describe("Confirm that visualize_read_me was already called in this session."),
111
+ title: tool.schema.string().min(1).describe("Short snake_case identifier for this widget."),
112
+ widget_code: tool.schema
113
+ .string()
114
+ .min(1)
115
+ .describe("HTML fragment or raw SVG. For HTML, do not include a full HTML document."),
116
+ width: tool.schema.number().int().positive().optional().describe("Window width in pixels. Default: 800."),
117
+ height: tool.schema.number().int().positive().optional().describe("Window height in pixels. Default: 600."),
118
+ floating: tool.schema.boolean().optional().describe("Keep the window always on top. Default: false."),
119
+ },
120
+ async execute(args, context) {
121
+ if (!args.i_have_seen_read_me || !hasLoadedGuidelines(context.sessionID)) {
122
+ throw new Error("You must call visualize_read_me before show_widget. Set i_have_seen_read_me: true after doing so.");
123
+ }
124
+ const code = args.widget_code;
125
+ const isSVG = code.trimStart().startsWith("<svg");
126
+ const title = normalizeTitle(args.title);
127
+ const width = args.width ?? 800;
128
+ const height = args.height ?? 600;
129
+ context.metadata({
130
+ title: `Show Widget: ${title}`,
131
+ metadata: {
132
+ title: args.title,
133
+ width,
134
+ height,
135
+ isSVG,
136
+ },
137
+ });
138
+ let win;
139
+ try {
140
+ win = open(wrapHTML(code, isSVG), {
141
+ width,
142
+ height,
143
+ title,
144
+ floating: args.floating ?? false,
145
+ });
146
+ }
147
+ catch (err) {
148
+ throw new Error(getGlimpseSetupHint(err));
149
+ }
150
+ trackWindow(context.sessionID, win);
151
+ return await new Promise((resolve) => {
152
+ let resolved = false;
153
+ let messageData;
154
+ const finish = (reason) => {
155
+ if (resolved)
156
+ return;
157
+ resolved = true;
158
+ if (messageData !== undefined) {
159
+ resolve(`Widget rendered. User interaction data: ${JSON.stringify(messageData)}`);
160
+ return;
161
+ }
162
+ resolve(`Widget \"${title}\" rendered and shown to the user (${width}x${height}). ${reason}`);
163
+ };
164
+ win.on("message", (data) => {
165
+ messageData = data;
166
+ finish("User sent data from widget.");
167
+ });
168
+ win.on("closed", () => {
169
+ untrackWindow(context.sessionID, win);
170
+ finish("Window closed by user.");
171
+ });
172
+ win.on("error", (err) => {
173
+ untrackWindow(context.sessionID, win);
174
+ finish(`Error: ${err.message}`);
175
+ });
176
+ context.abort.addEventListener("abort", () => {
177
+ try {
178
+ win.close();
179
+ }
180
+ catch { }
181
+ finish("Aborted.");
182
+ }, { once: true });
183
+ setTimeout(() => {
184
+ finish("Widget still open (timed out waiting for interaction).");
185
+ }, 120_000);
186
+ });
187
+ },
188
+ });
189
+ const SYSTEM_GUIDANCE = [
190
+ "Use show_widget when the user asks for visual content such as charts, diagrams, dashboards, SVG illustrations, mockups, or interactive explainers.",
191
+ "Always call visualize_read_me once before the first show_widget call in a session, then set i_have_seen_read_me: true.",
192
+ "For HTML widgets, provide a fragment only. Do not include DOCTYPE, html, head, or body tags.",
193
+ "For SVG widgets, start widget_code with <svg>.",
194
+ "Keep widgets focused and appropriately sized. Default size is 800x600 unless the content needs another size.",
195
+ ].join("\n");
196
+ let registeredExitHandler = false;
197
+ export const GenerativeUIPlugin = async () => {
198
+ if (!registeredExitHandler) {
199
+ registeredExitHandler = true;
200
+ process.on("exit", closeAllWindows);
201
+ }
202
+ return {
203
+ tool: {
204
+ visualize_read_me: visualizeReadMe,
205
+ show_widget: showWidget,
206
+ },
207
+ event: async ({ event }) => {
208
+ if (event.type === "session.deleted") {
209
+ clearSessionState(event.properties.info.id);
210
+ }
211
+ },
212
+ "experimental.chat.system.transform": async (_input, output) => {
213
+ output.system.push(SYSTEM_GUIDANCE);
214
+ },
215
+ };
216
+ };
217
+ export default GenerativeUIPlugin;