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,562 @@
1
+ // pi-tui component for a compiled OpenUI tree: themed colours, hover, click, keyboard, clipboard, pictures, notes.
2
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { drawBubble } from "../blocks/bubble.ts";
4
+ import { BADGE } from "../blocks/core.ts";
5
+ import { activate, describe, nextTarget, reveal, targetAt, type Env, type NavKey } from "../blocks/interact.ts";
6
+ import { buildMessage, cleanNote, NOTE_LIMITS, widgetTitle } from "../blocks/notes.ts";
7
+ import { renderTree } from "../blocks/render.ts";
8
+ import { newState, type Block, type Note, type Target, type WState } from "../blocks/types.ts";
9
+ import { cell, padRow, textCells } from "../blocks/util.ts";
10
+ import { rowsToLines, textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
11
+ import { createPalette } from "../diagram/palette.ts";
12
+ import type { El } from "../lang/library.ts";
13
+ import { imageService } from "./images.ts";
14
+
15
+ const GUTTER = 2;
16
+ /**
17
+ * The last column of every line is left empty. pi's fullscreen transcript paints its scrollbar over that column after the
18
+ * component has drawn (always with `fullscreenScrollbar: "always"`, while scrolling by default), so anything there is hidden:
19
+ * the notes chip lost its `]`, a card its right border, a chart its last axis label. pi's own text keeps a margin for the same
20
+ * reason.
21
+ */
22
+ const RIGHT = 1;
23
+ const CHIP = "notes:chip";
24
+ const SERIES = ["accent", "success", "warning", "error", "syntaxKeyword", "syntaxString", "syntaxType", "syntaxNumber"];
25
+ const sgr = (open: string, close: string) => (s: string) => `\x1b[${open}m${s}\x1b[${close}m`;
26
+
27
+ export function themePalette(theme: any) {
28
+ const f = (token: string) => (s: string) => theme.fg(token, s);
29
+ const colors = theme.colors ?? {};
30
+ return createPalette({
31
+ good: f("success"),
32
+ bad: f("error"),
33
+ warn: f("warning"),
34
+ accent: f("accent"),
35
+ info: f("accent"),
36
+ muted: f("muted"),
37
+ dim: f("dim"),
38
+ bold: sgr("1", "22"),
39
+ italic: sgr("3", "23"),
40
+ underline: sgr("4", "24"),
41
+ strike: sgr("9", "29"),
42
+ code: f("mdCode"),
43
+ keyword: f("syntaxKeyword"),
44
+ string: f("syntaxString"),
45
+ number: f("syntaxNumber"),
46
+ comment: f("syntaxComment"),
47
+ fn: f("syntaxFunction"),
48
+ type: f("syntaxType"),
49
+ link: f("mdLink"),
50
+ series: SERIES.map(f),
51
+ bubble: (s) => theme.bg("selectedBg", theme.fg("text", s)),
52
+ pie: (a, b) => (s) => {
53
+ if (a !== null && b !== null && typeof theme.style === "function" && colors[SERIES[b % 8]]) return theme.style(s, { fg: SERIES[a % 8], bg: colors[SERIES[b % 8]] });
54
+ return theme.fg(SERIES[((a ?? b) as number) % 8], s);
55
+ },
56
+ });
57
+ }
58
+
59
+ export type Mode = "live" | "echo";
60
+
61
+ interface Entry {
62
+ root: El;
63
+ state: WState;
64
+ mode: Mode;
65
+ block: Block | null;
66
+ key: string;
67
+ version: number;
68
+ warnings: string[];
69
+ notice: string | null;
70
+ noticeTimer: ReturnType<typeof setTimeout> | null;
71
+ /** the finished lines and what they were drawn from: pi asks for them on every frame, so an unchanged widget answers from here */
72
+ lines: string[] | null;
73
+ lineKey: string;
74
+ }
75
+
76
+ const entries = new Map<string, Entry>();
77
+ export const active: { id: string | null } = { id: null };
78
+ export const newestId: { id: string | null } = { id: null };
79
+ /** A dialog (the note editor) is open: our key and mouse handlers stand down so its keys are its own. */
80
+ export const dialog = { open: false };
81
+
82
+ /** Services the extension provides. Tests replace them. */
83
+ export const hooks = {
84
+ requestRender: () => {},
85
+ sendLive: (_text: string) => {},
86
+ sendEcho: (_text: string) => {},
87
+ /** Ask the user for the text of a note. Resolves undefined when they cancel. */
88
+ prompt: async (_title: string, _initial: string): Promise<string | undefined> => undefined,
89
+ /** Put text in pi's message box without sending it. */
90
+ toEditor: (_text: string) => {},
91
+ copy: (text: string) => {
92
+ try {
93
+ process.stdout.write(`\x1b]52;c;${Buffer.from(text, "utf8").toString("base64")}\x07`);
94
+ } catch {
95
+ /* no terminal */
96
+ }
97
+ },
98
+ };
99
+
100
+ export const entryOf = (id: string): Entry | undefined => entries.get(id);
101
+
102
+ // pi converts JPEG, GIF and WebP lazily after startup: when a picture becomes drawable, lay every widget out again
103
+ imageService.onLate = () => {
104
+ for (const e of entries.values()) e.version++;
105
+ hooks.requestRender();
106
+ };
107
+
108
+ function getEntry(id: string, root: El, mode: Mode): Entry {
109
+ let e = entries.get(id);
110
+ if (!e) {
111
+ e = { root, state: newState(), mode, block: null, key: "", version: 0, warnings: [], notice: null, noticeTimer: null, lines: null, lineKey: "" };
112
+ entries.set(id, e);
113
+ newestId.id = id;
114
+ }
115
+ return e;
116
+ }
117
+
118
+ function envFor(id: string, e: Entry): Env {
119
+ return {
120
+ send: (t) => (e.mode === "live" ? hooks.sendLive(t) : hooks.sendEcho(t)),
121
+ copy: (t) => hooks.copy(t),
122
+ note: (t) => setNotice(id, t),
123
+ };
124
+ }
125
+
126
+ function setNotice(id: string, text: string | null) {
127
+ const e = entries.get(id);
128
+ if (!e) return;
129
+ e.notice = text;
130
+ if (e.noticeTimer) clearTimeout(e.noticeTimer);
131
+ e.noticeTimer = text
132
+ ? setTimeout(() => {
133
+ e.notice = null;
134
+ hooks.requestRender();
135
+ }, 4000)
136
+ : null;
137
+ if (e.noticeTimer && typeof (e.noticeTimer as any).unref === "function") (e.noticeTimer as any).unref();
138
+ }
139
+
140
+ function afterActivate(id: string, e: Entry, t: Target, layoutChanged: boolean) {
141
+ if (layoutChanged) e.version++;
142
+ if (t.kind === "copy") {
143
+ const timer = setTimeout(() => {
144
+ if (e.state.copied === t.id) {
145
+ e.state.copied = null;
146
+ e.version++;
147
+ hooks.requestRender();
148
+ }
149
+ }, 1800);
150
+ if (typeof (timer as any).unref === "function") (timer as any).unref();
151
+ }
152
+ void id;
153
+ }
154
+
155
+ // ---------- notes ----------
156
+
157
+ const chipLabel = (s: WState) => (s.noteMode ? "[ notes: on ]" : s.notes.length ? `[ notes ${s.notes.length} ]` : "[ notes ]");
158
+
159
+ function setMode(id: string, e: Entry, on: boolean) {
160
+ e.state.noteMode = on;
161
+ e.state.focus = null;
162
+ e.state.hover = null;
163
+ e.state.tip = null;
164
+ e.version++;
165
+ if (on) active.id = id;
166
+ setNotice(id, null);
167
+ hooks.requestRender();
168
+ }
169
+
170
+ async function writeNote(id: string, e: Entry, t: Target): Promise<void> {
171
+ if (dialog.open || !t.item) return;
172
+ const existing = e.state.notes.find((n) => n.itemId === t.id);
173
+ if (!existing && e.state.notes.length >= NOTE_LIMITS.notes) {
174
+ setNotice(id, `${NOTE_LIMITS.notes} notes is the most per widget.`);
175
+ hooks.requestRender();
176
+ return;
177
+ }
178
+ await ask(id, e, existing ?? null, { itemId: t.id, info: t.item });
179
+ }
180
+
181
+ async function editNote(id: string, e: Entry, noteId: number | undefined): Promise<void> {
182
+ const n = e.state.notes.find((x) => x.id === noteId);
183
+ if (n && !dialog.open) await ask(id, e, n, { itemId: n.itemId, info: n.info });
184
+ }
185
+
186
+ async function ask(id: string, e: Entry, existing: Note | null, about: { itemId: string; info: Note["info"] }): Promise<void> {
187
+ dialog.open = true;
188
+ let text: string | undefined;
189
+ try {
190
+ text = await hooks.prompt(`Note on ${about.info.short}${about.info.tab ? ` [tab ${about.info.tab}]` : ""}${existing ? " (editing)" : ""}`, existing?.text ?? "");
191
+ } finally {
192
+ dialog.open = false;
193
+ }
194
+ if (text === undefined) return; // cancelled
195
+ let clean = cleanNote(text);
196
+ if (clean.length > NOTE_LIMITS.note) {
197
+ clean = clean.slice(0, NOTE_LIMITS.note).trimEnd();
198
+ setNotice(id, `Note shortened to ${NOTE_LIMITS.note} characters.`);
199
+ }
200
+ if (!clean) {
201
+ // saving an empty note removes it
202
+ if (existing) e.state.notes = e.state.notes.filter((n) => n !== existing);
203
+ } else if (existing) existing.text = clean;
204
+ else e.state.notes.push({ id: ++e.state.noteSeq, itemId: about.itemId, info: about.info, text: clean });
205
+ e.version++;
206
+ hooks.requestRender();
207
+ }
208
+
209
+ function addToMessage(id: string, e: Entry) {
210
+ const n = e.state.notes.length;
211
+ if (!n) return;
212
+ hooks.toEditor(buildMessage(e.state.notes, widgetTitle(e.root)));
213
+ e.state.notes = [];
214
+ e.state.noteMode = false;
215
+ e.state.focus = null;
216
+ // The next key is Enter, to send the message the user is about to read. It has to reach pi's editor, not this widget.
217
+ if (active.id === id) active.id = null;
218
+ e.version++;
219
+ setNotice(id, `Added ${n} note${n === 1 ? "" : "s"} to your message. Read it, edit it, then press Enter to send.`);
220
+ hooks.requestRender();
221
+ }
222
+
223
+ function clearNotes(id: string, e: Entry) {
224
+ e.state.notes = [];
225
+ e.state.focus = null;
226
+ e.version++;
227
+ setNotice(id, "Notes cleared.");
228
+ hooks.requestRender();
229
+ }
230
+
231
+ /** One place for what a click or Enter does, so the mouse and the keyboard cannot drift apart. */
232
+ function perform(id: string, e: Entry, t: Target, shift = false): void {
233
+ switch (t.kind) {
234
+ case "notes":
235
+ setMode(id, e, !e.state.noteMode);
236
+ return;
237
+ case "item": {
238
+ // Shift picks the item one level up: a table row instead of its cell
239
+ const up = shift && t.item?.parent ? e.block?.targets.find((x) => x.id === t.item!.parent) : undefined;
240
+ void writeNote(id, e, up ?? t);
241
+ return;
242
+ }
243
+ case "note":
244
+ void editNote(id, e, t.noteId);
245
+ return;
246
+ case "noteadd":
247
+ addToMessage(id, e);
248
+ return;
249
+ case "noteclear":
250
+ clearNotes(id, e);
251
+ return;
252
+ }
253
+ const changed = activate(t, e.state, envFor(id, e));
254
+ afterActivate(id, e, t, changed);
255
+ }
256
+
257
+ // ---------- the component ----------
258
+
259
+ /** Restyle one cell for hover or focus. Replaces the cell: the originals belong to the cached layout and are shared by every redraw. */
260
+ const mark = (row: Cell[], x: number, suffix: string) => {
261
+ const c = row[x];
262
+ if (c.role.startsWith("note.")) return; // a tooltip badge or a pin keeps its own colour
263
+ if (c.role) row[x] = cell(c.ch, `${c.role}.${suffix}`);
264
+ else if (c.ch.trim()) row[x] = cell(c.ch, suffix);
265
+ };
266
+
267
+ export class OpenUIWidget {
268
+ id: string;
269
+ theme: any;
270
+ private pal: ReturnType<typeof themePalette> | null = null;
271
+ private root: El;
272
+ private mode: Mode;
273
+ constructor(id: string, root: El, theme: any, mode: Mode) {
274
+ this.id = id;
275
+ this.theme = theme;
276
+ this.root = root;
277
+ this.mode = mode;
278
+ getEntry(id, root, mode);
279
+ }
280
+
281
+ private layout(width: number): Entry {
282
+ // the entry is made again if the session's state was dropped while this widget was still on screen
283
+ const e = getEntry(this.id, this.root, this.mode);
284
+ const key = `${width}:${e.version}`;
285
+ if (!e.block || e.key !== key) {
286
+ const r = renderTree(e.root, width, e.state, { images: imageService });
287
+ let block = r.block;
288
+ // a footer row with the notes chip, when there is anything to attach a note to
289
+ if (block.targets.some((t) => t.kind === "item")) {
290
+ const w = Math.max(block.w, width);
291
+ const label = chipLabel(e.state);
292
+ const cw = textWidth(label);
293
+ const chip: Cell[] = textCells(label, e.state.noteMode ? "notes.on" : "btn");
294
+ const row = padRow([], w - cw).concat(chip);
295
+ block = {
296
+ w,
297
+ rows: [...block.rows.map((x) => padRow(x, w)), row],
298
+ targets: [...block.targets, { id: CHIP, kind: "notes", rect: { x: w - cw, y: block.rows.length, w: cw, h: 1 }, label: "notes" }],
299
+ };
300
+ }
301
+ e.block = block;
302
+ e.warnings = r.warnings;
303
+ e.key = key;
304
+ }
305
+ return e;
306
+ }
307
+
308
+ /** the block as last laid out, for tests and the keyboard layer */
309
+ lastBlock(): Block | null {
310
+ return entries.get(this.id)?.block ?? null;
311
+ }
312
+
313
+ /**
314
+ * pi calls this for every widget in the transcript on every frame (up to 60 a second, and again for each mouse event), so
315
+ * the work is done once per distinct appearance. What changes the picture is in the key: the layout (width and version), the
316
+ * pointer and keyboard focus, whether the widget is armed or in notes mode, the notice and an open tooltip. It never throws:
317
+ * pi's render loop is not a place for an exception.
318
+ */
319
+ render(width: number): string[] {
320
+ try {
321
+ const avail = Math.max(24, width - GUTTER - RIGHT);
322
+ const e = this.layout(avail);
323
+ const s = e.state;
324
+ const key = `${e.key}|${s.hover}|${s.focus}|${s.tip}|${s.copied}|${s.noteMode}|${s.notes.length}|${e.notice}|${active.id === this.id}`;
325
+ if (e.lines && e.lineKey === key) return e.lines;
326
+ const lines = this.draw(e, width, avail);
327
+ e.lines = lines;
328
+ e.lineKey = key;
329
+ return lines;
330
+ } catch (err) {
331
+ const msg = String((err as Error)?.message ?? err).replace(/\s+/g, " ").slice(0, 120);
332
+ return [truncateToWidth(this.theme.fg("error", `! the widget could not be drawn: ${msg}`), Math.max(1, width))];
333
+ }
334
+ }
335
+
336
+ private draw(e: Entry, width: number, avail: number): string[] {
337
+ const block = e.block!;
338
+ const notes = e.state.noteMode;
339
+ // the cells themselves are shared with the layout: a cell is replaced, never changed (see `mark`)
340
+ const rows: Cell[][] = block.rows.map((r) => r.slice());
341
+ const gutter = new Set<number>();
342
+ const paint = (id: string | null, suffix: string) => {
343
+ if (!id) return;
344
+ const t = block.targets.find((x) => x.id === id);
345
+ if (!t) return;
346
+ if (t.kind === "item" && t.item?.key === "") {
347
+ // a whole component is too big to restyle: a bar in the left margin marks its rows
348
+ for (let y = t.rect.y; y < t.rect.y + t.rect.h; y++) gutter.add(y);
349
+ return;
350
+ }
351
+ if (t.kind === "image" && t.image) {
352
+ // the picture itself cannot be styled: light up the frame around it
353
+ const r = t.rect;
354
+ for (let y = r.y - 1; y <= r.y + r.h; y++)
355
+ for (let x = r.x - 1; x <= r.x + r.w; x++) {
356
+ const onRing = y === r.y - 1 || y === r.y + r.h || x === r.x - 1 || x === r.x + r.w;
357
+ if (onRing && rows[y]?.[x]) mark(rows[y], x, suffix);
358
+ }
359
+ return;
360
+ }
361
+ for (let y = t.rect.y; y < t.rect.y + t.rect.h; y++) for (let x = Math.max(0, t.rect.x); x < Math.min(block.w, t.rect.x + t.rect.w); x++) if (rows[y]?.[x]) mark(rows[y], x, suffix);
362
+ };
363
+ paint(e.state.hover, "hover");
364
+ paint(e.state.focus, "focus");
365
+ const current = block.targets.find((x) => x.id === (e.state.hover ?? e.state.focus));
366
+ if (current?.tip?.hint && !notes) drawBubble(rows, block.w, current);
367
+
368
+ // the footer: what the pointer or focus is on, a notice, or how to reach tooltips and notes
369
+ const footerCells = (): Cell[] => {
370
+ const tips = block.targets.filter((x) => x.tip).length;
371
+ if (e.notice) return [...textCells("▸ ", "foot.mark"), ...textCells(e.notice, "foot.text")];
372
+ if (current) return [...textCells("▸ ", "foot.mark"), ...textCells(describe(current), "foot.text")];
373
+ if (notes) return [...textCells("Notes: click an element, or Tab to it and press Enter. Esc leaves.", "foot.text")];
374
+ if (tips) return [...textCells(BADGE, "foot.mark"), ...textCells(` ${tips} element${tips === 1 ? " has a" : "s have"} tooltip${tips === 1 ? "" : "s"}: hover ${tips === 1 ? "it" : "one"}, or press Tab then Enter`, "foot.text")];
375
+ return [];
376
+ };
377
+ const chipT = block.targets.find((t) => t.kind === "notes");
378
+ const text = footerCells();
379
+ if (chipT && rows[chipT.rect.y]) {
380
+ const room = chipT.rect.x - 2;
381
+ const cells = textCells(truncateText(text.map((c) => c.ch).join(""), Math.max(0, room)), "foot.text");
382
+ text.forEach((c, i) => {
383
+ if (cells[i]) cells[i] = cell(cells[i].ch, c.role); // cells are shared: build another, never change one
384
+ });
385
+ cells.forEach((c, i) => {
386
+ if (i < rows[chipT.rect.y].length) rows[chipT.rect.y][i] = c;
387
+ });
388
+ } else if (text.length) rows.push(padRow(text, Math.min(avail, Math.max(text.length, 1))));
389
+
390
+ const th = this.theme;
391
+ const palette = (this.pal ??= themePalette(th));
392
+ const lines = rowsToLines(rows, palette);
393
+ // pictures: the rows are reserved and framed; the first row of each carries the terminal's image command
394
+ const imageRows = new Set<number>();
395
+ for (const t of block.targets) {
396
+ if (t.kind !== "image" || !t.image) continue;
397
+ const seq = imageService.sequence(t.image.key);
398
+ const row = rows[t.rect.y];
399
+ if (!seq || !row) continue;
400
+ const left = rowsToLines([row.slice(0, t.rect.x)], palette, false)[0] ?? "";
401
+ const right = rowsToLines([row.slice(t.rect.x + t.image.cols)], palette)[0] ?? "";
402
+ // Kitty places the picture at the cursor and leaves the cursor there: step over it before the rest of the row
403
+ lines[t.rect.y] = left + seq + `\x1b[${t.image.cols}C` + right;
404
+ imageRows.add(t.rect.y);
405
+ }
406
+ for (const w of e.warnings) lines.push(th.fg("warning", `! ${w}`));
407
+ const armed = active.id === this.id;
408
+ const edgeFor = (y: number) => (gutter.has(y) ? th.fg("accent", "┃") + " " : armed ? th.fg("accent", "▎") + " " : " ");
409
+ return lines.map((l, i) => (imageRows.has(i) ? edgeFor(i) + l : truncateToWidth(edgeFor(i) + l, width)));
410
+ }
411
+
412
+ handleMouse(ev: any) {
413
+ try {
414
+ return this.mouse(ev);
415
+ } catch {
416
+ return undefined; // pi's input handling is not a place for an exception either
417
+ }
418
+ }
419
+
420
+ private mouse(ev: any) {
421
+ const e = entries.get(this.id);
422
+ if (!e || !e.block || dialog.open) return undefined;
423
+ const t = targetAt(e.block, ev.x - GUTTER, ev.y, e.state.noteMode);
424
+ if (ev.type === "move") {
425
+ const id = t?.id ?? null;
426
+ if (id === e.state.hover) return undefined;
427
+ e.state.hover = id;
428
+ return { handled: !!t, render: true };
429
+ }
430
+ if (ev.type === "click" && ev.button === "left" && t) {
431
+ active.id = this.id;
432
+ e.state.focus = t.id;
433
+ setNotice(this.id, null);
434
+ perform(this.id, e, t, !!ev.shift);
435
+ if (reveal(t, e.state)) e.version++;
436
+ hooks.requestRender();
437
+ return { handled: true, render: true };
438
+ }
439
+ return undefined;
440
+ }
441
+
442
+ /** pi calls this when the theme, the cell size or a converter changes: the colours (and a picture's size) are stale. */
443
+ invalidate() {
444
+ this.pal = null;
445
+ const e = entries.get(this.id);
446
+ if (!e) return;
447
+ e.lines = null;
448
+ if (e.block?.targets.some((t) => t.kind === "image")) {
449
+ imageService.reset();
450
+ e.version++;
451
+ }
452
+ }
453
+ }
454
+
455
+ /** Keyboard layer while a widget is armed. Returns true when the key was used. Never throws. */
456
+ export function handleKey(data: string): boolean {
457
+ try {
458
+ return keyInner(data);
459
+ } catch {
460
+ return false;
461
+ }
462
+ }
463
+
464
+ function keyInner(data: string): boolean {
465
+ if (!active.id || dialog.open) return false;
466
+ const id = active.id;
467
+ const e = entries.get(id);
468
+ if (!e || !e.block) return false;
469
+ const block = e.block;
470
+ const notes = e.state.noteMode;
471
+ if (matchesKey(data, Key.escape)) {
472
+ if (e.state.tip) {
473
+ e.state.tip = null;
474
+ e.version++;
475
+ } else if (notes) {
476
+ setMode(id, e, false);
477
+ return true;
478
+ } else {
479
+ active.id = null;
480
+ e.state.focus = null;
481
+ }
482
+ hooks.requestRender();
483
+ return true;
484
+ }
485
+ let nav: NavKey | null = null;
486
+ if (matchesKey(data, Key.tab)) nav = "next";
487
+ else if (matchesKey(data, Key.shift("tab"))) nav = "prev";
488
+ else if (matchesKey(data, Key.left)) nav = "left";
489
+ else if (matchesKey(data, Key.right)) nav = "right";
490
+ else if (matchesKey(data, Key.up)) nav = "up";
491
+ else if (matchesKey(data, Key.down)) nav = "down";
492
+ if (nav) {
493
+ const t = nextTarget(block, e.state.focus, nav, notes);
494
+ if (!t) return false;
495
+ e.state.focus = t.id;
496
+ e.state.hover = null; // the keyboard is in charge now: a stale pointer hover must not describe another target
497
+ setNotice(id, null);
498
+ if (reveal(t, e.state)) e.version++;
499
+ hooks.requestRender();
500
+ return true;
501
+ }
502
+ if (matchesKey(data, Key.enter) || matchesKey(data, Key.shift("enter"))) {
503
+ const t = block.targets.find((x) => x.id === e.state.focus) ?? nextTarget(block, null, "next", notes);
504
+ if (!t) return false;
505
+ e.state.focus = t.id;
506
+ e.state.hover = null;
507
+ perform(id, e, t, matchesKey(data, Key.shift("enter")));
508
+ hooks.requestRender();
509
+ return true;
510
+ }
511
+ // Delete (or Backspace) on a note in the tray removes it
512
+ if (matchesKey(data, Key.delete) || matchesKey(data, Key.backspace)) {
513
+ const t = block.targets.find((x) => x.id === e.state.focus);
514
+ if (t?.kind === "note") {
515
+ e.state.notes = e.state.notes.filter((n) => n.id !== t.noteId);
516
+ e.state.focus = null;
517
+ e.version++;
518
+ hooks.requestRender();
519
+ return true;
520
+ }
521
+ }
522
+ // The user started typing (a letter, a space, a paste): the keyboard belongs to the editor again. Without this, Enter
523
+ // would keep re-firing the focused button instead of sending the message being typed.
524
+ if ([...data].some((ch) => ch >= " " && ch !== "\x7f")) {
525
+ active.id = null;
526
+ e.state.focus = null;
527
+ e.state.hover = null;
528
+ if (e.state.noteMode) {
529
+ e.state.noteMode = false;
530
+ e.version++;
531
+ }
532
+ hooks.requestRender();
533
+ }
534
+ return false;
535
+ }
536
+
537
+ export function armNewest(): string | null {
538
+ if (!newestId.id || !entries.has(newestId.id)) return null;
539
+ active.id = newestId.id;
540
+ const e = entries.get(newestId.id)!;
541
+ if (!e.state.focus && e.block) e.state.focus = nextTarget(e.block, null, "next", e.state.noteMode)?.id ?? null;
542
+ hooks.requestRender();
543
+ return newestId.id;
544
+ }
545
+
546
+ /** Arm the newest widget and switch notes mode on or off. Returns false when there is no widget. */
547
+ export function toggleNotesNewest(): boolean {
548
+ if (!newestId.id || !entries.has(newestId.id)) return false;
549
+ const e = entries.get(newestId.id)!;
550
+ setMode(newestId.id, e, !e.state.noteMode);
551
+ return true;
552
+ }
553
+
554
+ /** Forget every widget's state (notes, focus, layouts). Called when a session ends: pi processes live for days. */
555
+ export function resetAll() {
556
+ for (const e of entries.values()) if (e.noticeTimer) clearTimeout(e.noticeTimer);
557
+ entries.clear();
558
+ imageService.reset();
559
+ active.id = null;
560
+ newestId.id = null;
561
+ dialog.open = false;
562
+ }