pi-openui 0.2.0 → 0.3.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.
@@ -233,4 +233,138 @@ doc = Markdown("## A picture in the text\\n\\nThe line below is only an image: i
233
233
  img = Image("${SAMPLE_PNG_DATA_URI}", "Sunset over the ridge", "Generated test picture, 192x108", [t])
234
234
  t = Tooltip("image", "Test picture, drawn by the extension", "Pictures use the terminal's image protocol (Kitty, Ghostty, WezTerm, Warp, herdr). Other terminals get a labelled frame with the file details.", null, null, "info")`,
235
235
  },
236
+ // ---- Small screens. `/openui-gallery wide` draws these: each is something that does not fit a phone (about 60 columns), and
237
+ // each shows what it does instead of being cut. Try them in a narrow pane.
238
+ {
239
+ key: "wide-table",
240
+ title: "A wide table: it scrolls, and Host is pinned so a row stays readable",
241
+ source: `root = Stack([title, t])
242
+ title = Text("Homelab fleet: five columns, Host stays put while the rest scrolls", "heading")
243
+ t = Table(["Host", "OS", "Role", "Tailnet IP", "State"], [["dgx-spark", "Ubuntu 24.04", "Gateway host, LiteLLM", "100.76.148.76", "up"], ["dgx-spark2", "Ubuntu 24.04", "Cluster TP=2 node A", "100.87.121.4", "up"], ["divi-pc-ubuntu", "Ubuntu 24.04", "RTX 5090 workstation", "100.68.119.2", "down 8d"], ["bosgame-m5-ubuntu", "Ubuntu 26.04", "Strix Halo, dual-boot", "100.125.2.9", "down 7d"], ["davids-macbook", "macOS", "Control plane; llm-ctl", "100.123.55.118", "up"]], null, ["Host"])`,
244
+ },
245
+ {
246
+ key: "wide-table-cut",
247
+ title: "Long cells: one that is cut to fit ends in an ellipsis, tap it to read it whole",
248
+ source: `root = Stack([title, t])
249
+ title = Text("Three columns fit by squeezing: tap a cell that ends in … to read it all", "heading")
250
+ t = Table(["Service", "Status", "Last event"], [["billing-api-gateway", "degraded", ${JSON.stringify("Rolling restart in progress since 14:02 (owner: platform-team, ticket OPS-4471). Two of three replicas are draining; traffic is shifted to the third.")}], ["auth", "up", "Certificate renewed"], ["search-indexer", "down", ${JSON.stringify("Out of disk on /var/lib/index after the 02:10 compaction. The on-call was paged at 02:14 and the volume was extended at 02:51.")}]])`,
251
+ },
252
+ {
253
+ key: "wide-code",
254
+ title: "Code with a long line: it scrolls inside its box, [copy] still copies all of it",
255
+ source: `root = Stack([c])
256
+ c = CodeBlock(${JSON.stringify(`curl -sS -X POST https://api.example.com/v1/chat/completions -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"model": "qwen3-235b", "messages": [{"role": "user", "content": "hello"}]}' | jq -r '.choices[0].message.content'\necho done`)}, "bash", "Call the gateway")`,
257
+ },
258
+ {
259
+ key: "wide-tabs",
260
+ title: "Tabs whose labels do not fit one line: the strip wraps",
261
+ source: `root = Stack([tabs])
262
+ tabs = Tabs([a, b, c, d, e, f])
263
+ a = TabItem("Infrastructure map", [x1])
264
+ b = TabItem("Inventory table", [x2])
265
+ c = TabItem("Services and ports", [x3])
266
+ d = TabItem("Alerts", [x4])
267
+ e = TabItem("Deployments history", [x5])
268
+ f = TabItem("Settings", [x6])
269
+ x1 = Text("The first tab is selected. Tap another label: every one is whole, and each is a tap target.")
270
+ x2 = Text("Inventory")
271
+ x3 = Text("Services")
272
+ x4 = Text("Alerts")
273
+ x5 = Text("Deployments")
274
+ x6 = Text("Settings")`,
275
+ },
276
+ {
277
+ key: "wide-tiles",
278
+ title: "A row of tiles and buttons: it wraps onto more rows instead of squeezing them",
279
+ source: `root = Stack([tiles, actions])
280
+ tiles = Stack([a, b, c, d], "row", "m")
281
+ a = Metric("Requests per second", "1,234,567", "+12% vs last hour", "good")
282
+ b = Metric("p99 latency", "842 ms", "-3%", "bad")
283
+ c = Metric("Error budget remaining", "99.95 %")
284
+ d = Metric("Queue depth", "12,345,678", "+8%", "warn")
285
+ actions = Stack([b1, b2, b3, b4], "row", "s")
286
+ b1 = Button("Restart the vllm service", @ToAssistant("Restart the vllm service"))
287
+ b2 = Button("Drain node", @ToAssistant("Drain the node"))
288
+ b3 = Button("Show logs for the last hour", @ToAssistant("Show the logs for the last hour"))
289
+ b4 = Button("Escalate to on-call", @ToAssistant("Escalate to the on-call engineer"))`,
290
+ },
291
+ {
292
+ key: "wide-infra-map",
293
+ title: "A wide InfraMap: whole labels, scroll sideways",
294
+ source: `root = Stack([map])
295
+ map = InfraMap([clients, edge, spark, strix], [l1, l2, l3, l4, l5], "Homelab fleet", "LR")
296
+ clients = InfraZone("Clients", [mbp, mbp2, iphone, ipad])
297
+ edge = InfraZone("Public edge", [cf, caddy], "info")
298
+ spark = InfraZone("DGX Spark fleet", [s1, s2, s3], "good")
299
+ strix = InfraZone("Strix Halo", [bos, evo])
300
+ mbp = InfraNode("mbp", "MacBook Pro", "service", "up", "control plane; llm-ctl")
301
+ mbp2 = InfraNode("mbp2", "MacBook Pro 2", "service", "unknown", "idle 23h")
302
+ iphone = InfraNode("iphone", "iPhone 15 PM", "service", "up")
303
+ ipad = InfraNode("ipad", "iPad mini 6", "service", "unknown")
304
+ cf = InfraNode("cf", "Cloudflare tunnel", "cloud", "up", "ingress for gateway")
305
+ caddy = InfraNode("caddy", "Caddy", "gateway", "up", "reverse proxy; fronts services")
306
+ s1 = InfraNode("s1", "dgx-spark", "service", "up", "LiteLLM, Postgres")
307
+ s2 = InfraNode("s2", "dgx-spark2", "service", "up", "TP=2 half A")
308
+ s3 = InfraNode("s3", "dgx-spark3", "service", "up", "TP=2 half B")
309
+ bos = InfraNode("bos", "bosgame-m5", "service", "down", "Strix Halo")
310
+ evo = InfraNode("evo", "evo-x3", "service", "down", "Strix Halo")
311
+ l1 = InfraLink("mbp", "caddy", "tailnet")
312
+ l2 = InfraLink("iphone", "caddy", "tailnet")
313
+ l3 = InfraLink("cf", "caddy", "tunnel")
314
+ l4 = InfraLink("caddy", "s1", "/v1")
315
+ l5 = InfraLink("s2", "s3", "200G TP", "dashed")`,
316
+ },
317
+ {
318
+ key: "wide-dag",
319
+ title: "A pipeline with long step names: labels shrink a little, then it scrolls whole",
320
+ source: `root = Stack([d])
321
+ d = Dag([a, b, c, e, f, g], [["a", "b"], ["b", "c"], ["c", "e"], ["e", "f"], ["f", "g"]], "LR", "Release pipeline")
322
+ a = DagNode("a", "Compile the frontend bundle", "done")
323
+ b = DagNode("b", "Run the unit test suite", "done")
324
+ c = DagNode("c", "Build the docker image", "running")
325
+ e = DagNode("e", "Push to the registry", "pending")
326
+ f = DagNode("f", "Deploy to staging now", "pending")
327
+ g = DagNode("g", "Run the smoke tests live", "pending")`,
328
+ },
329
+ {
330
+ key: "wide-erd",
331
+ title: "A schema with long field lines: shrunk to fit (down to 30 characters), scrolling only past that",
332
+ source: `root = Stack([e])
333
+ e = Erd([u, o, li], [r1, r2], "Orders")
334
+ u = Entity("users", ["id uuid PK", "email_verification_token_hash text UNIQUE NOT NULL", "display_name text", "created_at timestamptz"])
335
+ o = Entity("orders", ["id uuid PK", "user_id uuid FK", "total_amount_in_minor_units bigint", "status text"])
336
+ li = Entity("order_line_items_with_a_long_name", ["id uuid PK", "order_id uuid FK", "sku text", "quantity int"])
337
+ r1 = Relation("users", "orders", "1:N", "places")
338
+ r2 = Relation("orders", "order_line_items_with_a_long_name", "1:N", "contains")`,
339
+ },
340
+ {
341
+ key: "wide-sequence",
342
+ title: "Five participants and long message labels",
343
+ source: `root = Stack([s])
344
+ s = SequenceDiagram([a, b, c, d, e], [m1, m2, m3, m4], "Checkout", true)
345
+ a = SeqParticipant("a", "Browser", "actor")
346
+ b = SeqParticipant("b", "API gateway", "service")
347
+ c = SeqParticipant("c", "Auth service", "service")
348
+ d = SeqParticipant("d", "Postgres primary", "database")
349
+ e = SeqParticipant("e", "Billing provider", "external")
350
+ m1 = SeqMessage("a", "b", "POST /v1/orders with the cart and a payment token")
351
+ m2 = SeqMessage("b", "c", "verify the session token")
352
+ m3 = SeqMessage("c", "d", "select * from sessions where id = ?")
353
+ m4 = SeqMessage("b", "e", "charge the card, idempotency key attached")`,
354
+ },
355
+ {
356
+ key: "wide-markdown",
357
+ title: "Markdown with a code fence and a table that are wider than a phone",
358
+ source: `root = Stack([m])
359
+ m = Markdown(${JSON.stringify("## Rollout notes\n\nRun this on the gateway first, then check the table below:\n\n```bash\nkubectl rollout status deployment/billing-api-gateway --namespace production --timeout=300s && kubectl get pods -l app=billing-api-gateway -o wide\n```\n\n| service | status | region | owner | notes | updated |\n|---|---|---|---|---|---|\n| billing-api-gateway | degraded | us-east-1 | platform team | rolling restart in progress | 2026-11-14 |\n| auth | up | eu-west-2 | identity | certificate renewed | 2026-11-13 |\n\nA word too long for any line: " + "abcdefghij".repeat(8))})`,
360
+ },
361
+ {
362
+ key: "wide-card",
363
+ title: "A long card title, a callout and a question with long options",
364
+ source: `root = Stack([c, note, q])
365
+ c = Card([t], "A card title that is much longer than a phone is wide, so it wraps inside the card")
366
+ t = Text("The title is not cut: when it does not fit the border it moves inside as a heading.")
367
+ note = Callout("A callout with a long unbroken word is broken, not cut: ${"averyveryverylongword".repeat(4)} and then the sentence goes on.", "warn")
368
+ q = Choice("Which of these long options would you like the agent to run next on the whole cluster?", ["Restart every service in the staging environment one at a time", "Drain the node and wait for the jobs to finish", "Do nothing"])`,
369
+ },
236
370
  ];
@@ -0,0 +1,185 @@
1
+ // The user's settings for pi-openui: one JSON file next to pi's own, `openui.json` in pi's agent directory.
2
+ //
3
+ // { "table": { "minColumnWidth": 12 }, "scroll": { "step": 8 } }
4
+ //
5
+ // Why not pi's settings.json: pi documents no place in it for another package's keys and may rewrite the file. Why not a
6
+ // per-project file: pi asks for trust before it reads those, and how wide a screen is belongs to the user, not the project.
7
+ // Everything here takes the file system and the environment as arguments, so the hostile cases are plain tests, and nothing
8
+ // throws on bad input: a bad value is the default plus a sentence the person can act on.
9
+ //
10
+ // A setting is one row of SETTINGS: its place in the file, its environment variable, its range and its default. Adding one is
11
+ // adding a row (and a field to `Settings`); validation, the environment, saving and /openui-config all read the table.
12
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { dirname, join } from "node:path";
15
+
16
+ export interface Settings {
17
+ /** the narrowest a table column is squeezed to before the table scrolls sideways instead */
18
+ tableMinCol: number;
19
+ /** cells one tap on a fine arrow (‹ ›) scrolls; the page arrows (« ») scroll three quarters of the pane */
20
+ scrollStep: number;
21
+ }
22
+
23
+ export const DEFAULTS: Settings = { tableMinCol: 12, scrollStep: 8 };
24
+ export const TABLE_MIN_COL_RANGE = [4, 40] as const;
25
+ export const SCROLL_STEP_RANGE = [1, 40] as const;
26
+
27
+ interface Def {
28
+ /** the dotted name, `section.key` in the file */
29
+ key: SettingKey;
30
+ field: keyof Settings;
31
+ env: string;
32
+ range: readonly [number, number];
33
+ what: string;
34
+ }
35
+ export type SettingKey = "table.minColumnWidth" | "scroll.step";
36
+
37
+ const SETTINGS: Def[] = [
38
+ { key: "table.minColumnWidth", field: "tableMinCol", env: "PI_OPENUI_TABLE_MIN_COL", range: TABLE_MIN_COL_RANGE, what: "the narrowest a table column is squeezed to before the table scrolls" },
39
+ { key: "scroll.step", field: "scrollStep", env: "PI_OPENUI_SCROLL_STEP", range: SCROLL_STEP_RANGE, what: "cells one tap on a fine arrow scrolls" },
40
+ ];
41
+ const def = (key: string): Def | undefined => SETTINGS.find((d) => d.key === key);
42
+ const rangeOf = (d: Def) => `a whole number from ${d.range[0]} to ${d.range[1]}`;
43
+
44
+ type Env = Record<string, string | undefined>;
45
+ type Source = "default" | "file" | "env";
46
+
47
+ export interface Parsed {
48
+ settings: Settings;
49
+ problems: string[];
50
+ sources: Record<keyof Settings, Source>;
51
+ }
52
+
53
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
54
+
55
+ /** A number into the setting's range. A problem is reported when it had to move. */
56
+ function fit(d: Def, n: number, where: string, problems: string[]): number {
57
+ const [lo, hi] = d.range;
58
+ const v = Math.min(hi, Math.max(lo, Math.round(n)));
59
+ if (v !== Math.round(n)) problems.push(`${where} is ${n}; ${d.key} must be from ${lo} to ${hi}, so ${v} is used`);
60
+ return v;
61
+ }
62
+
63
+ /** What the file (already parsed, or undefined when there is none) and the environment say. Never throws. */
64
+ export function parseSettings(raw: unknown, env: Env): Parsed {
65
+ const problems: string[] = [];
66
+ const settings: Settings = { ...DEFAULTS };
67
+ const sources = Object.fromEntries(SETTINGS.map((d) => [d.field, "default"])) as Record<keyof Settings, Source>;
68
+ if (raw !== undefined && !isObject(raw)) problems.push("openui.json must hold a JSON object, so the defaults are used");
69
+ for (const d of SETTINGS) {
70
+ const [section, name] = d.key.split(".");
71
+ if (isObject(raw)) {
72
+ const sec = raw[section];
73
+ if (sec !== undefined && !isObject(sec)) problems.push(`"${section}" in openui.json must be an object, so the defaults for it are used`);
74
+ else if (isObject(sec) && sec[name] !== undefined) {
75
+ const v = sec[name];
76
+ if (typeof v === "number" && Number.isFinite(v)) {
77
+ settings[d.field] = fit(d, v, `${d.key} in openui.json`, problems);
78
+ sources[d.field] = "file";
79
+ } else problems.push(`${d.key} in openui.json must be ${rangeOf(d)}, so ${DEFAULTS[d.field]} is used`);
80
+ }
81
+ }
82
+ const e = env[d.env];
83
+ if (e !== undefined && e.trim() !== "") {
84
+ const n = Number(e);
85
+ if (Number.isFinite(n)) {
86
+ settings[d.field] = fit(d, n, d.env, problems);
87
+ sources[d.field] = "env";
88
+ } else problems.push(`${d.env}="${e}" is not a number, so it is ignored`);
89
+ }
90
+ }
91
+ return { settings, problems: [...new Set(problems)], sources };
92
+ }
93
+
94
+ export function settingsPath(env: Env = process.env): string {
95
+ return join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "openui.json");
96
+ }
97
+
98
+ type Read = (path: string, enc: "utf8") => string;
99
+
100
+ export function loadSettings(env: Env = process.env, read: Read = readFileSync as Read): Parsed & { path: string } {
101
+ const path = settingsPath(env);
102
+ let raw: unknown;
103
+ const extra: string[] = [];
104
+ try {
105
+ raw = JSON.parse(read(path, "utf8"));
106
+ } catch (e) {
107
+ const code = (e as { code?: string }).code;
108
+ if (code === "ENOENT") raw = undefined;
109
+ else if (e instanceof SyntaxError) extra.push(`${path} is not valid JSON, so the defaults are used`);
110
+ else extra.push(`${path} cannot be read (${code ?? (e as Error).message}), so the defaults are used`);
111
+ }
112
+ const parsed = parseSettings(raw, env);
113
+ return { ...parsed, problems: [...extra, ...parsed.problems], path };
114
+ }
115
+
116
+ interface Fs {
117
+ read: Read;
118
+ write: (path: string, data: string) => void;
119
+ mkdir: (dir: string) => void;
120
+ }
121
+ const realFs: Fs = { read: readFileSync as Read, write: (p, d) => writeFileSync(p, d), mkdir: (d) => void mkdirSync(d, { recursive: true }) };
122
+
123
+ /** Set (or, with undefined, remove) one setting, keeping every other key in the file. A file that is not JSON is never overwritten. */
124
+ export function saveSetting(path: string, key: SettingKey, value: number | undefined, fs: Fs = realFs): void {
125
+ let obj: Record<string, unknown> = {};
126
+ try {
127
+ const parsed = JSON.parse(fs.read(path, "utf8"));
128
+ if (!isObject(parsed)) throw new Error(`${path} is not a JSON object; fix or delete it`);
129
+ obj = parsed;
130
+ } catch (e) {
131
+ const code = (e as { code?: string }).code;
132
+ if (code !== "ENOENT") {
133
+ if (e instanceof SyntaxError) throw new Error(`${path} is not valid JSON; fix or delete it`);
134
+ throw e;
135
+ }
136
+ }
137
+ const [section, name] = key.split(".");
138
+ const sec = isObject(obj[section]) ? { ...(obj[section] as Record<string, unknown>) } : {};
139
+ if (value === undefined) delete sec[name];
140
+ else sec[name] = value;
141
+ if (Object.keys(sec).length) obj[section] = sec;
142
+ else delete obj[section];
143
+ fs.mkdir(dirname(path));
144
+ fs.write(path, JSON.stringify(obj, null, 2) + "\n");
145
+ }
146
+
147
+ export interface CommandResult {
148
+ message: string;
149
+ level: "info" | "warning" | "error";
150
+ /** the settings now in force, when the command changed them */
151
+ settings?: Settings;
152
+ }
153
+
154
+ /** `/openui-config`: show what is in force, set a setting, or reset one or all. */
155
+ export function runConfigCommand(args: string, env: Env = process.env, fs: Fs = realFs): CommandResult {
156
+ const words = args.trim().split(/\s+/).filter(Boolean);
157
+ const path = settingsPath(env);
158
+ const names = SETTINGS.map((d) => d.key).join(" and ");
159
+ if (!words.length) {
160
+ const r = loadSettings(env, fs.read);
161
+ const lines = SETTINGS.map((d) => `${d.key} = ${r.settings[d.field]} (${r.sources[d.field]}): ${d.what}; ${rangeOf(d)}`);
162
+ return { level: "info", message: `${lines.join("\n")}\nFile: ${path}. Change one with /openui-config ${SETTINGS[1].key} ${DEFAULTS.scrollStep + 4}, put it back with /openui-config ${SETTINGS[1].key} reset, or reset everything with /openui-config reset.` };
163
+ }
164
+ const apply = (changes: Array<[Def, number | undefined]>): CommandResult => {
165
+ try {
166
+ for (const [d, v] of changes) saveSetting(path, d.key, v, fs);
167
+ } catch (e) {
168
+ return { level: "error", message: (e as Error).message };
169
+ }
170
+ const r = loadSettings(env, fs.read);
171
+ const said = changes.map(([d, v]) => (v === undefined ? `Reset ${d.key} to ${DEFAULTS[d.field]}.` : `Saved ${d.key} = ${v} in ${path}.`)).join(" ");
172
+ const wins = changes.filter(([d]) => r.sources[d.field] === "env");
173
+ if (wins.length) return { level: "warning", message: `${said} But ${wins.map(([d]) => d.env).join(" and ")} is set and wins: ${wins.map(([d]) => `${d.key} = ${r.settings[d.field]}`).join(", ")} is in force.`, settings: r.settings };
174
+ return { level: "info", message: said, settings: r.settings };
175
+ };
176
+ if (words[0] === "reset" && words.length === 1) return apply(SETTINGS.map((d) => [d, undefined]));
177
+ const d = def(words[0]);
178
+ if (!d) return { level: "error", message: `Unknown setting "${words[0]}". The settings are ${names}: /openui-config ${SETTINGS[0].key} 14, or /openui-config reset.` };
179
+ const given = words[1];
180
+ if (given === "reset") return apply([[d, undefined]]);
181
+ if (given === undefined || !/^\d+$/.test(given)) return { level: "error", message: `${d.key} takes ${rangeOf(d)}${given === undefined ? "" : ` (got "${given}")`}.` };
182
+ const n = Number(given);
183
+ if (n < d.range[0] || n > d.range[1]) return { level: "error", message: `${d.key} takes ${rangeOf(d)} (got ${n}).` };
184
+ return apply([[d, n]]);
185
+ }
@@ -0,0 +1,150 @@
1
+ // What does my terminal app send for a tap, a swipe, a flick? Nobody can see it from outside (pi consumes the bytes), so a widget
2
+ // can record what reaches it: `/openui-events on`, tap and swipe over a widget, `/openui-events show`.
3
+ //
4
+ // It records mouse events, and keys only when they are escape sequences or control keys. What a person types is never written
5
+ // down (a letter is not recorded, a paste is counted but not copied), it switches itself off after five minutes, and the file
6
+ // is bounded.
7
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { dirname, join } from "node:path";
10
+
11
+ type Env = Record<string, string | undefined>;
12
+
13
+ export const eventsPath = (env: Env = process.env): string => join(env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "openui-events.log");
14
+
15
+ /** `press btn=none x=9 y=1`, plus `shift`, `alt`, `ctrl` and `d=<wheel delta>` when there are any. */
16
+ export function describeMouse(ev: any): string {
17
+ const bits = [`${ev.type} btn=${ev.button} x=${ev.x} y=${ev.y}`];
18
+ if (ev.shift) bits.push("shift");
19
+ if (ev.alt) bits.push("alt");
20
+ if (ev.ctrl) bits.push("ctrl");
21
+ if (ev.wheelDelta !== undefined) bits.push(`d=${ev.wheelDelta}`);
22
+ return bits.join(" ");
23
+ }
24
+
25
+ const escaped = (s: string) =>
26
+ s.replace(/[\x00-\x1f\x7f]/g, (c) => (c === "\r" ? "\\r" : c === "\n" ? "\\n" : c === "\t" ? "\\t" : `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`));
27
+
28
+ /** A key worth recording (an escape sequence or a control key), or null for what a person types. A paste is counted, never copied. */
29
+ export function describeKey(data: string): string | null {
30
+ if (data.startsWith("\x1b[200~")) return `paste (${data.replace(/^\x1b\[200~/, "").replace(/\x1b\[201~$/, "").length} characters, not recorded)`;
31
+ if (!/^[\x00-\x1f\x7f]/.test(data)) return null;
32
+ return `key "${escaped(data.length > 24 ? data.slice(0, 24) + "…" : data)}"`;
33
+ }
34
+
35
+ export interface RecorderOptions {
36
+ write?: (line: string) => void;
37
+ path?: string;
38
+ now?: () => number;
39
+ minutes?: number;
40
+ maxLines?: number;
41
+ }
42
+
43
+ export class Recorder {
44
+ on = false;
45
+ count = 0;
46
+ moves = 0;
47
+ private startedAt = 0;
48
+ private readonly opts: RecorderOptions;
49
+
50
+ constructor(opts: RecorderOptions = {}) {
51
+ this.opts = opts;
52
+ }
53
+
54
+ get path(): string {
55
+ return this.opts.path ?? eventsPath();
56
+ }
57
+ private now(): number {
58
+ return (this.opts.now ?? Date.now)();
59
+ }
60
+ /** still recording? Five minutes (or `minutes`) after start it is not. */
61
+ private live(): boolean {
62
+ if (this.on && this.now() - this.startedAt > (this.opts.minutes ?? 5) * 60_000) this.on = false;
63
+ return this.on;
64
+ }
65
+ start(): void {
66
+ this.on = true;
67
+ this.count = 0;
68
+ this.moves = 0;
69
+ this.startedAt = this.now();
70
+ }
71
+ stop(): void {
72
+ this.on = false;
73
+ }
74
+ private line(text: string): void {
75
+ if (!this.live() || this.count >= (this.opts.maxLines ?? 2000)) return;
76
+ this.count++;
77
+ const at = new Date(this.now()).toISOString().slice(11, 23);
78
+ const l = `${at} ${text}\n`;
79
+ try {
80
+ if (this.opts.write) this.opts.write(l);
81
+ else {
82
+ mkdirSync(dirname(this.path), { recursive: true });
83
+ appendFileSync(this.path, l);
84
+ }
85
+ } catch {
86
+ /* a diagnostic must never get in the way */
87
+ }
88
+ }
89
+ mouse(ev: any): void {
90
+ if (!this.live()) return;
91
+ if (ev?.type === "move") this.moves++; // hover floods a log, and says nothing about a swipe
92
+ else this.line(describeMouse(ev));
93
+ }
94
+ key(data: string): void {
95
+ if (!this.live()) return;
96
+ const d = describeKey(data);
97
+ if (d) this.line(d);
98
+ }
99
+ /** what the widget made of the last event */
100
+ note(text: string): void {
101
+ this.line(`-> ${text}`);
102
+ }
103
+ }
104
+
105
+ export const recorder = new Recorder();
106
+
107
+ export interface EventsResult {
108
+ message: string;
109
+ level: "info" | "warning" | "error";
110
+ }
111
+
112
+ const TIP = "Tap, swipe and flick over a widget (a mouse event only reaches the widget under it), press an arrow or two, then run /openui-events show.";
113
+
114
+ /** `/openui-events [on|off|show|clear]` */
115
+ export function runEventsCommand(args: string, r: Recorder = recorder, env: Env = process.env): EventsResult {
116
+ const word = args.trim().toLowerCase();
117
+ const path = r.path ?? eventsPath(env);
118
+ switch (word) {
119
+ case "":
120
+ return { level: "info", message: r.on ? `Recording (${r.count} events so far). ${TIP}` : "Off. /openui-events on records what a tap, a swipe or a flick sends, for five minutes." };
121
+ case "on":
122
+ r.start();
123
+ return { level: "info", message: `Recording for five minutes into ${path}. ${TIP}` };
124
+ case "off":
125
+ r.stop();
126
+ return { level: "info", message: `Off. ${r.count} events recorded in ${path}.` };
127
+ case "clear":
128
+ try {
129
+ mkdirSync(dirname(path), { recursive: true });
130
+ writeFileSync(path, "");
131
+ } catch (e) {
132
+ return { level: "error", message: `Cannot clear ${path}: ${(e as Error).message}` };
133
+ }
134
+ return { level: "info", message: "Cleared." };
135
+ case "show": {
136
+ let text = "";
137
+ try {
138
+ text = readFileSync(path, "utf8");
139
+ } catch {
140
+ /* nothing yet */
141
+ }
142
+ const lines = text.split("\n").filter(Boolean);
143
+ if (!lines.length) return { level: "info", message: `Nothing recorded yet. ${TIP}` };
144
+ const tail = lines.slice(-25);
145
+ return { level: "info", message: `${lines.length} events in ${path} (moves are counted, not logged: ${r.moves}). Last ${tail.length}:\n${tail.join("\n")}` };
146
+ }
147
+ default:
148
+ return { level: "error", message: `Unknown "${args.trim()}". Use /openui-events on, off, show or clear.` };
149
+ }
150
+ }
@@ -3,28 +3,32 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import { readFileSync } from "node:fs";
6
- import { compile, LIMITS, reference, type Compiled, type El } from "../lang/library.ts";
6
+ import { fallbackLines } from "../blocks/fallback.ts";
7
+ import { compile, LANG_VERSION, LIMITS, reference, type Compiled, type El } from "../lang/library.ts";
7
8
  import { markdownImages } from "../lang/md.ts";
8
9
  import { LANG_SAMPLES } from "../lang/samples.ts";
9
10
  import { getCapabilities } from "@earendil-works/pi-tui";
11
+ import { loadSettings, runConfigCommand } from "./config.ts";
12
+ import { recorder, runEventsCommand } from "./events.ts";
10
13
  import { loadImage } from "./images.ts";
11
14
  import { syncWidths } from "./widths.ts";
12
- import { active, armNewest, confirmDelivery, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
15
+ import { active, applySettings, armNewest, confirmDelivery, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
13
16
 
14
17
  const compiled = new Map<string, Compiled>();
15
18
  let compiledChars = 0;
16
19
  /** 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. */
17
20
  const CACHE_CHARS = 2_000_000;
18
- export function compileCached(source: string): Compiled {
19
- if (source.length > LIMITS.source) return compile(source); // rejected at once; never kept
20
- let c = compiled.get(source);
21
+ export function compileCached(source: string, mode: "strict" | "restore" = "strict", from = 1): Compiled {
22
+ if (source.length > LIMITS.source) return compile(source, { mode, from }); // rejected at once; never kept
23
+ const key = `${mode}${mode === "restore" ? from : ""}\u0000${source}`;
24
+ let c = compiled.get(key);
21
25
  if (!c) {
22
- c = compile(source);
26
+ c = compile(source, { mode, from });
23
27
  if (compiled.size >= 64 || compiledChars + source.length > CACHE_CHARS) {
24
28
  compiled.clear();
25
29
  compiledChars = 0;
26
30
  }
27
- compiled.set(source, c);
31
+ compiled.set(key, c);
28
32
  compiledChars += source.length;
29
33
  }
30
34
  return c;
@@ -57,6 +61,21 @@ export function checkImages(root: El | undefined): string[] {
57
61
  return errs;
58
62
  }
59
63
 
64
+ /** The language version a stored widget was written under: a whole number from 1, else 1 (older sessions stored none). */
65
+ export const langOf = (details: any): number => (typeof details?.lang === "number" && Number.isInteger(details.lang) && details.lang >= 1 ? details.lang : 1);
66
+
67
+ /**
68
+ * A widget read back from a session: compiled in restore mode (an older source keeps drawing), and when even that fails a card with
69
+ * the reason and the start of the source. Never nothing.
70
+ */
71
+ function restoredWidget(details: any, id: string, theme: any, mode: "live" | "echo") {
72
+ const source = String(details?.source ?? "");
73
+ const lang = langOf(details);
74
+ const c = compileCached(source, "restore", lang);
75
+ if (!c.ok || !c.root) return new Text(fallbackLines(source, c.errors, lang).map((l) => theme.fg("warning", l)).join("\n"), 0, 0);
76
+ return new OpenUIWidget(id, c.root, theme, mode, c.notes);
77
+ }
78
+
60
79
  export default function (pi: ExtensionAPI) {
61
80
  syncWidths(); // our grid must agree with this pi about which characters take two cells
62
81
  hooks.sendLive = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
@@ -90,7 +109,7 @@ export default function (pi: ExtensionAPI) {
90
109
  const noPics = hasImage && getCapabilities().images !== "kitty";
91
110
  const note = [...c.warnings, ...(noPics ? ["this terminal cannot show pictures, so each Image is drawn as a labelled frame with its file details"] : [])];
92
111
  const noteText = note.length ? ` Notes: ${note.join("; ")}.` : "";
93
- 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 } };
112
+ 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, lang: LANG_VERSION } };
94
113
  },
95
114
  renderCall(args: any, theme: any) {
96
115
  const n = typeof args?.source === "string" ? args.source.split("\n").length : 0;
@@ -104,26 +123,22 @@ export default function (pi: ExtensionAPI) {
104
123
  return new Text(lines.map((l) => theme.fg("error", l)).join("\n"), 0, 0);
105
124
  }
106
125
  if (result?.details?.kind !== "widget") return new Text(theme.fg("dim", "component reference loaded"), 0, 0);
107
- const c = compileCached(String(result.details.source ?? ""));
108
- if (!c.ok || !c.root) return new Text(theme.fg("error", "widget could not be restored"), 0, 0);
109
- return new OpenUIWidget(ctx.toolCallId, c.root, theme, "live");
126
+ return restoredWidget(result.details, ctx.toolCallId, theme, "live");
110
127
  },
111
128
  });
112
129
 
113
130
  pi.registerMessageRenderer("openui-widget", (message: any, _o: any, theme: any) => {
114
- const c = compileCached(String(message.details?.source ?? ""));
115
- if (!c.ok || !c.root) return undefined;
116
- return new OpenUIWidget(String(message.details.id), c.root, theme, message.details.mode === "live" ? "live" : "echo");
131
+ return restoredWidget(message.details, String(message.details?.id ?? "w-unknown"), theme, message.details?.mode === "live" ? "live" : "echo");
117
132
  });
118
133
 
119
134
  const draw = (source: string, title: string, ctx: any) => {
120
135
  const c = compileCached(source);
121
136
  if (!c.ok) return ctx.ui.notify(`${title}: ${c.errors.slice(0, 3).join(" | ")}`, "error");
122
- 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" } });
137
+ 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", lang: LANG_VERSION } });
123
138
  };
124
139
 
125
140
  pi.registerCommand("openui-gallery", {
126
- description: "Draw one example per OpenUI component (or a key prefix: dashboard, table, tabs, choice, line, pie, radar, image, dag, erd, infra, sequence, geo ...). Clicks echo locally.",
141
+ description: "Draw one example per OpenUI component, or those whose key starts with a prefix: dashboard, table, tabs, choice, line, pie, radar, image, dag, erd, infra, sequence, geo ... `wide` draws the examples that do not fit a phone (long tables, code, tabs, diagrams). Clicks echo locally.",
127
142
  handler: async (args: string, ctx: any) => {
128
143
  const which = args.trim() || "all";
129
144
  const picked = LANG_SAMPLES.filter((s) => which === "all" || s.key.startsWith(which));
@@ -145,6 +160,23 @@ export default function (pi: ExtensionAPI) {
145
160
  },
146
161
  });
147
162
 
163
+ pi.registerCommand("openui-config", {
164
+ description: "Show or change pi-openui settings: /openui-config, /openui-config table.minColumnWidth 14, /openui-config reset",
165
+ handler: async (args: string, ctx: any) => {
166
+ const r = runConfigCommand(args);
167
+ if (r.settings) applySettings(r.settings);
168
+ ctx.ui.notify(r.message, r.level);
169
+ },
170
+ });
171
+
172
+ pi.registerCommand("openui-events", {
173
+ description: "See what your terminal sends for a tap, a swipe or a flick over a widget: /openui-events on, try them, /openui-events show (off, clear)",
174
+ handler: async (args: string, ctx: any) => {
175
+ const r = runEventsCommand(args);
176
+ ctx.ui.notify(r.message, r.level);
177
+ },
178
+ });
179
+
148
180
  pi.registerCommand("openui-notes", {
149
181
  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.",
150
182
  handler: async (_a: string, ctx: any) => {
@@ -167,10 +199,18 @@ export default function (pi: ExtensionAPI) {
167
199
  confirmDelivery(words);
168
200
  });
169
201
 
202
+ const told = new Set<string>();
170
203
  let unsub: (() => void) | undefined;
171
204
  pi.on("session_start", async (_e: any, ctx: any) => {
172
205
  if (!ctx.hasUI) return;
173
206
  unsub?.();
207
+ // the user's settings file: read once per session, and a problem in it is said once
208
+ const loaded = loadSettings();
209
+ applySettings(loaded.settings);
210
+ for (const p of loaded.problems) if (!told.has(p)) {
211
+ told.add(p);
212
+ ctx.ui.notify(`openui: ${p}`, "warning");
213
+ }
174
214
  hooks.queued = () => {
175
215
  try {
176
216
  return !!ctx.hasPendingMessages();
@@ -178,7 +218,10 @@ export default function (pi: ExtensionAPI) {
178
218
  return false; // a context from a replaced session
179
219
  }
180
220
  };
181
- unsub = ctx.ui.onTerminalInput((data: string) => (handleKey(data) ? { consume: true } : undefined));
221
+ unsub = ctx.ui.onTerminalInput((data: string) => {
222
+ recorder.key(data); // only escape sequences and control keys, and only while /openui-events is on
223
+ return handleKey(data) ? { consume: true } : undefined;
224
+ });
182
225
  // the note editor is pi's own multi-line editor: Enter saves, Shift+Enter adds a line, Esc cancels
183
226
  hooks.prompt = (title: string, initial: string) => ctx.ui.editor(title, initial);
184
227
  hooks.toEditor = (text: string) => ctx.ui.pasteToEditor(text);
@@ -190,7 +233,9 @@ export default function (pi: ExtensionAPI) {
190
233
  const e = entryOf(active.id);
191
234
  const keys = e?.state.noteMode
192
235
  ? "notes: click or Enter writes a note arrows/Tab move Shift+Enter picks the parent Esc leaves"
193
- : e?.state.focus
236
+ : e?.state.focus && e.block?.targets.find((t: any) => t.id === e.state.focus)?.kind === "pan"
237
+ ? "left/right arrows scroll, Shift or PageUp/PageDown a page Tab next Esc leave"
238
+ : e?.state.focus
194
239
  ? "arrows move Tab next Enter activate Esc close/leave"
195
240
  : "Tab or arrows select Enter activate Esc leave";
196
241
  return [truncateToWidth(theme.fg("accent", " openui ") + theme.fg("dim", keys), w)];