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/src/lang/docs.ts CHANGED
@@ -29,7 +29,10 @@ export const DOCS: Record<string, ComponentDoc> = {
29
29
  Stack: {
30
30
  group: "Layout and text",
31
31
  summary: "Lays its children out in a column (the default) or a row. Every widget starts with `root = Stack([...])`.",
32
- shots: [{ file: "stack", caption: "A row of tiles, then a row of buttons that wraps when it runs out of width" }],
32
+ shots: [
33
+ { file: "stack", caption: "A row of tiles, then a row of buttons that wraps when it runs out of width" },
34
+ { file: "tiles-wrap", caption: "On a phone-width pane (52 columns) the same idea: tiles and buttons wrap onto more rows, every label whole" },
35
+ ],
33
36
  use: ["The root of every widget.", "A row of metric tiles or buttons.", "Grouping a few components with a chosen gap."],
34
37
  avoid: "Do not put a Stack inside a Stack just to add space: use `gap`.",
35
38
  args: {
@@ -46,7 +49,7 @@ b = Metric("Middle", "2")
46
49
  c = Metric("Right", "3")`,
47
50
  tips: [
48
51
  "In a row, buttons, metric tiles and short text keep their natural width; every other child shares what is left equally.",
49
- "A row that is wider than the terminal shrinks its widest children first, never below 6 columns.",
52
+ "A row of buttons, tiles and short text that does not fit the terminal wraps onto more rows instead of squeezing them. A row with a child that shares the space (a Card, a Table) keeps its children side by side and shrinks the widest first, never below 6 columns.",
50
53
  "Inside Tabs, wrap a tab's content in one Stack: items placed directly in a tab touch each other.",
51
54
  ],
52
55
  related: ["Card", "Tabs", "Text"],
@@ -71,9 +74,12 @@ t3 = Text("Updated 2 minutes ago", "muted")`,
71
74
  Card: {
72
75
  group: "Layout and text",
73
76
  summary: "A bordered panel with an optional title in the top border.",
74
- shots: [{ file: "card", caption: "A card with a nested card" }],
77
+ shots: [
78
+ { file: "card", caption: "A card with a nested card" },
79
+ { file: "card-wide", caption: "A title too long for the border moves inside as a heading; a callout breaks a long word instead of cutting it" },
80
+ ],
75
81
  use: ["Giving a chart or table a heading and a frame.", "Separating sections inside one widget."],
76
- args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border. Cut with an ellipsis if it does not fit." },
82
+ args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border. If it does not fit there it moves inside the card as a wrapped heading." },
77
83
  example: `root = Stack([c])
78
84
  c = Card([chart], "Load by node")
79
85
  chart = BarChart(["a", "b", "c"], [3, 5, 2])`,
@@ -118,6 +124,9 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
118
124
  { file: "table", caption: "Numbers right-aligned, long text truncated with an ellipsis" },
119
125
  { file: "table-tooltip", caption: "Hovering the row with a tooltip shows its hint in a bubble" },
120
126
  { file: "table-panel", caption: "Clicking it, or pressing Enter, opens the detail panel" },
127
+ { file: "table-cut", caption: "On a narrow pane a few columns fit by squeezing: the cells that were cut end in an ellipsis" },
128
+ { file: "table-read-full", caption: "Tapping a cut cell (or Tab to it and Enter) opens it whole under the table, as plain text" },
129
+ { file: "pan-table", caption: "Many columns do not fit: the table scrolls under a bar, and the columns the model pinned (Host and State here) stay in place" },
121
130
  ],
122
131
  use: ["Comparing a handful of items across several fields.", "A list of results with a status column."],
123
132
  avoid: "More than about 12 rows: summarise, or split across Tabs.",
@@ -125,14 +134,17 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
125
134
  "Table.columns": "The header strings.",
126
135
  "Table.rows": "An array of rows. Each row has exactly one cell per column, a string or a number. A mismatch is a validation error that names the row.",
127
136
  "Table.tooltips": "Targets: `row:3` (1-based, not counting the header), `col:status`, or `cell:3.status`. Duplicate header names make `col:` and `cell:` ambiguous.",
137
+ "Table.pin": "The 4th argument: header names of the columns that stay in place while a table wider than the terminal scrolls sideways, such as `[\"Host\"]` (write `null` for tooltips you do not have: `Table(cols, rows, null, [\"Host\"])`). Left out, nothing is pinned and the whole table scrolls. Pin only the one or two narrow columns that identify each row: pinned columns take at most half the width, and one that would not fit is not pinned (the table then scrolls whole). A name that is not a column is a validation error that lists the columns. On a table that fits, nothing changes.",
128
138
  },
129
139
  example: `root = Stack([t])
130
140
  t = Table(["Node", "State", "GPU %"], [["spark-1", "serving", 91], ["spark-2", "draining", 84]], [tip])
131
141
  tip = Tooltip("row:2", "Rebooting for a driver update")`,
132
- interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel."],
142
+ interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel.", "A cell that was cut to fit ends in `…`. Click it, or Tab to it and press Enter, to open it whole under the table as plain text; click it again or press Esc to close it. A cut cell that also has a tooltip shows the whole text in the tooltip's panel."],
133
143
  tips: [
134
- "When the table is wider than the terminal the widest columns shrink first, to a minimum of 3 characters.",
135
- "A table that still does not fit becomes a scrollable window; moving focus to a target scrolls it into view.",
144
+ "When the table is wider than the terminal the widest columns shrink first, but never below the minimum column width (12 characters unless the user changes it: see Settings in the README).",
145
+ "A table that still does not fit becomes a window that scrolls sideways under a pan bar. Nothing is pinned unless you list it in `pin`: pin the one or two narrow columns that identify each row (a host, an id, a date) when a row is hard to read without them, and leave `pin` out otherwise. Pinned columns never take more than half the width: they are tried in the order you list them, and one that would not fit is skipped (never squeezed), so a long column is not pinned by accident and nothing in the table is ever out of reach.",
146
+ "While a wide table scrolls, its pinned columns are drawn first, in the order they have in the table, then the others. A table that fits is drawn in the order you wrote.",
147
+ "You cannot see the width of the user's terminal, so do not shorten text to make a table fit: a column is squeezed only to the user's minimum width (and a cell cut by that squeeze ends in an ellipsis; the reader can tap it to read it whole); a table that needs more than that scrolls instead, and then no cell is cut.",
136
148
  ],
137
149
  related: ["Markdown", "Metric", "Tooltip"],
138
150
  },
@@ -143,12 +155,13 @@ tip = Tooltip("row:2", "Rebooting for a driver update")`,
143
155
  shots: [
144
156
  { file: "tabs", caption: "The first tab is open when the widget is drawn" },
145
157
  { file: "tabs-nodes", caption: "After clicking Nodes" },
158
+ { file: "tabs-wrap", caption: "On a narrow pane (44 columns) a strip that does not fit wraps onto more rows; every label stays whole and tappable" },
146
159
  ],
147
160
  use: ["Alternate views of one subject: overview, nodes, notes.", "The same snippet in several languages."],
148
161
  avoid: "Steps that must be read in order, or content the reader must see at once: a tab that is not selected is hidden.",
149
162
  args: {
150
163
  "Tabs.items": "One TabItem per tab. The first is open at the start.",
151
- "TabItem.label": "The text in the strip. Cut to 22 characters, and shorter when the strip does not fit.",
164
+ "TabItem.label": "The text in the strip. When the labels do not fit the terminal the strip wraps onto more rows; a label wider than the whole terminal is the only one that is cut.",
152
165
  "TabItem.content": "The components in the tab. Wrap them in a single Stack: `[panel]`.",
153
166
  },
154
167
  example: `root = Stack([tabs])
@@ -183,19 +196,51 @@ b = Button("Restart vllm", @ToAssistant("Restart the vllm service on dgx-spark-1
183
196
  },
184
197
  Choice: {
185
198
  group: "Interactive",
186
- summary: "A question with clickable options for you to answer.",
187
- shots: [{ file: "choice", caption: "Options on one line when they fit, stacked when they do not" }],
188
- use: ["Asking a clarifying question instead of guessing."],
199
+ 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.",
200
+ shots: [
201
+ { 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" },
202
+ { file: "choice-card", caption: "Three questions in one Card, two answered. The second allows several answers (square indicators); the third has no Other row" },
203
+ { file: "choice-other", caption: "Other... opens pi's editor. The text you save shows under the row, and selects it" },
204
+ { file: "choice-sent", caption: "After Send answers every question is locked, the chosen rows stay marked, and the bar says so" },
205
+ ],
206
+ use: [
207
+ "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.",
208
+ "A confirmation before something risky, under a Callout that explains the cost.",
209
+ "A question where several answers can be right (`multiple`), or where you may want to say something that is not listed (the Other row).",
210
+ ],
211
+ 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
212
  args: {
190
- "Choice.question": "The question.",
191
- "Choice.options": "Two to six short answers.",
192
- "Choice.hint": "A line of explanation under the question.",
213
+ "Choice.question": "Shown in bold above the options. It is also the first half of the answer sent back. It must have text.",
214
+ "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.",
215
+ "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.",
216
+ "Choice.multiple": "`true`: options toggle independently and several can be answered. Default `false`: one answer, and picking another option moves the pick.",
217
+ "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
218
  },
194
- example: `root = Stack([q])
195
- q = Choice("Which model should serve chat?", ["qwen3-32b", "llama-3.3-70b"], "Pick one")`,
196
- interaction: ["Click an option, or move with the arrow keys and press Enter. The pick is filled (●) and `<question> — <option>` is sent as your next message."],
197
- tips: ["You can change the pick; each click sends a new message."],
198
- related: ["Button"],
219
+ example: `root = Stack([plan])
220
+ plan = Card([q1, q2, q3], "Before I restart")
221
+ q1 = Choice("Which node should I restart first?", ["web-1", "web-2"])
222
+ q2 = Choice("Which checks should I run?", ["Disk", "Memory", "Network"], null, true)
223
+ q3 = Choice("Clear the cache on db?", ["Yes", "No"], null, null, false)`,
224
+ 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.",
225
+ interaction: [
226
+ "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.",
227
+ "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.",
228
+ "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.",
229
+ "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.",
230
+ "In notes mode the options, Other and the bar do nothing, like a Button.",
231
+ ],
232
+ tips: [
233
+ "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.",
234
+ "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).",
235
+ "Ask everything in one widget. A second widget has its own bar and sends its own message.",
236
+ "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.",
237
+ "`(no answer)` means skipped. Do not read it as a \"no\".",
238
+ "Options are plain text: no icons, links or per-option descriptions. Put detail in the hint or the question.",
239
+ "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.",
240
+ "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.",
241
+ "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.",
242
+ ],
243
+ related: ["Button", "Callout", "Card", "Tabs"],
199
244
  },
200
245
  Faq: {
201
246
  group: "Interactive",
@@ -223,6 +268,7 @@ b = FaqItem("How do I undo it?", "Run \`uncordon\`.")`,
223
268
  shots: [
224
269
  { file: "markdown", caption: "Headings, emphasis, lists, a quote, a table, a rule and a link" },
225
270
  { file: "markdown-image", caption: "A picture inside the text, written as a line of its own, with its caption underneath" },
271
+ { file: "markdown-wide", caption: "At 60 columns a code fence and a table that are wider than the pane scroll on their own, each under its own bar" },
226
272
  ],
227
273
  use: ["Release notes, explanations, anything with structure.", "A README-style view: text with pictures in it."],
228
274
  args: { "Markdown.text": "Markdown. Use `\\n` for newlines. Supported: `#`-`######` headings, **bold**, *italic*, `code`, ~~strike~~, `[text](https://example.com)`, lists (nested), `>` quotes, tables, fenced code, `---`, and a picture: a line that is only `![alt](path \"caption\")`." },
@@ -238,7 +284,10 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
238
284
  CodeBlock: {
239
285
  group: "Rich content",
240
286
  summary: "Syntax-highlighted code in a frame, with a copy button.",
241
- shots: [{ file: "code", caption: "Highlighting for JavaScript, SQL, YAML, JSON, bash, Go and Rust" }],
287
+ shots: [
288
+ { file: "code", caption: "Highlighting for JavaScript, SQL, YAML, JSON, bash, Go and Rust" },
289
+ { file: "code-pan", caption: "A line wider than the pane is not cut: the code scrolls inside its box, under a bar, and [copy] still copies all of it" },
290
+ ],
242
291
  use: ["Commands to run, config to paste, a query."],
243
292
  args: {
244
293
  "CodeBlock.code": "The source. Use `\\n` for new lines. Tabs become two spaces; more than 200 lines are cut.",
@@ -248,7 +297,7 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
248
297
  example: `root = Stack([c])
249
298
  c = CodeBlock("kubectl rollout restart deploy/api\\nkubectl rollout status deploy/api", "bash", "restart.sh")`,
250
299
  interaction: ["Click `[copy]` (or Tab to it and press Enter) to put the exact code on the clipboard with an OSC 52 escape. The label changes to `✓ copied` for two seconds."],
251
- tips: ["Long lines are cut with an ellipsis, not wrapped, so the code stays aligned. The copy still has the full line.", "The clipboard needs a terminal that accepts OSC 52 (most modern ones do)."],
300
+ tips: ["Long lines are not wrapped (the code stays aligned) and not cut: when a line is wider than the box the code scrolls sideways inside it, under a pan bar. Lines are cut at 240 columns. The copy has every line in full.", "The clipboard needs a terminal that accepts OSC 52 (most modern ones do)."],
252
301
  related: ["Markdown"],
253
302
  },
254
303
  Image: {
@@ -426,6 +475,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
426
475
  shots: [
427
476
  { file: "dag", caption: "A CI run with every status: done, failed, running, skipped and pending" },
428
477
  { file: "dag-tooltip", caption: "A focused node: bold accent border, hint bubble, and the tooltip panel below" },
478
+ { file: "dag-wide", caption: "Long step names on a 60-column pane: the labels shrink a little, then the graph scrolls with whole labels" },
429
479
  ],
430
480
  use: ["A pipeline where each step has a state.", "A fan-out and fan-in.", "Which job waits for which."],
431
481
  avoid: "Database tables (Erd) or a plain list of steps (Table).",
@@ -436,7 +486,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
436
486
  "Dag.title": "A bold line above the graph.",
437
487
  "Dag.tooltips": "Target: a node id.",
438
488
  "DagNode.id": "Unique in the graph. Edges refer to it.",
439
- "DagNode.label": "The text in the box. Shortened with an ellipsis when space is tight.",
489
+ "DagNode.label": "The text in the box. Shortened to 14 characters at the least to fit the terminal; if it still does not fit, the graph scrolls sideways with whole labels (28 characters at most).",
440
490
  "DagNode.status": "`done` ✓, `running` ◔, `failed` ✗, `pending` ○ or `skipped` ⊘ (also a dashed box). Omit it for a plain box. A legend names the statuses in use.",
441
491
  },
442
492
  example: `root = Stack([flow])
@@ -448,7 +498,7 @@ c = DagNode("deploy", "Deploy", "pending")`,
448
498
  tips: [
449
499
  "A cycle is allowed: the edge that closes it is drawn dashed, pointing back.",
450
500
  "Every edge has its own wire, wires cross only at right angles (`┼`), and the layout is checked by decoding the picture back into edges. An edge that truly cannot be routed is left out and listed under the graph as `a -> b (not drawn)`.",
451
- "Wider than the terminal: labels shrink first, then the direction switches, then the view scrolls sideways.",
501
+ "Wider than the terminal: labels shrink first (never below 14 characters), then the direction switches, then the view scrolls sideways with whole labels.",
452
502
  ],
453
503
  related: ["Erd", "InfraMap", "SequenceDiagram"],
454
504
  },
@@ -488,7 +538,10 @@ r = Relation("users", "projects", "1:N", "owns")`,
488
538
  group: "Diagrams and maps",
489
539
  summary: "Zones holding nodes (servers, databases, queues), with labelled arrows showing which talks to which.",
490
540
  helpers: ["InfraZone", "InfraNode", "InfraLink"],
491
- shots: [{ file: "infra", caption: "Three zones, with a degraded worker and a dashed link" }],
541
+ shots: [
542
+ { file: "infra", caption: "Three zones, with a degraded worker and a dashed link" },
543
+ { file: "infra-wide", caption: "A four-zone map on a 60-column pane: whole labels, the first zone in view, the rest a swipe or a tap on » away" },
544
+ ],
492
545
  use: ["How a system fits together.", "Where a fault is: nodes carry a status."],
493
546
  avoid: "More than about 4 zones and 12 nodes: split it in two.",
494
547
  args: {
@@ -524,7 +577,10 @@ l = InfraLink("lb", "api", "HTTP")`,
524
577
  group: "Diagrams and maps",
525
578
  summary: "Calls between participants: columns left to right, steps top to bottom, with notes and alt/loop frames.",
526
579
  helpers: ["SeqParticipant", "SeqMessage", "SeqNote", "SeqGroup"],
527
- shots: [{ file: "sequence", caption: "An alt/else frame, a note, a self-call and activation bars" }],
580
+ shots: [
581
+ { file: "sequence", caption: "An alt/else frame, a note, a self-call and activation bars" },
582
+ { file: "sequence-wide", caption: "Five participants and long labels on a 60-column pane: it scrolls, labels whole" },
583
+ ],
528
584
  use: ["The order of calls in a request.", "Who calls whom, and what comes back."],
529
585
  avoid: "More than about 5 participants or 20 steps: split it.",
530
586
  args: {
@@ -81,6 +81,15 @@ export const LIMITS = {
81
81
  steps: 100,
82
82
  /** pictures in one widget: Image components and `![alt](path)` lines together. Each can be megabytes */
83
83
  images: 12,
84
+ /** Choice questions in one widget: they share one Send answers bar and one message */
85
+ choices: 12,
86
+ /** options of one Choice (the lower bound is 2) */
87
+ choiceOptions: 6,
88
+ /** characters in a Choice question and in one of its options (the message carries them as written) */
89
+ question: 200,
90
+ option: 120,
91
+ /** characters of the user's own Other answer */
92
+ other: 500,
84
93
  } as const;
85
94
 
86
95
  const S: T = "string";
@@ -95,7 +104,7 @@ export const SPECS: CompSpec[] = [
95
104
  { name: "Text", params: [p("text", S, false, LIMITS.text), p("variant", e("title", "heading", "body", "muted", "code"), true)], desc: "A line or paragraph of text (default body)." },
96
105
  { name: "Card", params: [p("children", kids), p("title", S, true)], desc: "Bordered panel with an optional title." },
97
106
  { name: "Metric", params: [p("label", S), p("value", S), p("delta", S, true), p("tone", e("good", "warn", "bad", "neutral"), true), p("tooltips", tips, true)], desc: "KPI tile. value and delta are strings. tone colours the delta." },
98
- { name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers)." },
107
+ { name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true), p("pin", { arr: S, max: LIMITS.tableCols }, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers). pin (4th argument, a list of header names) keeps those columns in place while a table wider than the reader's screen scrolls sideways. Nothing is pinned unless you list it: leave pin out when the rows are readable without a fixed column. Pin only the one or two narrow columns that identify each row (a name, an id). Pinned columns take at most half the width, and one that would not fit is not pinned: the table then just scrolls whole." },
99
108
  { name: "BarChart", params: [p("labels", { arr: S, max: LIMITS.bars }), p("values", { arr: "number", max: LIMITS.bars }), p("title", S, true), p("unit", S, true), p("tooltips", tips, true)], desc: "Bar chart of one series. labels and values are parallel arrays. unit is a suffix such as %." },
100
109
  { name: "Callout", params: [p("text", S), p("tone", e("info", "success", "warn", "error"), true)], desc: "Highlighted note." },
101
110
  { name: "Button", params: [p("label", S), p("action", "action", true)], desc: "Clickable button. Without an action it sends its label to the assistant." },
@@ -113,7 +122,7 @@ export const SPECS: CompSpec[] = [
113
122
  { name: "GeoMap", params: [p("points", { arr: { comp: ["GeoPoint"] }, min: 1, max: LIMITS.geoPoints }), p("links", { arr: { arr: S }, max: LIMITS.links }, true), p("title", S, true), p("tooltips", tips, true)], desc: "Offline world map (coarse coastlines) zoomed to fit the pins. links are [labelA, labelB] pairs drawn dashed." },
114
123
  { name: "FaqItem", helper: true, params: [p("question", S), p("answer", S, false, LIMITS.answer)], desc: "One Q&A entry (answer is markdown). Only valid inside Faq." },
115
124
  { name: "Faq", params: [p("items", { arr: { comp: ["FaqItem"] }, min: 1, max: LIMITS.faq }), p("firstOpen", "boolean", true)], desc: "Collapsible Q&A list." },
116
- { name: "Choice", params: [p("question", S), p("options", { arr: S, min: 2, max: 6 }), p("hint", S, true)], desc: 'A question with clickable options for the USER. Clicking sends "<question> — <option>" to the assistant.' },
125
+ { name: "Choice", params: [p("question", S, false, LIMITS.question), p("options", { arr: S, min: 2, max: LIMITS.choiceOptions }), p("hint", S, true), p("multiple", "boolean", true), p("other", "boolean", true)], desc: "A question for the USER. Answers are staged and sent together as ONE message when the user presses Send answers. multiple (4th argument) true allows several options. other (5th argument, default true) adds an Other... row where the user types their own answer; false hides it." },
117
126
  { name: "Series", helper: true, params: [p("category", S), p("values", { arr: "number", max: LIMITS.points })], desc: "One data series: name and numbers parallel to the chart labels." },
118
127
  { name: "LineChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Line chart over categories, several series. variant natural (smooth, default), linear or step." },
119
128
  { name: "AreaChart", params: [p("labels", { arr: S, max: LIMITS.points }), p("series", { arr: { comp: ["Series"] }, min: 1, max: LIMITS.series }), p("variant", e("linear", "natural", "step"), true), p("xLabel", S, true), p("yLabel", S, true), p("height", "number", true), p("tooltips", tips, true)], desc: "Filled STACKED area chart: series stack into a growing total." },
@@ -212,9 +221,71 @@ export interface Compiled {
212
221
  ok: boolean;
213
222
  errors: string[];
214
223
  warnings: string[];
224
+ /** what `restore` mode changed to make an older source draw (values cut to a limit, arguments ignored, ...). Always empty in strict mode. */
225
+ notes: string[];
215
226
  root?: El;
216
227
  }
217
228
 
229
+ /**
230
+ * The version of OpenUI Lang this code reads. A widget written by an earlier version of the language is version 1 or whatever was
231
+ * stored with it. Bump it when a change would stop an older source from compiling (a removed or renamed component or argument, a
232
+ * positional argument inserted, a required one added, a lower limit), and add an alias or a migration below. AGENTS.md has the rule.
233
+ */
234
+ export const LANG_VERSION = 1;
235
+
236
+ /** An old component name and what it is called now. Applied only when an old source is read again (`restore`), never to what the model writes. */
237
+ export const ALIASES: Record<string, string> = {};
238
+
239
+ /** Rewrites a source written under version `from` into one the next version reads. Run in order, only in `restore` mode. */
240
+ export const MIGRATIONS: Array<{ from: number; run(source: string): string }> = [];
241
+
242
+ export interface CompileOpts {
243
+ /** `strict` (default) is for what the model writes: every mistake is an error it can fix. `restore` is for a source read again from a session. */
244
+ mode?: "strict" | "restore";
245
+ /** the language version the source was written under (default 1) */
246
+ from?: number;
247
+ /** tests pass their own; the real tables above are the default */
248
+ migrations?: typeof MIGRATIONS;
249
+ aliases?: typeof ALIASES;
250
+ }
251
+
252
+ /** Rename `Old(` to `New(` outside string literals. */
253
+ function renameCalls(source: string, aliases: Record<string, string>): string {
254
+ const names = Object.keys(aliases);
255
+ if (!names.length) return source;
256
+ let out = "";
257
+ let i = 0;
258
+ while (i < source.length) {
259
+ const ch = source[i];
260
+ if (ch === '"') {
261
+ let j = i + 1;
262
+ while (j < source.length && source[j] !== '"') j += source[j] === "\\" ? 2 : 1;
263
+ out += source.slice(i, j + 1);
264
+ i = j + 1;
265
+ continue;
266
+ }
267
+ const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(i, i + 80));
268
+ if (m) {
269
+ const word = m[0];
270
+ const after = source.slice(i + word.length).match(/^\s*\(/);
271
+ out += after && Object.prototype.hasOwnProperty.call(aliases, word) ? aliases[word] : word;
272
+ i += word.length;
273
+ continue;
274
+ }
275
+ out += ch;
276
+ i++;
277
+ }
278
+ return out;
279
+ }
280
+
281
+ /** An older source brought up to date: migrations for the versions after the one it was written under, then renamed components. */
282
+ function upgrade(source: string, opts: CompileOpts): string {
283
+ const from = opts.from ?? 1;
284
+ let text = source;
285
+ for (const m of [...(opts.migrations ?? MIGRATIONS)].sort((a, b) => a.from - b.from)) if (m.from >= from) text = m.run(text);
286
+ return renameCalls(text, opts.aliases ?? ALIASES);
287
+ }
288
+
218
289
  export function showType(t: T): string {
219
290
  if (typeof t === "string") return t === "child" ? "component" : t === "cell" ? "string | number" : t;
220
291
  if ("enum" in t) return t.enum.map((x) => JSON.stringify(x)).join(" | ");
@@ -226,28 +297,32 @@ export function showType(t: T): string {
226
297
  }
227
298
 
228
299
  /** Reads and checks a program. Never throws: whatever goes wrong comes back as an error the model can read. */
229
- export function compile(source: string): Compiled {
300
+ export function compile(source: string, opts: CompileOpts = {}): Compiled {
230
301
  try {
231
- const text = String(source ?? "");
232
- if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [] };
233
- const c = compileProgram(text);
302
+ const restoring = opts.mode === "restore";
303
+ let text = String(source ?? "");
304
+ if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [], notes: [] };
305
+ if (restoring) text = upgrade(text, opts);
306
+ const c = compileProgram(text, restoring);
234
307
  if (c.errors.length > LIMITS.errors) {
235
308
  const more = c.errors.length - LIMITS.errors;
236
309
  return { ...c, errors: [...c.errors.slice(0, LIMITS.errors), `... and ${more} more error${more === 1 ? "" : "s"}; fix these first`], root: undefined, ok: false };
237
310
  }
238
311
  return c;
239
312
  } catch (e) {
240
- return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [] };
313
+ return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [], notes: [] };
241
314
  }
242
315
  }
243
316
 
244
- function compileProgram(source: string): Compiled {
317
+ function compileProgram(source: string, restore = false): Compiled {
245
318
  const prog = parseProgram(source);
246
319
  const errors = [...prog.errors];
247
320
  const warnings: string[] = [];
321
+ /** restore mode: what was changed to make an older source draw. In strict mode the same things are errors. */
322
+ const notes: string[] = [];
248
323
  if (!prog.statements.has("root")) {
249
324
  errors.push("define the entry point: root = Stack([...]) (put it on the first line)");
250
- return { ok: false, errors, warnings };
325
+ return { ok: false, errors, warnings, notes };
251
326
  }
252
327
  if (prog.order[0] !== "root") warnings.push("root is not the first statement; put it first so the shell appears immediately");
253
328
 
@@ -308,8 +383,15 @@ function compileProgram(source: string): Compiled {
308
383
  }
309
384
  };
310
385
 
311
- const tooLong = (v: string, max: number, ctx: string, line: number) => {
312
- if (v.length > max) errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
386
+ /** A string within its limit. Over it: an error for the model, or (restoring an older widget) the string cut to the limit, with a note. */
387
+ const fitString = (v: string, max: number, ctx: string, line: number): string => {
388
+ if (v.length <= max) return v;
389
+ if (restore) {
390
+ notes.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}, so it was cut to ${max}`);
391
+ return v.slice(0, max);
392
+ }
393
+ errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
394
+ return v;
313
395
  };
314
396
  const resolve = (x: Expr, t: T, ctx: string, maxChars: number = LIMITS.string): any => {
315
397
  const d0 = x;
@@ -324,16 +406,14 @@ function compileProgram(source: string): Compiled {
324
406
  switch (t) {
325
407
  case "string":
326
408
  if (d.k !== "str") return bad("a string");
327
- tooLong(d.v, maxChars, ctx, d.line);
328
- return d.v;
409
+ return fitString(d.v, maxChars, ctx, d.line);
329
410
  case "number":
330
411
  return d.k === "num" ? d.v : bad("a number");
331
412
  case "boolean":
332
413
  return d.k === "bool" ? d.v : bad("true or false");
333
414
  case "cell":
334
415
  if (d.k !== "str" && d.k !== "num") return bad("a string or a number");
335
- if (d.k === "str") tooLong(d.v, LIMITS.cell, ctx, d.line);
336
- return d.v;
416
+ return d.k === "str" ? fitString(d.v, LIMITS.cell, ctx, d.line) : d.v;
337
417
  case "any":
338
418
  return plain(d);
339
419
  case "action": {
@@ -386,7 +466,13 @@ function compileProgram(source: string): Compiled {
386
466
  if (d.k !== "arr") return bad("an array [...]");
387
467
  const out = d.items.map((it, i) => resolve(it, t.arr, `${ctx}[${i + 1}]`)).filter((v) => v !== undefined);
388
468
  if (t.min !== undefined && d.items.length < t.min) errors.push(`${where(d.line)}${ctx} needs at least ${t.min} item(s)`);
389
- if (t.max !== undefined && d.items.length > t.max) errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
469
+ if (t.max !== undefined && d.items.length > t.max) {
470
+ if (restore) {
471
+ notes.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}; the extra ones were dropped`);
472
+ return out.slice(0, t.max);
473
+ }
474
+ errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
475
+ }
390
476
  return out;
391
477
  }
392
478
  // { comp: [...] }
@@ -407,6 +493,7 @@ function compileProgram(source: string): Compiled {
407
493
  let depth = 0;
408
494
  let built = 0;
409
495
  let pictures = 0;
496
+ let choices = 0;
410
497
  const build = (call: Extract<Expr, { k: "call" }>): El | undefined => {
411
498
  const spec = LIB.get(call.name);
412
499
  if (!spec) {
@@ -427,7 +514,10 @@ function compileProgram(source: string): Compiled {
427
514
  }
428
515
  };
429
516
  const buildInner = (call: Extract<Expr, { k: "call" }>, spec: CompSpec): El | undefined => {
430
- if (call.args.length > spec.params.length) errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
517
+ if (call.args.length > spec.params.length) {
518
+ if (restore) notes.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}; the extra ones were ignored`);
519
+ else errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
520
+ }
431
521
  const props: Record<string, any> = {};
432
522
  spec.params.forEach((param, i) => {
433
523
  const arg = call.args[i];
@@ -436,6 +526,14 @@ function compileProgram(source: string): Compiled {
436
526
  if (!param.opt) errors.push(`${where(call.line)}${spec.name} is missing the required argument "${param.name}" (position ${i + 1})`);
437
527
  return;
438
528
  }
529
+ if (spec.name === "Choice" && param.name === "hint" && d && d.k === "bool") {
530
+ 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)`);
531
+ return;
532
+ }
533
+ if (spec.name === "Choice" && (param.name === "multiple" || param.name === "other") && d && d.k !== "bool") {
534
+ 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"));
535
+ return;
536
+ }
439
537
  props[param.name] = resolve(arg, param.t, `${spec.name} argument "${param.name}"`, param.max);
440
538
  });
441
539
  const el: El = { type: spec.name, props, line: call.line, ...(nameOf.has(call) ? { name: nameOf.get(call) } : {}) };
@@ -485,6 +583,23 @@ function compileProgram(source: string): Compiled {
485
583
  pr.rows.forEach((r: any[], i: number) => {
486
584
  if (r.length !== pr.columns.length) err(`row ${i + 1} has ${r.length} cells but there are ${pr.columns.length} columns`);
487
585
  });
586
+ if (Array.isArray(pr.pin) && Array.isArray(pr.columns)) {
587
+ // headers are compared the way the table draws them: spacing collapsed
588
+ const header = (c: unknown) => String(c ?? "").replace(/\s+/g, " ").trim();
589
+ const heads = pr.columns.map(header);
590
+ const kept: string[] = [];
591
+ for (const name of pr.pin as string[]) {
592
+ const h = header(name);
593
+ if (!heads.includes(h)) {
594
+ if (restore) notes.push(`${where(el.line)}Table: pin "${h}" is not a column, so it was dropped`);
595
+ else err(`pin "${h}" is not a column. Columns: ${heads.join(", ")}`);
596
+ } else if (kept.includes(h)) {
597
+ if (restore) notes.push(`${where(el.line)}Table: pin lists "${h}" twice, so the second was dropped`);
598
+ else err(`pin lists "${h}" twice`);
599
+ } else kept.push(h);
600
+ }
601
+ if (restore) pr.pin = kept;
602
+ }
488
603
  break;
489
604
  case "BarChart":
490
605
  case "PieChart":
@@ -515,6 +630,17 @@ function compileProgram(source: string): Compiled {
515
630
  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
631
  break;
517
632
  }
633
+ case "Choice": {
634
+ 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`);
635
+ // empty once control, invisible and blank characters are gone: it would draw as nothing and be sent as nothing
636
+ const blank = (v: unknown) => String(v ?? "").replace(/[\p{Cc}\p{Cf}\s]/gu, "") === "";
637
+ if (blank(pr.question)) err("the question is empty. Write the question the user answers");
638
+ ((pr.options ?? []) as string[]).forEach((o, i) => {
639
+ if (blank(o)) err(`option ${i + 1} is empty. Give every option some text`);
640
+ else if (o.length > LIMITS.option) err(`option ${i + 1} is ${o.length} characters; the limit is ${LIMITS.option}. Shorten it`);
641
+ });
642
+ break;
643
+ }
518
644
  case "CodeBlock":
519
645
  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
646
  break;
@@ -561,10 +687,15 @@ function compileProgram(source: string): Compiled {
561
687
  const rootEl = resolve(rootStmt.expr, "child", "root");
562
688
  visiting.pop();
563
689
  if (rootEl && rootEl.type !== "Stack") errors.push(`${where(rootStmt.line)}root must be a Stack(...), not ${rootEl.type}`);
564
- for (const name of prog.order) if (!used.has(name)) errors.push(`${where(prog.statements.get(name)!.line)}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
690
+ for (const name of prog.order) {
691
+ if (used.has(name)) continue;
692
+ const line = where(prog.statements.get(name)!.line);
693
+ if (restore) notes.push(`${line}"${name}" is defined but not reachable from root, so it is not drawn`);
694
+ else errors.push(`${line}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
695
+ }
565
696
  const seen = new Set<string>();
566
697
  const uniq = errors.filter((m) => (seen.has(m) ? false : (seen.add(m), true)));
567
- return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, root: uniq.length === 0 ? rootEl : undefined };
698
+ return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, notes: [...new Set(notes)], root: uniq.length === 0 ? rootEl : undefined };
568
699
  }
569
700
 
570
701
  /** Text for the model: syntax rules, signatures, actions, tooltips, examples. */
@@ -584,20 +715,21 @@ export function reference(): string {
584
715
  ${SPECS.map(sig).join("\n")}
585
716
 
586
717
  ## Tooltips
587
- \`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Table, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]).
718
+ \`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]). A Table takes tooltips as its 3rd argument and \`pin\` as its 4th: Table(columns, rows, [t1], ["Host"]), or Table(columns, rows, null, ["Host"]) without tooltips.
588
719
  Targets: Dag node id; Erd entity name or entity.column; InfraMap node id, zone:<zone name> or <fromId>><toId>; SequenceDiagram participant id or SeqMessage id; Table row:3, col:status or cell:3.status; Metric its own label; BarChart a bar label; GeoMap a point label or A>B; Image the word image; LineChart and AreaChart an x label (that point) or series:<name>; PieChart a slice label; RadarChart an axis label or series:<name>.
589
720
  An element that has a tooltip shows a small ◆ on it. Hovering shows the hint; clicking (or Enter) opens the panel with detail, code and link. Use a tooltip for context the reader may want but should not have to read: why a point spiked, what a slice contains, what a node does.
590
721
  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
722
 
592
723
  ## 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.
724
+ 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
725
 
595
726
  ## Actions
596
727
  Button("Restart", @ToAssistant("Restart the vllm service")) sends that text to the assistant as the user's next turn (500 characters at most). A Button without an action sends its label. Several steps: Action([@ToAssistant("...")]). Do not use @OpenUrl: put links in a Text.
597
- Choice(question, options) asks the user; a click sends "<question> — <option>".
728
+ 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
729
 
599
730
  ## Choosing components
600
731
  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.
732
+ The reader's terminal may be narrow (a phone is about 60 columns) and you cannot see its width. A Dag, Erd, InfraMap or SequenceDiagram wider than it, and a Table with many columns, scroll sideways under a bar and nothing in them is cut. A Table with only a few columns is squeezed to fit instead and a long cell ends in an ellipsis (the reader can tap it to read it whole), so still keep table cells short and put long text in Text or Markdown. A Table pins nothing unless you ask: when it has many columns and a row is hard to read without its first one, list the one or two narrow columns that identify each row (a name, an id) in \`pin\`, e.g. Table(columns, rows, null, ["Host"]). They stay in place while the rest scrolls. Do not pin a long column or one that is not needed to tell the rows apart: pinned columns take at most half the width, one that would not fit is not pinned, and the table scrolls whole without it.
601
733
 
602
734
  ## Example
603
735
  root = Stack([title, kpis, load, go])