@ryan_nookpi/pi-extension-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/README.md +16 -0
- package/guidelines.ts +794 -0
- package/html-utils.ts +94 -0
- package/index.ts +524 -0
- package/package.json +33 -0
- package/svg-styles.ts +167 -0
package/html-utils.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { SVG_STYLES } from "./svg-styles.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Keyboard shortcut handler for WKWebView windows.
|
|
5
|
+
* glimpseui's native window doesn't create a macOS Edit menu,
|
|
6
|
+
* so Cmd+C/A/X key equivalents aren't routed to the WKWebView.
|
|
7
|
+
* This JS-level handler bridges that gap.
|
|
8
|
+
*/
|
|
9
|
+
const KEYBOARD_SCRIPT = `<script>
|
|
10
|
+
document.addEventListener('keydown',function(e){
|
|
11
|
+
if(!e.metaKey)return;
|
|
12
|
+
switch(e.key){
|
|
13
|
+
case'c':case'x':case'a':
|
|
14
|
+
e.preventDefault();
|
|
15
|
+
document.execCommand(e.key==='c'?'copy':e.key==='x'?'cut':'selectAll');
|
|
16
|
+
break;
|
|
17
|
+
case'w':
|
|
18
|
+
e.preventDefault();
|
|
19
|
+
if(window.glimpse&&typeof window.glimpse.close==='function')window.glimpse.close();
|
|
20
|
+
else if(typeof window.close==='function')window.close();
|
|
21
|
+
break;
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
</script>`;
|
|
25
|
+
|
|
26
|
+
/** Shell HTML with a root container — used for streaming.
|
|
27
|
+
* Content is injected via win.send() JS eval, not setHTML(), to avoid full-page flashes. */
|
|
28
|
+
export function shellHTML(): string {
|
|
29
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
30
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
31
|
+
<style>
|
|
32
|
+
*{box-sizing:border-box;-webkit-user-select:text;user-select:text}
|
|
33
|
+
body{margin:0;padding:1rem;font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0;cursor:text}
|
|
34
|
+
@keyframes _fadeIn{from{opacity:0;transform:translateY(4px);}to{opacity:1;transform:none;}}
|
|
35
|
+
${SVG_STYLES}
|
|
36
|
+
</style>
|
|
37
|
+
</head><body><div id="root"></div>
|
|
38
|
+
${KEYBOARD_SCRIPT}
|
|
39
|
+
<script>
|
|
40
|
+
window._morphReady = false;
|
|
41
|
+
window._pending = null;
|
|
42
|
+
window._applyPending = function() {
|
|
43
|
+
if (!window._morphReady || !window._pending) return;
|
|
44
|
+
var html = window._pending;
|
|
45
|
+
window._pending = null;
|
|
46
|
+
window._setContent(html);
|
|
47
|
+
};
|
|
48
|
+
window._setContent = function(html) {
|
|
49
|
+
if (!window._morphReady) { window._pending = html; return; }
|
|
50
|
+
var root = document.getElementById('root');
|
|
51
|
+
var target = document.createElement('div');
|
|
52
|
+
target.id = 'root';
|
|
53
|
+
target.innerHTML = html;
|
|
54
|
+
morphdom(root, target, {
|
|
55
|
+
onBeforeElUpdated: function(from, to) {
|
|
56
|
+
if (from.isEqualNode(to)) return false;
|
|
57
|
+
return true;
|
|
58
|
+
},
|
|
59
|
+
onNodeAdded: function(node) {
|
|
60
|
+
if (node.nodeType === 1 && node.tagName !== 'STYLE' && node.tagName !== 'SCRIPT') {
|
|
61
|
+
node.style.animation = '_fadeIn 0.3s ease both';
|
|
62
|
+
}
|
|
63
|
+
return node;
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
};
|
|
67
|
+
</script>
|
|
68
|
+
<script src="https://cdn.jsdelivr.net/npm/morphdom@2.7.4/dist/morphdom-umd.min.js"
|
|
69
|
+
onload="window._morphReady=true;window._applyPending();"></script>
|
|
70
|
+
</body></html>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Wrap HTML fragment into a full document for Glimpse (non-streaming fallback) */
|
|
74
|
+
export function wrapHTML(code: string, isSVG = false): string {
|
|
75
|
+
if (isSVG) {
|
|
76
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>*{-webkit-user-select:text;user-select:text}body{cursor:text}${SVG_STYLES}</style></head>
|
|
77
|
+
<body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#1a1a1a;color:#e0e0e0;">
|
|
78
|
+
${code}${KEYBOARD_SCRIPT}</body></html>`;
|
|
79
|
+
}
|
|
80
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
|
82
|
+
<style>*{box-sizing:border-box;-webkit-user-select:text;user-select:text}body{margin:0;padding:1rem;font-family:system-ui,-apple-system,sans-serif;background:#1a1a1a;color:#e0e0e0;cursor:text}${SVG_STYLES}</style>
|
|
83
|
+
</head><body>${code}${KEYBOARD_SCRIPT}</body></html>`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Escape a string for safe injection into a JS string literal */
|
|
87
|
+
export function escapeJS(s: string): string {
|
|
88
|
+
return s
|
|
89
|
+
.replace(/\\/g, "\\\\")
|
|
90
|
+
.replace(/'/g, "\\'")
|
|
91
|
+
.replace(/\n/g, "\\n")
|
|
92
|
+
.replace(/\r/g, "\\r")
|
|
93
|
+
.replace(/<\/script>/gi, "<\\/script>");
|
|
94
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { StringEnum } from "@mariozechner/pi-ai";
|
|
4
|
+
import type { ExtensionAPI, ThemeColor } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { Key, matchesKey, Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
import { Type } from "@sinclair/typebox";
|
|
8
|
+
import { AVAILABLE_MODULES, getGuidelines } from "./guidelines.js";
|
|
9
|
+
import { escapeJS, shellHTML, wrapHTML } from "./html-utils.js";
|
|
10
|
+
|
|
11
|
+
interface WidgetHistoryEntry {
|
|
12
|
+
title: string;
|
|
13
|
+
code: string;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
isSVG: boolean;
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GlimpseWindow {
|
|
21
|
+
on(event: "ready" | "closed", handler: () => void): void;
|
|
22
|
+
send(script: string): void;
|
|
23
|
+
setHTML(html: string): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface GlimpseModule {
|
|
27
|
+
open(html: string, options: { width: number; height: number; title: string; floating?: boolean }): GlimpseWindow;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function shouldApplyFinalStreamingHTML(finalHTML: string | null, finalHTMLApplied: boolean): boolean {
|
|
31
|
+
return Boolean(finalHTML) && !finalHTMLApplied;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface StreamingWidget {
|
|
35
|
+
contentIndex: number;
|
|
36
|
+
window: GlimpseWindow | null;
|
|
37
|
+
lastHTML: string;
|
|
38
|
+
updateTimer: ReturnType<typeof setTimeout> | null;
|
|
39
|
+
ready: boolean;
|
|
40
|
+
finalHTML: string | null;
|
|
41
|
+
finalIsSVG: boolean;
|
|
42
|
+
finalHTMLApplied: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createStreamingWidget(contentIndex: number): StreamingWidget {
|
|
46
|
+
return {
|
|
47
|
+
contentIndex,
|
|
48
|
+
window: null,
|
|
49
|
+
lastHTML: "",
|
|
50
|
+
updateTimer: null,
|
|
51
|
+
ready: false,
|
|
52
|
+
finalHTML: null,
|
|
53
|
+
finalIsSVG: false,
|
|
54
|
+
finalHTMLApplied: false,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function extractStreamingWidgetCode(
|
|
59
|
+
block: { type?: string; arguments?: Record<string, unknown> } | undefined,
|
|
60
|
+
): string | undefined {
|
|
61
|
+
const widgetCode = block?.type === "toolCall" ? block.arguments?.widget_code : undefined;
|
|
62
|
+
return typeof widgetCode === "string" ? widgetCode : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default function (pi: ExtensionAPI) {
|
|
66
|
+
let activeWindows: GlimpseWindow[] = [];
|
|
67
|
+
let glimpseModule: GlimpseModule | null = null;
|
|
68
|
+
const widgetHistory: WidgetHistoryEntry[] = [];
|
|
69
|
+
const require = createRequire(import.meta.url);
|
|
70
|
+
const glimpsePath = pathToFileURL(require.resolve("glimpseui")).href;
|
|
71
|
+
|
|
72
|
+
// Lazy-load glimpse module using package resolution
|
|
73
|
+
async function getGlimpse(): Promise<GlimpseModule> {
|
|
74
|
+
if (!glimpseModule) {
|
|
75
|
+
glimpseModule = (await import(glimpsePath)) as unknown as GlimpseModule;
|
|
76
|
+
}
|
|
77
|
+
return glimpseModule;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Streaming state ─────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
let streaming: StreamingWidget | null = null;
|
|
83
|
+
|
|
84
|
+
// ── message_update: intercept streaming tool calls ────────────────────
|
|
85
|
+
|
|
86
|
+
function sendStreamingHTML(currentStreaming: StreamingWidget): void {
|
|
87
|
+
if (currentStreaming.finalHTMLApplied || !currentStreaming.lastHTML) return;
|
|
88
|
+
const escaped = escapeJS(currentStreaming.lastHTML);
|
|
89
|
+
currentStreaming.window?.send(`window._setContent('${escaped}')`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function applyFinalStreamingHTML(currentStreaming: StreamingWidget): boolean {
|
|
93
|
+
const finalHTML = currentStreaming.finalHTML;
|
|
94
|
+
if (!finalHTML || !shouldApplyFinalStreamingHTML(finalHTML, currentStreaming.finalHTMLApplied)) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
currentStreaming.finalHTMLApplied = true;
|
|
98
|
+
currentStreaming.window?.setHTML(wrapHTML(finalHTML, currentStreaming.finalIsSVG));
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function attachStreamingWindowReadyHandler(currentStreaming: StreamingWidget): void {
|
|
103
|
+
currentStreaming.window?.on("ready", () => {
|
|
104
|
+
currentStreaming.ready = true;
|
|
105
|
+
if (!applyFinalStreamingHTML(currentStreaming)) {
|
|
106
|
+
sendStreamingHTML(currentStreaming);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function ensureStreamingWindow(
|
|
112
|
+
currentStreaming: StreamingWidget,
|
|
113
|
+
block: { type?: string; arguments?: Record<string, unknown> } | undefined,
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
if (currentStreaming.window) return;
|
|
116
|
+
const args = block?.type === "toolCall" ? (block.arguments ?? {}) : {};
|
|
117
|
+
const title = String(args.title ?? "Widget").replace(/_/g, " ");
|
|
118
|
+
const width = typeof args.width === "number" ? args.width : 800;
|
|
119
|
+
const height = typeof args.height === "number" ? args.height : 600;
|
|
120
|
+
const { open } = await getGlimpse();
|
|
121
|
+
currentStreaming.window = open(shellHTML(), { width, height, title });
|
|
122
|
+
activeWindows.push(currentStreaming.window);
|
|
123
|
+
attachStreamingWindowReadyHandler(currentStreaming);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function scheduleStreamingUpdate(
|
|
127
|
+
currentStreaming: StreamingWidget,
|
|
128
|
+
block: { type?: string; arguments?: Record<string, unknown> } | undefined,
|
|
129
|
+
): void {
|
|
130
|
+
if (currentStreaming.updateTimer) return;
|
|
131
|
+
currentStreaming.updateTimer = setTimeout(async () => {
|
|
132
|
+
currentStreaming.updateTimer = null;
|
|
133
|
+
try {
|
|
134
|
+
await ensureStreamingWindow(currentStreaming, block);
|
|
135
|
+
if (currentStreaming.ready) {
|
|
136
|
+
sendStreamingHTML(currentStreaming);
|
|
137
|
+
}
|
|
138
|
+
} catch {}
|
|
139
|
+
}, 150);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function finalizeStreamingWidget(widgetCode: string | undefined): void {
|
|
143
|
+
if (!streaming) return;
|
|
144
|
+
if (streaming.updateTimer) {
|
|
145
|
+
clearTimeout(streaming.updateTimer);
|
|
146
|
+
streaming.updateTimer = null;
|
|
147
|
+
}
|
|
148
|
+
if (!widgetCode) return;
|
|
149
|
+
streaming.finalHTML = widgetCode;
|
|
150
|
+
streaming.finalIsSVG = widgetCode.trimStart().startsWith("<svg");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
pi.on("message_update", async (event) => {
|
|
154
|
+
const raw = event.assistantMessageEvent;
|
|
155
|
+
if (raw.type === "toolcall_start") {
|
|
156
|
+
const block = raw.partial.content[raw.contentIndex];
|
|
157
|
+
if (block?.type === "toolCall" && block?.name === "show_widget") {
|
|
158
|
+
streaming = createStreamingWidget(raw.contentIndex);
|
|
159
|
+
}
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (raw.type === "toolcall_delta" && streaming && raw.contentIndex === streaming.contentIndex) {
|
|
163
|
+
const block = raw.partial.content[raw.contentIndex];
|
|
164
|
+
const html = extractStreamingWidgetCode(block);
|
|
165
|
+
if (!html || html.length < 20 || html === streaming.lastHTML) return;
|
|
166
|
+
streaming.lastHTML = html;
|
|
167
|
+
scheduleStreamingUpdate(streaming, block);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (raw.type === "toolcall_end" && streaming && raw.contentIndex === streaming.contentIndex) {
|
|
171
|
+
finalizeStreamingWidget(extractStreamingWidgetCode(raw.toolCall));
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── read_me tool ──────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
pi.registerTool({
|
|
178
|
+
name: "visualize_read_me",
|
|
179
|
+
label: "Read Guidelines",
|
|
180
|
+
description:
|
|
181
|
+
"Returns design guidelines for show_widget (CSS patterns, colors, typography, layout rules, examples). " +
|
|
182
|
+
"Call once before your first show_widget call. Do NOT mention this call to the user — it is an internal setup step.",
|
|
183
|
+
promptSnippet: "Load design guidelines before creating widgets. Call silently before first show_widget use.",
|
|
184
|
+
promptGuidelines: [
|
|
185
|
+
"Call visualize_read_me once before your first show_widget call to load design guidelines.",
|
|
186
|
+
"Do NOT mention the read_me call to the user — call it silently, then proceed directly to building the widget.",
|
|
187
|
+
"Pick the modules that match your use case: interactive, chart, mockup, art, diagram.",
|
|
188
|
+
],
|
|
189
|
+
parameters: Type.Object({
|
|
190
|
+
modules: Type.Array(StringEnum(AVAILABLE_MODULES as readonly string[]), {
|
|
191
|
+
description: "Which module(s) to load. Pick all that fit.",
|
|
192
|
+
}),
|
|
193
|
+
}),
|
|
194
|
+
|
|
195
|
+
async execute(_toolCallId, params) {
|
|
196
|
+
const content = getGuidelines(params.modules);
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text" as const, text: content }],
|
|
199
|
+
details: { modules: params.modules },
|
|
200
|
+
};
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
renderCall(args, theme) {
|
|
204
|
+
const mods = (args.modules ?? []).join(", ");
|
|
205
|
+
return new Text(theme.fg("toolTitle", theme.bold("read_me ")) + theme.fg("muted", mods), 0, 0);
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
renderResult(_result, { isPartial }, theme) {
|
|
209
|
+
if (isPartial) return new Text(theme.fg("warning", "Loading guidelines..."), 0, 0);
|
|
210
|
+
return new Text(theme.fg("dim", "Guidelines loaded"), 0, 0);
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── show_widget tool ──────────────────────────────────────────────────────
|
|
215
|
+
|
|
216
|
+
pi.registerTool({
|
|
217
|
+
name: "show_widget",
|
|
218
|
+
label: "Show Widget",
|
|
219
|
+
description:
|
|
220
|
+
"Show visual content — SVG graphics, diagrams, charts, or interactive HTML widgets — in a native macOS window. " +
|
|
221
|
+
"Use for flowcharts, dashboards, forms, calculators, data tables, games, illustrations, or any visual content. " +
|
|
222
|
+
"The HTML is rendered in a native WKWebView with full CSS/JS support including Canvas and CDN libraries. " +
|
|
223
|
+
"IMPORTANT: Call visualize_read_me once before your first show_widget call.",
|
|
224
|
+
promptSnippet:
|
|
225
|
+
"Render interactive HTML/SVG widgets in a native macOS window (WKWebView). Supports full CSS, JS, Canvas, Chart.js.",
|
|
226
|
+
promptGuidelines: [
|
|
227
|
+
"Use show_widget when the user asks for visual content: charts, diagrams, interactive explainers, UI mockups, art.",
|
|
228
|
+
"Always call visualize_read_me first to load design guidelines, then set i_have_seen_read_me: true.",
|
|
229
|
+
"The widget opens in a native macOS window — it has full browser capabilities (Canvas, JS, CDN libraries).",
|
|
230
|
+
"Structure HTML as fragments: no DOCTYPE/<html>/<head>/<body>. Style first, then HTML, then scripts.",
|
|
231
|
+
"Keep widgets focused and appropriately sized. Default is 800x600 but adjust to fit content.",
|
|
232
|
+
"For interactive explainers: sliders, live calculations, Chart.js charts.",
|
|
233
|
+
"For SVG: start code with <svg> tag, it will be auto-detected.",
|
|
234
|
+
"Be concise in your responses",
|
|
235
|
+
],
|
|
236
|
+
parameters: Type.Object({
|
|
237
|
+
i_have_seen_read_me: Type.Boolean({
|
|
238
|
+
description: "Confirm you have already called visualize_read_me in this conversation.",
|
|
239
|
+
}),
|
|
240
|
+
title: Type.String({
|
|
241
|
+
description: "Short snake_case identifier for this widget (used as window title).",
|
|
242
|
+
}),
|
|
243
|
+
widget_code: Type.String({
|
|
244
|
+
description:
|
|
245
|
+
"HTML or SVG code to render. For SVG: raw SVG starting with <svg>. " +
|
|
246
|
+
"For HTML: raw content fragment, no DOCTYPE/<html>/<head>/<body>.",
|
|
247
|
+
}),
|
|
248
|
+
width: Type.Optional(Type.Number({ description: "Window width in pixels. Default: 800." })),
|
|
249
|
+
height: Type.Optional(Type.Number({ description: "Window height in pixels. Default: 600." })),
|
|
250
|
+
floating: Type.Optional(Type.Boolean({ description: "Keep window always on top. Default: false." })),
|
|
251
|
+
}),
|
|
252
|
+
|
|
253
|
+
async execute(_toolCallId, params, _signal) {
|
|
254
|
+
if (!params.i_have_seen_read_me) {
|
|
255
|
+
throw new Error(
|
|
256
|
+
"You must call visualize_read_me before show_widget. Set i_have_seen_read_me: true after doing so.",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const code = params.widget_code;
|
|
261
|
+
const isSVG = code.trimStart().startsWith("<svg");
|
|
262
|
+
const title = params.title.replace(/_/g, " ");
|
|
263
|
+
const width = params.width ?? 800;
|
|
264
|
+
const height = params.height ?? 600;
|
|
265
|
+
|
|
266
|
+
// Check if we already have a streaming window from message_update
|
|
267
|
+
let win: GlimpseWindow;
|
|
268
|
+
const existingWindow = streaming?.window;
|
|
269
|
+
|
|
270
|
+
if (existingWindow) {
|
|
271
|
+
const currentStreaming = streaming;
|
|
272
|
+
if (!currentStreaming) {
|
|
273
|
+
throw new Error("Missing streaming state for existing widget window.");
|
|
274
|
+
}
|
|
275
|
+
win = existingWindow;
|
|
276
|
+
currentStreaming.finalHTML = code;
|
|
277
|
+
currentStreaming.finalIsSVG = isSVG;
|
|
278
|
+
// Replace the streaming shell with the final document so browser-native script execution runs.
|
|
279
|
+
if (shouldApplyFinalStreamingHTML(currentStreaming.finalHTML, currentStreaming.finalHTMLApplied)) {
|
|
280
|
+
currentStreaming.finalHTMLApplied = true;
|
|
281
|
+
win.setHTML(wrapHTML(code, isSVG));
|
|
282
|
+
}
|
|
283
|
+
streaming = null;
|
|
284
|
+
} else {
|
|
285
|
+
// No streaming window — open fresh (fallback for non-streaming providers)
|
|
286
|
+
const { open } = await getGlimpse();
|
|
287
|
+
win = open(wrapHTML(code, isSVG), {
|
|
288
|
+
width,
|
|
289
|
+
height,
|
|
290
|
+
title,
|
|
291
|
+
floating: params.floating ?? false,
|
|
292
|
+
});
|
|
293
|
+
activeWindows.push(win);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Save to history for /widgets gallery
|
|
297
|
+
widgetHistory.push({ title, code, width, height, isSVG, timestamp: Date.now() });
|
|
298
|
+
|
|
299
|
+
// Clean up activeWindows when the window is closed
|
|
300
|
+
win.on("closed", () => {
|
|
301
|
+
activeWindows = activeWindows.filter((w) => w !== win);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
content: [
|
|
306
|
+
{
|
|
307
|
+
type: "text" as const,
|
|
308
|
+
text: `Widget "${title}" rendered and shown to the user (${width}×${height}).`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
details: {
|
|
312
|
+
title: params.title,
|
|
313
|
+
width,
|
|
314
|
+
height,
|
|
315
|
+
isSVG,
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
renderCall(args, theme) {
|
|
321
|
+
const title = (args.title ?? "widget").replace(/_/g, " ");
|
|
322
|
+
const size = args.width && args.height ? ` ${args.width}×${args.height}` : "";
|
|
323
|
+
let text = theme.fg("toolTitle", theme.bold("show_widget "));
|
|
324
|
+
text += theme.fg("accent", title);
|
|
325
|
+
if (size) text += theme.fg("dim", size);
|
|
326
|
+
|
|
327
|
+
const code = typeof args.widget_code === "string" ? args.widget_code : "";
|
|
328
|
+
if (code.length > 0) {
|
|
329
|
+
const lines = code.split("\n");
|
|
330
|
+
const lineCount = lines.length;
|
|
331
|
+
const bytes = Buffer.byteLength(code, "utf8");
|
|
332
|
+
const isSVG = code.trimStart().startsWith("<svg");
|
|
333
|
+
const tag = isSVG ? "SVG" : "HTML";
|
|
334
|
+
text += theme.fg("muted", ` (${tag} ${lineCount} lines • ${bytes} bytes)`);
|
|
335
|
+
|
|
336
|
+
// Show last 4 lines as preview (sanitized + truncated)
|
|
337
|
+
const MAX_LINE_WIDTH = 100;
|
|
338
|
+
const previewLines = lines.slice(-4);
|
|
339
|
+
const hidden = lines.length - previewLines.length;
|
|
340
|
+
if (hidden > 0) {
|
|
341
|
+
text += `\n${theme.fg("muted", `… (${hidden} earlier lines)`)}`;
|
|
342
|
+
}
|
|
343
|
+
for (const line of previewLines) {
|
|
344
|
+
// Strip control chars / ANSI sequences, replace tabs, truncate
|
|
345
|
+
let safe = line
|
|
346
|
+
.replace(/\t/g, " ")
|
|
347
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional sanitization
|
|
348
|
+
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "")
|
|
349
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip ANSI CSI sequences
|
|
350
|
+
.replace(/\x1b\[[0-9;]*[A-Za-z]/g, "")
|
|
351
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: strip OSC sequences
|
|
352
|
+
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
|
|
353
|
+
if (safe.length > MAX_LINE_WIDTH) {
|
|
354
|
+
safe = `${safe.slice(0, MAX_LINE_WIDTH)}…`;
|
|
355
|
+
}
|
|
356
|
+
text += `\n${theme.fg("dim", safe)}`;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return new Text(text, 0, 0);
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
renderResult(result, { isPartial }, theme) {
|
|
364
|
+
if (isPartial) {
|
|
365
|
+
return new Text(theme.fg("warning", "⟳ Widget rendering..."), 0, 0);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const details = result.details ?? {};
|
|
369
|
+
const title = (details.title ?? "widget").replace(/_/g, " ");
|
|
370
|
+
let text = theme.fg("success", "✓ ") + theme.fg("accent", title);
|
|
371
|
+
text += theme.fg("dim", ` ${details.width ?? 800}×${details.height ?? 600}`);
|
|
372
|
+
if (details.isSVG) text += theme.fg("dim", " (SVG)");
|
|
373
|
+
|
|
374
|
+
return new Text(text, 0, 0);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ── /widgets command — gallery of past widgets ──────────────────────────
|
|
379
|
+
|
|
380
|
+
pi.registerCommand("widgets", {
|
|
381
|
+
description: "Browse and reopen past widgets",
|
|
382
|
+
handler: async (_args, ctx) => {
|
|
383
|
+
if (!ctx.hasUI) return;
|
|
384
|
+
|
|
385
|
+
if (widgetHistory.length === 0) {
|
|
386
|
+
ctx.ui.notify("No widgets yet — use show_widget first", "info");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
await ctx.ui.custom(
|
|
391
|
+
(tui, theme, _kb, done) => {
|
|
392
|
+
const ui = new WidgetGalleryUI(
|
|
393
|
+
widgetHistory,
|
|
394
|
+
async (entry) => {
|
|
395
|
+
const { open } = await getGlimpse();
|
|
396
|
+
const win = open(wrapHTML(entry.code, entry.isSVG), {
|
|
397
|
+
width: entry.width,
|
|
398
|
+
height: entry.height,
|
|
399
|
+
title: entry.title,
|
|
400
|
+
});
|
|
401
|
+
activeWindows.push(win);
|
|
402
|
+
win.on("closed", () => {
|
|
403
|
+
activeWindows = activeWindows.filter((w) => w !== win);
|
|
404
|
+
});
|
|
405
|
+
if (ctx.hasUI) ctx.ui.notify(`Reopened "${entry.title}"`, "info");
|
|
406
|
+
tui.requestRender();
|
|
407
|
+
},
|
|
408
|
+
() => done(undefined),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return {
|
|
412
|
+
render: (w) => ui.render(w, tui.terminal.rows ?? 40, theme),
|
|
413
|
+
handleInput: (data) => ui.handleInput(data, tui),
|
|
414
|
+
invalidate: () => {},
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
{
|
|
418
|
+
overlay: true,
|
|
419
|
+
overlayOptions: { width: "70%", maxHeight: "70%", anchor: "center" },
|
|
420
|
+
},
|
|
421
|
+
);
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ── Widget Gallery Overlay ──────────────────────────────────────────────────
|
|
427
|
+
|
|
428
|
+
type OverlayTui = { requestRender: () => void };
|
|
429
|
+
type OverlayTheme = { fg: (color: ThemeColor, text: string) => string; bold: (text: string) => string };
|
|
430
|
+
|
|
431
|
+
class WidgetGalleryUI {
|
|
432
|
+
private selectedIndex = 0;
|
|
433
|
+
private reopenedSet = new Set<number>();
|
|
434
|
+
|
|
435
|
+
constructor(
|
|
436
|
+
private history: WidgetHistoryEntry[],
|
|
437
|
+
private onReopen: (entry: WidgetHistoryEntry) => void,
|
|
438
|
+
private onDone: () => void,
|
|
439
|
+
) {
|
|
440
|
+
// Start at newest
|
|
441
|
+
this.selectedIndex = history.length - 1;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
handleInput(data: string, tui: OverlayTui): void {
|
|
445
|
+
if (matchesKey(data, Key.up) || data === "k") {
|
|
446
|
+
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
|
|
447
|
+
} else if (matchesKey(data, Key.down) || data === "j") {
|
|
448
|
+
this.selectedIndex = Math.min(this.history.length - 1, this.selectedIndex + 1);
|
|
449
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
450
|
+
const entry = this.history[this.selectedIndex];
|
|
451
|
+
if (entry) {
|
|
452
|
+
this.reopenedSet.add(this.selectedIndex);
|
|
453
|
+
this.onReopen(entry);
|
|
454
|
+
}
|
|
455
|
+
} else if (data === "a") {
|
|
456
|
+
for (let i = 0; i < this.history.length; i++) {
|
|
457
|
+
this.reopenedSet.add(i);
|
|
458
|
+
this.onReopen(this.history[i]);
|
|
459
|
+
}
|
|
460
|
+
} else if (matchesKey(data, Key.escape) || data === "q") {
|
|
461
|
+
this.onDone();
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
tui.requestRender();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
render(width: number, height: number, theme: OverlayTheme): string[] {
|
|
468
|
+
const lines: string[] = [];
|
|
469
|
+
|
|
470
|
+
// Header
|
|
471
|
+
lines.push(...new DynamicBorder((s: string) => theme.fg("accent", s)).render(width));
|
|
472
|
+
lines.push(
|
|
473
|
+
`${theme.fg("accent", theme.bold(" WIDGETS"))} ${theme.fg("dim", "|")} ${theme.fg("muted", `${this.history.length} widget(s) this session`)}`,
|
|
474
|
+
);
|
|
475
|
+
lines.push("");
|
|
476
|
+
|
|
477
|
+
// List area
|
|
478
|
+
const listHeight = height - 7; // header(3) + footer(4)
|
|
479
|
+
const total = this.history.length;
|
|
480
|
+
|
|
481
|
+
// Compute visible window (scroll so selected is always visible)
|
|
482
|
+
let scrollTop = 0;
|
|
483
|
+
if (total > listHeight) {
|
|
484
|
+
scrollTop = Math.min(Math.max(0, this.selectedIndex - Math.floor(listHeight / 2)), total - listHeight);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
for (let vi = 0; vi < listHeight; vi++) {
|
|
488
|
+
const idx = scrollTop + vi;
|
|
489
|
+
if (idx >= total) {
|
|
490
|
+
lines.push("");
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const entry = this.history[idx];
|
|
495
|
+
const isSelected = idx === this.selectedIndex;
|
|
496
|
+
const wasReopened = this.reopenedSet.has(idx);
|
|
497
|
+
|
|
498
|
+
const cursor = isSelected ? theme.fg("accent", " ❯ ") : " ";
|
|
499
|
+
const num = theme.fg("dim", `${String(idx + 1).padStart(2)}.`);
|
|
500
|
+
const tag = entry.isSVG ? theme.fg("warning", "SVG") : theme.fg("accent", "HTM");
|
|
501
|
+
const title = isSelected ? theme.fg("accent", theme.bold(entry.title)) : theme.fg("muted", entry.title);
|
|
502
|
+
const size = theme.fg("dim", `${entry.width}×${entry.height}`);
|
|
503
|
+
const time = theme.fg("dim", formatTime(entry.timestamp));
|
|
504
|
+
const reopenBadge = wasReopened ? theme.fg("success", " ✓") : "";
|
|
505
|
+
|
|
506
|
+
const line = `${cursor}${num} ${tag} ${title} ${size} ${time}${reopenBadge}`;
|
|
507
|
+
lines.push(truncateToWidth(line, width - 2));
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Footer
|
|
511
|
+
lines.push("");
|
|
512
|
+
lines.push(theme.fg("dim", " ↑/↓ Select • Enter Reopen • a All • Esc Close"));
|
|
513
|
+
lines.push(...new DynamicBorder((s: string) => theme.fg("accent", s)).render(width));
|
|
514
|
+
|
|
515
|
+
return lines;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function formatTime(ts: number): string {
|
|
520
|
+
const d = new Date(ts);
|
|
521
|
+
const h = String(d.getHours()).padStart(2, "0");
|
|
522
|
+
const m = String(d.getMinutes()).padStart(2, "0");
|
|
523
|
+
return `${h}:${m}`;
|
|
524
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ryan_nookpi/pi-extension-generative-ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Generative UI widget extension for pi.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"index.ts",
|
|
11
|
+
"README.md",
|
|
12
|
+
"guidelines.ts",
|
|
13
|
+
"html-utils.ts",
|
|
14
|
+
"svg-styles.ts"
|
|
15
|
+
],
|
|
16
|
+
"pi": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./index.ts"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"@mariozechner/pi-ai": "*",
|
|
23
|
+
"@mariozechner/pi-coding-agent": "*",
|
|
24
|
+
"@mariozechner/pi-tui": "*",
|
|
25
|
+
"@sinclair/typebox": "*"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"glimpseui": "^0.6.2"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
}
|
|
33
|
+
}
|