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.
@@ -3,27 +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
- import { active, armNewest, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
14
+ import { syncWidths } from "./widths.ts";
15
+ import { active, applySettings, armNewest, confirmDelivery, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
12
16
 
13
17
  const compiled = new Map<string, Compiled>();
14
18
  let compiledChars = 0;
15
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. */
16
20
  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);
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);
20
25
  if (!c) {
21
- c = compile(source);
26
+ c = compile(source, { mode, from });
22
27
  if (compiled.size >= 64 || compiledChars + source.length > CACHE_CHARS) {
23
28
  compiled.clear();
24
29
  compiledChars = 0;
25
30
  }
26
- compiled.set(source, c);
31
+ compiled.set(key, c);
27
32
  compiledChars += source.length;
28
33
  }
29
34
  return c;
@@ -56,7 +61,23 @@ export function checkImages(root: El | undefined): string[] {
56
61
  return errs;
57
62
  }
58
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
+
59
79
  export default function (pi: ExtensionAPI) {
80
+ syncWidths(); // our grid must agree with this pi about which characters take two cells
60
81
  hooks.sendLive = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
61
82
  hooks.sendEcho = (text) => pi.sendMessage({ customType: "openui-click", content: `[widget click → assistant] ${text}`, display: true });
62
83
 
@@ -64,14 +85,14 @@ export default function (pi: ExtensionAPI) {
64
85
  name: "render_widget",
65
86
  label: "render_widget",
66
87
  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.",
88
+ "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. A Button click sends its text back to you as the user's next message. Choice answers are staged and arrive together as one message when the user presses Send answers.",
68
89
  promptSnippet: "render_widget: draw an interactive terminal widget (dashboards, charts, diagrams) from OpenUI Lang",
69
90
  promptGuidelines: [
70
91
  "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
92
  "After render_widget succeeds, reply with at most one short sentence. Do not repeat the widget's data in prose.",
72
93
  "If render_widget returns errors, fix exactly the errors listed and call it again with the full corrected source.",
73
94
  "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.",
95
+ "A Button click arrives as your next user message: act on it, then render a NEW widget if needed. Choice answers arrive together as ONE message after the user presses Send answers, so ask all your questions in one widget (a Card or Tabs), then act on the answers.",
75
96
  ],
76
97
  parameters: Type.Object({
77
98
  source: Type.Optional(Type.String({ description: "OpenUI Lang source: root = Stack([...]) first, one statement per line" })),
@@ -88,7 +109,7 @@ export default function (pi: ExtensionAPI) {
88
109
  const noPics = hasImage && getCapabilities().images !== "kitty";
89
110
  const note = [...c.warnings, ...(noPics ? ["this terminal cannot show pictures, so each Image is drawn as a labelled frame with its file details"] : [])];
90
111
  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 } };
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 } };
92
113
  },
93
114
  renderCall(args: any, theme: any) {
94
115
  const n = typeof args?.source === "string" ? args.source.split("\n").length : 0;
@@ -102,26 +123,22 @@ export default function (pi: ExtensionAPI) {
102
123
  return new Text(lines.map((l) => theme.fg("error", l)).join("\n"), 0, 0);
103
124
  }
104
125
  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");
126
+ return restoredWidget(result.details, ctx.toolCallId, theme, "live");
108
127
  },
109
128
  });
110
129
 
111
130
  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");
131
+ return restoredWidget(message.details, String(message.details?.id ?? "w-unknown"), theme, message.details?.mode === "live" ? "live" : "echo");
115
132
  });
116
133
 
117
134
  const draw = (source: string, title: string, ctx: any) => {
118
135
  const c = compileCached(source);
119
136
  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" } });
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 } });
121
138
  };
122
139
 
123
140
  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.",
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.",
125
142
  handler: async (args: string, ctx: any) => {
126
143
  const which = args.trim() || "all";
127
144
  const picked = LANG_SAMPLES.filter((s) => which === "all" || s.key.startsWith(which));
@@ -143,6 +160,23 @@ export default function (pi: ExtensionAPI) {
143
160
  },
144
161
  });
