pi-openui 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/README.md +49 -6
- package/package.json +3 -2
- package/src/blocks/choice.ts +152 -0
- package/src/blocks/code.ts +16 -3
- package/src/blocks/core.ts +150 -55
- package/src/blocks/diagrams.ts +8 -26
- package/src/blocks/fallback.ts +25 -0
- package/src/blocks/gesture.ts +112 -0
- package/src/blocks/interact.ts +85 -11
- package/src/blocks/markdown.ts +13 -3
- package/src/blocks/pan.ts +139 -0
- package/src/blocks/panblock.ts +44 -0
- package/src/blocks/render.ts +34 -21
- package/src/blocks/sequence.ts +4 -4
- package/src/blocks/table.ts +122 -14
- package/src/blocks/types.ts +31 -4
- package/src/blocks/util.ts +5 -0
- package/src/diagram/canvas.ts +11 -4
- package/src/diagram/dag.ts +4 -3
- package/src/diagram/erd.ts +4 -2
- package/src/diagram/infra.ts +3 -2
- package/src/diagram/palette.ts +1 -1
- package/src/diagram/wide.ts +4 -5
- package/src/diagram/widefix.ts +56 -0
- package/src/lang/docs.ts +81 -25
- package/src/lang/library.ts +154 -22
- package/src/lang/samples.ts +144 -1
- package/src/pi/config.ts +185 -0
- package/src/pi/events.ts +150 -0
- package/src/pi/extension.ts +82 -20
- package/src/pi/widget.ts +189 -18
- package/src/pi/widths.ts +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 (2026-11-14)
|
|
6
|
+
|
|
7
|
+
- **A cell that was cut to fit can be read whole.** A table with a few columns is squeezed to fit and a long cell ends in `…`; now
|
|
8
|
+
that cell is a target. Click it, or Tab to it and press Enter, and it opens under the table as plain text (line breaks kept,
|
|
9
|
+
nothing read as Markdown); click again or Esc closes it. A cut header too, and Markdown tables the same. If the agent gave that
|
|
10
|
+
cell a tooltip, the tooltip's panel opens with the whole text added. A table that scrolls cuts nothing, so it has none of these.
|
|
11
|
+
- **`/openui-gallery wide`** draws eleven examples of things that do not fit a phone: a wide table with a pinned column, long cells,
|
|
12
|
+
code with a long line, tabs and tiles that wrap, a wide InfraMap, a Dag, an ERD, a sequence diagram, Markdown with a wide fence
|
|
13
|
+
and table, and a card with a long title. `docs/gallery-narrow.txt` is the same set as text at 60 columns, `docs/gallery.txt` is
|
|
14
|
+
current again (it was not regenerated for the pan bar), and `npm run gallery:files` writes both; a test fails when they are stale.
|
|
15
|
+
`node scripts/langgallery.ts <prefix>` now matches by prefix, as the command does.
|
|
16
|
+
- **New screenshots** for the pan bar, pinned columns, cut and opened cells, code, tabs, tiles, an InfraMap, a Dag and a sequence
|
|
17
|
+
diagram at phone width, on the pages of the components they show.
|
|
18
|
+
|
|
19
|
+
- **A table's columns can be pinned.** `Table(columns, rows, tooltips, pin)`: `pin` (new, 4th argument, optional) lists header names
|
|
20
|
+
that stay in place while a table wider than the pane scrolls, e.g. `Table(cols, rows, null, ["Host", "State"])`. The agent decides:
|
|
21
|
+
left out, nothing is pinned and the whole table scrolls. Pinned columns never take more than half the pane: they are tried in the
|
|
22
|
+
order listed and one that does not fit is skipped, never squeezed, so no text is ever out of reach. While the table scrolls they
|
|
23
|
+
are drawn first, in the order they have in the table; a table that fits is drawn as it was written. A name that is not a column is
|
|
24
|
+
a validation error that lists the columns. Every source written before this still compiles and draws as it did. The reference the
|
|
25
|
+
agent reads says when to pin (one or two narrow columns that identify each row) and when not to.
|
|
26
|
+
- **Scrolling in two sizes.** The pan bar is now ` « ‹ ████░░░ 1–53/87 › » `. `‹ ›` move a few cells (`scroll.step`, 8 by default),
|
|
27
|
+
`« »` a page (three quarters of the pane, to a column edge in a table). Left and Right on a focused bar are fine steps; Shift+arrow,
|
|
28
|
+
PageUp and PageDown are pages. Dragging the bar's thumb is still cell-precise. A bar too narrow for four arrows keeps the fine pair.
|
|
29
|
+
- **`scroll.step` in the settings file**, with `PI_OPENUI_SCROLL_STEP` and `/openui-config scroll.step <1-40>`. `/openui-config reset`
|
|
30
|
+
now puts every setting back; `/openui-config <setting> reset` puts one back.
|
|
31
|
+
- **The language cannot be broken by accident any more.** `test/compat/signatures.json` records every component and argument the
|
|
32
|
+
language has had; a test fails if an argument is inserted, moved, removed or made required, if a list or choice accepts less, or if a
|
|
33
|
+
limit goes down. New arguments go at the end and are optional. `AGENTS.md` says so.
|
|
34
|
+
|
|
35
|
+
- **A pan bar says when a widget is wider than the pane.** Tables, Dag, Erd, InfraMap and SequenceDiagram that do not fit now show
|
|
36
|
+
`‹ ███░░░░░░ 1–53/87 ›` under them (only when something is cut off). Tap or click `‹` and `›`, click or drag the bar, swipe
|
|
37
|
+
sideways or use a horizontal wheel where your terminal app sends one, Shift+wheel, or Tab to the bar and press Left and Right
|
|
38
|
+
(also in notes mode). The plain wheel still scrolls the transcript. The old text hint `select a node to pan` is gone.
|
|
39
|
+
- **Tables keep a minimum column width.** A column is squeezed only down to 12 characters (not three) before the table scrolls
|
|
40
|
+
sideways. Markdown tables are unchanged. Tables that fit are unchanged.
|
|
41
|
+
- **A settings file.** `~/.pi/agent/openui.json` (`{ "table": { "minColumnWidth": 12 } }`, 4 to 40),
|
|
42
|
+
`PI_OPENUI_TABLE_MIN_COL`, and `/openui-config` to look at it, set it or `reset` it.
|
|
43
|
+
- **Old sessions keep working.** A session stores a widget's source and draws it again, so everything above reaches widgets from
|
|
44
|
+
earlier versions at once. New widgets store the language version (`details.lang`). A source that no longer compiles under today's
|
|
45
|
+
strict rules is now read leniently (a value over a limit is cut, extra arguments are ignored, a statement nothing uses is
|
|
46
|
+
ignored) with one quiet line saying it was redrawn with changes, and one that still cannot be read shows the reason and the start
|
|
47
|
+
of its source instead of a blank. `AGENTS.md` now says language changes must be additive, and `test/compat/` keeps sources as older
|
|
48
|
+
versions wrote them so a change that breaks one fails the tests.
|
|
49
|
+
- **Pan rather than cut.** On a narrow pane text used to be shortened with `…` wherever a layout could not give: now it scrolls or wraps
|
|
50
|
+
instead. Code scrolls inside its box, code fences and tables in Markdown scroll on their own, a row of buttons or metric tiles wraps
|
|
51
|
+
onto more rows, a tab strip wraps onto more rows, and a title that does not fit a box's border moves inside as a heading. A Dag,
|
|
52
|
+
InfraMap, Erd or SequenceDiagram that cannot fit scrolls with whole labels instead of 6-character ones (they shrink to 14
|
|
53
|
+
characters at the least), and a table that scrolls cuts no cell at all (before: at 20 characters). Anything still wider than the pane
|
|
54
|
+
gets the pan bar. `test/narrow-audit.test.ts` holds every component to it.
|
|
55
|
+
- **A picture's frame says what it is.** If the terminal does not draw the picture (a phone app that reports no cell size, or a second
|
|
56
|
+
client attached to the same herdr), the frame is no longer an empty box: it names the picture and its size.
|
|
57
|
+
- **`/openui-events`** records what a tap, a swipe or a flick sends to a widget, for five minutes, so what your terminal app really
|
|
58
|
+
does can be read instead of guessed. It never records what you type.
|
|
59
|
+
- **The agent is told the terminal may be narrow.** The reference `render_widget` returns says a phone is about 60 columns, that
|
|
60
|
+
wide blocks scroll under a bar, and when to pin a Table's columns.
|
|
61
|
+
- **A tap right after a sideways flick works.** pi-tui does not understand horizontal wheel events and leaves a press unreleased,
|
|
62
|
+
which made the next tap do nothing; the widget now recognises that tap itself.
|
|
63
|
+
|
|
64
|
+
## 0.2.0 (2026-11-02)
|
|
65
|
+
|
|
66
|
+
- **`Choice` takes several answers and an Other answer, and sends them together.** `Choice(question, options, hint, multiple, other)`.
|
|
67
|
+
`multiple: true` lets you pick several options (square checkboxes and the tag `Choose all that apply`; a single question has
|
|
68
|
+
round indicators and `Choose one`). `other` is on by default and adds an **Other...** row: it opens pi's own editor (Enter saves,
|
|
69
|
+
Shift+Enter adds a line, Esc cancels) and the text shows under the row. Write `null` for each skipped argument before `multiple`
|
|
70
|
+
or `other`; a wrong one is a validation error that gives the argument's position and an example.
|
|
71
|
+
- **A click on a Choice no longer sends anything.** It stages the answer. A bar under the widget, `N of M answered [ Send answers ]
|
|
72
|
+
[ Clear ]`, sends the answers of every Choice in the widget (also those on tabs nobody opened) as ONE message and locks the
|
|
73
|
+
widget. The buttons stack on a narrow terminal. A Choice written for the old behaviour keeps working, now with an Other row and the
|
|
74
|
+
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
|
|
75
|
+
widget redrawn from a resumed session opens unanswered.
|
|
76
|
+
- **The message.** One question that takes one answer, answered with an option, still sends the line `<question> — <option>`.
|
|
77
|
+
Anything else is a numbered list that starts ``Answers to the questions in widget `Title` (3):``, with `(no answer)` for a skipped
|
|
78
|
+
question and `other:` for typed text. The words the agent wrote are cleaned (control and invisible characters removed, whitespace
|
|
79
|
+
collapsed, `@file:` and similar written `(at)file:`), and a question that would start the message like a command (`/new session?`)
|
|
80
|
+
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
|
|
81
|
+
blank lines becomes one.
|
|
82
|
+
- **Delivery.** The bar says `Sending to the agent…` until pi reports the message, then `Sent to the agent.` If pi does not take it
|
|
83
|
+
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
|
|
84
|
+
back with your answers and a notice, and Send answers works again once what pi complained about is fixed. After Send the widget
|
|
85
|
+
gives the keyboard back to pi.
|
|
86
|
+
- **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
|
|
87
|
+
click target, and a long option wraps under its indicator. Down from the last option reaches Send answers, never Clear. After
|
|
88
|
+
Clear the focus moves to the first option, and Enter never presses an element other than the focused one. In notes mode the
|
|
89
|
+
options and the bar do nothing.
|
|
90
|
+
- **New limits**, listed in the model's reference and in docs/components/README.md: 12 Choices per widget, 6 options, a question of
|
|
91
|
+
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.
|
|
92
|
+
Over a limit is a validation error with the number in it.
|
|
93
|
+
- **The model is told.** The `render_widget` description and guideline now say that a Button click arrives as the next message and
|
|
94
|
+
that Choice answers arrive together after Send answers, so the model asks all its questions in one widget. The model's reference
|
|
95
|
+
grows by about 190 tokens. New sample: `/openui-gallery choice`.
|
|
96
|
+
- **Rows with emoji stay aligned across pi upgrades.** The two-cell character table (`src/diagram/wide.ts`) was a snapshot of one pi.
|
|
97
|
+
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
|
|
98
|
+
slid on rows that contain them. The extension now asks the running pi about the blocks where emoji are added (U+2190-2BFF,
|
|
99
|
+
U+1F000-1FFFF, about 11 ms) when it loads, and the table is regenerated for pi 1.4.0 (`npm run width`).
|
|
100
|
+
|
|
5
101
|
## 0.1.2 (2026-10-31)
|
|
6
102
|
|
|
7
103
|
- 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
|
|
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,16 +61,54 @@ 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` |
|
|
65
|
+
| The examples that do not fit a phone | `/openui-gallery wide`: a wide table, long cells, long code, tabs, tiles, diagrams. Try it in a narrow pane |
|
|
61
66
|
| Draw a file | `/openui-file path/to/widget.oui` |
|
|
62
67
|
| Use the keyboard without the mouse | `/openui-focus` |
|
|
68
|
+
| Change a setting | `/openui-config`, for example `/openui-config table.minColumnWidth 14` |
|
|
69
|
+
| See what a swipe or tap sends | `/openui-events on`, try it over a widget, `/openui-events show` |
|
|
63
70
|
| Write notes on a widget | Click `[ notes ]` under it, or `/openui-notes` |
|
|
64
71
|
|
|
65
|
-
Gallery clicks are echoed locally and cost nothing. In a widget the agent drew, a Button
|
|
66
|
-
|
|
72
|
+
Gallery clicks are echoed locally and cost nothing. In a widget the agent drew, a Button click is sent to the agent as your
|
|
73
|
+
next message, and the answers to its Choices are sent together, as one message, when you press **Send answers**.
|
|
67
74
|
|
|
68
75
|
The mouse needs pi's default fullscreen mode. Everything also works from the keyboard.
|
|
69
76
|
|
|
77
|
+
## Small screens
|
|
78
|
+
|
|
79
|
+
On a narrow terminal (a phone over SSH or herdr, a small split) a widget that is wider than the pane shows a **pan bar**
|
|
80
|
+
under it, `‹ ███░░░░░░ 1–53/87 ›`, and scrolls sideways. Tap the arrows or the bar, swipe or use a horizontal wheel where the
|
|
81
|
+
terminal app sends one, Shift+wheel, or Tab to the bar and use the arrow keys. `‹ ›` move a few cells at a time, `« »` a page.
|
|
82
|
+
A table scrolls whole; the agent can pin a column or two (never more than half the width) to keep them in view, and a table never
|
|
83
|
+
squeezes a column below a minimum width before it scrolls. A cell that was cut to fit ends in `…`: tap it to read it whole.
|
|
84
|
+
|
|
85
|
+

|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
`/openui-gallery wide` draws eleven examples of things that would be cut on a phone, so you can see what each does in your pane. Code scrolls inside its box, rows of buttons and tabs wrap, and
|
|
90
|
+
diagrams scroll with whole labels: nothing is cut with `…` when it can be reached another way. Not sure what your app sends for a
|
|
91
|
+
swipe? `/openui-events on`, try it over a widget, `/openui-events show`. Details are in
|
|
92
|
+
[docs/interaction.md](https://github.com/newtonapple/pi-openui/blob/main/docs/interaction.md#wide-diagrams-and-tables).
|
|
93
|
+
|
|
94
|
+
## Settings
|
|
95
|
+
|
|
96
|
+
One file, `~/.pi/agent/openui.json` (or `$PI_CODING_AGENT_DIR/openui.json`):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{ "table": { "minColumnWidth": 12 }, "scroll": { "step": 8 } }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Setting | Default | Meaning |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `table.minColumnWidth` | `12` (4 to 40) | The narrowest a table column is squeezed to. A table that does not fit at that width scrolls sideways. Lower it to see more columns at once; raise it to read more of each cell. |
|
|
105
|
+
| `scroll.step` | `8` (1 to 40) | Cells one tap on a fine arrow (`‹ ›`) scrolls. The page arrows (`« »`) always go about three quarters of the pane. Lower it if scrolling feels jumpy. |
|
|
106
|
+
|
|
107
|
+
`PI_OPENUI_TABLE_MIN_COL` and `PI_OPENUI_SCROLL_STEP` override the file. `/openui-config` shows what is in force,
|
|
108
|
+
`/openui-config scroll.step 5` writes the file (handy on a phone) and redraws every widget, `/openui-config scroll.step reset` puts
|
|
109
|
+
one setting back, and `/openui-config reset` puts all of them back. A bad value falls back to the
|
|
110
|
+
default with one notice, and the file is never overwritten if it is not valid JSON.
|
|
111
|
+
|
|
70
112
|
## Notes
|
|
71
113
|
|
|
72
114
|
Pin a comment to one part of a widget, then send all your comments to the agent as a single message. Use it to say "this row
|
|
@@ -153,7 +195,8 @@ After you change the code, run `/reload` in pi. The rest:
|
|
|
153
195
|
npm test # a few seconds, no network, no model calls
|
|
154
196
|
npm run docs # regenerate docs/components/ from the signature table
|
|
155
197
|
npm run screenshots # regenerate docs/screenshots/, and the two README composites (needs Python with Pillow, and the font)
|
|
156
|
-
npm run gallery # print every component to the terminal (-- --plain for no colour)
|
|
198
|
+
npm run gallery # print every component to the terminal (-- --plain for no colour; a key prefix, e.g. `wide`, picks some)
|
|
199
|
+
npm run gallery:files # regenerate docs/gallery.txt and docs/gallery-narrow.txt (a test fails when they are stale)
|
|
157
200
|
make help # the release process (check, version, publish, tag, push)
|
|
158
201
|
```
|
|
159
202
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"width": "node --import ./test/register-pi.mjs scripts/width-table.ts",
|
|
53
53
|
"screenshots": "node --import ./test/register-pi.mjs scripts/shots.ts && python3 scripts/render_shots.py && python3 scripts/hero.py",
|
|
54
54
|
"docs": "node scripts/gen_docs.ts",
|
|
55
|
-
"prepublishOnly": "npm test"
|
|
55
|
+
"prepublishOnly": "npm test",
|
|
56
|
+
"gallery:files": "node scripts/gen-gallery.ts"
|
|
56
57
|
}
|
|
57
58
|
}
|
|
@@ -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/code.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
|
|
2
|
-
import type { Block, Target } from "./types.ts";
|
|
2
|
+
import type { Block, Ctx, Target } from "./types.ts";
|
|
3
|
+
import { panBlock } from "./panblock.ts";
|
|
3
4
|
import { boxed, cell, fromRows, textCells, vstack } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
const KW: Record<string, string[]> = {
|
|
@@ -84,8 +85,20 @@ export function codeInner(code: string, lang: string | undefined, width: number)
|
|
|
84
85
|
return fromRows(width, rows);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
/** The longest line of the code that is drawn, in cells. */
|
|
89
|
+
const longestLine = (code: string) => Math.max(0, ...String(code ?? "").replace(/\t/g, " ").replace(/\n$/, "").split("\n").slice(0, 200).map((l) => textWidth(l)));
|
|
90
|
+
|
|
91
|
+
/** A line longer than this is cut: even a scrolling view needs an end. */
|
|
92
|
+
export const MAX_CODE_COLS = 240;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `pan` (a render context and a key for the scroll offset) lets code wider than its box scroll inside it, with a pan bar under the
|
|
96
|
+
* last line; the box and its copy button stay where they are. Without it (a quote, a tooltip panel) a long line is cut as it was.
|
|
97
|
+
*/
|
|
98
|
+
export function codeBlock(code: string, lang: string | undefined, title: string | undefined, width: number, copyId: string | null, copied: boolean, pan?: { ctx: Ctx; key: string }): Block {
|
|
99
|
+
const innerW = Math.max(8, width - 4);
|
|
100
|
+
const longest = longestLine(code);
|
|
101
|
+
const inner = pan && longest > innerW ? panBlock(codeInner(code, lang, Math.min(longest, MAX_CODE_COLS)), innerW, pan.key, pan.ctx) : codeInner(code, lang, innerW);
|
|
89
102
|
const label = [title, lang && !title ? lang : title && lang ? lang : ""].filter(Boolean).join(" · ");
|
|
90
103
|
const tagText = copyId ? (copied ? "✓ copied" : "[copy]") : undefined;
|
|
91
104
|
const b = boxed(inner, { role: "code.border", title: label || undefined, titleRole: "code.title", tag: tagText, tagRole: copied ? "code.copied" : "btn" });
|