pi-openui 0.1.2 → 0.2.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 CHANGED
@@ -2,6 +2,43 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.2.0 (2026-11-02)
6
+
7
+ - **`Choice` takes several answers and an Other answer, and sends them together.** `Choice(question, options, hint, multiple, other)`.
8
+ `multiple: true` lets you pick several options (square checkboxes and the tag `Choose all that apply`; a single question has
9
+ round indicators and `Choose one`). `other` is on by default and adds an **Other...** row: it opens pi's own editor (Enter saves,
10
+ Shift+Enter adds a line, Esc cancels) and the text shows under the row. Write `null` for each skipped argument before `multiple`
11
+ or `other`; a wrong one is a validation error that gives the argument's position and an example.
12
+ - **A click on a Choice no longer sends anything.** It stages the answer. A bar under the widget, `N of M answered [ Send answers ]
13
+ [ Clear ]`, sends the answers of every Choice in the widget (also those on tabs nobody opened) as ONE message and locks the
14
+ widget. The buttons stack on a narrow terminal. A Choice written for the old behaviour keeps working, now with an Other row and the
15
+ bar (write `other: false` for a closed list), unless it breaks one of the new limits. Answers live in memory with the widget, so a
16
+ widget redrawn from a resumed session opens unanswered.
17
+ - **The message.** One question that takes one answer, answered with an option, still sends the line `<question> — <option>`.
18
+ Anything else is a numbered list that starts ``Answers to the questions in widget `Title` (3):``, with `(no answer)` for a skipped
19
+ question and `other:` for typed text. The words the agent wrote are cleaned (control and invisible characters removed, whitespace
20
+ collapsed, `@file:` and similar written `(at)file:`), and a question that would start the message like a command (`/new session?`)
21
+ uses the list. Your own Other text is sent as you typed it, except for control characters: a tab becomes a space and a run of
22
+ blank lines becomes one.
23
+ - **Delivery.** The bar says `Sending to the agent…` until pi reports the message, then `Sent to the agent.` If pi does not take it
24
+ within 3 seconds (no model selected, no credentials) and is not holding one for the end of the agent's turn, the widget is given
25
+ back with your answers and a notice, and Send answers works again once what pi complained about is fixed. After Send the widget
26
+ gives the keyboard back to pi.
27
+ - **Layout and keys.** Options are always one per row (the old layout with every option on one line is gone). The whole row is the
28
+ click target, and a long option wraps under its indicator. Down from the last option reaches Send answers, never Clear. After
29
+ Clear the focus moves to the first option, and Enter never presses an element other than the focused one. In notes mode the
30
+ options and the bar do nothing.
31
+ - **New limits**, listed in the model's reference and in docs/components/README.md: 12 Choices per widget, 6 options, a question of
32
+ at most 200 characters, an option of at most 120, an Other answer of at most 500. An empty question or option is an error too.
33
+ Over a limit is a validation error with the number in it.
34
+ - **The model is told.** The `render_widget` description and guideline now say that a Button click arrives as the next message and
35
+ that Choice answers arrive together after Send answers, so the model asks all its questions in one widget. The model's reference
36
+ grows by about 190 tokens. New sample: `/openui-gallery choice`.
37
+ - **Rows with emoji stay aligned across pi upgrades.** The two-cell character table (`src/diagram/wide.ts`) was a snapshot of one pi.
38
+ pi 1.4.0 draws a few newer emoji (U+1F6D9, U+1FA8B-1FA8D, U+1FACC, U+1FADD, U+1FAEB, U+1FAF9-1FAFA) two cells wide, so borders
39
+ slid on rows that contain them. The extension now asks the running pi about the blocks where emoji are added (U+2190-2BFF,
40
+ U+1F000-1FFFF, about 11 ms) when it loads, and the table is regenerated for pi 1.4.0 (`npm run width`).
41
+
5
42
  ## 0.1.2 (2026-10-31)
6
43
 
