pi-openui 0.1.2 → 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.
@@ -64,11 +64,20 @@ note = Callout("spark-3 was rebooted on 21 Oct.", "info")`,
64
64
  b = Stack([b1, b2], "row")
65
65
  b1 = Button("Restart vllm", @ToAssistant("Restart the vllm service on dgx-spark-1"))
66
66
  b2 = Button("Drain node")
67
- ch = Choice("Which model should serve chat?", ["qwen3-32b", "llama-3.3-70b", "gemma-3-27b"], "Pick one; it is sent to the assistant")
67
+ ch = Choice("Which model should serve chat?", ["qwen3-32b", "llama-3.3-70b", "gemma-3-27b"], "I will use it for the next request")
68
68
  fq = Faq([q1, q2, q3], true)
69
69
  q1 = FaqItem("What does draining do?", "It stops **new** requests and lets running ones finish.\\n\\n1. queue pauses\\n2. in-flight requests complete\\n3. node is marked *drained*")
70
70
  q2 = FaqItem("How do I undo it?", "Run \`uncordon\` on the node: it rejoins the pool immediately.")
71
71
  q3 = FaqItem("Why did spark-3 reboot?", "A driver update needed it. See the [runbook](https://example.com/runbook).")`,
72
+ },
73
+ {
74
+ key: "choice-card",
75
+ title: "Choice: several questions, one Send",
76
+ source: `root = Stack([plan])
77
+ plan = Card([q1, q2, q3], "Before I restart")
78
+ q1 = Choice("Which node should I restart first?", ["web-1", "web-2"])
79
+ q2 = Choice("Which checks should I run?", ["Disk", "Memory", "Network"], null, true)
80
+ q3 = Choice("Clear the cache on db?", ["Yes", "No"], null, null, false)`,
72
81
  },
73
82
  {
74
83
  key: "markdown",
@@ -224,4 +233,138 @@ doc = Markdown("## A picture in the text\\n\\nThe line below is only an image: i
224
233
  img = Image("${SAMPLE_PNG_DATA_URI}", "Sunset over the ridge", "Generated test picture, 192x108", [t])
225
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")`,
226
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
+ },
227
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
+ }