145
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
+
146
180
  pi.registerCommand("openui-notes", {
147
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.",
148
182
  handler: async (_a: string, ctx: any) => {
@@ -157,11 +191,37 @@ export default function (pi: ExtensionAPI) {
157
191
  },
158
192
  });
159
193
 
194
+ // pi reports every user message it takes, whoever sent it: that is how a widget learns its answers were delivered
195
+ pi.on("message_start", async (event: any) => {
196
+ const m = event?.message;
197
+ if (m?.role !== "user") return;
198
+ const words = typeof m.content === "string" ? m.content : Array.isArray(m.content) ? m.content.filter((p: any) => p?.type === "text").map((p: any) => p.text).join("") : "";
199
+ confirmDelivery(words);
200
+ });
201
+
202
+ const told = new Set<string>();
160
203
  let unsub: (() => void) | undefined;
161
204
  pi.on("session_start", async (_e: any, ctx: any) => {
162
205
  if (!ctx.hasUI) return;
163
206
  unsub?.();
164
- unsub = ctx.ui.onTerminalInput((data: string) => (handleKey(data) ? { consume: true } : undefined));
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
+ }
214
+ hooks.queued = () => {
215
+ try {
216
+ return !!ctx.hasPendingMessages();
217
+ } catch {
218
+ return false; // a context from a replaced session
219
+ }
220
+ };
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
+ });
165
225
  // the note editor is pi's own multi-line editor: Enter saves, Shift+Enter adds a line, Esc cancels
166
226
  hooks.prompt = (title: string, initial: string) => ctx.ui.editor(title, initial);
167
227
  hooks.toEditor = (text: string) => ctx.ui.pasteToEditor(text);
