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.
- package/CHANGELOG.md +96 -0
- package/README.md +49 -6
- package/package.json +3 -2
- package/src/blocks/choice.ts +152 -0
- package/src/blocks/code.ts +16 -3
- package/src/blocks/core.ts +150 -55
- package/src/blocks/diagrams.ts +8 -26
- package/src/blocks/fallback.ts +25 -0
- package/src/blocks/gesture.ts +112 -0
- package/src/blocks/interact.ts +85 -11
- package/src/blocks/markdown.ts +13 -3
- package/src/blocks/pan.ts +139 -0
- package/src/blocks/panblock.ts +44 -0
- package/src/blocks/render.ts +34 -21
- package/src/blocks/sequence.ts +4 -4
- package/src/blocks/table.ts +122 -14
- package/src/blocks/types.ts +31 -4
- package/src/blocks/util.ts +5 -0
- package/src/diagram/canvas.ts +11 -4
- package/src/diagram/dag.ts +4 -3
- package/src/diagram/erd.ts +4 -2
- package/src/diagram/infra.ts +3 -2
- package/src/diagram/palette.ts +1 -1
- package/src/diagram/wide.ts +4 -5
- package/src/diagram/widefix.ts +56 -0
- package/src/lang/docs.ts +81 -25
- package/src/lang/library.ts +154 -22
- package/src/lang/samples.ts +144 -1
- package/src/pi/config.ts +185 -0
- package/src/pi/events.ts +150 -0
- package/src/pi/extension.ts +82 -20
- package/src/pi/widget.ts +189 -18
- package/src/pi/widths.ts +16 -0
package/src/pi/extension.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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"
|
|
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
|
|
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())
|
|
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;
|
package/src/pi/widths.ts
ADDED
|
@@ -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
|
+
}
|