pi-openui 0.1.1 → 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 +46 -0
- package/README.md +19 -5
- package/package.json +2 -1
- package/src/blocks/choice.ts +152 -0
- package/src/blocks/core.ts +72 -28
- package/src/blocks/interact.ts +30 -8
- package/src/blocks/render.ts +9 -4
- package/src/blocks/types.ts +17 -2
- package/src/diagram/canvas.ts +27 -14
- package/src/diagram/infra.ts +2 -1
- package/src/diagram/palette.ts +1 -1
- package/src/diagram/wide.ts +35 -0
- package/src/diagram/widefix.ts +56 -0
- package/src/lang/docs.ts +44 -12
- package/src/lang/library.ts +32 -3
- package/src/lang/samples.ts +10 -1
- package/src/pi/extension.ts +21 -4
- package/src/pi/widget.ts +124 -6
- package/src/pi/widths.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
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
|
+
|
|
42
|
+
## 0.1.2 (2026-10-31)
|
|
43
|
+
|
|
44
|
+
- Fixed borders drawn out of line on rows with wide characters. The terminal draws emoji and many symbols (✅ ❌ ⚡ 🚀 ⭐ ☕
|
|
45
|
+
and about 8,800 more) two cells wide, and the widgets counted them as one, so everything to their right on that row, and the
|
|
46
|
+
right border, slid by a cell. The widgets now measure with the same table pi uses. The `storage` icon of an InfraNode was one
|
|
47
|
+
of these characters (U+2630); it is now ⊟.
|
|
48
|
+
- The README now says what to do when the widget does not appear because pi-compact-tools collapses the tool call into a
|
|
49
|
+
"Called render_widget" row: exclude `render_widget` in its config. Nothing changes in the extension.
|
|
50
|
+
|
|
5
51
|
## 0.1.1 (2026-10-30)
|
|
6
52
|
|
|
7
53
|
- The README now renders on pi.dev and npm: screenshots are markdown images (pi.dev drops raw HTML tables and images) and
|
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
|
|
21
|
-
|
|
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
|
|
66
|
-
|
|
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
|
|
|
@@ -128,6 +132,16 @@ More in [docs/design.md](https://github.com/newtonapple/pi-openui/blob/main/docs
|
|
|
128
132
|
Mono. A different font works if it has them: `PI_OPENUI_FONT=/path/to/font.ttf npm test` checks yours.
|
|
129
133
|
- Pictures need a terminal that supports the Kitty graphics protocol. Other terminals get a framed placeholder.
|
|
130
134
|
|
|
135
|
+
## If the widget does not appear
|
|
136
|
+
|
|
137
|
+
You see a collapsed `Called render_widget ... + Show details` row and no widget, and the model says the widget was drawn. Another
|
|
138
|
+
extension is drawing the tool call in place of this one. [pi-compact-tools](https://www.npmjs.com/package/pi-compact-tools) does
|
|
139
|
+
this to every tool by default. Tell it to leave `render_widget` alone in `~/.pi/agent/compact-tools.json`, then run `/reload`:
|
|
140
|
+
|
|
141
|
+
```json
|
|
142
|
+
{ "custom_tools": { "exclude": ["render_widget"] } }
|
|
143
|
+
```
|
|
144
|
+
|
|
131
145
|
## Development
|
|
132
146
|
|
|
133
147
|
Working on the code? Start pi in this folder instead of installing it: `.pi/extensions/openui.ts` loads the extension (pi asks
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openui",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"scripts": {
|
|
50
50
|
"test": "node --import ./test/register-pi.mjs --test --test-reporter=spec test/*.test.ts",
|
|
51
51
|
"gallery": "node scripts/langgallery.ts",
|
|
52
|
+
"width": "node --import ./test/register-pi.mjs scripts/width-table.ts",
|
|
52
53
|
"screenshots": "node --import ./test/register-pi.mjs scripts/shots.ts && python3 scripts/render_shots.py && python3 scripts/hero.py",
|
|
53
54
|
"docs": "node scripts/gen_docs.ts",
|
|
54
55
|
"prepublishOnly": "npm test"
|
|
@@ -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
|
+
}
|
package/src/blocks/core.ts
CHANGED
|
@@ -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
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const hint =
|
|
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
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
187
|
+
rows.push(count, send, clear);
|
|
188
|
+
sendAt = { x: 0, y: 1 };
|
|
189
|
+
clearAt = { x: 0, y: 2 };
|
|
149
190
|
}
|
|
150
|
-
|
|
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 {
|
package/src/blocks/interact.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
168
|
-
const
|
|
169
|
-
state.
|
|
170
|
-
|
|
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, "
|
|
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}`);
|
package/src/blocks/render.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
28
|
-
|
|
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
|
|
package/src/blocks/types.ts
CHANGED
|
@@ -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
|
-
|
|
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: {},
|
|
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 {
|
package/src/diagram/canvas.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Text measurement and a cell canvas. Pure, no dependencies.
|
|
2
|
+
import { WIDE } from "./wide.ts";
|
|
2
3
|
|
|
3
4
|
const seg: { segment(s: string): Iterable<{ segment: string }> } | null =
|
|
4
5
|
typeof Intl !== "undefined" && (Intl as any).Segmenter ? new (Intl as any).Segmenter(undefined, { granularity: "grapheme" }) : null;
|
|
@@ -7,23 +8,35 @@ export function graphemes(s: string): string[] {
|
|
|
7
8
|
return seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
|
|
8
9
|
}
|
|
9
10
|
|
|
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). */
|
|
19
|
+
function isWide(cp: number): boolean {
|
|
20
|
+
let lo = 0;
|
|
21
|
+
let hi = table.length / 2 - 1;
|
|
22
|
+
while (lo <= hi) {
|
|
23
|
+
const mid = (lo + hi) >> 1;
|
|
24
|
+
if (cp < table[mid * 2]) hi = mid - 1;
|
|
25
|
+
else if (cp > table[mid * 2 + 1]) lo = mid + 1;
|
|
26
|
+
else return true;
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Columns one code point takes. It must agree with the terminal, or everything to its right on the row slides and the borders
|
|
33
|
+
* come out of line: wide characters (CJK, and the emoji and symbols models like: U+2705 U+274C U+26A1 U+1F680) are two, taken from pi's own width
|
|
34
|
+
* function through src/diagram/wide.ts; marks and controls are none.
|
|
35
|
+
*/
|
|
10
36
|
function cpWidth(cp: number): number {
|
|
11
37
|
if (cp === 0 || cp < 32 || (cp >= 0x7f && cp < 0xa0)) return 0;
|
|
12
38
|
if ((cp >= 0x300 && cp <= 0x36f) || (cp >= 0x200b && cp <= 0x200f) || (cp >= 0xfe00 && cp <= 0xfe0f) || (cp >= 0x20d0 && cp <= 0x20ff)) return 0;
|
|
13
|
-
|
|
14
|
-
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
15
|
-
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
16
|
-
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
17
|
-
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
18
|
-
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
19
|
-
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
20
|
-
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
21
|
-
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
22
|
-
(cp >= 0x1f900 && cp <= 0x1f9ff) ||
|
|
23
|
-
(cp >= 0x20000 && cp <= 0x3fffd)
|
|
24
|
-
)
|
|
25
|
-
return 2;
|
|
26
|
-
return 1;
|
|
39
|
+
return isWide(cp) ? 2 : 1;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
/** Display width of one grapheme cluster (0, 1 or 2 columns). */
|
package/src/diagram/infra.ts
CHANGED
|
@@ -11,7 +11,8 @@ import { drawWires, type WireSpec } from "./wires.ts";
|
|
|
11
11
|
export const INFRA_KINDS = ["client", "gateway", "service", "gpu", "database", "storage", "queue", "cloud"] as const;
|
|
12
12
|
export const INFRA_STATUSES = ["up", "degraded", "down", "unknown"] as const;
|
|
13
13
|
export const INFRA_TONES = ["neutral", "info", "good", "warn", "bad"] as const;
|
|
14
|
-
|
|
14
|
+
// Every icon must be one cell wide in pi (test/width.test.ts): a wide one (the old storage icon, U+2630, was) slides the rest of its row.
|
|
15
|
+
export const KIND_ICON: Record<string, string> = { client: "□", gateway: "↔", service: "◇", gpu: "⌸", database: "◎", storage: "⊟", queue: "≡", cloud: "◌" };
|
|
15
16
|
const STATUS_GLYPH: Record<string, string> = { up: "●", degraded: "●", down: "●", unknown: "○" };
|
|
16
17
|
|
|
17
18
|
export interface InfraNodeIn {
|
package/src/diagram/palette.ts
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
// The code points pi draws two cells wide, as inclusive [first, last] pairs in ascending order.
|
|
3
|
+
export const WIDE: readonly number[] = [
|
|
4
|
+
0x1100, 0x115e, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec,
|
|
5
|
+
0x23f0, 0x23f0, 0x23f3, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615,
|
|
6
|
+
0x2630, 0x2637, 0x2648, 0x2653, 0x267f, 0x267f, 0x268a, 0x268f,
|
|
7
|
+
0x2693, 0x2693, 0x26a1, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be,
|
|
8
|
+
0x26c4, 0x26c5, 0x26ce, 0x26ce, 0x26d4, 0x26d4, 0x26ea, 0x26ea,
|
|
9
|
+
0x26f2, 0x26f3, 0x26f5, 0x26f5, 0x26fa, 0x26fa, 0x26fd, 0x26fd,
|
|
10
|
+
0x2705, 0x2705, 0x270a, 0x270b, 0x2728, 0x2728, 0x274c, 0x274c,
|
|
11
|
+
0x274e, 0x274e, 0x2753, 0x2755, 0x2757, 0x2757, 0x2795, 0x2797,
|
|
12
|
+
0x27b0, 0x27b0, 0x27bf, 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b50,
|
|
13
|
+
0x2b55, 0x2b55, 0x2e80, 0x2e99, 0x2e9b, 0x2ef3, 0x2f00, 0x2fd5,
|
|
14
|
+
0x2ff0, 0x3029, 0x3030, 0x303e, 0x3041, 0x3096, 0x309b, 0x30ff,
|
|
15
|
+
0x3105, 0x312f, 0x3131, 0x3163, 0x3165, 0x318e, 0x3190, 0x31e5,
|
|
16
|
+
0x31ef, 0x321e, 0x3220, 0x3247, 0x3250, 0xa48c, 0xa490, 0xa4c6,
|
|
17
|
+
0xa960, 0xa97c, 0xac00, 0xd7a3, 0xf900, 0xfaff, 0xfe10, 0xfe19,
|
|
18
|
+
0xfe30, 0xfe52, 0xfe54, 0xfe66, 0xfe68, 0xfe6b, 0xff01, 0xff60,
|
|
19
|
+
0xffe0, 0xffe6, 0x16fe0, 0x16fe3, 0x16ff2, 0x16ff6, 0x17000, 0x18cd5,
|
|
20
|
+
0x18cff, 0x18d1e, 0x18d80, 0x18df2, 0x1aff0, 0x1aff3, 0x1aff5, 0x1affb,
|
|
21
|
+
0x1affd, 0x1affe, 0x1b000, 0x1b122, 0x1b132, 0x1b132, 0x1b150, 0x1b152,
|
|
22
|
+
0x1b155, 0x1b155, 0x1b164, 0x1b167, 0x1b170, 0x1b2fb, 0x1d300, 0x1d356,
|
|
23
|
+
0x1d360, 0x1d376, 0x1f004, 0x1f004, 0x1f0cf, 0x1f0cf, 0x1f18e, 0x1f18e,
|
|
24
|
+
0x1f191, 0x1f19a, 0x1f1e6, 0x1f202, 0x1f210, 0x1f23b, 0x1f240, 0x1f248,
|
|
25
|
+
0x1f250, 0x1f251, 0x1f260, 0x1f265, 0x1f300, 0x1f320, 0x1f32d, 0x1f335,
|
|
26
|
+
0x1f337, 0x1f37c, 0x1f37e, 0x1f393, 0x1f3a0, 0x1f3ca, 0x1f3cf, 0x1f3d3,
|
|
27
|
+
0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440,
|
|
28
|
+
0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567,
|
|
29
|
+
0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f,
|
|
30
|
+
0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d9,
|
|
31
|
+
0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb,
|
|
32
|
+
0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff,
|
|
33
|
+
0x1fa70, 0x1fa7c, 0x1fa80, 0x1fac6, 0x1fac8, 0x1fac8, 0x1facc, 0x1fadd,
|
|
34
|
+
0x1fadf, 0x1faeb, 0x1faef, 0x1fafa, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
|
|
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
|
|
187
|
-
shots: [
|
|
188
|
-
|
|
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": "
|
|
191
|
-
"Choice.options": "Two to six
|
|
192
|
-
"Choice.hint": "A line
|
|
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([
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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",
|
|
@@ -502,7 +534,7 @@ r = Relation("users", "projects", "1:N", "owns")`,
|
|
|
502
534
|
"InfraZone.tone": "Tints the zone: `neutral` (default), `info`, `good`, `warn`, `bad`.",
|
|
503
535
|
"InfraNode.id": "Unique across the whole map. Links use it.",
|
|
504
536
|
"InfraNode.label": "Bold text in the box.",
|
|
505
|
-
"InfraNode.kind": "Picks the icon: client □, gateway ↔, service ◇, gpu ⌸, database ◎, storage
|
|
537
|
+
"InfraNode.kind": "Picks the icon: client □, gateway ↔, service ◇, gpu ⌸, database ◎, storage ⊟, queue ≡, cloud ◌.",
|
|
506
538
|
"InfraNode.status": "`up` (green dot), `degraded` (amber), `down` (red) or `unknown` (hollow, dashed box). Omit it for no dot.",
|
|
507
539
|
"InfraNode.detail": "A short second line, such as a port or a count.",
|
|
508
540
|
"InfraLink.from": "The id of the node where the arrow starts (marked with a dot).",
|
package/src/lang/library.ts
CHANGED
|
@@ -81,6 +81,15 @@ export const LIMITS = {
|
|
|
81
81
|
steps: 100,
|
|
82
82
|
/** pictures in one widget: Image components and `` 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:
|
|
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
|
|
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.
|
package/src/lang/samples.ts
CHANGED
|
@@ -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"], "
|
|
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",
|
package/src/pi/extension.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
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())
|
|
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;
|
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
|
+
}
|