@@ -173,7 +233,9 @@ export default function (pi: ExtensionAPI) {
173
233
  const e = entryOf(active.id);
174
234
  const keys = e?.state.noteMode
175
235
  ? "notes: click or Enter writes a note arrows/Tab move Shift+Enter picks the parent Esc leaves"
176
- : 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
177
239
  ? "arrows move Tab next Enter activate Esc close/leave"
178
240
  : "Tab or arrows select Enter activate Esc leave";
179
241
  return [truncateToWidth(theme.fg("accent", " openui ") + theme.fg("dim", keys), w)];
package/src/pi/widget.ts CHANGED
@@ -2,14 +2,18 @@
2
2
  import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
3
  import { drawBubble } from "../blocks/bubble.ts";
4
4
  import { BADGE } from "../blocks/core.ts";
5
- import { activate, describe, nextTarget, reveal, targetAt, type Env, type NavKey } from "../blocks/interact.ts";
5
+ import { activate, describe, nextTarget, panKey, readingOrder, reveal, targetAt, type Env, type NavKey } from "../blocks/interact.ts";
6
+ import { otherLength, setOther } from "../blocks/choice.ts";
6
7
  import { buildMessage, cleanNote, NOTE_LIMITS, widgetTitle } from "../blocks/notes.ts";
8
+ import { Recognizer } from "../blocks/gesture.ts";
7
9
  import { renderTree } from "../blocks/render.ts";
8
10
  import { newState, type Block, type Note, type Target, type WState } from "../blocks/types.ts";
9
11
  import { cell, padRow, textCells } from "../blocks/util.ts";
10
12
  import { rowsToLines, textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
11
13
  import { createPalette } from "../diagram/palette.ts";
12
- import type { El } from "../lang/library.ts";
14
+ import { LIMITS, type El } from "../lang/library.ts";
15
+ import { DEFAULTS, type Settings } from "./config.ts";
16
+ import { recorder as defaultRecorder, type Recorder } from "./events.ts";
13
17
  import { imageService } from "./images.ts";
14
18
 
15
19
  const GUTTER = 2;
@@ -68,12 +72,33 @@ interface Entry {
68
72
  warnings: string[];
69
73
  notice: string | null;
70
74
  noticeTimer: ReturnType<typeof setTimeout> | null;
75
+ /** answers handed to pi, waiting for its word that it took them (see watchDelivery) */
76
+ pending: { text: string; timer: ReturnType<typeof setTimeout> | null } | null;
71
77
  /** the finished lines and what they were drawn from: pi asks for them on every frame, so an unchanged widget answers from here */
72
78
  lines: string[] | null;
73
79
  lineKey: string;
80
+ /** what the pointer is doing to a clipped block (flicks, the thumb, the tap that follows a flick) */
81
+ gesture: Recognizer;
82
+ /** what was changed to draw this widget from an older source (see compile's restore mode); shown as one quiet line */
83
+ notes: string[];
74
84
  }
75
85
 
76
86
  const entries = new Map<string, Entry>();
87
+ let settings: Settings = { ...DEFAULTS };
88
+ /** what reaches a widget is recorded when /openui-events is on; tests swap it */
89
+ let recorder: Recorder = defaultRecorder;
90
+ export const setRecorder = (r: Recorder): void => void (recorder = r);
91
+ export const currentSettings = (): Settings => settings;
92
+ /** The user's settings changed (the file was read, or /openui-config wrote it): every widget is drawn again. */
93
+ export function applySettings(next: Settings): void {
94
+ if (next.tableMinCol === settings.tableMinCol && next.scrollStep === settings.scrollStep) return;
95
+ settings = { ...next };
96
+ for (const e of entries.values()) {
97
+ e.version++;
98
+ e.lines = null;
99
+ }
100
+ hooks.requestRender();
101
+ }
77
102
  export const active: { id: string | null } = { id: null };
78
103
  export const newestId: { id: string | null } = { id: null };
79
104
  /** A dialog (the note editor) is open: our key and mouse handlers stand down so its keys are its own. */
@@ -84,6 +109,10 @@ export const hooks = {
84
109
  requestRender: () => {},
85
110
  sendLive: (_text: string) => {},
86
111
  sendEcho: (_text: string) => {},
112
+ /** Is pi holding messages for later (the agent is busy and a follow-up is queued)? Then a message it has not shown yet is not lost. */
113
+ queued: (): boolean => false,
114
+ /** How long to wait for pi to say it took the answers before giving the widget back. */
115
+ deliveryMs: 3000,
87
116
  /** Ask the user for the text of a note. Resolves undefined when they cancel. */
88
117
  prompt: async (_title: string, _initial: string): Promise<string | undefined> => undefined,
89
118
  /** Put text in pi's message box without sending it. */
@@ -108,7 +137,7 @@ imageService.onLate = () => {
108
137
  function getEntry(id: string, root: El, mode: Mode): Entry {
109
138
  let e = entries.get(id);
110
139
  if (!e) {
111
- e = { root, state: newState(), mode, block: null, key: "", version: 0, warnings: [], notice: null, noticeTimer: null, lines: null, lineKey: "" };
140
+ e = { root, state: newState(), mode, block: null, key: "", version: 0, warnings: [], notice: null, noticeTimer: null, pending: null, lines: null, lineKey: "", gesture: new Recognizer(), notes: [] };
112
141
  entries.set(id, e);
113
142
  newestId.id = id;
114
143
  }
@@ -117,7 +146,11 @@ function getEntry(id: string, root: El, mode: Mode): Entry {
117
146
 
118
147
  function envFor(id: string, e: Entry): Env {
119
148
  return {
120
- send: (t) => (e.mode === "live" ? hooks.sendLive(t) : hooks.sendEcho(t)),
149
+ send: (t, o) => {
150
+ if (e.mode !== "live") return hooks.sendEcho(t); // a local echo is delivered when it is shown
151
+ if (o?.confirm) watchDelivery(id, e, t); // before sending, so a pi that confirms at once finds it waiting
152
+ hooks.sendLive(t);
153
+ },
121
154
  copy: (t) => hooks.copy(t),
122
155
  note: (t) => setNotice(id, t),
123
156
  };
@@ -152,6 +185,62 @@ function afterActivate(id: string, e: Entry, t: Target, layoutChanged: boolean)
152
185
  void id;
153
186
  }
154
187
 
188
+ // ---------- delivery ----------
189
+
190
+ /**
191
+ * pi.sendUserMessage returns nothing and swallows its failures (no model selected, no credentials, a compaction in progress), so
192
+ * "sent" cannot be assumed: a widget that said Sent and locked would leave the user with no way to send again once they have fixed
193
+ * what pi complained about. The answers are marked as sending until pi reports the user message (message_start, see
194
+ * confirmDelivery). If it does not within hooks.deliveryMs, and pi is not holding a message for later, the widget is given back
195
+ * with its answers and says why.
196
+ */
197
+ function watchDelivery(id: string, e: Entry, text: string): void {
198
+ if (e.pending?.timer) clearTimeout(e.pending.timer);
199
+ e.state.sending = true;
200
+ const pending: NonNullable<Entry["pending"]> = { text, timer: null };
201
+ e.pending = pending;
202
+ const check = () => {
203
+ if (e.pending !== pending) return;
204
+ let held = false;
205
+ try {
206
+ held = hooks.queued();
207
+ } catch {
208
+ /* pi's context went stale: treat it as not held */
209
+ }
210
+ if (held) return void (pending.timer = later(check)); // the agent is busy: pi has it queued and will show it after the turn
211
+ e.pending = null;
212
+ e.state.sent = false;
213
+ e.state.sending = false;
214
+ e.version++;
215
+ setNotice(id, "pi did not take the answers. Fix what it says above, then Send again.");
216
+ hooks.requestRender();
217
+ };
218
+ pending.timer = later(check);
219
+ }
220
+ const later = (f: () => void) => {
221
+ const t = setTimeout(f, Math.max(1, hooks.deliveryMs));
222
+ if (typeof (t as any).unref === "function") (t as any).unref();
223
+ return t;
224
+ };
225
+
226
+ /**
227
+ * pi reported a user message with this text: the oldest widget waiting for it has been delivered. One message confirms one widget,
228
+ * so two widgets that sent the same words are each confirmed by their own message.
229
+ */
230
+ export function confirmDelivery(text: string): boolean {
231
+ const want = text.trim();
232
+ for (const e of entries.values()) {
233
+ if (!e.pending || e.pending.text.trim() !== want) continue;
234
+ if (e.pending.timer) clearTimeout(e.pending.timer);
235
+ e.pending = null;
236
+ e.state.sending = false;
237
+ e.version++;
238
+ hooks.requestRender();
239
+ return true;
240
+ }
241
+ return false;
242
+ }
243
+
155
244
  // ---------- notes ----------
156
245
 
157
246
  const chipLabel = (s: WState) => (s.noteMode ? "[ notes: on ]" : s.notes.length ? `[ notes ${s.notes.length} ]` : "[ notes ]");
@@ -228,6 +317,29 @@ function clearNotes(id: string, e: Entry) {
228
317
  hooks.requestRender();
229
318
  }
230
319
 
320
+ // ---------- choices ----------
321
+
322
+ /**
323
+ * The Other row of a Choice. The user types their own answer in pi's editor (the one Notes uses), never inside the widget: an
324
+ * armed widget must not keep letters. Saved text selects Other, empty text unselects it, Esc changes nothing.
325
+ */
326
+ async function editOther(id: string, e: Entry, t: Target): Promise<void> {
327
+ const x = t.choice;
328
+ if (!x || dialog.open || e.state.sent) return;
329
+ dialog.open = true;
330
+ let text: string | undefined;
331
+ try {
332
+ text = await hooks.prompt(`Your own answer: ${truncateText(x.of.question, 60)}`, e.state.answers[x.of.key]?.text ?? "");
333
+ } finally {
334
+ dialog.open = false;
335
+ }
336
+ if (text === undefined || e.state.sent) return; // cancelled (the sent check is defence: nothing can send while the editor owns the keys)
337
+ if (otherLength(text) > LIMITS.other) setNotice(id, `Answer shortened to ${LIMITS.other} characters.`);
338
+ e.state.answers = setOther(e.state.answers, x.of, text);
339
+ e.version++;
340
+ hooks.requestRender();
341
+ }
342
+
231
343
  /** One place for what a click or Enter does, so the mouse and the keyboard cannot drift apart. */
232
344
  function perform(id: string, e: Entry, t: Target, shift = false): void {
233
345
  switch (t.kind) {
@@ -249,9 +361,26 @@ function perform(id: string, e: Entry, t: Target, shift = false): void {
249
361
  case "noteclear":
250
362
  clearNotes(id, e);
251
363
  return;
364
+ case "choice":
365
+ if (t.choice?.index === "other") {
366
+ void editOther(id, e, t).catch(() => {
367
+ // the editor could not open (pi's context went stale): leave the answers as they were
368
+ setNotice(id, "The editor could not be opened.");
369
+ hooks.requestRender();
370
+ });
371
+ return;
372
+ }
373
+ break;
252
374
  }
253
375
  const changed = activate(t, e.state, envFor(id, e));
254
376
  afterActivate(id, e, t, changed);
377
+ // The button that was pressed is gone from the next picture. Focus must not be left pointing at it: Enter would find nothing
378
+ // and fall through to the first element of the widget, and that can be a Button that sends a message.
379
+ if (t.kind === "send" && e.state.sent) {
380
+ // a locked widget has nothing left to hold the keyboard for: Enter is pi's again, to send the user's own message
381
+ e.state.focus = null;
382
+ if (active.id === id) active.id = null;
383
+ } else if (t.kind === "clear" && e.block) e.state.focus = readingOrder(e.block).find((x) => x.kind === "choice")?.id ?? null;
255
384
  }
256
385
 
257
386
  // ---------- the component ----------
@@ -270,12 +399,12 @@ export class OpenUIWidget {
270
399
  private pal: ReturnType<typeof themePalette> | null = null;
271
400
  private root: El;
272
401
  private mode: Mode;
273
- constructor(id: string, root: El, theme: any, mode: Mode) {
402
+ constructor(id: string, root: El, theme: any, mode: Mode, notes: string[] = []) {
274
403
  this.id = id;
275
404
  this.theme = theme;
276
405
  this.root = root;
277
406
  this.mode = mode;
278
- getEntry(id, root, mode);
407
+ getEntry(id, root, mode).notes = notes;
279
408
  }
280
409
 
281
410
  private layout(width: number): Entry {
@@ -283,7 +412,7 @@ export class OpenUIWidget {
283
412
  const e = getEntry(this.id, this.root, this.mode);
284
413
  const key = `${width}:${e.version}`;
285
414
  if (!e.block || e.key !== key) {
286
- const r = renderTree(e.root, width, e.state, { images: imageService });
415
+ const r = renderTree(e.root, width, e.state, { images: imageService, settings });
287
416
  let block = r.block;
288
417
  // a footer row with the notes chip, when there is anything to attach a note to
289
418
  if (block.targets.some((t) => t.kind === "item")) {
@@ -404,6 +533,7 @@ export class OpenUIWidget {
404
533
  imageRows.add(t.rect.y);
405
534
  }
406
535
  for (const w of e.warnings) lines.push(th.fg("warning", `! ${w}`));
536
+ if (e.notes.length) lines.push(th.fg("dim", `(redrawn by a newer version of the extension: ${e.notes.length} change${e.notes.length === 1 ? "" : "s"}. ${e.notes[0]})`));
407
537
  const armed = active.id === this.id;
408
538
  const edgeFor = (y: number) => (gutter.has(y) ? th.fg("accent", "┃") + " " : armed ? th.fg("accent", "▎") + " " : " ");
409
539
  return lines.map((l, i) => (imageRows.has(i) ? edgeFor(i) + l : truncateToWidth(edgeFor(i) + l, width)));
@@ -421,24 +551,41 @@ export class OpenUIWidget {
421
551
  const e = entries.get(this.id);
422
552
  if (!e || !e.block || dialog.open) return undefined;
423
553
  const t = targetAt(e.block, ev.x - GUTTER, ev.y, e.state.noteMode);
554
+ recorder.mouse(ev);
555
+ const act = e.gesture.feed({ type: ev.type, button: ev.button, x: ev.x - GUTTER, y: ev.y, shift: !!ev.shift, alt: !!ev.alt, wheelDelta: ev.wheelDelta }, e.block, e.state.pan);
556
+ if (act.kind !== "none") recorder.note(act.kind === "pan" ? `pan to ${act.to}${act.capture ? " (dragging the thumb)" : ""}` : act.kind === "tap" ? `tap at ${act.x},${act.y} (after a flick)` : "a click already handled as a tap");
557
+ if (act.kind === "pan") {
558
+ if ((e.state.pan[act.key] ?? 0) !== act.to) {
559
+ e.state.pan[act.key] = act.to;
560
+ e.version++;
561
+ }
562
+ hooks.requestRender();
563
+ return act.capture ? { handled: true, render: true, capture: true } : { handled: true, render: true };
564
+ }
565
+ if (act.kind === "swallow") return { handled: true };
566
+ if (act.kind === "tap") return this.click(e, t, ev);
424
567
  if (ev.type === "move") {
425
568
  const id = t?.id ?? null;
426
569
  if (id === e.state.hover) return undefined;
427
570
  e.state.hover = id;
428
571
  return { handled: !!t, render: true };
429
572
  }
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
- }
573
+ if (ev.type === "click" && ev.button === "left") return this.click(e, t, ev);
439
574
  return undefined;
440
575
  }
441
576
 
577
+ /** A click, or a tap the recognizer found for pi (see gesture.ts). */
578
+ private click(e: Entry, t: Target | null, ev: any) {
579
+ if (!t) return undefined;
580
+ active.id = this.id;
581
+ e.state.focus = t.id;
582
+ setNotice(this.id, null);
583
+ perform(this.id, e, t, !!ev.shift);
584
+ if (reveal(t, e.state)) e.version++;
585
+ hooks.requestRender();
586
+ return { handled: true, render: true };
587
+ }
588
+
442
589
  /** pi calls this when the theme, the cell size or a converter changes: the colours (and a picture's size) are stale. */
443
590
  invalidate() {
444
591
  this.pal = null;
@@ -482,6 +629,13 @@ function keyInner(data: string): boolean {
482
629
  hooks.requestRender();
483
630
  return true;
484
631
  }
632
+ // the pan bar is focused: Shift+arrow, PageUp and PageDown scroll a page (the plain arrows, below, a fine step)
633
+ const page = matchesKey(data, Key.shift("left")) || matchesKey(data, Key.pageUp) ? "left" : matchesKey(data, Key.shift("right")) || matchesKey(data, Key.pageDown) ? "right" : null;
634
+ if (page && panKey(block, e.state, e.state.focus, page, true)) {
635
+ e.version++;
636
+ hooks.requestRender();
637
+ return true;
638
+ }
485
639
  let nav: NavKey | null = null;
486
640
  if (matchesKey(data, Key.tab)) nav = "next";
487
641
  else if (matchesKey(data, Key.shift("tab"))) nav = "prev";
@@ -490,6 +644,12 @@ function keyInner(data: string): boolean {
490
644
  else if (matchesKey(data, Key.up)) nav = "up";
491
645
  else if (matchesKey(data, Key.down)) nav = "down";
492
646
  if (nav) {
647
+ // the pan bar is focused: Left and Right scroll instead of moving the focus
648
+ if ((nav === "left" || nav === "right") && panKey(block, e.state, e.state.focus, nav)) {
649
+ e.version++;
650
+ hooks.requestRender();
651
+ return true;
652
+ }
493
653
  const t = nextTarget(block, e.state.focus, nav, notes);
494
654
  if (!t) return false;
495
655
  e.state.focus = t.id;
@@ -500,7 +660,15 @@ function keyInner(data: string): boolean {
500
660
  return true;
501
661
  }
502
662
  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);
663
+ const focused = e.state.focus ? block.targets.find((x) => x.id === e.state.focus) : undefined;
664
+ if (e.state.focus && !focused) {
665
+ // the focused element is gone (a button removes itself when it is pressed): never press another one in its place
666
+ e.state.focus = null;
667
+ e.state.hover = null;
668
+ hooks.requestRender();
669
+ return true;
670
+ }
671
+ const t = focused ?? nextTarget(block, null, "next", notes);
504
672
  if (!t) return false;
505
673
  e.state.focus = t.id;
506
674
  e.state.hover = null;
@@ -553,7 +721,10 @@ export function toggleNotesNewest(): boolean {
553
721
 
554
722
  /** Forget every widget's state (notes, focus, layouts). Called when a session ends: pi processes live for days. */
555
723
  export function resetAll() {
556
- for (const e of entries.values()) if (e.noticeTimer) clearTimeout(e.noticeTimer);
724
+ for (const e of entries.values()) {
725
+ if (e.noticeTimer) clearTimeout(e.noticeTimer);
726
+ if (e.pending?.timer) clearTimeout(e.pending.timer);
727
+ }
557
728
  entries.clear();
558
729
  imageService.reset();
559
730
  active.id = null;
@@ -0,0 +1,16 @@
1
+ // Make our idea of which characters are two cells wide agree with the pi that is running, not only with the pi the committed table
2
+ // (src/diagram/wide.ts) was generated from. Unicode adds emoji with every release, and a character we count as one cell that pi
3
+ // draws as two slides the rest of its row and pushes the right border out of line.
4
+ import { visibleWidth } from "@earendil-works/pi-tui";
5
+ import { setWide } from "../diagram/canvas.ts";
6
+ import { WIDE } from "../diagram/wide.ts";
7
+ import { healWide } from "../diagram/widefix.ts";
8
+
9
+ /** Ask pi about the blocks where new wide characters appear (see PROBE_SPANS) and use its answers. Never throws: on a failure the committed table stays. */
10
+ export function syncWidths(): void {
11
+ try {
12
+ setWide(healWide(WIDE, (cp) => visibleWidth(String.fromCodePoint(cp))));
13
+ } catch {
14
+ setWide(WIDE);
15
+ }
16
+ }