7
44
  - Fixed borders drawn out of line on rows with wide characters. The terminal draws emoji and many symbols (✅ ❌ ⚡ 🚀 ⭐ ☕
package/README.md CHANGED
@@ -17,8 +17,12 @@ dependency and no network access.
17
17
  - **23 components.** Layout and text, metric tiles, tables, tabs, buttons and choices, FAQ, markdown, code with
18
18
  highlighting and a copy button, images, bar, line, area, pie and radar charts, a world map, and four diagram types
19
19
  (DAG, ER, infrastructure map, sequence). See the [component gallery](https://github.com/newtonapple/pi-openui/blob/main/docs/components/README.md).
20
- - **Interaction.** Click, or use Tab, the arrow keys, Enter and Esc. A button or a choice sends text to the agent as your next
21
- message. See [docs/interaction.md](https://github.com/newtonapple/pi-openui/blob/main/docs/interaction.md).
20
+ - **Interaction.** Click, or use Tab, the arrow keys, Enter and Esc. A button sends text to the agent as your next message.
21
+ See [docs/interaction.md](https://github.com/newtonapple/pi-openui/blob/main/docs/interaction.md).
22
+ - **Questions.** A `Choice` asks you something with a list of options. A click only stages your answer; a `[ Send answers ]`
23
+ bar under the widget sends every answer of the widget as one message. A question can allow several options, and every
24
+ question has an **Other...** row where you type your own answer in pi's editor. See
25
+ [Choice](https://github.com/newtonapple/pi-openui/blob/main/docs/components/Choice.md).
22
26
  - **Notes.** Pin a comment to a table cell, a graph node or a pie slice, then send all of them to the agent as one numbered
23
27
  message. You read and edit the message before it goes. See [Notes](#notes).
24
28
  - **Tooltips.** An element with a tooltip shows a small `◆`. Hover for a hint bubble, click or press Enter for a panel with
@@ -57,13 +61,13 @@ update pi shows this package's [changelog](CHANGELOG.md). Maintainers: see [docs
57
61
  | What | How |
58
62
  |---|---|
59
63
  | Let the agent draw | Ask for a dashboard, a chart, a diagram, a table. It calls `render_widget`. |
60
- | One example of every component | `/openui-gallery`, or one kind: `/openui-gallery dag`, `erd`, `infra`, `sequence`, `geo`, `pie`, `line`, `image`, `table`, `tabs`, `markdown` |
64
+ | One example of every component | `/openui-gallery`, or one kind: `/openui-gallery dag`, `erd`, `infra`, `sequence`, `geo`, `pie`, `line`, `image`, `table`, `tabs`, `markdown`, `choice` |
61
65
  | Draw a file | `/openui-file path/to/widget.oui` |
62
66
  | Use the keyboard without the mouse | `/openui-focus` |
63
67
  | Write notes on a widget | Click `[ notes ]` under it, or `/openui-notes` |
64
68
 
65
- Gallery clicks are echoed locally and cost nothing. In a widget the agent drew, a Button or Choice click is sent to the
66
- agent as your next message.
69
+ Gallery clicks are echoed locally and cost nothing. In a widget the agent drew, a Button click is sent to the agent as your
70
+ next message, and the answers to its Choices are sent together, as one message, when you press **Send answers**.
67
71
 
68
72
  The mouse needs pi's default fullscreen mode. Everything also works from the keyboard.
69
73
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openui",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: interactive dashboards, charts, diagrams and maps in the terminal. The agent draws with OpenUI Lang; you click, hover and pin notes.",
6
6
  "license": "MIT",
@@ -0,0 +1,152 @@
1
+ // Choice: what an answer is and the one message the user sends. Pure logic, no pi imports.
2
+ // A click stages an answer; nothing is sent until the user presses Send answers, and then every Choice of the widget goes in ONE
3
+ // message. Port of hermes-openui's choice-core.js, on pi-openui's own cleaning rules (notes.ts).
4
+ //
5
+ // The message is the contract with the agent. One single-choice question answered with an option keeps the line agents already
6
+ // know, `<question> — <option>`. Anything else is a numbered list. The agent wrote the question and the options and the message
7
+ // is sent without a review step, so those words are cleaned like a Notes quote: control and invisible characters out, whitespace
8
+ // collapsed, `@file:`-style references defused. The user's own Other text is theirs: only control characters go.
9
+ import type { El } from "../lang/library.ts";
10
+ import { LIMITS } from "../lang/library.ts";
11
+ import { clip, collapse, neutralize, quiet, strip } from "./notes.ts";
12
+
13
+ export const NO_ANSWER = "(no answer)";
14
+
15
+ /** A question as the board knows it. `question` and `options` are the cleaned words the message uses. */
16
+ export interface Choice {
17
+ question: string;
18
+ options: string[];
19
+ multiple: boolean;
20
+ other: boolean;
21
+ /** a fingerprint of the words and the two flags: Choices with one key are ONE question, they share their answer and count once */
22
+ key: string;
23
+ }
24
+
25
+ /** What the user has staged for one question. `picks` are option indexes, ascending. `other` is the Other row being selected. */
26
+ export interface Answer {
27
+ picks: number[];
28
+ other: boolean;
29
+ text: string;
30
+ }
31
+ export type Answers = Record<string, Answer>;
32
+
33
+ const BLANK: Answer = { picks: [], other: false, text: "" };
34
+ const cur = (a: Answers, c: Choice): Answer => a[c.key] ?? BLANK;
35
+ const word = (raw: unknown, max: number): string => clip(neutralize(collapse(raw)), max);
36
+
37
+ export function makeChoice(props: Record<string, any>): Choice {
38
+ const options = (Array.isArray(props?.options) ? props.options : []).map((o: unknown) => word(o, LIMITS.option));
39
+ const question = word(props?.question, LIMITS.question);
40
+ const multiple = props?.multiple === true;
41
+ const other = props?.other !== false;
42
+ return { question, options, multiple, other, key: JSON.stringify([question, options, multiple, other]) };
43
+ }
44
+
45
+ /** Every Choice of a compiled tree in document order, whether or not its tab was ever shown. Identical questions are merged. */
46
+ export function collectChoices(root: El | undefined): Choice[] {
47
+ const out: Choice[] = [];
48
+ const keys = new Set<string>();
49
+ // the compiler bounds the depth (LIMITS.depth) and an element reached twice is read once, so the walk needs no bound of its own:
50
+ // one would silently drop a Choice that is drawn and clickable
51
+ const seen = new Set<object>();
52
+ const walk = (x: unknown) => {
53
+ if (!x || typeof x !== "object" || seen.has(x)) return;
54
+ seen.add(x);
55
+ if (Array.isArray(x)) return x.forEach(walk);
56
+ const el = x as El;
57
+ if (el.type === "Choice") {
58
+ const c = makeChoice(el.props);
59
+ if (!keys.has(c.key)) {
60
+ keys.add(c.key);
61
+ out.push(c);
62
+ }
63
+ return;
64
+ }
65
+ if (el.props && typeof el.props === "object") for (const v of Object.values(el.props)) walk(v);
66
+ };
67
+ walk(root);
68
+ return out;
69
+ }
70
+
71
+ // ---------- the answers (every function returns new objects) ----------
72
+
73
+ /** The user's own text, tidied: a tab is a space, trailing blanks and control characters go, a run of blank lines is one blank line. */
74
+ function tidyOther(raw: unknown): string {
75
+ return strip(raw == null ? "" : String(raw).replace(/\t/g, " "), true)
76
+ .split("\n")
77
+ .map((l) => l.replace(/[ \t]+$/g, ""))
78
+ .join("\n")
79
+ .replace(/\n{3,}/g, "\n\n")
80
+ .trim();
81
+ }
82
+
83
+ /**
84
+ * The user's own text as it is kept and sent: newlines stay (a run of blank lines is one, so a message cannot be padded), a tab is
85
+ * a space, trailing blanks and control characters go, at most LIMITS.other characters. Not defused: it is theirs.
86
+ */
87
+ export function cleanOther(raw: unknown): string {
88
+ return Array.from(tidyOther(raw)).slice(0, LIMITS.other).join("");
89
+ }
90
+
91
+ /** How long the text is once tidied and before the cut, so "shortened" is said only when something is cut. */
92
+ export const otherLength = (raw: unknown): number => Array.from(tidyOther(raw)).length;
93
+
94
+ export function toggleOption(a: Answers, c: Choice, i: number): Answers {
95
+ if (!Number.isInteger(i) || i < 0 || i >= c.options.length) return a;
96
+ const now = cur(a, c);
97
+ let next: Answer;
98
+ if (c.multiple) next = { ...now, picks: now.picks.includes(i) ? now.picks.filter((x) => x !== i) : [...now.picks, i].sort((x, y) => x - y) };
99
+ else next = now.picks.length === 1 && now.picks[0] === i ? { ...now, picks: [] } : { ...now, picks: [i], other: false }; // another option moves the pick and replaces Other
100
+ return { ...a, [c.key]: next };
101
+ }
102
+
103
+ /** The user saved their own answer. Text selects the Other row (single mode: it replaces the option); empty text unselects it. */
104
+ export function setOther(a: Answers, c: Choice, text: string): Answers {
105
+ if (!c.other) return a;
106
+ const clean = cleanOther(text);
107
+ const now = cur(a, c);
108
+ if (!clean) return { ...a, [c.key]: { ...now, other: false, text: "" } };
109
+ return { ...a, [c.key]: { picks: c.multiple ? now.picks : [], other: true, text: clean } };
110
+ }
111
+
112
+ const otherOf = (c: Choice, a: Answer): string => (c.other && a.other ? cleanOther(a.text) : "");
113
+
114
+ /** An Other row that is selected but empty is not an answer. */
115
+ export function isAnswered(c: Choice, a: Answers): boolean {
116
+ const now = cur(a, c);
117
+ return now.picks.some((i) => i >= 0 && i < c.options.length) || otherOf(c, now) !== "";
118
+ }
119
+
120
+ export function progress(cs: Choice[], a: Answers): { answered: number; total: number } {
121
+ return { answered: cs.filter((c) => isAnswered(c, a)).length, total: cs.length };
122
+ }
123
+
124
+ /** `disk; memory; other: also look at the logs`, or "" when not answered. A second line of Other text is indented under the question. */
125
+ export function answerWords(c: Choice, a: Answers): string {
126
+ const now = cur(a, c);
127
+ const parts = now.picks.filter((i) => i >= 0 && i < c.options.length).map((i) => c.options[i]);
128
+ const text = otherOf(c, now);
129
+ if (text) parts.push("other: " + text.split("\n").join("\n "));
130
+ return parts.join("; ");
131
+ }
132
+
133
+ // ---------- the message ----------
134
+
135
+ /** pi, like most hosts, may run a message that starts with `/name` as a command; a question must never be the first words of one. */
136
+ const SLASH_COMMAND = /^\/[^\s/]*(?:\s|$)/;
137
+ export const runsAsCommand = (text: string): boolean => SLASH_COMMAND.test(String(text).trim());
138
+
139
+ export function buildMessage(cs: Choice[], a: Answers, title: string): string {
140
+ if (cs.length === 1 && !cs[0].multiple) {
141
+ const c = cs[0];
142
+ const now = cur(a, c);
143
+ if (now.picks.length === 1 && c.options[now.picks[0]] !== undefined && !otherOf(c, now)) {
144
+ const line = `${c.question} \u2014 ${c.options[now.picks[0]]}`;
145
+ if (!runsAsCommand(line)) return line;
146
+ }
147
+ }
148
+ const name = title ? quiet(title, 60) : "";
149
+ const head = name ? `Answers to the questions in widget \`${name}\` (${cs.length}):` : `Answers to the questions in this widget (${cs.length}):`;
150
+ const lines = cs.map((c, i) => `${i + 1}. ${c.question}\n ${answerWords(c, a) || NO_ANSWER}`);
151
+ return `${head}\n\n${lines.join("\n")}`;
152
+ }
@@ -1,10 +1,11 @@
1
1
  // Layout and simple components: Stack, Text, Card, Callout, Metric, Button, Choice, Tabs, Faq, Image + tooltips.
2
- import { textWidth, truncateText, type Rect } from "../diagram/canvas.ts";
2
+ import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
3
3
  import type { El } from "../lang/library.ts";
4
+ import { buildMessage, cleanOther, makeChoice, progress, type Choice } from "./choice.ts";
4
5
  import { codeInner } from "./code.ts";
5
6
  import { markdownBlock } from "./markdown.ts";
6
7
  import { itemId, itemInfo, NOTEABLE, parentKeyOf, WHOLE_ONLY } from "./notes.ts";
7
- import type { Block, Ctx, Target, Tip } from "./types.ts";
8
+ import type { Block, Ctx, Target, Tip, WState } from "./types.ts";
8
9
  import { blank, boxed, cell, fromRows, hstack, padRow, shiftTargets, textCells, vstack, wrapRuns, wrapText } from "./util.ts";
9
10
 
10
11
  const ROW_GAP: Record<string, number> = { none: 0, xs: 0, s: 0, m: 1, l: 1, xl: 2 };
@@ -118,36 +119,79 @@ export function button(el: El, width: number, path: string): Block {
118
119
  return fromRows(Math.max(cells.length, 1), [cells], [t]);
119
120
  }
120
121
 
122
+ const SEND = "[ Send answers ]";
123
+ const CLEAR = "[ Clear ]";
124
+
125
+ /** One question: its tag, hint, one row per option and (unless `other` is false) an Other row. A click only stages the answer. */
121
126
  export function choice(el: El, width: number, ctx: Ctx, path: string): Block {
122
- const p = el.props;
123
- const q = String(p.question ?? "");
124
- const opts: string[] = p.options ?? [];
125
- const picked = ctx.state.picked[path];
126
- const head = wrapRuns([{ t: q, role: "choice.q" }], width);
127
- const hint = p.hint ? wrapText(String(p.hint), width, "text.muted") : [];
127
+ const c = makeChoice(el.props);
128
+ const ans = ctx.state.answers[c.key];
129
+ const locked = ctx.state.sent;
130
+ const head = wrapRuns([{ t: c.question, role: "choice.q" }], width);
131
+ const tag = [textCells(c.multiple ? "Choose all that apply" : "Choose one", "choice.tag")];
132
+ const hint = el.props.hint ? wrapText(String(el.props.hint), width, "text.muted") : [];
133
+ const rows: Cell[][] = [...head, ...tag, ...hint];
128
134
  const targets: Target[] = [];
129
- const lines: Array<ReturnType<typeof textCells>> = [];
130
- const optCells = opts.map((o, i) => {
131
- const on = picked === o;
132
- return { i, cells: [cell(on ? "●" : "○", on ? "choice.picked" : "choice"), cell(" "), ...textCells(truncateText(o, Math.max(4, width - 3)), on ? "choice.picked" : "choice")] };
133
- });
134
- const total = optCells.reduce((a, o, k) => a + o.cells.length + (k ? 3 : 0), 0);
135
- const baseY = head.length + hint.length;
136
- if (total <= width) {
137
- let line: typeof lines[number] = [];
138
- optCells.forEach((o, k) => {
139
- if (k) line.push(cell(" "), cell(" "), cell(" "));
140
- targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: line.length, y: baseY, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
141
- line.push(...o.cells);
142
- });
143
- lines.push(line);
135
+ const row = (on: boolean, label: string, index: number | "other") => {
136
+ const role = on ? "choice.picked" : locked ? "choice.locked" : "choice";
137
+ const mark = c.multiple ? (on ? "[✓]" : "[ ]") : on ? "●" : "○";
138
+ const indent = textWidth(mark) + 1;
139
+ // the whole option is drawn: what is sent is what the user could read. A long one wraps under its first word.
140
+ const lines = wrapText(label, Math.max(4, width - indent), role);
141
+ const y = rows.length;
142
+ lines.forEach((l, k) => rows.push(k === 0 ? [...textCells(mark, role), cell(" ", role), ...l] : [...Array.from({ length: indent }, () => cell(" ")), ...l]));
143
+ if (!locked) targets.push({ id: `choice:${path}:${index}`, kind: "choice", rect: { x: 0, y, w: width, h: lines.length }, label, choice: { of: c, index } });
144
+ };
145
+ c.options.forEach((o, i) => row(!!ans?.picks.includes(i), o, i));
146
+ if (c.other) {
147
+ const typed = ans?.other ? cleanOther(ans.text) : "";
148
+ row(typed !== "", "Other...", "other");
149
+ if (typed) {
150
+ const room = Math.max(4, width - 4);
151
+ const lines = wrapText(typed.replace(/\n/g, " ¶ "), room, "choice.other");
152
+ const shown = lines.slice(0, 2);
153
+ if (lines.length > 2) shown[1] = textCells(truncateText(shown[1].map((x) => x.ch).join("") + "…", room), "choice.other");
154
+ for (const l of shown) rows.push([cell(" "), cell(" "), cell(" "), cell(" "), ...l]);
155
+ }
156
+ }
157
+ return fromRows(width, rows, targets);
158
+ }
159
+
160
+ /**
161
+ * The bar under a widget that has Choices: how many are answered, and the buttons. Send carries the finished message, and has a
162
+ * target only when something is answered; Clear only when something is staged. After Send the bar only says so.
163
+ */
164
+ export function answerBar(choices: Choice[], state: WState, title: string, width: number): Block {
165
+ if (state.sent) return fromRows(width, [state.sending ? textCells("Sending to the agent…", "text.muted") : textCells("Sent to the agent.", "choice.sent")]);
166
+ const { answered, total } = progress(choices, state.answers);
167
+ const staged = Object.values(state.answers).some((a) => a.picks.length > 0 || a.other);
168
+ const count = textCells(`${answered} of ${total} answered`, "text.muted");
169
+ const sendW = textWidth(SEND);
170
+ const clearW = textWidth(CLEAR);
171
+ const send = textCells(SEND, answered > 0 ? "btn" : "choice.locked");
172
+ const clear = textCells(CLEAR, staged ? "btn" : "choice.locked");
173
+ const gap = [cell(" "), cell(" ")];
174
+ // as much as fits on one row: the count and both buttons, else the count above the buttons, else one button per row
175
+ const rows: Cell[][] = [];
176
+ let sendAt: { x: number; y: number };
177
+ let clearAt: { x: number; y: number };
178
+ if (count.length + 2 + sendW + 2 + clearW <= width) {
179
+ rows.push([...count, ...gap, ...send, ...gap, ...clear]);
180
+ sendAt = { x: count.length + 2, y: 0 };
181
+ clearAt = { x: count.length + 2 + sendW + 2, y: 0 };
182
+ } else if (sendW + 2 + clearW <= width) {
183
+ rows.push(count, [...send, ...gap, ...clear]);
184
+ sendAt = { x: 0, y: 1 };
185
+ clearAt = { x: sendW + 2, y: 1 };
144
186
  } else {
145
- optCells.forEach((o, k) => {
146
- targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: 0, y: baseY + k, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
147
- lines.push(o.cells);
148
- });
187
+ rows.push(count, send, clear);
188
+ sendAt = { x: 0, y: 1 };
189
+ clearAt = { x: 0, y: 2 };
149
190
  }
150
- return fromRows(width, [...head, ...hint, ...lines], targets);
191
+ const targets: Target[] = [];
192
+ if (answered > 0) targets.push({ id: "send", kind: "send", rect: { ...sendAt, w: sendW, h: 1 }, label: "Send answers", bar: { choices, title } });
193
+ if (staged) targets.push({ id: "clear", kind: "clear", rect: { ...clearAt, w: clearW, h: 1 }, label: "Clear" });
194
+ return fromRows(width, rows, targets);
151
195
  }
152
196
 
153
197
  export function tabs(el: El, width: number, ctx: Ctx, path: string): Block {
@@ -1,9 +1,10 @@
1
1
  // Interaction logic, independent of any terminal: hit testing, activation, keyboard navigation, pan reveal.
2
+ import { buildMessage, progress, toggleOption } from "./choice.ts";
2
3
  import type { Block, Target, WState } from "./types.ts";
3
4
 
4
5
  export interface Env {
5
- /** send text to the assistant as the user's next turn */
6
- send(text: string): void;
6
+ /** send text to the assistant as the user's next turn. `confirm`: the sender will lock something until the host says it took the text */
7
+ send(text: string, opts?: { confirm?: boolean }): void;
7
8
  /** put text on the clipboard */
8
9
  copy(text: string): void;
9
10
  /** show a short notice under the widget */
@@ -143,7 +144,10 @@ export function nextTarget(block: Block, currentId: string | null, key: NavKey,
143
144
  return order[(i + (key === "next" ? 1 : -1) + order.length) % order.length];
144
145
  }
145
146
  if (!cur) return order[0];
146
- return spatial(order, cur, key);
147
+ const to = spatial(order, cur, key);
148
+ // Down from the options reaches Send answers. The nearest centre would be Clear, and one Enter on it drops every answer.
149
+ if ((key === "down" || key === "up") && to.kind === "clear" && cur.kind !== "send" && cur.kind !== "clear") return order.find((t) => t.kind === "send") ?? to;
150
+ return to;
147
151
  }
148
152
 
149
153
  const lastColon = (id: string) => id.lastIndexOf(":");
@@ -164,12 +168,24 @@ export function activate(t: Target, state: WState, env: Env): boolean {
164
168
  state.faq[t.id] = !state.faq[t.id];
165
169
  return true;
166
170
  case "choice": {
167
- const k = lastColon(t.id);
168
- const path = t.id.slice(7, k);
169
- state.picked[path] = t.label;
170
- if (t.send) env.send(t.send);
171
+ // a click only stages the answer. The Other row opens pi's editor, which the widget handles (it is asynchronous).
172
+ const x = t.choice;
173
+ if (!x || state.sent || x.index === "other") return false;
174
+ state.answers = toggleOption(state.answers, x.of, x.index);
175
+ return true;
176
+ }
177
+ case "send": {
178
+ // built now, from the answers as they are: an answer staged since the last frame is in the message, a cleared one is not
179
+ const x = t.bar;
180
+ if (!x || state.sent || !progress(x.choices, state.answers).answered) return false;
181
+ env.send(buildMessage(x.choices, state.answers, x.title), { confirm: true });
182
+ state.sent = true;
171
183
  return true;
172
184
  }
185
+ case "clear":
186
+ if (state.sent) return false;
187
+ state.answers = {};
188
+ return true;
173
189
  case "button":
174
190
  if (t.send) env.send(t.send);
175
191
  if (t.openUrl) {
@@ -214,7 +230,13 @@ export function describe(t: Target): string {
214
230
  bits.push(`[${t.label}]`, t.send ? `sends "${t.send.length > 70 ? t.send.slice(0, 69) + "…" : t.send}"` : t.openUrl ? `link ${t.openUrl}` : "");
215
231
  break;
216
232
  case "choice":
217
- bits.push(t.label, "choose to answer");
233
+ bits.push(t.label, t.choice?.index === "other" ? "Enter to type your own answer" : t.choice?.of.multiple ? "Enter to toggle" : "Enter to select");
234
+ break;
235
+ case "send":
236
+ bits.push("send all the answers to the agent as one message");
237
+ break;
238
+ case "clear":
239
+ bits.push("clear every answer");
218
240
  break;
219
241
  case "tab":
220
242
  bits.push(`tab ${t.label}`);
@@ -2,12 +2,13 @@
2
2
  import type { El } from "../lang/library.ts";
3
3
  import { barChart, lineArea, pieChart, radarChart } from "./charts.ts";
4
4
  import { codeBlock } from "./code.ts";
5
- import { addWholeItem, button, callout, card, choice, faq, image, metric, stack, tabs, text, withTips, type Anchor } from "./core.ts";
5
+ import { addWholeItem, answerBar, button, callout, card, choice, faq, image, metric, stack, tabs, text, withTips, type Anchor } from "./core.ts";
6
6
  import { dagBlock, erdBlock, infraBlock, panBlock } from "./diagrams.ts";
7
7
  import { geoMap } from "./geo.ts";
8
8
  import { markdownBlock } from "./markdown.ts";
9
9
  import { sequence } from "./sequence.ts";
10
- import { decorateNotes } from "./notes.ts";
10
+ import { collectChoices } from "./choice.ts";
11
+ import { decorateNotes, widgetTitle } from "./notes.ts";
11
12
  import { tableBlock } from "./table.ts";
12
13
  import type { Block, Ctx, Services, WState } from "./types.ts";
13
14
  import { blank, textBlock, vstack } from "./util.ts";
@@ -24,8 +25,12 @@ function capRows(block: Block, warnings: string[]): Block {
24
25
  export function renderTree(root: El, width: number, state: WState, services: Services = {}): { block: Block; warnings: string[] } {
25
26
  const warnings: string[] = [];
26
27
  const ctx: Ctx = { state, warnings, services, tabs: [], render: (el, w, path) => renderEl(el, w, path, ctx) };
27
- const base = capRows(renderEl(root, Math.max(20, width), "0", ctx), warnings);
28
- const block = decorateNotes(base, state, Math.max(20, width));
28
+ const w = Math.max(20, width);
29
+ let base = capRows(renderEl(root, w, "0", ctx), warnings);
30
+ // every Choice of the widget, also on tabs nobody opened, shares one bar under the widget (after the row cap, so it is never cut)
31
+ const choices = collectChoices(root);
32
+ if (choices.length) base = vstack([base, answerBar(choices, state, widgetTitle(root), w)], 1, Math.max(base.w, w));
33
+ const block = decorateNotes(base, state, w);
29
34
  return { block, warnings };
30
35
  }
31
36
 
@@ -1,5 +1,6 @@
1
1
  import type { Cell, Rect } from "../diagram/canvas.ts";
2
2
  import type { El } from "../lang/library.ts";
3
+ import type { Answers, Choice } from "./choice.ts";
3
4
 
4
5
  export interface Tip {
5
6
  hint?: string;
@@ -14,7 +15,12 @@ export interface Tip {
14
15
  export type TargetKind =
15
16
  | "tab"
16
17
  | "button"
18
+ /** one option of a Choice, or its Other row */
17
19
  | "choice"
20
+ /** the Send answers button under a widget that has Choices */
21
+ | "send"
22
+ /** the Clear button next to it */
23
+ | "clear"
18
24
  | "faq"
19
25
  | "node"
20
26
  | "copy"
@@ -68,6 +74,10 @@ export interface Target {
68
74
  send?: string;
69
75
  openUrl?: string;
70
76
  copy?: string;
77
+ /** for kind "send": what it sends. The message is built from the answers as they are when it is pressed, never from a picture that may be a frame old. */
78
+ bar?: { choices: Choice[]; title: string };
79
+ /** for kind "choice": the question and which row of it this is (an option index, or its Other row) */
80
+ choice?: { of: Choice; index: number | "other" };
71
81
  /** extra cells (block coordinates) that belong to the target besides its rect, e.g. the wedge of a pie slice */
72
82
  cells?: Array<[number, number]>;
73
83
  /** for kind "item": what a note on it is about */
@@ -89,7 +99,12 @@ export interface Block {
89
99
  export interface WState {
90
100
  tab: Record<string, number>;
91
101
  faq: Record<string, boolean>;
92
- picked: Record<string, string>;
102
+ /** what the user has staged for each Choice, by question key. Nothing is sent until Send answers. */
103
+ answers: Answers;
104
+ /** the answers have been sent: every Choice is locked */
105
+ sent: boolean;
106
+ /** sent, but pi has not said yet that it took the message (the lock is held; the bar says Sending) */
107
+ sending: boolean;
93
108
  pan: Record<string, number>;
94
109
  tip: string | null;
95
110
  focus: string | null;
@@ -102,7 +117,7 @@ export interface WState {
102
117
  noteSeq: number;
103
118
  }
104
119
 
105
- export const newState = (): WState => ({ tab: {}, faq: {}, picked: {}, pan: {}, tip: null, focus: null, hover: null, copied: null, notes: [], noteMode: false, noteSeq: 0 });
120
+ export const newState = (): WState => ({ tab: {}, faq: {}, answers: {}, sent: false, sending: false, pan: {}, tip: null, focus: null, hover: null, copied: null, notes: [], noteMode: false, noteSeq: 0 });
106
121
 
107
122
  /** Supplied by the host when it can draw bitmaps. Without it, an Image is drawn as a labelled frame. */
108
123
  export interface ImageService {
@@ -8,14 +8,21 @@ export function graphemes(s: string): string[] {
8
8
  return seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
9
9
  }
10
10
 
11
- /** Whether `cp` is in the generated table of code points pi draws two cells wide (binary search over [first, last] pairs). */
11
+ // The code points pi draws two cells wide: the generated table, which src/pi/widths.ts corrects against the running pi when the
12
+ // extension loads (the blocks where Unicode adds emoji). Flat [first, last] pairs, ascending.
13
+ let table: readonly number[] = WIDE;
14
+ export function setWide(t: readonly number[]): void {
15
+ table = t;
16
+ }
17
+
18
+ /** Whether `cp` is in the table of code points pi draws two cells wide (binary search over [first, last] pairs). */
12
19
  function isWide(cp: number): boolean {
13
20
  let lo = 0;
14
- let hi = WIDE.length / 2 - 1;
21
+ let hi = table.length / 2 - 1;
15
22
  while (lo <= hi) {
16
23
  const mid = (lo + hi) >> 1;
17
- if (cp < WIDE[mid * 2]) hi = mid - 1;
18
- else if (cp > WIDE[mid * 2 + 1]) lo = mid + 1;
24
+ if (cp < table[mid * 2]) hi = mid - 1;
25
+ else if (cp > table[mid * 2 + 1]) lo = mid + 1;
19
26
  else return true;
20
27
  }
21
28
  return false;
@@ -112,7 +112,7 @@ export function createPalette(t: PaletteTokens): Palette {
112
112
  f = compose(t.accent, t.bold);
113
113
  break;
114
114
  case "choice":
115
- f = a === "q" ? t.bold : a === "picked" ? compose(t.accent, t.bold) : undefined;
115
+ f = a === "q" ? t.bold : a === "picked" ? compose(t.accent, t.bold) : a === "tag" || a === "other" ? t.dim : a === "locked" ? t.dim : a === "sent" ? t.good : undefined;
116
116
  break;
117
117
  case "tab":
118
118
  f = a === "on" ? t.accent : a === "label" ? compose(t.accent, t.bold) : a === "off" ? t.dim : a === "rule" ? t.muted : undefined;
@@ -1,4 +1,4 @@
1
- // Generated by scripts/width-table.ts from pi's own width function: do not edit. Run `npm run width` after pi updates.
1
+ // Generated by scripts/width-table.ts from pi's own width function: do not edit. Run `npm run width` to refresh it (the extension corrects its emoji blocks from the running pi when it loads).
2
2
  // The code points pi draws two cells wide, as inclusive [first, last] pairs in ascending order.
3
3
  export const WIDE: readonly number[] = [
4
4
  0x1100, 0x115e, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec,
@@ -27,10 +27,9 @@ export const WIDE: readonly number[] = [
27
27
  0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440,
28
28
  0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567,
29
29
  0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f,
30
- 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d8,
30
+ 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d9,
31
31
  0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb,
32
32
  0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff,
33
- 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa8a, 0x1fa8e, 0x1fac6, 0x1fac8, 0x1fac8,
34
- 0x1facd, 0x1fadc, 0x1fadf, 0x1faea, 0x1faef, 0x1faf8, 0x20000, 0x2fffd,
35
- 0x30000, 0x3fffd,
33
+ 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fac6, 0x1fac8, 0x1fac8, 0x1facc, 0x1fadd,
34
+ 0x1fadf, 0x1faeb, 0x1faef, 0x1fafa, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
36
35
  ];
@@ -0,0 +1,56 @@
1
+ // Keep the committed table of two-cell characters (wide.ts), but let the running pi decide the blocks where Unicode keeps adding
2
+ // emoji. Pure: the probe is any function from a code point to its width in columns, so nothing here needs pi.
3
+
4
+ /**
5
+ * Where new two-cell characters appear with each Unicode release: the arrows, symbols and dingbats that got an emoji form
6
+ * (U+2190-2BFF) and the emoji blocks (U+1F000-1FFFF). Everything else the table covers (CJK, Hangul, fullwidth forms) is stable.
7
+ * About 6,800 code points: cheap to ask pi about when the extension loads.
8
+ */
9
+ export const PROBE_SPANS: ReadonlyArray<readonly [number, number]> = [
10
+ [0x2190, 0x2bff],
11
+ [0x1f000, 0x1ffff],
12
+ ];
13
+
14
+ /**
15
+ * A table (flat [first, last] pairs, ascending, merged) that is `base` outside `spans` and what `probe` says inside them.
16
+ * `probe` is only ever asked about code points inside the spans.
17
+ */
18
+ export function healWide(base: readonly number[], probe: (cp: number) => number, spans: ReadonlyArray<readonly [number, number]> = PROBE_SPANS): number[] {
19
+ // the base ranges with the spans cut out of them
20
+ const keep: Array<[number, number]> = [];
21
+ for (let i = 0; i < base.length; i += 2) {
22
+ let pieces: Array<[number, number]> = [[base[i], base[i + 1]]];
23
+ for (const [s, e] of spans) {
24
+ const next: Array<[number, number]> = [];
25
+ for (const [a, b] of pieces) {
26
+ if (b < s || a > e) next.push([a, b]);
27
+ else {
28
+ if (a < s) next.push([a, s - 1]);
29
+ if (b > e) next.push([e + 1, b]);
30
+ }
31
+ }
32
+ pieces = next;
33
+ }
34
+ keep.push(...pieces);
35
+ }
36
+ // what the probe says about the spans
37
+ const asked: Array<[number, number]> = [];
38
+ for (const [s, e] of spans) {
39
+ let start = -1;
40
+ for (let cp = s; cp <= e; cp++) {
41
+ const wide = probe(cp) === 2;
42
+ if (wide && start < 0) start = cp;
43
+ else if (!wide && start >= 0) {
44
+ asked.push([start, cp - 1]);
45
+ start = -1;
46
+ }
47
+ }
48
+ if (start >= 0) asked.push([start, e]);
49
+ }
50
+ const out: number[] = [];
51
+ for (const [a, b] of [...keep, ...asked].sort((x, y) => x[0] - y[0])) {
52
+ if (out.length && a <= out[out.length - 1] + 1) out[out.length - 1] = Math.max(out[out.length - 1], b);
53
+ else out.push(a, b);
54
+ }
55
+ return out;
56
+ }
package/src/lang/docs.ts CHANGED
@@ -183,19 +183,51 @@ b = Button("Restart vllm", @ToAssistant("Restart the vllm service on dgx-spark-1
183
183
  },
184
184
  Choice: {
185
185
  group: "Interactive",
186
- summary: "A question with clickable options for you to answer.",
187
- shots: [{ file: "choice", caption: "Options on one line when they fit, stacked when they do not" }],
188
- use: ["Asking a clarifying question instead of guessing."],
186
+ summary: "A question with a short list of options for you to answer. A click only stages the answer: one **Send answers** button under the widget sends every answer of the widget together, as one message. An option can allow several answers, and every question has an **Other...** row where you type your own.",
187
+ shots: [
188
+ { file: "choice", caption: "One question with a hint: the tag says Choose one, the options have round indicators, Other... is the last row, and the bar counts" },
189
+ { file: "choice-card", caption: "Three questions in one Card, two answered. The second allows several answers (square indicators); the third has no Other row" },
190
+ { file: "choice-other", caption: "Other... opens pi's editor. The text you save shows under the row, and selects it" },
191
+ { file: "choice-sent", caption: "After Send answers every question is locked, the chosen rows stay marked, and the bar says so" },
192
+ ],
193
+ use: [
194
+ "A clarifying question with a few known answers: which node, which environment, yes or no. Ask all of them in ONE widget, in a Card or in Tabs: you answer them together and send once.",
195
+ "A confirmation before something risky, under a Callout that explains the cost.",
196
+ "A question where several answers can be right (`multiple`), or where you may want to say something that is not listed (the Other row).",
197
+ ],
198
+ avoid: "a command that should run as written. Use a Button with `@ToAssistant`, which sends exactly the text you give it on one click.",
189
199
  args: {
190
- "Choice.question": "The question.",
191
- "Choice.options": "Two to six short answers.",
192
- "Choice.hint": "A line of explanation under the question.",
200
+ "Choice.question": "Shown in bold above the options. It is also the first half of the answer sent back. It must have text.",
201
+ "Choice.options": "One row per entry, each with an indicator: a round radio for a single question, a square checkbox for `multiple`. Two to six, each at most 120 characters. A long option wraps under its indicator, so you can read everything that is sent. An empty option is an error.",
202
+ "Choice.hint": "A muted line between the tag and the options. The tag already says `Choose one` or `Choose all that apply`, so do not repeat that here.",
203
+ "Choice.multiple": "`true`: options toggle independently and several can be answered. Default `false`: one answer, and picking another option moves the pick.",
204
+ "Choice.other": "Default `true`: a last row, `Other...`, where you type your own answer. `false` hides it. Write `null` for every skipped argument before it: `Choice(\"Clear the cache?\", [\"Yes\", \"No\"], null, null, false)`.",
193
205
  },
194
- example: `root = Stack([q])
195
- q = Choice("Which model should serve chat?", ["qwen3-32b", "llama-3.3-70b"], "Pick one")`,
196
- interaction: ["Click an option, or move with the arrow keys and press Enter. The pick is filled (●) and `<question> — <option>` is sent as your next message."],
197
- tips: ["You can change the pick; each click sends a new message."],
198
- related: ["Button"],
206
+ example: `root = Stack([plan])
207
+ plan = Card([q1, q2, q3], "Before I restart")
208
+ q1 = Choice("Which node should I restart first?", ["web-1", "web-2"])
209
+ q2 = Choice("Which checks should I run?", ["Disk", "Memory", "Network"], null, true)
210
+ q3 = Choice("Clear the cache on db?", ["Yes", "No"], null, null, false)`,
211
+ exampleNote: "Three questions in one widget: one bar counts and sends them. `null` skips `hint`, and the third question turns the Other row off.\n\nAnswered as in the last screenshot above (web-2, Disk, and the words `also look at the logs`, with the third question skipped), the agent receives this one message:\n\n```text\nAnswers to the questions in widget `Before I restart` (3):\n\n1. Which node should I restart first?\n web-2\n2. Which checks should I run?\n Disk; other: also look at the logs\n3. Clear the cache on db?\n (no answer)\n```\n\nThe widget is named, in backticks, by the first title or heading in it. The words you type in Other are sent as typed. The question and option words come from the agent, so before they go out control and invisible characters are removed, whitespace is collapsed, and anything that could attach a file or run a command is defused: `@file:x` becomes `(at)file:x`, and a single question that starts like a command, such as `/new session?`, uses the numbered list.",
212
+ interaction: [
213
+ "Click an option, or move with the arrow keys and press Enter. Nothing is sent. In a single question a second click on the chosen option clears it and another option moves the pick; in a `multiple` question each option toggles on its own.",
214
+ "Click Other... (or Enter on it). pi's own editor opens with the question as its title: Enter saves, Shift+Enter adds a line, Esc leaves the answer as it was. Saved text selects the row (a single question drops the option, a `multiple` one keeps its options); saving empty text unselects it. The text shows under the row, up to two lines (a line break shows as a ¶). A tab becomes a space, and a run of blank lines becomes one.",
215
+ "The bar under the widget reads `N of M answered`. It counts every question, also those on a tab you have not opened. **[ Send answers ]** is live once one question is answered; **[ Clear ]** drops every staged answer. Down from the last option reaches Send answers, never Clear.",
216
+ "Send answers sends one message and locks every question. The chosen rows stay marked and the bar says `Sent to the agent.` A locked widget cannot be answered again, and it gives the keyboard back to pi, so Enter sends your own message. The agent draws a new widget to ask again.",
217
+ "In notes mode the options, Other and the bar do nothing, like a Button.",
218
+ ],
219
+ tips: [
220
+ "This changed from earlier versions: a click used to send `<question> — <option>` at once. Now nothing is sent until Send answers, and a Choice written for the old behaviour gets an Other row and the bar. Write `other: false` to keep a closed list.",
221
+ "The message for ONE single question answered with an option is still the line `<question> — <option>`, for example `Which node should I restart first? — web-2`. Anything else is a numbered list, with `(no answer)` for a question you skipped and `other: ...` for typed text (shown under the example).",
222
+ "Ask everything in one widget. A second widget has its own bar and sends its own message.",
223
+ "Write the question and options so `<question> — <option>` still reads as a sentence: \"Clear the cache on db? — Yes, clear it\" works; \"Are you sure? — OK\" does not say what was agreed.",
224
+ "`(no answer)` means skipped. Do not read it as a \"no\".",
225
+ "Options are plain text: no icons, links or per-option descriptions. Put detail in the hint or the question.",
226
+ "At most 12 questions per widget, 6 options each. A widget that was written for the old behaviour keeps working unless it breaks one of the new limits (a question over 200 characters, an option over 120, more than 12 questions), which is an error the agent is told about.",
227
+ "The answers live in memory with the widget. They are gone when the session ends or pi restarts, and a widget redrawn from a resumed session opens unanswered, with Send answers live again even if it was sent before.",
228
+ "The bar says `Sending to the agent…` until pi reports that it took the message, then `Sent to the agent.` If pi cannot take it (no model selected, no credentials), pi shows its own error, and after 3 seconds the widget is given back with your answers and Send answers works again.",
229
+ ],
230
+ related: ["Button", "Callout", "Card", "Tabs"],
199
231
  },
200
232
  Faq: {
201
233
  group: "Interactive",
@@ -81,6 +81,15 @@ export const LIMITS = {
81
81
  steps: 100,
82
82
  /** pictures in one widget: Image components and `![alt](path)` lines together. Each can be megabytes */
83
83
  images: 12,
84
+ /** Choice questions in one widget: they share one Send answers bar and one message */
85
+ choices: 12,
86
+ /** options of one Choice (the lower bound is 2) */
87
+ choiceOptions: 6,
88
+ /** characters in a Choice question and in one of its options (the message carries them as written) */
89
+ question: 200,
90
+ option: 120,
91
+ /** characters of the user's own Other answer */
92
+ other: 500,
84
93
  } as const;
85
94
 
86
95
  const S: T = "string";
@@ -113,7 +122,7 @@ export const SPECS: CompSpec[] = [
113
122
  { name: "GeoMap", params: [p("points", { arr: { comp: ["GeoPoint"] }, min: 1, max: LIMITS.geoPoints }), p("links", { arr: { arr: S }, max: LIMITS.links }, true), p("title", S, true), p("tooltips", tips, true)], desc: "Offline world map (coarse coastlines) zoomed to fit the pins. links are [labelA, labelB] pairs drawn dashed." },
114
123
  { name: "FaqItem", helper: true, params: [p("question", S), p("answer", S, false, LIMITS.answer)], desc: "One Q&A entry (answer is markdown). Only valid inside Faq." },
115
124
  { name: "Faq", params: [p("items", { arr: { comp: ["FaqItem"] }, min: 1, max: LIMITS.faq }), p("firstOpen", "boolean", true)], desc: "Collapsible Q&A list." },
116
- { name: "Choice", params: [p("question", S), p("options", { arr: S, min: 2, max: 6 }), p("hint", S, true)], desc: 'A question with clickable options for the USER. Clicking sends "<question> — <option>" to the assistant.' },
125
+ { name: "Choice", params: [p("question", S, false, LIMITS.question), p("options", { arr: S, min: 2, max: LIMITS.choiceOptions }), p("hint", S, true), p("multiple", "boolean", true), p("other", "boolean", true)], desc: "A question for the USER. Answers are staged and sent together as ONE message when the user presses Send answers. multiple (4th argument) true allows several options. other (5th argument, default true) adds an Other... row where the user types their own answer; false hides it." },
117
126
  { name: "Series", helper: true, params: [p("category", S), p("values", { arr: "number", max: LIMITS.points })], desc: "One data series: name and numbers parallel to the chart labels." },
118
127
  { name: "LineChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Line chart over categories, several series. variant natural (smooth, default), linear or step." },
119
128
  { name: "AreaChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Filled STACKED area chart: series stack into a growing total." },
@@ -407,6 +416,7 @@ function compileProgram(source: string): Compiled {
407
416
  let depth = 0;
408
417
  let built = 0;
409
418
  let pictures = 0;
419
+ let choices = 0;
410
420
  const build = (call: Extract<Expr, { k: "call" }>): El | undefined => {
411
421
  const spec = LIB.get(call.name);
412
422
  if (!spec) {
@@ -436,6 +446,14 @@ function compileProgram(source: string): Compiled {
436
446
  if (!param.opt) errors.push(`${where(call.line)}${spec.name} is missing the required argument "${param.name}" (position ${i + 1})`);
437
447
  return;
438
448
  }
449
+ if (spec.name === "Choice" && param.name === "hint" && d && d.k === "bool") {
450
+ errors.push(`${where(d.line)}Choice argument "hint" expects a string but got ${d.v}. hint is the third argument: write null for a skipped optional argument, e.g. Choice("Which checks?", ["Disk", "Memory"], null, true)`);
451
+ return;
452
+ }
453
+ if (spec.name === "Choice" && (param.name === "multiple" || param.name === "other") && d && d.k !== "bool") {
454
+ errors.push(`${where(d.line)}Choice argument "${param.name}" expects true or false but got ${label(d)}. It is the ${i + 1}th argument: write null for each skipped optional argument, e.g. Choice("Which checks?", ["Disk", "Memory"], null, true)`.replace("1th", "1st").replace("2th", "2nd").replace("3th", "3rd"));
455
+ return;
456
+ }
439
457
  props[param.name] = resolve(arg, param.t, `${spec.name} argument "${param.name}"`, param.max);
440
458
  });
441
459
  const el: El = { type: spec.name, props, line: call.line, ...(nameOf.has(call) ? { name: nameOf.get(call) } : {}) };
@@ -515,6 +533,17 @@ function compileProgram(source: string): Compiled {
515
533
  for (const r of (pr.relations ?? []) as El[]) for (const k of ["from", "to"]) if (r.props[k] && !names.includes(r.props[k])) err(`relation ${k} "${r.props[k]}" is not an Entity name. Names: ${names.join(", ")}`);
516
534
  break;
517
535
  }
536
+ case "Choice": {
537
+ if (++choices === LIMITS.choices + 1) err(`this is Choice ${LIMITS.choices + 1}: a widget has at most ${LIMITS.choices} Choices. Ask fewer questions, or split them into several widgets`);
538
+ // empty once control, invisible and blank characters are gone: it would draw as nothing and be sent as nothing
539
+ const blank = (v: unknown) => String(v ?? "").replace(/[\p{Cc}\p{Cf}\s]/gu, "") === "";
540
+ if (blank(pr.question)) err("the question is empty. Write the question the user answers");
541
+ ((pr.options ?? []) as string[]).forEach((o, i) => {
542
+ if (blank(o)) err(`option ${i + 1} is empty. Give every option some text`);
543
+ else if (o.length > LIMITS.option) err(`option ${i + 1} is ${o.length} characters; the limit is ${LIMITS.option}. Shorten it`);
544
+ });
545
+ break;
546
+ }
518
547
  case "CodeBlock":
519
548
  if (typeof pr.code === "string" && pr.code.split("\n").length > LIMITS.codeLines) err(`the code has more than ${LIMITS.codeLines} lines; show the part that matters`);
520
549
  break;
@@ -590,11 +619,11 @@ An element that has a tooltip shows a small ◆ on it. Hovering shows the hint;
590
619
  hint is the short text shown on hover (140 characters). detail (800), code (40 lines) and link (http/https) open in a panel when the element is clicked.
591
620
 
592
621
  ## Limits
593
- A widget over a limit is not drawn, and the error says which. Source ${LIMITS.source / 1000} KB, ${LIMITS.elements} components, ${LIMITS.depth} levels of nesting, ${LIMITS.children} children per Stack or Card, ${LIMITS.tabs} tabs. Text ${LIMITS.text} characters, Markdown ${LIMITS.markdown}, any other string ${LIMITS.string}, a table cell ${LIMITS.cell}. Table ${LIMITS.tableRows} rows by ${LIMITS.tableCols} columns. BarChart ${LIMITS.bars} bars. LineChart and AreaChart ${LIMITS.points} points and ${LIMITS.series} series. RadarChart ${LIMITS.axes} axes. Dag ${LIMITS.dagNodes} nodes and ${LIMITS.dagEdges} edges. Erd ${LIMITS.entities} entities, ${LIMITS.fields} fields each, ${LIMITS.relations} relations. InfraMap ${LIMITS.infraNodes} nodes and ${LIMITS.infraLinks} links. GeoMap ${LIMITS.geoPoints} points and ${LIMITS.links} links. Faq ${LIMITS.faq} items. Pictures ${LIMITS.images} (Image components and markdown picture lines together). CodeBlock ${LIMITS.codeLines} lines. A SequenceDiagram ${LIMITS.steps} steps. Prefer far less: the reader sees one terminal screen.
622
+ A widget over a limit is not drawn, and the error says which. Source ${LIMITS.source / 1000} KB, ${LIMITS.elements} components, ${LIMITS.depth} levels of nesting, ${LIMITS.children} children per Stack or Card, ${LIMITS.tabs} tabs. Text ${LIMITS.text} characters, Markdown ${LIMITS.markdown}, any other string ${LIMITS.string}, a table cell ${LIMITS.cell}. Table ${LIMITS.tableRows} rows by ${LIMITS.tableCols} columns. BarChart ${LIMITS.bars} bars. LineChart and AreaChart ${LIMITS.points} points and ${LIMITS.series} series. RadarChart ${LIMITS.axes} axes. Dag ${LIMITS.dagNodes} nodes and ${LIMITS.dagEdges} edges. Erd ${LIMITS.entities} entities, ${LIMITS.fields} fields each, ${LIMITS.relations} relations. InfraMap ${LIMITS.infraNodes} nodes and ${LIMITS.infraLinks} links. GeoMap ${LIMITS.geoPoints} points and ${LIMITS.links} links. Faq ${LIMITS.faq} items. Pictures ${LIMITS.images} (Image components and markdown picture lines together). CodeBlock ${LIMITS.codeLines} lines. Choices ${LIMITS.choices} per widget, a question ${LIMITS.question} characters, an option ${LIMITS.option}, ${LIMITS.choiceOptions} options, the user's Other answer ${LIMITS.other}. A SequenceDiagram ${LIMITS.steps} steps. Prefer far less: the reader sees one terminal screen.
594
623
 
595
624
  ## Actions
596
625
  Button("Restart", @ToAssistant("Restart the vllm service")) sends that text to the assistant as the user's next turn (500 characters at most). A Button without an action sends its label. Several steps: Action([@ToAssistant("...")]). Do not use @OpenUrl: put links in a Text.
597
- Choice(question, options) asks the user; a click sends "<question> — <option>".
626
+ Choice(question, options, hint, multiple, other) asks the user. A click only stages an answer; the user's Send answers button sends every Choice of the widget together as ONE message (one single question gives "<question> — <option>"; otherwise a numbered list, "(no answer)" for a skipped question, "other: ..." for typed text). Ask all your questions in one widget, in a Card or Tabs. multiple: true allows several options; other (default true) adds an Other... row for the user's own answer. Write null for a skipped optional argument: Choice("Which checks?", ["Disk", "Memory"], null, true).
598
627
 
599
628
  ## Choosing components
600
629
  Metric for a number, Table for comparisons, BarChart for one series of categories, LineChart for trends with several series, AreaChart for stacked volume, PieChart for parts of a whole (8 slices at most), RadarChart for comparing entities across metrics, Dag for task graphs and pipelines, Erd for database schemas, InfraMap for how systems connect, SequenceDiagram for call flows, GeoMap for places, Tabs/Card to organise. Series values must have the same length as labels. All data is literal: no queries, no variables. Image shows a real picture in terminals that support it (Kitty, Ghostty, WezTerm, Warp, herdr): src is an absolute file path (png, jpg, gif, webp) or a data: URI.
@@ -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",
@@ -8,7 +8,8 @@ import { markdownImages } from "../lang/md.ts";
8
8
  import { LANG_SAMPLES } from "../lang/samples.ts";
9
9
  import { getCapabilities } from "@earendil-works/pi-tui";
10
10
  import { loadImage } from "./images.ts";
11
- import { active, armNewest, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
11
+ import { syncWidths } from "./widths.ts";
12
+ import { active, armNewest, confirmDelivery, entryOf, handleKey, hooks, OpenUIWidget, resetAll, toggleNotesNewest } from "./widget.ts";
12
13
 
13
14
  const compiled = new Map<string, Compiled>();
14
15
  let compiledChars = 0;
@@ -57,6 +58,7 @@ export function checkImages(root: El | undefined): string[] {
57
58
  }
58
59
 
59
60
  export default function (pi: ExtensionAPI) {
61
+ syncWidths(); // our grid must agree with this pi about which characters take two cells
60
62
  hooks.sendLive = (text) => pi.sendUserMessage(text, { deliverAs: "followUp" });
61
63
  hooks.sendEcho = (text) => pi.sendMessage({ customType: "openui-click", content: `[widget click → assistant] ${text}`, display: true });
62
64
 
@@ -64,14 +66,14 @@ export default function (pi: ExtensionAPI) {
64
66
  name: "render_widget",
65
67
  label: "render_widget",
66
68
  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.",
69
+ "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
70
  promptSnippet: "render_widget: draw an interactive terminal widget (dashboards, charts, diagrams) from OpenUI Lang",
69
71
  promptGuidelines: [
70
72
  "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
73
  "After render_widget succeeds, reply with at most one short sentence. Do not repeat the widget's data in prose.",
72
74
  "If render_widget returns errors, fix exactly the errors listed and call it again with the full corrected source.",
73
75
  "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.",
76
+ "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
77
  ],
76
78
  parameters: Type.Object({
77
79
  source: Type.Optional(Type.String({ description: "OpenUI Lang source: root = Stack([...]) first, one statement per line" })),
@@ -121,7 +123,7 @@ export default function (pi: ExtensionAPI) {
121
123
  };
122
124
 
123
125
  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.",
126
+ description: "Draw one example per OpenUI component (or a key prefix: dashboard, table, tabs, choice, line, pie, radar, image, dag, erd, infra, sequence, geo ...). Clicks echo locally.",
125
127
  handler: async (args: string, ctx: any) => {
126
128
  const which = args.trim() || "all";
127
129
  const picked = LANG_SAMPLES.filter((s) => which === "all" || s.key.startsWith(which));
@@ -157,10 +159,25 @@ export default function (pi: ExtensionAPI) {
157
159
  },
158
160
  });
159
161
 
162
+ // pi reports every user message it takes, whoever sent it: that is how a widget learns its answers were delivered
163
+ pi.on("message_start", async (event: any) => {
164
+ const m = event?.message;
165
+ if (m?.role !== "user") return;
166
+ 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("") : "";
167
+ confirmDelivery(words);
168
+ });
169
+
160
170
  let unsub: (() => void) | undefined;
161
171
  pi.on("session_start", async (_e: any, ctx: any) => {
162
172
  if (!ctx.hasUI) return;
163
173
  unsub?.();
174
+ hooks.queued = () => {
175
+ try {
176
+ return !!ctx.hasPendingMessages();
177
+ } catch {
178
+ return false; // a context from a replaced session
179
+ }
180
+ };
164
181
  unsub = ctx.ui.onTerminalInput((data: string) => (handleKey(data) ? { consume: true } : undefined));
165
182
  // the note editor is pi's own multi-line editor: Enter saves, Shift+Enter adds a line, Esc cancels
166
183
  hooks.prompt = (title: string, initial: string) => ctx.ui.editor(title, initial);
package/src/pi/widget.ts CHANGED
@@ -2,14 +2,15 @@
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, 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";
7
8
  import { renderTree } from "../blocks/render.ts";
8
9
  import { newState, type Block, type Note, type Target, type WState } from "../blocks/types.ts";
9
10
  import { cell, padRow, textCells } from "../blocks/util.ts";
10
11
  import { rowsToLines, textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
11
12
  import { createPalette } from "../diagram/palette.ts";
12
- import type { El } from "../lang/library.ts";
13
+ import { LIMITS, type El } from "../lang/library.ts";
13
14
  import { imageService } from "./images.ts";
14
15
 
15
16
  const GUTTER = 2;
@@ -68,6 +69,8 @@ interface Entry {
68
69
  warnings: string[];
69
70
  notice: string | null;
70
71
  noticeTimer: ReturnType<typeof setTimeout> | null;
72
+ /** answers handed to pi, waiting for its word that it took them (see watchDelivery) */
73
+ pending: { text: string; timer: ReturnType<typeof setTimeout> | null } | null;
71
74
  /** the finished lines and what they were drawn from: pi asks for them on every frame, so an unchanged widget answers from here */
72
75
  lines: string[] | null;
73
76
  lineKey: string;
@@ -84,6 +87,10 @@ export const hooks = {
84
87
  requestRender: () => {},
85
88
  sendLive: (_text: string) => {},
86
89
  sendEcho: (_text: string) => {},
90
+ /** 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. */
91
+ queued: (): boolean => false,
92
+ /** How long to wait for pi to say it took the answers before giving the widget back. */
93
+ deliveryMs: 3000,
87
94
  /** Ask the user for the text of a note. Resolves undefined when they cancel. */
88
95
  prompt: async (_title: string, _initial: string): Promise<string | undefined> => undefined,
89
96
  /** Put text in pi's message box without sending it. */
@@ -108,7 +115,7 @@ imageService.onLate = () => {
108
115
  function getEntry(id: string, root: El, mode: Mode): Entry {
109
116
  let e = entries.get(id);
110
117
  if (!e) {
111
- e = { root, state: newState(), mode, block: null, key: "", version: 0, warnings: [], notice: null, noticeTimer: null, lines: null, lineKey: "" };
118
+ e = { root, state: newState(), mode, block: null, key: "", version: 0, warnings: [], notice: null, noticeTimer: null, pending: null, lines: null, lineKey: "" };
112
119
  entries.set(id, e);
113
120
  newestId.id = id;
114
121
  }
@@ -117,7 +124,11 @@ function getEntry(id: string, root: El, mode: Mode): Entry {
117
124
 
118
125
  function envFor(id: string, e: Entry): Env {
119
126
  return {
120
- send: (t) => (e.mode === "live" ? hooks.sendLive(t) : hooks.sendEcho(t)),
127
+ send: (t, o) => {
128
+ if (e.mode !== "live") return hooks.sendEcho(t); // a local echo is delivered when it is shown
129
+ if (o?.confirm) watchDelivery(id, e, t); // before sending, so a pi that confirms at once finds it waiting
130
+ hooks.sendLive(t);
131
+ },
121
132
  copy: (t) => hooks.copy(t),
122
133
  note: (t) => setNotice(id, t),
123
134
  };
@@ -152,6 +163,62 @@ function afterActivate(id: string, e: Entry, t: Target, layoutChanged: boolean)
152
163
  void id;
153
164
  }
154
165
 
166
+ // ---------- delivery ----------
167
+
168
+ /**
169
+ * pi.sendUserMessage returns nothing and swallows its failures (no model selected, no credentials, a compaction in progress), so
170
+ * "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
171
+ * what pi complained about. The answers are marked as sending until pi reports the user message (message_start, see
172
+ * confirmDelivery). If it does not within hooks.deliveryMs, and pi is not holding a message for later, the widget is given back
173
+ * with its answers and says why.
174
+ */
175
+ function watchDelivery(id: string, e: Entry, text: string): void {
176
+ if (e.pending?.timer) clearTimeout(e.pending.timer);
177
+ e.state.sending = true;
178
+ const pending: NonNullable<Entry["pending"]> = { text, timer: null };
179
+ e.pending = pending;
180
+ const check = () => {
181
+ if (e.pending !== pending) return;
182
+ let held = false;
183
+ try {
184
+ held = hooks.queued();
185
+ } catch {
186
+ /* pi's context went stale: treat it as not held */
187
+ }
188
+ if (held) return void (pending.timer = later(check)); // the agent is busy: pi has it queued and will show it after the turn
189
+ e.pending = null;
190
+ e.state.sent = false;
191
+ e.state.sending = false;
192
+ e.version++;
193
+ setNotice(id, "pi did not take the answers. Fix what it says above, then Send again.");
194
+ hooks.requestRender();
195
+ };
196
+ pending.timer = later(check);
197
+ }
198
+ const later = (f: () => void) => {
199
+ const t = setTimeout(f, Math.max(1, hooks.deliveryMs));
200
+ if (typeof (t as any).unref === "function") (t as any).unref();
201
+ return t;
202
+ };
203
+
204
+ /**
205
+ * pi reported a user message with this text: the oldest widget waiting for it has been delivered. One message confirms one widget,
206
+ * so two widgets that sent the same words are each confirmed by their own message.
207
+ */
208
+ export function confirmDelivery(text: string): boolean {
209
+ const want = text.trim();
210
+ for (const e of entries.values()) {
211
+ if (!e.pending || e.pending.text.trim() !== want) continue;
212
+ if (e.pending.timer) clearTimeout(e.pending.timer);
213
+ e.pending = null;
214
+ e.state.sending = false;
215
+ e.version++;
216
+ hooks.requestRender();
217
+ return true;
218
+ }
219
+ return false;
220
+ }
221
+
155
222
  // ---------- notes ----------
156
223
 
157
224
  const chipLabel = (s: WState) => (s.noteMode ? "[ notes: on ]" : s.notes.length ? `[ notes ${s.notes.length} ]` : "[ notes ]");
@@ -228,6 +295,29 @@ function clearNotes(id: string, e: Entry) {
228
295
  hooks.requestRender();
229
296
  }
230
297
 
298
+ // ---------- choices ----------
299
+
300
+ /**
301
+ * 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
302
+ * armed widget must not keep letters. Saved text selects Other, empty text unselects it, Esc changes nothing.
303
+ */
304
+ async function editOther(id: string, e: Entry, t: Target): Promise<void> {
305
+ const x = t.choice;
306
+ if (!x || dialog.open || e.state.sent) return;
307
+ dialog.open = true;
308
+ let text: string | undefined;
309
+ try {
310
+ text = await hooks.prompt(`Your own answer: ${truncateText(x.of.question, 60)}`, e.state.answers[x.of.key]?.text ?? "");
311
+ } finally {
312
+ dialog.open = false;
313
+ }
314
+ if (text === undefined || e.state.sent) return; // cancelled (the sent check is defence: nothing can send while the editor owns the keys)
315
+ if (otherLength(text) > LIMITS.other) setNotice(id, `Answer shortened to ${LIMITS.other} characters.`);
316
+ e.state.answers = setOther(e.state.answers, x.of, text);
317
+ e.version++;
318
+ hooks.requestRender();
319
+ }
320
+
231
321
  /** One place for what a click or Enter does, so the mouse and the keyboard cannot drift apart. */
232
322
  function perform(id: string, e: Entry, t: Target, shift = false): void {
233
323
  switch (t.kind) {
@@ -249,9 +339,26 @@ function perform(id: string, e: Entry, t: Target, shift = false): void {
249
339
  case "noteclear":
250
340
  clearNotes(id, e);
251
341
  return;
342
+ case "choice":
343
+ if (t.choice?.index === "other") {
344
+ void editOther(id, e, t).catch(() => {
345
+ // the editor could not open (pi's context went stale): leave the answers as they were
346
+ setNotice(id, "The editor could not be opened.");
347
+ hooks.requestRender();
348
+ });
349
+ return;
350
+ }
351
+ break;
252
352
  }
253
353
  const changed = activate(t, e.state, envFor(id, e));
254
354
  afterActivate(id, e, t, changed);
355
+ // The button that was pressed is gone from the next picture. Focus must not be left pointing at it: Enter would find nothing
356
+ // and fall through to the first element of the widget, and that can be a Button that sends a message.
357
+ if (t.kind === "send" && e.state.sent) {
358
+ // a locked widget has nothing left to hold the keyboard for: Enter is pi's again, to send the user's own message
359
+ e.state.focus = null;
360
+ if (active.id === id) active.id = null;
361
+ } else if (t.kind === "clear" && e.block) e.state.focus = readingOrder(e.block).find((x) => x.kind === "choice")?.id ?? null;
255
362
  }
256
363
 
257
364
  // ---------- the component ----------
@@ -500,7 +607,15 @@ function keyInner(data: string): boolean {
500
607
  return true;
501
608
  }
502
609
  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);
610
+ const focused = e.state.focus ? block.targets.find((x) => x.id === e.state.focus) : undefined;
611
+ if (e.state.focus && !focused) {
612
+ // the focused element is gone (a button removes itself when it is pressed): never press another one in its place
613
+ e.state.focus = null;
614
+ e.state.hover = null;
615
+ hooks.requestRender();
616
+ return true;
617
+ }
618
+ const t = focused ?? nextTarget(block, null, "next", notes);
504
619
  if (!t) return false;
505
620
  e.state.focus = t.id;
506
621
  e.state.hover = null;
@@ -553,7 +668,10 @@ export function toggleNotesNewest(): boolean {
553
668
 
554
669
  /** Forget every widget's state (notes, focus, layouts). Called when a session ends: pi processes live for days. */
555
670
  export function resetAll() {
556
- for (const e of entries.values()) if (e.noticeTimer) clearTimeout(e.noticeTimer);
671
+ for (const e of entries.values()) {
672
+ if (e.noticeTimer) clearTimeout(e.noticeTimer);
673
+ if (e.pending?.timer) clearTimeout(e.pending.timer);
674
+ }
557
675
  entries.clear();
558
676
  imageService.reset();
559
677
  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
+ }