pi-openui 0.0.0-stage → 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.
@@ -0,0 +1,190 @@
1
+ // pi extension: render_widget (OpenUI Lang -> interactive terminal widget) plus a few commands.
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { Type } from "typebox";
5
+ import { readFileSync } from "node:fs";
6
+ import { compile, LIMITS, reference, type Compiled, type El } from "../lang/library.ts";
7
+ import { markdownImages } from "../lang/md.ts";
8
+ import { LANG_SAMPLES } from "../lang/samples.ts";
9
+ import { getCapabilities } from "@earendil-works/pi-tui";
10
+ import { loadImage } from "./images.ts";
11
+ import { active, armNewest, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
12
+
13
+ const compiled = new Map<string, Compiled>();
14
+ let compiledChars = 0;
15
+ /** A source is the key, so the cache is bounded by size as well as count: 64 sources at the 100 KB limit would be 6 MB. */
16
+ const CACHE_CHARS = 2_000_000;
17
+ export function compileCached(source: string): Compiled {
18
+ if (source.length > LIMITS.source) return compile(source); // rejected at once; never kept
19
+ let c = compiled.get(source);
20
+ if (!c) {
21
+ c = compile(source);
22
+ if (compiled.size >= 64 || compiledChars + source.length > CACHE_CHARS) {
23
+ compiled.clear();
24
+ compiledChars = 0;
25
+ }
26
+ compiled.set(source, c);
27
+ compiledChars += source.length;
28
+ }
29
+ return c;
30
+ }
31
+
32
+ /** A picture the widget will draw: an Image component, or a `![alt](path)` line in a Markdown or an FAQ answer. */
33
+ export interface Picture {
34
+ src: string;
35
+ line: number;
36
+ via: "Image" | "Markdown picture";
37
+ }
38
+
39
+ /** Every picture in a compiled tree, wherever it was written. */
40
+ export function picturesIn(el: El | undefined, out: Picture[] = []): Picture[] {
41
+ if (!el || typeof el !== "object") return out;
42
+ if (el.type === "Image") out.push({ src: String(el.props.src ?? ""), line: el.line, via: "Image" });
43
+ const text = el.type === "Markdown" ? el.props.text : el.type === "FaqItem" ? el.props.answer : undefined;
44
+ if (text !== undefined) for (const m of markdownImages(text)) out.push({ src: m.src, line: el.line, via: "Markdown picture" });
45
+ for (const v of Object.values(el.props ?? {})) for (const x of Array.isArray(v) ? v : [v]) if (x && typeof x === "object") picturesIn(Array.isArray(x) ? undefined : (x as El), out);
46
+ return out;
47
+ }
48
+
49
+ /** Problems the model can fix: the picture must exist and be a format a terminal can show. */
50
+ export function checkImages(root: El | undefined): string[] {
51
+ const errs: string[] = [];
52
+ for (const im of picturesIn(root)) {
53
+ const r = loadImage(im.src);
54
+ if ("error" in r) errs.push(`line ${im.line}: ${im.via} ${im.src.startsWith("data:") ? "data: URI" : `"${im.src.length > 80 ? im.src.slice(0, 77) + "..." : im.src}"`}: ${r.error}`);
55
+ }
56
+ return errs;
57
+ }
58
+
59
+ export default function (pi: ExtensionAPI) {
60
+ hooks.sendLive = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
61
+ hooks.sendEcho = (text) => pi.sendMessage({ customType: "openui-click", content: `[widget click → assistant] ${text}`, display: true });
62
+
63
+ pi.registerTool({
64
+ name: "render_widget",
65
+ label: "render_widget",
66
+ description:
67
+ "Draw an interactive widget in the terminal: dashboards, tables, charts (bar, line, area, pie, radar), diagrams (DAG, ER, infrastructure map, sequence), maps, tabs, buttons and choices. Pass OpenUI Lang as `source`. Call with reference=true once to get the component signatures. Clicking a Button or Choice sends its text back to you as the user's next message.",
68
+ promptSnippet: "render_widget: draw an interactive terminal widget (dashboards, charts, diagrams) from OpenUI Lang",
69
+ promptGuidelines: [
70
+ "Before the first render_widget call in a session, call render_widget with reference=true to read the component signatures. Never guess a signature.",
71
+ "After render_widget succeeds, reply with at most one short sentence. Do not repeat the widget's data in prose.",
72
+ "If render_widget returns errors, fix exactly the errors listed and call it again with the full corrected source.",
73
+ "Use plain text, not a widget, for a single value or a short answer. One widget per reply: use Tabs or Card to organise sections.",
74
+ "A click on a widget Button or Choice arrives as your next user message: act on it, then render a NEW widget if needed.",
75
+ ],
76
+ parameters: Type.Object({
77
+ source: Type.Optional(Type.String({ description: "OpenUI Lang source: root = Stack([...]) first, one statement per line" })),
78
+ reference: Type.Optional(Type.Boolean({ description: "Return the component reference instead of drawing" })),
79
+ }),
80
+ renderShell: "self",
81
+ async execute(_id: string, params: { source?: string; reference?: boolean }) {
82
+ if (params.reference || params.source === undefined) return { content: [{ type: "text" as const, text: reference() }], details: { kind: "reference" } };
83
+ const c = compileCached(params.source);
84
+ if (!c.ok) throw new Error(`The widget was not drawn: ${c.errors.length} error(s). Fix these and call render_widget again with the full corrected source.\n${c.errors.map((e) => `- ${e}`).join("\n")}`);
85
+ const imgErrs = checkImages(c.root);
86
+ if (imgErrs.length) throw new Error(`The widget was not drawn: ${imgErrs.length} error(s). Fix these and call render_widget again with the full corrected source.\n${imgErrs.map((e) => `- ${e}`).join("\n")}`);
87
+ const hasImage = picturesIn(c.root).length > 0;
88
+ const noPics = hasImage && getCapabilities().images !== "kitty";
89
+ const note = [...c.warnings, ...(noPics ? ["this terminal cannot show pictures, so each Image is drawn as a labelled frame with its file details"] : [])];
90
+ const noteText = note.length ? ` Notes: ${note.join("; ")}.` : "";
91
+ return { content: [{ type: "text" as const, text: `Widget drawn. The user can see and click it in the terminal; do not repeat its contents.${noteText}` }], details: { kind: "widget", source: params.source } };
92
+ },
93
+ renderCall(args: any, theme: any) {
94
+ const n = typeof args?.source === "string" ? args.source.split("\n").length : 0;
95
+ const label = args?.reference ? "reference" : n ? `${n} lines of OpenUI` : "…";
96
+ return new Text(theme.fg("toolTitle", theme.bold("render_widget")) + theme.fg("dim", ` ${label}`), 0, 0);
97
+ },
98
+ renderResult(result: any, _opts: any, theme: any, ctx: any) {
99
+ if (ctx.isError) {
100
+ // the model gets the whole list; show a person the same thing
101
+ const lines = String(result?.content?.[0]?.text ?? "failed").split("\n").slice(0, 9);
102
+ return new Text(lines.map((l) => theme.fg("error", l)).join("\n"), 0, 0);
103
+ }
104
+ if (result?.details?.kind !== "widget") return new Text(theme.fg("dim", "component reference loaded"), 0, 0);
105
+ const c = compileCached(String(result.details.source ?? ""));
106
+ if (!c.ok || !c.root) return new Text(theme.fg("error", "widget could not be restored"), 0, 0);
107
+ return new OpenUIWidget(ctx.toolCallId, c.root, theme, "live");
108
+ },
109
+ });
110
+
111
+ pi.registerMessageRenderer("openui-widget", (message: any, _o: any, theme: any) => {
112
+ const c = compileCached(String(message.details?.source ?? ""));
113
+ if (!c.ok || !c.root) return undefined;
114
+ return new OpenUIWidget(String(message.details.id), c.root, theme, message.details.mode === "live" ? "live" : "echo");
115
+ });
116
+
117
+ const draw = (source: string, title: string, ctx: any) => {
118
+ const c = compileCached(source);
119
+ if (!c.ok) return ctx.ui.notify(`${title}: ${c.errors.slice(0, 3).join(" | ")}`, "error");
120
+ pi.sendMessage({ customType: "openui-widget", content: `[widget: ${title}]`, display: true, details: { id: `w-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, source, mode: "echo" } });
121
+ };
122
+
123
+ pi.registerCommand("openui-gallery", {
124
+ description: "Draw one example per OpenUI component (or a key prefix: dashboard, table, tabs, line, pie, radar, image, dag, erd, infra, sequence, geo ...). Clicks echo locally.",
125
+ handler: async (args: string, ctx: any) => {
126
+ const which = args.trim() || "all";
127
+ const picked = LANG_SAMPLES.filter((s) => which === "all" || s.key.startsWith(which));
128
+ if (!picked.length) return ctx.ui.notify(`unknown example "${which}". Try: all, ${LANG_SAMPLES.map((s) => s.key).join(", ")}`, "warning");
129
+ for (const s of picked) draw(s.source, s.title, ctx);
130
+ },
131
+ });
132
+
133
+ pi.registerCommand("openui-file", {
134
+ description: "Draw a widget from an OpenUI Lang file: /openui-file path/to/widget.oui",
135
+ handler: async (args: string, ctx: any) => {
136
+ const path = args.trim();
137
+ if (!path) return ctx.ui.notify("usage: /openui-file path/to/widget.oui", "warning");
138
+ try {
139
+ draw(readFileSync(path, "utf8"), path.split("/").pop() ?? path, ctx);
140
+ } catch (e) {
141
+ ctx.ui.notify(`cannot read ${path}: ${(e as Error).message}`, "error");
142
+ }
143
+ },
144
+ });
145
+
146
+ pi.registerCommand("openui-notes", {
147
+ description: "Write notes on the newest widget: click an element (or Tab to it and press Enter), type, Enter to save. Then add them to your message.",
148
+ handler: async (_a: string, ctx: any) => {
149
+ if (!toggleNotesNewest()) ctx.ui.notify("no widget yet: run /openui-gallery", "warning");
150
+ },
151
+ });
152
+
153
+ pi.registerCommand("openui-focus", {
154
+ description: "Give the keyboard to the newest widget (Tab/arrows move, Enter activates, Esc leaves)",
155
+ handler: async (_a: string, ctx: any) => {
156
+ if (!armNewest()) ctx.ui.notify("no widget yet: run /openui-gallery", "warning");
157
+ },
158
+ });
159
+
160
+ let unsub: (() => void) | undefined;
161
+ pi.on("session_start", async (_e: any, ctx: any) => {
162
+ if (!ctx.hasUI) return;
163
+ unsub?.();
164
+ unsub = ctx.ui.onTerminalInput((data: string) => (handleKey(data) ? { consume: true } : undefined));
165
+ // the note editor is pi's own multi-line editor: Enter saves, Shift+Enter adds a line, Esc cancels
166
+ hooks.prompt = (title: string, initial: string) => ctx.ui.editor(title, initial);
167
+ hooks.toEditor = (text: string) => ctx.ui.pasteToEditor(text);
168
+ ctx.ui.setWidget("openui-hint", (tui: any, theme: any) => {
169
+ hooks.requestRender = () => tui.requestRender();
170
+ return {
171
+ render: (w: number) => {
172
+ if (!active.id) return [];
173
+ const e = entryOf(active.id);
174
+ const keys = e?.state.noteMode
175
+ ? "notes: click or Enter writes a note arrows/Tab move Shift+Enter picks the parent Esc leaves"
176
+ : e?.state.focus
177
+ ? "arrows move Tab next Enter activate Esc close/leave"
178
+ : "Tab or arrows select Enter activate Esc leave";
179
+ return [truncateToWidth(theme.fg("accent", " openui ") + theme.fg("dim", keys), w)];
180
+ },
181
+ invalidate() {},
182
+ };
183
+ });
184
+ });
185
+ pi.on("session_shutdown", async () => {
186
+ unsub?.();
187
+ unsub = undefined;
188
+ resetAll(); // a pi process lives for days across /new and resume: each session's widgets must not pile up
189
+ });
190
+ }
@@ -0,0 +1,185 @@
1
+ // Pictures for the Image component. pi-tui's own Image does the sizing and the Kitty encoding (so cell maths, image ids
2
+ // and the alt-screen cropping metadata are pi's), this file loads the bytes and keeps the placements.
3
+ import { getCapabilities, getImageDimensions, Image } from "@earendil-works/pi-tui";
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { basename, isAbsolute, resolve } from "node:path";
7
+ import type { ImageService } from "../blocks/types.ts";
8
+
9
+ export const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
10
+
11
+ export interface LoadedImage {
12
+ base64: string;
13
+ mime: string;
14
+ name: string;
15
+ bytes: number;
16
+ dims: { widthPx: number; heightPx: number } | null;
17
+ }
18
+
19
+ function sniff(buf: Buffer): string | null {
20
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) return "image/png";
21
+ if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
22
+ if (buf.length >= 6 && buf.subarray(0, 4).toString("ascii") === "GIF8") return "image/gif";
23
+ if (buf.length >= 12 && buf.subarray(0, 4).toString("ascii") === "RIFF" && buf.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
24
+ return null;
25
+ }
26
+
27
+ const cache = new Map<string, { stamp: string; value: LoadedImage | { error: string } }>();
28
+ /**
29
+ * Pictures are kept as base64 (a file of 12 MB is 16 MB of string), so the cache is bounded by size, not by count: 32 large
30
+ * screenshots would otherwise pin half a gigabyte for as long as pi runs.
31
+ */
32
+ const CACHE_BUDGET = 48 * 1024 * 1024;
33
+ let cacheBytes = 0;
34
+ const weigh = (v: LoadedImage | { error: string }) => ("base64" in v ? v.base64.length : 0);
35
+ export function imageCacheStats() {
36
+ return { entries: cache.size, bytes: cacheBytes, budget: CACHE_BUDGET };
37
+ }
38
+ function remember(path: string, stamp: string, value: LoadedImage | { error: string }) {
39
+ const old = cache.get(path);
40
+ if (old) cacheBytes -= weigh(old.value);
41
+ cache.delete(path);
42
+ cache.set(path, { stamp, value });
43
+ cacheBytes += weigh(value);
44
+ // oldest first; the newest picture is always kept, whatever its size
45
+ for (const [k, v] of cache) {
46
+ if (cache.size <= 1 || (cache.size <= 32 && cacheBytes <= CACHE_BUDGET)) break;
47
+ cache.delete(k);
48
+ cacheBytes -= weigh(v.value);
49
+ }
50
+ }
51
+
52
+ /** Read a local file (absolute, ~/ or relative to the working directory) or a base64 data: URI. Never throws. */
53
+ export function loadImage(src: string): LoadedImage | { error: string } {
54
+ const s = String(src ?? "").trim();
55
+ if (!s) return { error: "src is empty" };
56
+ if (/^https?:\/\//i.test(s)) return { error: "remote URLs are not fetched; use a local file path or a data: URI" };
57
+ let stamp = "data";
58
+ let buf: Buffer;
59
+ let name = "image";
60
+ if (s.startsWith("data:")) {
61
+ const m = /^data:([^;,]*)(;[^,]*)?,(.*)$/s.exec(s);
62
+ if (!m || !/;base64/i.test(m[2] ?? "")) return { error: "a data: URI must be base64, like data:image/png;base64,..." };
63
+ try {
64
+ buf = Buffer.from(m[3], "base64");
65
+ } catch {
66
+ return { error: "the data: URI is not valid base64" };
67
+ }
68
+ name = `inline.${(m[1] || "image/png").split("/")[1] ?? "png"}`;
69
+ } else {
70
+ const path = s.startsWith("~/") ? resolve(homedir(), s.slice(2)) : isAbsolute(s) ? s : resolve(process.cwd(), s);
71
+ let st;
72
+ try {
73
+ st = statSync(path);
74
+ } catch {
75
+ return { error: `file not found: ${path}` };
76
+ }
77
+ if (!st.isFile()) return { error: `not a file: ${path}` };
78
+ if (st.size > MAX_IMAGE_BYTES) return { error: `file is ${Math.round(st.size / 1024 / 1024)} MB; the limit is ${MAX_IMAGE_BYTES / 1024 / 1024} MB` };
79
+ stamp = `${st.mtimeMs}:${st.size}`;
80
+ const hit = cache.get(path);
81
+ if (hit && hit.stamp === stamp) return hit.value;
82
+ try {
83
+ buf = readFileSync(path);
84
+ } catch (e) {
85
+ return { error: `cannot read ${path}: ${(e as Error).message}` };
86
+ }
87
+ name = basename(path);
88
+ const value = decode(buf, name);
89
+ remember(path, stamp, value);
90
+ return value;
91
+ }
92
+ if (buf.length > MAX_IMAGE_BYTES) return { error: "the data: URI is too large" };
93
+ return decode(buf, name);
94
+ }
95
+
96
+ function decode(buf: Buffer, name: string): LoadedImage | { error: string } {
97
+ const mime = sniff(buf);
98
+ if (!mime) {
99
+ const head = buf.subarray(0, 200).toString("utf8").trimStart();
100
+ if (/^<(\?xml|svg)/i.test(head)) return { error: "SVG cannot be drawn in a terminal; use png, jpg, gif or webp" };
101
+ return { error: "not a png, jpg, gif or webp picture" };
102
+ }
103
+ const base64 = buf.toString("base64");
104
+ return { base64, mime, name, bytes: buf.length, dims: getImageDimensions(base64, mime) };
105
+ }
106
+
107
+ const kb = (n: number) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
108
+
109
+ interface Placement {
110
+ sequence: string;
111
+ cols: number;
112
+ rows: number;
113
+ }
114
+
115
+ function hash(s: string): string {
116
+ let h = 2166136261;
117
+ for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 16777619);
118
+ return (h >>> 0).toString(36) + s.length.toString(36);
119
+ }
120
+
121
+ export interface PiImageService extends ImageService {
122
+ /** The terminal sequence that draws a placed image (the first line pi-tui's Image returns). */
123
+ sequence(key: string): string | null;
124
+ /** Called after a picture that could not be drawn yet becomes drawable (pi loads its JPEG/GIF/WebP converter lazily). */
125
+ onLate: (() => void) | null;
126
+ /** Forget every placement: the terminal reported another cell size, so the same picture now needs other columns and rows. */
127
+ reset(): void;
128
+ }
129
+
130
+ export function createImageService(): PiImageService {
131
+ const placements = new Map<string, Placement>();
132
+ const pending = new Map<string, number>();
133
+ // a placement holds the picture's whole transmit command: bound them by size too
134
+ let placedChars = 0;
135
+ const svc: PiImageService = {
136
+ onLate: null,
137
+ reset() {
138
+ placements.clear();
139
+ pending.clear();
140
+ placedChars = 0;
141
+ },
142
+ sequence: (key) => placements.get(key)?.sequence ?? null,
143
+ describe(src) {
144
+ const img = loadImage(src);
145
+ if ("error" in img) return img.error;
146
+ const fmt = img.mime.split("/")[1];
147
+ return [img.name, img.dims ? `${img.dims.widthPx}×${img.dims.heightPx}` : "", fmt, kb(img.bytes)].filter(Boolean).join(" · ");
148
+ },
149
+ place(src, maxCols, maxRows) {
150
+ if (getCapabilities().images !== "kitty") return null;
151
+ const img = loadImage(src);
152
+ if ("error" in img) return null;
153
+ const key = `${hash(src)}:${maxCols}x${maxRows}`;
154
+ let p = placements.get(key);
155
+ if (!p) {
156
+ const comp = new Image(img.base64, img.mime, { fallbackColor: (s: string) => s }, { maxWidthCells: maxCols, maxHeightCells: maxRows, filename: img.name }, img.dims ?? undefined);
157
+ const lines = comp.render(maxCols + 2);
158
+ const seq = lines[0] ?? "";
159
+ if (!seq.includes("\x1b_G")) {
160
+ // pi converts JPEG, GIF and WebP lazily after startup; look again shortly so the picture replaces the frame
161
+ const tries = pending.get(key) ?? 0;
162
+ if (tries < 8 && img.mime !== "image/png") {
163
+ pending.set(key, tries + 1);
164
+ const t = setTimeout(() => svc.onLate?.(), 350);
165
+ if (typeof (t as any).unref === "function") (t as any).unref();
166
+ }
167
+ return null;
168
+ }
169
+ const c = /\x1b_G[^;]*,c=(\d+)/.exec(seq);
170
+ p = { sequence: seq, cols: c ? Number(c[1]) : maxCols, rows: lines.length };
171
+ placements.set(key, p);
172
+ placedChars += p.sequence.length;
173
+ for (const [k, old] of placements) {
174
+ if (placements.size <= 1 || (placements.size <= 40 && placedChars <= CACHE_BUDGET)) break;
175
+ placements.delete(k);
176
+ placedChars -= old.sequence.length;
177
+ }
178
+ }
179
+ return { key, cols: p.cols, rows: p.rows };
180
+ },
181
+ };
182
+ return svc;
183
+ }
184
+
185
+ export const imageService = createImageService();