pi-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/.pi/extensions/generative-ui/claude-guidelines/CORE.md +89 -0
- package/.pi/extensions/generative-ui/claude-guidelines/art.md +175 -0
- package/.pi/extensions/generative-ui/claude-guidelines/art_interactive.md +297 -0
- package/.pi/extensions/generative-ui/claude-guidelines/chart.md +255 -0
- package/.pi/extensions/generative-ui/claude-guidelines/chart_interactive.md +255 -0
- package/.pi/extensions/generative-ui/claude-guidelines/diagram.md +624 -0
- package/.pi/extensions/generative-ui/claude-guidelines/interactive.md +209 -0
- package/.pi/extensions/generative-ui/claude-guidelines/mockup.md +209 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/art_and_illustration.md +11 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/charts_chart_js.md +43 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/color_palette.md +31 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/core_design_system.md +60 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/diagram_types.md +427 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/mapping.json +44 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/modules.md +17 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/preamble.md +1 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/svg_setup.md +73 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/ui_components.md +87 -0
- package/.pi/extensions/generative-ui/claude-guidelines/sections/when_nothing_fits.md +6 -0
- package/.pi/extensions/generative-ui/guidelines.ts +795 -0
- package/.pi/extensions/generative-ui/index.ts +401 -0
- package/README.md +124 -0
- package/package.json +22 -0
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Type } from "@sinclair/typebox";
|
|
3
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
4
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
5
|
+
import { join, dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { getGuidelines, AVAILABLE_MODULES } from "./guidelines.js";
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const GLIMPSE_PATH = join(__dirname, "../../../node_modules/glimpseui/src/glimpse.mjs");
|
|
12
|
+
|
|
13
|
+
// Shell HTML with a root container — used for streaming.
|
|
14
|
+
// Content is injected via win.send() JS eval, not setHTML(), to avoid full-page flashes.
|
|
15
|
+
function shellHTML(): string {
|
|
16
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
17
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
18
|
+
<style>
|
|
19
|
+
*{box-sizing:border-box}
|
|
20
|
+
body{margin:0;padding:1rem;font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0;}
|
|
21
|
+
@keyframes _fadeIn{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
|
|
22
|
+
</style>
|
|
23
|
+
</head><body><div id="root"></div>
|
|
24
|
+
<script>
|
|
25
|
+
window._morphReady = false;
|
|
26
|
+
window._pending = null;
|
|
27
|
+
window._setContent = function(html) {
|
|
28
|
+
if (!window._morphReady) { window._pending = html; return; }
|
|
29
|
+
var root = document.getElementById('root');
|
|
30
|
+
var target = document.createElement('div');
|
|
31
|
+
target.id = 'root';
|
|
32
|
+
target.innerHTML = html;
|
|
33
|
+
morphdom(root, target, {
|
|
34
|
+
onBeforeElUpdated: function(from, to) {
|
|
35
|
+
if (from.isEqualNode(to)) return false;
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
onNodeAdded: function(node) {
|
|
39
|
+
if (node.nodeType === 1 && node.tagName !== 'STYLE' && node.tagName !== 'SCRIPT') {
|
|
40
|
+
node.style.animation = '_fadeIn 0.3s ease both';
|
|
41
|
+
}
|
|
42
|
+
return node;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
window._runScripts = function() {
|
|
47
|
+
document.querySelectorAll('#root script').forEach(function(old) {
|
|
48
|
+
var s = document.createElement('script');
|
|
49
|
+
if (old.src) { s.src = old.src; } else { s.textContent = old.textContent; }
|
|
50
|
+
old.parentNode.replaceChild(s, old);
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
</script>
|
|
54
|
+
<script src="https://cdn.jsdelivr.net/npm/morphdom@2.7.4/dist/morphdom-umd.min.js"
|
|
55
|
+
onload="window._morphReady=true;if(window._pending){window._setContent(window._pending);window._pending=null;}"></script>
|
|
56
|
+
</body></html>`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Wrap HTML fragment into a full document for Glimpse (non-streaming fallback)
|
|
60
|
+
function wrapHTML(code: string, isSVG = false): string {
|
|
61
|
+
if (isSVG) {
|
|
62
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"></head>
|
|
63
|
+
<body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1a1a1a;color:#e0e0e0;">
|
|
64
|
+
${code}</body></html>`;
|
|
65
|
+
}
|
|
66
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
67
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
68
|
+
<style>*{box-sizing:border-box}body{margin:0;padding:1rem;font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0}</style>
|
|
69
|
+
</head><body>${code}</body></html>`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Escape a string for safe injection into a JS string literal
|
|
73
|
+
function escapeJS(s: string): string {
|
|
74
|
+
return s.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/<\/script>/gi, '<\\/script>');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default function (pi: ExtensionAPI) {
|
|
78
|
+
let hasSeenReadMe = false;
|
|
79
|
+
let activeWindows: any[] = [];
|
|
80
|
+
let glimpseModule: any = null;
|
|
81
|
+
|
|
82
|
+
// Lazy-load glimpse module
|
|
83
|
+
async function getGlimpse() {
|
|
84
|
+
if (!glimpseModule) {
|
|
85
|
+
glimpseModule = await import(GLIMPSE_PATH);
|
|
86
|
+
}
|
|
87
|
+
return glimpseModule;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Streaming state ─────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
// Tracks in-flight show_widget tool calls being streamed
|
|
93
|
+
interface StreamingWidget {
|
|
94
|
+
contentIndex: number;
|
|
95
|
+
window: any | null;
|
|
96
|
+
lastHTML: string;
|
|
97
|
+
updateTimer: any;
|
|
98
|
+
ready: boolean;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let streaming: StreamingWidget | null = null;
|
|
102
|
+
|
|
103
|
+
// ── message_update: intercept streaming tool calls ────────────────────
|
|
104
|
+
|
|
105
|
+
pi.on("message_update", async (event) => {
|
|
106
|
+
const raw: any = event.assistantMessageEvent;
|
|
107
|
+
if (!raw) return;
|
|
108
|
+
|
|
109
|
+
// Tool call starts streaming
|
|
110
|
+
if (raw.type === "toolcall_start") {
|
|
111
|
+
const partial: any = raw.partial;
|
|
112
|
+
const block = partial?.content?.[raw.contentIndex];
|
|
113
|
+
if (block?.type === "toolCall" && block?.name === "show_widget") {
|
|
114
|
+
streaming = {
|
|
115
|
+
contentIndex: raw.contentIndex,
|
|
116
|
+
window: null,
|
|
117
|
+
lastHTML: "",
|
|
118
|
+
updateTimer: null,
|
|
119
|
+
ready: false,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Tool call input JSON delta — arguments already parsed by pi-ai
|
|
126
|
+
if (raw.type === "toolcall_delta" && streaming && raw.contentIndex === streaming.contentIndex) {
|
|
127
|
+
const partial: any = raw.partial;
|
|
128
|
+
const block = partial?.content?.[raw.contentIndex];
|
|
129
|
+
const html = block?.arguments?.widget_code;
|
|
130
|
+
if (!html || html.length < 20 || html === streaming.lastHTML) return;
|
|
131
|
+
|
|
132
|
+
streaming.lastHTML = html;
|
|
133
|
+
|
|
134
|
+
// Debounce updates to ~150ms for smooth rendering
|
|
135
|
+
if (streaming.updateTimer) return;
|
|
136
|
+
streaming.updateTimer = setTimeout(async () => {
|
|
137
|
+
if (!streaming) return;
|
|
138
|
+
streaming.updateTimer = null;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (!streaming.window) {
|
|
142
|
+
// Open window with empty shell — content will be injected via JS eval
|
|
143
|
+
const args = block?.arguments ?? {};
|
|
144
|
+
const title = (args.title ?? "Widget").replace(/_/g, " ");
|
|
145
|
+
const width = args.width ?? 800;
|
|
146
|
+
const height = args.height ?? 600;
|
|
147
|
+
|
|
148
|
+
const { open } = await getGlimpse();
|
|
149
|
+
streaming.window = open(shellHTML(), { width, height, title });
|
|
150
|
+
activeWindows.push(streaming.window);
|
|
151
|
+
|
|
152
|
+
streaming.window.on("ready", () => {
|
|
153
|
+
if (!streaming) return;
|
|
154
|
+
streaming.ready = true;
|
|
155
|
+
// Inject the content we've accumulated so far
|
|
156
|
+
const escaped = escapeJS(streaming.lastHTML);
|
|
157
|
+
streaming.window.send(`window._setContent('${escaped}')`);
|
|
158
|
+
});
|
|
159
|
+
} else if (streaming.ready) {
|
|
160
|
+
// Update content via JS — no full page replace
|
|
161
|
+
const escaped = escapeJS(streaming.lastHTML);
|
|
162
|
+
streaming.window.send(`window._setContent('${escaped}')`);
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
}, 150);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Tool call complete — final update with complete HTML + execute scripts
|
|
170
|
+
if (raw.type === "toolcall_end" && streaming && raw.contentIndex === streaming.contentIndex) {
|
|
171
|
+
if (streaming.updateTimer) {
|
|
172
|
+
clearTimeout(streaming.updateTimer);
|
|
173
|
+
streaming.updateTimer = null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const toolCall = raw.toolCall;
|
|
177
|
+
if (toolCall?.arguments?.widget_code && streaming.window && streaming.ready) {
|
|
178
|
+
const escaped = escapeJS(toolCall.arguments.widget_code);
|
|
179
|
+
streaming.window.send(`window._setContent('${escaped}'); window._runScripts();`);
|
|
180
|
+
}
|
|
181
|
+
// Don't clear streaming — execute() will pick up the window
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── read_me tool ──────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
pi.registerTool({
|
|
189
|
+
name: "visualize_read_me",
|
|
190
|
+
label: "Read Guidelines",
|
|
191
|
+
description:
|
|
192
|
+
"Returns design guidelines for show_widget (CSS patterns, colors, typography, layout rules, examples). " +
|
|
193
|
+
"Call once before your first show_widget call. Do NOT mention this call to the user — it is an internal setup step.",
|
|
194
|
+
promptSnippet: "Load design guidelines before creating widgets. Call silently before first show_widget use.",
|
|
195
|
+
promptGuidelines: [
|
|
196
|
+
"Call visualize_read_me once before your first show_widget call to load design guidelines.",
|
|
197
|
+
"Do NOT mention the read_me call to the user — call it silently, then proceed directly to building the widget.",
|
|
198
|
+
"Pick the modules that match your use case: interactive, chart, mockup, art, diagram.",
|
|
199
|
+
],
|
|
200
|
+
parameters: Type.Object({
|
|
201
|
+
modules: Type.Array(
|
|
202
|
+
StringEnum(AVAILABLE_MODULES as readonly string[]),
|
|
203
|
+
{ description: "Which module(s) to load. Pick all that fit." }
|
|
204
|
+
),
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
async execute(_toolCallId, params) {
|
|
208
|
+
hasSeenReadMe = true;
|
|
209
|
+
const content = getGuidelines(params.modules);
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: "text" as const, text: content }],
|
|
212
|
+
details: { modules: params.modules },
|
|
213
|
+
};
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
renderCall(args: any, theme: any) {
|
|
217
|
+
const mods = (args.modules ?? []).join(", ");
|
|
218
|
+
return new Text(
|
|
219
|
+
theme.fg("toolTitle", theme.bold("read_me ")) + theme.fg("muted", mods),
|
|
220
|
+
0, 0
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
renderResult(_result: any, { isPartial }: any, theme: any) {
|
|
225
|
+
if (isPartial) return new Text(theme.fg("warning", "Loading guidelines..."), 0, 0);
|
|
226
|
+
return new Text(theme.fg("dim", "Guidelines loaded"), 0, 0);
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── show_widget tool ──────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
pi.registerTool({
|
|
233
|
+
name: "show_widget",
|
|
234
|
+
label: "Show Widget",
|
|
235
|
+
description:
|
|
236
|
+
"Show visual content — SVG graphics, diagrams, charts, or interactive HTML widgets — in a native macOS window. " +
|
|
237
|
+
"Use for flowcharts, dashboards, forms, calculators, data tables, games, illustrations, or any visual content. " +
|
|
238
|
+
"The HTML is rendered in a native WKWebView with full CSS/JS support including Canvas and CDN libraries. " +
|
|
239
|
+
"The page gets a window.glimpse.send(data) bridge to send JSON data back to the agent. " +
|
|
240
|
+
"IMPORTANT: Call visualize_read_me once before your first show_widget call.",
|
|
241
|
+
promptSnippet: "Render interactive HTML/SVG widgets in a native macOS window (WKWebView). Supports full CSS, JS, Canvas, Chart.js.",
|
|
242
|
+
promptGuidelines: [
|
|
243
|
+
"Use show_widget when the user asks for visual content: charts, diagrams, interactive explainers, UI mockups, art.",
|
|
244
|
+
"Always call visualize_read_me first to load design guidelines, then set i_have_seen_read_me: true.",
|
|
245
|
+
"The widget opens in a native macOS window — it has full browser capabilities (Canvas, JS, CDN libraries).",
|
|
246
|
+
"Structure HTML as fragments: no DOCTYPE/<html>/<head>/<body>. Style first, then HTML, then scripts.",
|
|
247
|
+
"The page has window.glimpse.send(data) to send data back. Use it for user choices and interactions.",
|
|
248
|
+
"Keep widgets focused and appropriately sized. Default is 800x600 but adjust to fit content.",
|
|
249
|
+
"For interactive explainers: sliders, live calculations, Chart.js charts.",
|
|
250
|
+
"For SVG: start code with <svg> tag, it will be auto-detected.",
|
|
251
|
+
"Be concise in your responses",
|
|
252
|
+
],
|
|
253
|
+
parameters: Type.Object({
|
|
254
|
+
i_have_seen_read_me: Type.Boolean({
|
|
255
|
+
description: "Confirm you have already called visualize_read_me in this conversation.",
|
|
256
|
+
}),
|
|
257
|
+
title: Type.String({
|
|
258
|
+
description: "Short snake_case identifier for this widget (used as window title).",
|
|
259
|
+
}),
|
|
260
|
+
widget_code: Type.String({
|
|
261
|
+
description:
|
|
262
|
+
"HTML or SVG code to render. For SVG: raw SVG starting with <svg>. " +
|
|
263
|
+
"For HTML: raw content fragment, no DOCTYPE/<html>/<head>/<body>.",
|
|
264
|
+
}),
|
|
265
|
+
width: Type.Optional(Type.Number({ description: "Window width in pixels. Default: 800." })),
|
|
266
|
+
height: Type.Optional(Type.Number({ description: "Window height in pixels. Default: 600." })),
|
|
267
|
+
floating: Type.Optional(Type.Boolean({ description: "Keep window always on top. Default: false." })),
|
|
268
|
+
}),
|
|
269
|
+
|
|
270
|
+
async execute(_toolCallId, params, signal) {
|
|
271
|
+
if (!params.i_have_seen_read_me) {
|
|
272
|
+
throw new Error("You must call visualize_read_me before show_widget. Set i_have_seen_read_me: true after doing so.");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const code = params.widget_code;
|
|
276
|
+
const isSVG = code.trimStart().startsWith("<svg");
|
|
277
|
+
const title = params.title.replace(/_/g, " ");
|
|
278
|
+
const width = params.width ?? 800;
|
|
279
|
+
const height = params.height ?? 600;
|
|
280
|
+
|
|
281
|
+
// Check if we already have a streaming window from message_update
|
|
282
|
+
let win: any = null;
|
|
283
|
+
|
|
284
|
+
if (streaming?.window) {
|
|
285
|
+
win = streaming.window;
|
|
286
|
+
// Send final complete HTML + run scripts via JS eval (no full page replace)
|
|
287
|
+
if (streaming.ready) {
|
|
288
|
+
const escaped = escapeJS(code);
|
|
289
|
+
win.send(`window._setContent('${escaped}'); window._runScripts();`);
|
|
290
|
+
}
|
|
291
|
+
streaming = null;
|
|
292
|
+
} else {
|
|
293
|
+
// No streaming window — open fresh (fallback for non-streaming providers)
|
|
294
|
+
const { open } = await getGlimpse();
|
|
295
|
+
win = open(wrapHTML(code, isSVG), {
|
|
296
|
+
width,
|
|
297
|
+
height,
|
|
298
|
+
title,
|
|
299
|
+
floating: params.floating ?? false,
|
|
300
|
+
});
|
|
301
|
+
activeWindows.push(win);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return new Promise<any>((resolve) => {
|
|
305
|
+
let messageData: any = null;
|
|
306
|
+
let resolved = false;
|
|
307
|
+
|
|
308
|
+
const finish = (reason: string) => {
|
|
309
|
+
if (resolved) return;
|
|
310
|
+
resolved = true;
|
|
311
|
+
activeWindows = activeWindows.filter((w) => w !== win);
|
|
312
|
+
resolve({
|
|
313
|
+
content: [
|
|
314
|
+
{
|
|
315
|
+
type: "text" as const,
|
|
316
|
+
text: messageData
|
|
317
|
+
? `Widget rendered. User interaction data: ${JSON.stringify(messageData)}`
|
|
318
|
+
: `Widget "${title}" rendered and shown to the user (${width}×${height}). ${reason}`,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
details: {
|
|
322
|
+
title: params.title,
|
|
323
|
+
width,
|
|
324
|
+
height,
|
|
325
|
+
isSVG,
|
|
326
|
+
messageData,
|
|
327
|
+
closedReason: reason,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
win.on("message", (data: any) => {
|
|
333
|
+
messageData = data;
|
|
334
|
+
finish("User sent data from widget.");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
win.on("closed", () => {
|
|
338
|
+
finish("Window closed by user.");
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
win.on("error", (err: Error) => {
|
|
342
|
+
finish(`Error: ${err.message}`);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
if (signal) {
|
|
346
|
+
signal.addEventListener("abort", () => {
|
|
347
|
+
try { win.close(); } catch {}
|
|
348
|
+
finish("Aborted.");
|
|
349
|
+
}, { once: true });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Auto-resolve after 120s if no interaction
|
|
353
|
+
setTimeout(() => {
|
|
354
|
+
finish("Widget still open (timed out waiting for interaction).");
|
|
355
|
+
}, 120_000);
|
|
356
|
+
});
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
renderCall(args: any, theme: any) {
|
|
360
|
+
const title = (args.title ?? "widget").replace(/_/g, " ");
|
|
361
|
+
const size = args.width && args.height ? ` ${args.width}×${args.height}` : "";
|
|
362
|
+
let text = theme.fg("toolTitle", theme.bold("show_widget "));
|
|
363
|
+
text += theme.fg("accent", title);
|
|
364
|
+
if (size) text += theme.fg("dim", size);
|
|
365
|
+
return new Text(text, 0, 0);
|
|
366
|
+
},
|
|
367
|
+
|
|
368
|
+
renderResult(result: any, { isPartial, expanded }: any, theme: any) {
|
|
369
|
+
if (isPartial) {
|
|
370
|
+
return new Text(theme.fg("warning", "⟳ Widget rendering..."), 0, 0);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const details = result.details ?? {};
|
|
374
|
+
const title = (details.title ?? "widget").replace(/_/g, " ");
|
|
375
|
+
let text = theme.fg("success", "✓ ") + theme.fg("accent", title);
|
|
376
|
+
text += theme.fg("dim", ` ${details.width ?? 800}×${details.height ?? 600}`);
|
|
377
|
+
if (details.isSVG) text += theme.fg("dim", " (SVG)");
|
|
378
|
+
|
|
379
|
+
if (details.closedReason) {
|
|
380
|
+
text += "\n" + theme.fg("muted", ` ${details.closedReason}`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (expanded && details.messageData) {
|
|
384
|
+
text += "\n" + theme.fg("dim", ` Data: ${JSON.stringify(details.messageData, null, 2)}`);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return new Text(text, 0, 0);
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ── cleanup on shutdown ───────────────────────────────────────────────────
|
|
392
|
+
|
|
393
|
+
pi.on("session_shutdown", async () => {
|
|
394
|
+
if (streaming?.updateTimer) clearTimeout(streaming.updateTimer);
|
|
395
|
+
streaming = null;
|
|
396
|
+
for (const win of activeWindows) {
|
|
397
|
+
try { win.close(); } catch {}
|
|
398
|
+
}
|
|
399
|
+
activeWindows = [];
|
|
400
|
+
});
|
|
401
|
+
}
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# pi-generative-ui
|
|
2
|
+
|
|
3
|
+
Claude.ai's generative UI - reverse-engineered, rebuilt for [pi](https://github.com/badlogic/pi).
|
|
4
|
+
|
|
5
|
+
Ask pi to "show me how compound interest works" and get a live interactive widget - sliders, charts, animations - rendered in a native macOS window. Not a screenshot. Not a code block. A real HTML application with JavaScript, streaming live as the LLM generates it.
|
|
6
|
+
|
|
7
|
+
https://github.com/user-attachments/assets/placeholder-demo-video
|
|
8
|
+
|
|
9
|
+
## How it works
|
|
10
|
+
|
|
11
|
+
On claude.ai, when you ask Claude to visualize something, it calls a tool called `show_widget` that renders HTML inline in the conversation. The HTML streams live - you see cards, charts, and sliders appear as tokens arrive.
|
|
12
|
+
|
|
13
|
+
This extension replicates that system for pi:
|
|
14
|
+
|
|
15
|
+
1. **LLM calls `visualize_read_me`** - loads design guidelines (lazy, only the relevant modules)
|
|
16
|
+
2. **LLM calls `show_widget`** - generates an HTML fragment as a tool call parameter
|
|
17
|
+
3. **Extension intercepts the stream** - opens a native macOS window via [Glimpse](https://github.com/hazat/glimpse) and feeds partial HTML as tokens arrive
|
|
18
|
+
4. **[morphdom](https://github.com/patrick-steele-idem/morphdom) diffs the DOM** - new elements fade in smoothly, unchanged elements stay untouched
|
|
19
|
+
5. **Scripts execute on completion** - Chart.js, D3, Three.js, anything from CDN
|
|
20
|
+
|
|
21
|
+
The widget window has full browser capabilities (WKWebView) and a bidirectional bridge - `window.glimpse.send(data)` sends data back to the agent.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install git:github.com/user/pi-generative-ui
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
> macOS only. Requires Swift toolchain (ships with Xcode or Xcode Command Line Tools).
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
Just ask pi to visualize things. The extension adds two tools that the LLM calls automatically:
|
|
34
|
+
|
|
35
|
+
- **"Show me how compound interest works"** → interactive explainer with sliders and Chart.js
|
|
36
|
+
- **"Visualize the architecture of a transformer"** → SVG diagram with labeled components
|
|
37
|
+
- **"Create a dashboard for this data"** → metric cards, charts, tables
|
|
38
|
+
- **"Draw a particle system"** → Canvas animation
|
|
39
|
+
|
|
40
|
+
The LLM decides when to use widgets vs text based on the request. Explanatory/visual requests trigger widgets; code/text requests stay in the terminal.
|
|
41
|
+
|
|
42
|
+
## What's inside
|
|
43
|
+
|
|
44
|
+
### The guidelines - extracted from Claude
|
|
45
|
+
|
|
46
|
+
The design guidelines aren't hand-written. They're **extracted verbatim from claude.ai**.
|
|
47
|
+
|
|
48
|
+
Here's the trick: you can export any claude.ai conversation as JSON. The export includes full tool call payloads - including the complete `read_me` tool results containing Anthropic's actual design system. 72K of production rules covering typography, color palettes, streaming-safe CSS patterns, Chart.js configuration, SVG diagram engineering, and more.
|
|
49
|
+
|
|
50
|
+
We triggered `read_me` with each module combination, exported the conversation, parsed the JSON, split the responses into deduplicated sections, and verified byte-level accuracy against the originals. The result: our LLM gets the exact same instructions Claude gets on claude.ai.
|
|
51
|
+
|
|
52
|
+
Five modules, loaded on demand:
|
|
53
|
+
|
|
54
|
+
| Module | Size | What it covers |
|
|
55
|
+
|---|---|---|
|
|
56
|
+
| `interactive` | 19KB | Sliders, metric cards, live calculations |
|
|
57
|
+
| `chart` | 22KB | Chart.js setup, custom legends, number formatting |
|
|
58
|
+
| `mockup` | 19KB | UI component tokens, cards, forms, skeleton loading |
|
|
59
|
+
| `art` | 17KB | SVG illustration, Canvas animation, creative patterns |
|
|
60
|
+
| `diagram` | 59KB | Flowcharts, architecture diagrams, SVG arrow systems |
|
|
61
|
+
|
|
62
|
+
### Streaming architecture
|
|
63
|
+
|
|
64
|
+
The extension intercepts pi's streaming events (`toolcall_start` / `toolcall_delta` / `toolcall_end`) to render the widget live as tokens arrive:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
toolcall_start → initialize streaming state
|
|
68
|
+
toolcall_delta → debounce 150ms, open window, morphdom diff
|
|
69
|
+
toolcall_end → final diff + execute <script> tags
|
|
70
|
+
execute() → reuse window, wait for interaction or close
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Key details:
|
|
74
|
+
- **Shell HTML + JS eval** - window opens with an empty shell; content injected via `win.send()`, not `setHTML()`, to avoid full-page flashes
|
|
75
|
+
- **morphdom DOM diffing** - only changed nodes update; new nodes get a 0.3s fade-in animation
|
|
76
|
+
- **pi-ai's `parseStreamingJson`** - no need for a partial JSON parser; pi already provides parsed `arguments` on every delta
|
|
77
|
+
- **150ms debounce** - batches rapid token updates for smooth visual rendering
|
|
78
|
+
- **Dark mode by default** - `#1a1a1a` background, designed for macOS WKWebView
|
|
79
|
+
|
|
80
|
+
### Glimpse
|
|
81
|
+
|
|
82
|
+
[Glimpse](https://github.com/hazat/glimpse) is a native macOS micro-UI library. It opens a WKWebView window in under 50ms via a tiny Swift binary. No Electron, no browser tab, no runtime dependencies beyond the system WebKit.
|
|
83
|
+
|
|
84
|
+
The Swift source compiles automatically on `npm install` via `postinstall`.
|
|
85
|
+
|
|
86
|
+
## Project structure
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
pi-generative-ui/
|
|
90
|
+
├── .pi/extensions/generative-ui/
|
|
91
|
+
│ ├── index.ts # Extension: tools, streaming, Glimpse integration
|
|
92
|
+
│ ├── guidelines.ts # 72K of verbatim claude.ai design guidelines
|
|
93
|
+
│ └── claude-guidelines/ # Raw extracted markdown (reference)
|
|
94
|
+
│ ├── art.md
|
|
95
|
+
│ ├── chart.md
|
|
96
|
+
│ ├── diagram.md
|
|
97
|
+
│ ├── interactive.md
|
|
98
|
+
│ ├── mockup.md
|
|
99
|
+
│ └── sections/ # Deduplicated sections
|
|
100
|
+
└── package.json # pi-package manifest
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## How the guidelines were extracted
|
|
104
|
+
|
|
105
|
+
1. Start a conversation on claude.ai that triggers `show_widget`
|
|
106
|
+
2. Call `read_me` with each module combination (`art`, `chart`, `diagram`, `interactive`, `mockup`)
|
|
107
|
+
3. Export the conversation as JSON from claude.ai settings
|
|
108
|
+
4. Parse the JSON - every `tool_result` for `visualize:read_me` contains the complete guidelines
|
|
109
|
+
5. Split each response at `##` heading boundaries
|
|
110
|
+
6. Deduplicate shared sections (e.g., "Color palette" appears in chart, mockup, interactive, diagram)
|
|
111
|
+
7. Verify reconstruction matches the originals (4/5 exact, 1 has a single whitespace char difference)
|
|
112
|
+
|
|
113
|
+
The raw `read_me` responses are preserved in [`claude-guidelines/`](.pi/extensions/generative-ui/claude-guidelines/) - the original markdown exactly as claude.ai returned it, before splitting and deduplication. The conversation export JSON is not included in this repo.
|
|
114
|
+
|
|
115
|
+
## Credits
|
|
116
|
+
|
|
117
|
+
- [pi](https://github.com/badlogic/pi) - the extensible coding agent that makes this possible
|
|
118
|
+
- [Glimpse](https://github.com/hazat/glimpse) - native macOS WKWebView windows
|
|
119
|
+
- [morphdom](https://github.com/patrick-steele-idem/morphdom) - DOM diffing for smooth streaming
|
|
120
|
+
- Anthropic - for building the generative UI system we reverse-engineered
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-generative-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generative UI for pi — render interactive HTML/SVG widgets in native macOS windows via Glimpse",
|
|
5
|
+
"type": "commonjs",
|
|
6
|
+
"keywords": ["pi-package"],
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": [".pi/extensions/generative-ui"]
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"glimpseui": "^0.3.5"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@mariozechner/pi-ai": "*",
|
|
15
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
16
|
+
"@mariozechner/pi-tui": "*",
|
|
17
|
+
"@sinclair/typebox": "*"
|
|
18
|
+
},
|
|
19
|
+
"os": ["darwin"],
|
|
20
|
+
"author": "michaelliv",
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|