@sigrun/tui 0.0.0-stage → 0.1.1
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 +22 -0
- package/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +152 -0
- package/dist/api.js +189 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +96 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +53 -0
- package/dist/blocks/form/form.js +703 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +104 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +355 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +145 -0
- package/dist/blocks/surface-view.d.ts +33 -0
- package/dist/blocks/surface-view.js +86 -0
- package/dist/blocks/surface.d.ts +75 -0
- package/dist/blocks/surface.js +468 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +149 -0
- package/dist/launch.js +446 -0
- package/dist/sse.d.ts +109 -0
- package/dist/sse.js +401 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +81 -0
- package/dist/ui/ambient.js +301 -0
- package/dist/ui/app.d.ts +245 -0
- package/dist/ui/app.js +1325 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +125 -0
- package/dist/ui/conversation-pane.d.ts +123 -0
- package/dist/ui/conversation-pane.js +463 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +90 -0
- package/dist/ui/interactions.js +330 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +49 -5
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list field (UI-blocks U2 Task 5, spec §5.6): a repeatable group —
|
|
3
|
+
* `item` describes the field of ONE entry; the value is an array of
|
|
4
|
+
* entry values. Add (`a` on the field), remove (`d`), and reorder (`u`
|
|
5
|
+
* up / `j` down) honour `min`/`max`. Entries edit inline through the
|
|
6
|
+
* item field's text editor (choice/other items stay text-shaped in v1 —
|
|
7
|
+
* noted in the module header of the form).
|
|
8
|
+
*/
|
|
9
|
+
import type { JsonValue, ListField } from "@sigrun/shared/blocks";
|
|
10
|
+
/** The entries of a list value (never undefined). */
|
|
11
|
+
export declare function listEntries(value: JsonValue): readonly JsonValue[];
|
|
12
|
+
/** Can the list add an entry (`max`)? */
|
|
13
|
+
export declare function listCanAdd(field: ListField, value: JsonValue): boolean;
|
|
14
|
+
/** Can the list remove an entry (`min`)? */
|
|
15
|
+
export declare function listCanRemove(field: ListField, value: JsonValue): boolean;
|
|
16
|
+
/** Add an entry (the item field's empty value). */
|
|
17
|
+
export declare function listAdd(value: JsonValue): JsonValue;
|
|
18
|
+
/** Remove the entry at `index`. */
|
|
19
|
+
export declare function listRemove(value: JsonValue, index: number): JsonValue;
|
|
20
|
+
/** Move the entry at `index` by `delta` (reorder; clamped). */
|
|
21
|
+
export declare function listMove(value: JsonValue, index: number, delta: number): JsonValue;
|
|
22
|
+
/** Replace the entry at `index`. */
|
|
23
|
+
export declare function listSet(value: JsonValue, index: number, entry: JsonValue): JsonValue;
|
|
24
|
+
/** The per-field list state: the entry cursor. */
|
|
25
|
+
export declare class ListFieldState {
|
|
26
|
+
cursor: number;
|
|
27
|
+
}
|
|
28
|
+
/** Render the entries (one editor line each) plus the hint row. */
|
|
29
|
+
export declare function listLines(opts: {
|
|
30
|
+
field: ListField;
|
|
31
|
+
width: number;
|
|
32
|
+
value: JsonValue;
|
|
33
|
+
state: ListFieldState;
|
|
34
|
+
focused: boolean;
|
|
35
|
+
/** The rendered editor line of an entry (the form seeds caret state). */
|
|
36
|
+
entryLine(index: number, entry: JsonValue, focused: boolean): string;
|
|
37
|
+
}): string[];
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The list field (UI-blocks U2 Task 5, spec §5.6): a repeatable group —
|
|
3
|
+
* `item` describes the field of ONE entry; the value is an array of
|
|
4
|
+
* entry values. Add (`a` on the field), remove (`d`), and reorder (`u`
|
|
5
|
+
* up / `j` down) honour `min`/`max`. Entries edit inline through the
|
|
6
|
+
* item field's text editor (choice/other items stay text-shaped in v1 —
|
|
7
|
+
* noted in the module header of the form).
|
|
8
|
+
*/
|
|
9
|
+
import { truncate } from "../width.js";
|
|
10
|
+
/** The entries of a list value (never undefined). */
|
|
11
|
+
export function listEntries(value) {
|
|
12
|
+
return Array.isArray(value) ? value : [];
|
|
13
|
+
}
|
|
14
|
+
/** Can the list add an entry (`max`)? */
|
|
15
|
+
export function listCanAdd(field, value) {
|
|
16
|
+
return field.max === undefined || listEntries(value).length < field.max;
|
|
17
|
+
}
|
|
18
|
+
/** Can the list remove an entry (`min`)? */
|
|
19
|
+
export function listCanRemove(field, value) {
|
|
20
|
+
return listEntries(value).length > (field.min ?? 0);
|
|
21
|
+
}
|
|
22
|
+
/** Add an entry (the item field's empty value). */
|
|
23
|
+
export function listAdd(value) {
|
|
24
|
+
return [...listEntries(value), ""];
|
|
25
|
+
}
|
|
26
|
+
/** Remove the entry at `index`. */
|
|
27
|
+
export function listRemove(value, index) {
|
|
28
|
+
return listEntries(value).filter((_, at) => at !== index);
|
|
29
|
+
}
|
|
30
|
+
/** Move the entry at `index` by `delta` (reorder; clamped). */
|
|
31
|
+
export function listMove(value, index, delta) {
|
|
32
|
+
const entries = [...listEntries(value)];
|
|
33
|
+
const target = index + delta;
|
|
34
|
+
if (target < 0 || target >= entries.length)
|
|
35
|
+
return entries;
|
|
36
|
+
const [entry] = entries.splice(index, 1);
|
|
37
|
+
entries.splice(target, 0, entry);
|
|
38
|
+
return entries;
|
|
39
|
+
}
|
|
40
|
+
/** Replace the entry at `index`. */
|
|
41
|
+
export function listSet(value, index, entry) {
|
|
42
|
+
return listEntries(value).map((current, at) => (at === index ? entry : current));
|
|
43
|
+
}
|
|
44
|
+
/** The per-field list state: the entry cursor. */
|
|
45
|
+
export class ListFieldState {
|
|
46
|
+
cursor = 0;
|
|
47
|
+
}
|
|
48
|
+
/** Render the entries (one editor line each) plus the hint row. */
|
|
49
|
+
export function listLines(opts) {
|
|
50
|
+
const { field, width, value, state, focused } = opts;
|
|
51
|
+
const entries = listEntries(value);
|
|
52
|
+
const lines = [];
|
|
53
|
+
entries.forEach((entry, index) => {
|
|
54
|
+
const cursor = focused && index === state.cursor ? "> " : " ";
|
|
55
|
+
lines.push(truncate(`${cursor}${opts.entryLine(index, entry, focused && index === state.cursor)}`, width));
|
|
56
|
+
});
|
|
57
|
+
if (focused) {
|
|
58
|
+
const hints = [];
|
|
59
|
+
if (listCanAdd(field, value))
|
|
60
|
+
hints.push("a add");
|
|
61
|
+
if (entries.length > 0 && listCanRemove(field, value))
|
|
62
|
+
hints.push("d remove");
|
|
63
|
+
if (field.reorder === true && entries.length > 1)
|
|
64
|
+
hints.push("u/j move");
|
|
65
|
+
if (hints.length > 0) {
|
|
66
|
+
lines.push(truncate(` ${hints.join(" · ")}`, width));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return lines;
|
|
70
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form values plumbing (UI-blocks U2 Task 5, spec §5.6 — the port of
|
|
3
|
+
* U1's `form/values.ts`): the form reads its initial values from the
|
|
4
|
+
* bound object and tracks edits locally; these helpers flatten the
|
|
5
|
+
* field tree (sections nest fields), read a field's value out of the
|
|
6
|
+
* bound object, and validate a value against the shared field props
|
|
7
|
+
* (`required`, `min`/`max`, `minLength`/`maxLength`, `pattern`).
|
|
8
|
+
*/
|
|
9
|
+
import type { Field, FormBlock, JsonValue } from "@sigrun/shared/blocks";
|
|
10
|
+
/** Whether a form child is a field (sections hold fields/blocks). */
|
|
11
|
+
export declare function isFieldNode(node: unknown): node is Field;
|
|
12
|
+
/** All fields of the form, depth-first through sections. */
|
|
13
|
+
export declare function collectFields(block: FormBlock): readonly Field[];
|
|
14
|
+
/** The initial value of a field from the bound object (missing →
|
|
15
|
+
* a type-appropriate empty; a secret is write-only — the bound slot
|
|
16
|
+
* carries the STATUS, never a value to echo back, §5.6/§15). */
|
|
17
|
+
export declare function initialValue(field: Field, bound: Record<string, JsonValue>): JsonValue;
|
|
18
|
+
/** Client validation (spec §5.6): the error message or null.
|
|
19
|
+
* `label` names the field in the message. */
|
|
20
|
+
export declare function validateField(field: Field, value: JsonValue, label: string): string | null;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form values plumbing (UI-blocks U2 Task 5, spec §5.6 — the port of
|
|
3
|
+
* U1's `form/values.ts`): the form reads its initial values from the
|
|
4
|
+
* bound object and tracks edits locally; these helpers flatten the
|
|
5
|
+
* field tree (sections nest fields), read a field's value out of the
|
|
6
|
+
* bound object, and validate a value against the shared field props
|
|
7
|
+
* (`required`, `min`/`max`, `minLength`/`maxLength`, `pattern`).
|
|
8
|
+
*/
|
|
9
|
+
/** Whether a form child is a field (sections hold fields/blocks). */
|
|
10
|
+
export function isFieldNode(node) {
|
|
11
|
+
return (typeof node === "object" &&
|
|
12
|
+
node !== null &&
|
|
13
|
+
typeof node.type === "string" &&
|
|
14
|
+
typeof node.name === "string");
|
|
15
|
+
}
|
|
16
|
+
/** All fields of the form, depth-first through sections. */
|
|
17
|
+
export function collectFields(block) {
|
|
18
|
+
const out = [];
|
|
19
|
+
const walk = (children) => {
|
|
20
|
+
for (const child of children) {
|
|
21
|
+
if (isFieldNode(child))
|
|
22
|
+
out.push(child);
|
|
23
|
+
else if (typeof child === "object" &&
|
|
24
|
+
child !== null &&
|
|
25
|
+
child.type === "section") {
|
|
26
|
+
walk(child.children);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
walk(block.children);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/** The initial value of a field from the bound object (missing →
|
|
34
|
+
* a type-appropriate empty; a secret is write-only — the bound slot
|
|
35
|
+
* carries the STATUS, never a value to echo back, §5.6/§15). */
|
|
36
|
+
export function initialValue(field, bound) {
|
|
37
|
+
if (field.type === "secret")
|
|
38
|
+
return "";
|
|
39
|
+
const value = bound[field.name];
|
|
40
|
+
if (value !== undefined)
|
|
41
|
+
return value;
|
|
42
|
+
switch (field.type) {
|
|
43
|
+
case "toggle":
|
|
44
|
+
return false;
|
|
45
|
+
case "choice":
|
|
46
|
+
return field.multi === true ? [] : "";
|
|
47
|
+
case "list":
|
|
48
|
+
return [];
|
|
49
|
+
case "daterange":
|
|
50
|
+
return {};
|
|
51
|
+
default:
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Client validation (spec §5.6): the error message or null.
|
|
56
|
+
* `label` names the field in the message. */
|
|
57
|
+
export function validateField(field, value, label) {
|
|
58
|
+
const empty = value === undefined ||
|
|
59
|
+
value === null ||
|
|
60
|
+
value === "" ||
|
|
61
|
+
(Array.isArray(value) && value.length === 0) ||
|
|
62
|
+
(typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0);
|
|
63
|
+
if (field.required === true && empty)
|
|
64
|
+
return `${label} is required`;
|
|
65
|
+
if (empty)
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof value === "number") {
|
|
68
|
+
if (field.min !== undefined && value < field.min) {
|
|
69
|
+
return `${label} must be at least ${field.min}`;
|
|
70
|
+
}
|
|
71
|
+
if (field.max !== undefined && value > field.max) {
|
|
72
|
+
return `${label} must be at most ${field.max}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === "string") {
|
|
76
|
+
if (field.minLength !== undefined && value.length < field.minLength) {
|
|
77
|
+
return `${label} must be at least ${field.minLength} characters`;
|
|
78
|
+
}
|
|
79
|
+
if (field.maxLength !== undefined && value.length > field.maxLength) {
|
|
80
|
+
return `${label} must be at most ${field.maxLength} characters`;
|
|
81
|
+
}
|
|
82
|
+
if (field.pattern !== undefined && value !== "") {
|
|
83
|
+
let regex = null;
|
|
84
|
+
try {
|
|
85
|
+
regex = new RegExp(`^(?:${field.pattern})$`);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
regex = null;
|
|
89
|
+
}
|
|
90
|
+
if (regex !== null && !regex.test(value)) {
|
|
91
|
+
return `${label} does not match ${field.pattern}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
if (field.min !== undefined && value.length < field.min) {
|
|
97
|
+
return `${label} needs at least ${field.min} entries`;
|
|
98
|
+
}
|
|
99
|
+
if (field.max !== undefined && value.length > field.max) {
|
|
100
|
+
return `${label} allows at most ${field.max} entries`;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named icons in the terminal (UI-blocks spec §10.2): each of the 49
|
|
3
|
+
* closed-set names maps to a Unicode glyph with an ASCII fallback. The
|
|
4
|
+
* ASCII form is used when the terminal can't be assumed to render the
|
|
5
|
+
* glyph (the plain/no-Unicode path, and tests that assert structure).
|
|
6
|
+
*
|
|
7
|
+
* Glyphs are chosen for terminal legibility over pictorial fidelity
|
|
8
|
+
* (a 1-cell glyph beats a 2-cell emoji that shifts the grid): mostly
|
|
9
|
+
* Miscellaneous Symbols and Dingbats, all narrow in standard terminals.
|
|
10
|
+
*/
|
|
11
|
+
import type { IconName } from "@sigrun/shared/blocks";
|
|
12
|
+
/** The icon glyph for `name` (the ASCII fallback when the terminal
|
|
13
|
+
* lacks Unicode support — callers pass `!ctx.unicode`). */
|
|
14
|
+
export declare function iconGlyph(name: IconName, ascii: boolean): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named icons in the terminal (UI-blocks spec §10.2): each of the 49
|
|
3
|
+
* closed-set names maps to a Unicode glyph with an ASCII fallback. The
|
|
4
|
+
* ASCII form is used when the terminal can't be assumed to render the
|
|
5
|
+
* glyph (the plain/no-Unicode path, and tests that assert structure).
|
|
6
|
+
*
|
|
7
|
+
* Glyphs are chosen for terminal legibility over pictorial fidelity
|
|
8
|
+
* (a 1-cell glyph beats a 2-cell emoji that shifts the grid): mostly
|
|
9
|
+
* Miscellaneous Symbols and Dingbats, all narrow in standard terminals.
|
|
10
|
+
*/
|
|
11
|
+
/** The Unicode glyph and its ASCII fallback, per icon name. */
|
|
12
|
+
const GLYPHS = {
|
|
13
|
+
// navigation and actions
|
|
14
|
+
add: ["+", "+"],
|
|
15
|
+
edit: ["✎", "e"],
|
|
16
|
+
delete: ["✖", "x"],
|
|
17
|
+
refresh: ["↻", "r"],
|
|
18
|
+
search: ["⌕", "/"],
|
|
19
|
+
filter: ["▽", "f"],
|
|
20
|
+
share: ["⇱", "s"],
|
|
21
|
+
copy: ["⧉", "c"],
|
|
22
|
+
link: ["⚓", "l"],
|
|
23
|
+
external: ["↗", "^"],
|
|
24
|
+
more: ["⋯", "."],
|
|
25
|
+
check: ["✓", "v"],
|
|
26
|
+
close: ["✕", "x"],
|
|
27
|
+
drag: ["⠿", "#"],
|
|
28
|
+
// objects
|
|
29
|
+
file: ["▤", "f"],
|
|
30
|
+
folder: ["▸", ">"],
|
|
31
|
+
calendar: ["▦", "#"],
|
|
32
|
+
clock: ["◷", "o"],
|
|
33
|
+
user: ["◔", "u"],
|
|
34
|
+
users: ["◍", "U"],
|
|
35
|
+
tag: ["◇", "t"],
|
|
36
|
+
list: ["☰", "="],
|
|
37
|
+
board: ["▥", "|"],
|
|
38
|
+
chart: ["▄", "c"],
|
|
39
|
+
image: ["▣", "i"],
|
|
40
|
+
book: ["❑", "b"],
|
|
41
|
+
cart: ["▤", "C"],
|
|
42
|
+
home: ["⌂", "h"],
|
|
43
|
+
bell: ["⚑", "B"],
|
|
44
|
+
lock: ["▪", "L"],
|
|
45
|
+
key: ["◂", "K"],
|
|
46
|
+
// status
|
|
47
|
+
info: ["ℹ", "i"],
|
|
48
|
+
warn: ["▲", "!"],
|
|
49
|
+
error: ["✖", "!!"],
|
|
50
|
+
success: ["✓", "v"],
|
|
51
|
+
pending: ["◌", "-"],
|
|
52
|
+
running: ["▶", "R"],
|
|
53
|
+
// weather
|
|
54
|
+
sun: ["☀", "*"],
|
|
55
|
+
moon: ["☾", ")"],
|
|
56
|
+
cloud: ["☁", "o"],
|
|
57
|
+
"cloud-sun": ["⛅", "o*"],
|
|
58
|
+
"cloud-rain": ["☔", "or"],
|
|
59
|
+
"cloud-snow": ["❄", "os"],
|
|
60
|
+
"cloud-lightning": ["⚡", "ol"],
|
|
61
|
+
fog: ["≋", "~"],
|
|
62
|
+
wind: ["≈", "w"],
|
|
63
|
+
droplets: ["❍", "d"],
|
|
64
|
+
thermometer: ["▴", "T"],
|
|
65
|
+
umbrella: ["☂", "u"],
|
|
66
|
+
};
|
|
67
|
+
/** The icon glyph for `name` (the ASCII fallback when the terminal
|
|
68
|
+
* lacks Unicode support — callers pass `!ctx.unicode`). */
|
|
69
|
+
export function iconGlyph(name, ascii) {
|
|
70
|
+
const pair = GLYPHS[name];
|
|
71
|
+
if (pair === undefined)
|
|
72
|
+
return ascii ? "?" : name;
|
|
73
|
+
return ascii ? pair[1] : pair[0];
|
|
74
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `image` block (UI-blocks spec §11.3): pi-tui's `Image` when
|
|
3
|
+
* `getCapabilities().images` is kitty/iterm2 and the bytes arrived;
|
|
4
|
+
* `[image: <alt>]` otherwise (no protocol, a failed or refused fetch,
|
|
5
|
+
* a missing alt). Every non-PNG source is requested with `as=png`
|
|
6
|
+
* (the server-side conversion, ruling 17); TMUX handling is pi-tui's
|
|
7
|
+
* own (`terminal-image.js` turns images off under TMUX unless
|
|
8
|
+
* `PI_IMAGE_PROTOCOL` overrides) — this block adds no detection.
|
|
9
|
+
*
|
|
10
|
+
* The byte-fetch is an injectable seam ({@link FetchImageBytes}): the
|
|
11
|
+
* surface transport that resolves `asset:`/URL sources lands in
|
|
12
|
+
* Task 4; tests fake it. Images size to the block's width in cells,
|
|
13
|
+
* capped at 20 rows.
|
|
14
|
+
*/
|
|
15
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
16
|
+
import type { ImageBlock } from "@sigrun/shared/blocks";
|
|
17
|
+
import { type BlockCtx } from "./context.js";
|
|
18
|
+
/**
|
|
19
|
+
* Fetch the bytes for one image source. `asPng` asks the transport
|
|
20
|
+
* for the server-side PNG conversion (ruling 17); rejection or a
|
|
21
|
+
* throw renders the alt fallback.
|
|
22
|
+
*/
|
|
23
|
+
export type FetchImageBytes = (src: string, opts: {
|
|
24
|
+
asPng: boolean;
|
|
25
|
+
}) => Promise<Uint8Array>;
|
|
26
|
+
/** The extra options {@link renderImageBlock} takes (the seams). */
|
|
27
|
+
export interface ImageOptions {
|
|
28
|
+
/** The byte transport; absent renders the alt (nothing to fetch with). */
|
|
29
|
+
fetchImage?: FetchImageBytes;
|
|
30
|
+
}
|
|
31
|
+
/** The fallback line for `alt` (`[image]` when the alt is missing). */
|
|
32
|
+
export declare function imageFallbackLine(alt: string): string;
|
|
33
|
+
/** `image`: fetch-then-render, with the alt as the standing fallback. */
|
|
34
|
+
export declare class ImageComponent implements Component {
|
|
35
|
+
#private;
|
|
36
|
+
constructor(block: ImageBlock, ctx: BlockCtx, options?: ImageOptions);
|
|
37
|
+
render(width: number): string[];
|
|
38
|
+
invalidate(): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `image` block (UI-blocks spec §11.3): pi-tui's `Image` when
|
|
3
|
+
* `getCapabilities().images` is kitty/iterm2 and the bytes arrived;
|
|
4
|
+
* `[image: <alt>]` otherwise (no protocol, a failed or refused fetch,
|
|
5
|
+
* a missing alt). Every non-PNG source is requested with `as=png`
|
|
6
|
+
* (the server-side conversion, ruling 17); TMUX handling is pi-tui's
|
|
7
|
+
* own (`terminal-image.js` turns images off under TMUX unless
|
|
8
|
+
* `PI_IMAGE_PROTOCOL` overrides) — this block adds no detection.
|
|
9
|
+
*
|
|
10
|
+
* The byte-fetch is an injectable seam ({@link FetchImageBytes}): the
|
|
11
|
+
* surface transport that resolves `asset:`/URL sources lands in
|
|
12
|
+
* Task 4; tests fake it. Images size to the block's width in cells,
|
|
13
|
+
* capped at 20 rows.
|
|
14
|
+
*/
|
|
15
|
+
import { getCapabilities, Image } from "@earendil-works/pi-tui";
|
|
16
|
+
import { bindText } from "./context.js";
|
|
17
|
+
import { truncate } from "./width.js";
|
|
18
|
+
/** The row cap (spec §11.3). */
|
|
19
|
+
const MAX_IMAGE_ROWS = 20;
|
|
20
|
+
/** Does the source name a PNG already (extension or data URL)? */
|
|
21
|
+
function isPng(src) {
|
|
22
|
+
if (src.startsWith("data:"))
|
|
23
|
+
return src.startsWith("data:image/png");
|
|
24
|
+
return /\.png($|[?#])/i.test(src);
|
|
25
|
+
}
|
|
26
|
+
/** The fallback line for `alt` (`[image]` when the alt is missing). */
|
|
27
|
+
export function imageFallbackLine(alt) {
|
|
28
|
+
return alt === "" ? "[image]" : `[image: ${alt}]`;
|
|
29
|
+
}
|
|
30
|
+
/** `image`: fetch-then-render, with the alt as the standing fallback. */
|
|
31
|
+
export class ImageComponent {
|
|
32
|
+
#block;
|
|
33
|
+
#ctx;
|
|
34
|
+
#options;
|
|
35
|
+
/** The fetched component once the bytes are in (one fetch ever). */
|
|
36
|
+
#image;
|
|
37
|
+
/** "idle" not started; "pending" renders [] while the fetch runs. */
|
|
38
|
+
#state = "idle";
|
|
39
|
+
constructor(block, ctx, options = {}) {
|
|
40
|
+
this.#block = block;
|
|
41
|
+
this.#ctx = ctx;
|
|
42
|
+
this.#options = options;
|
|
43
|
+
}
|
|
44
|
+
render(width) {
|
|
45
|
+
const alt = bindText(this.#block.alt, this.#ctx);
|
|
46
|
+
const protocol = getCapabilities().images;
|
|
47
|
+
const src = bindText(this.#block.src, this.#ctx);
|
|
48
|
+
const fetch = this.#options.fetchImage;
|
|
49
|
+
if (protocol !== "kitty" && protocol !== "iterm2") {
|
|
50
|
+
return [truncate(imageFallbackLine(alt), width)];
|
|
51
|
+
}
|
|
52
|
+
if (fetch === undefined || src === "") {
|
|
53
|
+
return [truncate(imageFallbackLine(alt), width)];
|
|
54
|
+
}
|
|
55
|
+
if (this.#state === "idle") {
|
|
56
|
+
this.#state = "pending";
|
|
57
|
+
void fetch(src, { asPng: !isPng(src) })
|
|
58
|
+
.then((bytes) => {
|
|
59
|
+
let binary = "";
|
|
60
|
+
for (const byte of bytes)
|
|
61
|
+
binary += String.fromCharCode(byte);
|
|
62
|
+
this.#image = new Image(btoa(binary), "image/png", { fallbackColor: this.#ctx.theme.dim }, { maxWidthCells: width, maxHeightCells: MAX_IMAGE_ROWS });
|
|
63
|
+
})
|
|
64
|
+
.catch(() => {
|
|
65
|
+
this.#state = "failed";
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
if (this.#state === "failed") {
|
|
69
|
+
return [truncate(imageFallbackLine(alt), width)];
|
|
70
|
+
}
|
|
71
|
+
if (this.#image === undefined) {
|
|
72
|
+
// Bytes pending: render nothing until the fetch resolves
|
|
73
|
+
// (the host re-renders; a transient empty beats a flash of
|
|
74
|
+
// the fallback line).
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return this.#image.render(width);
|
|
78
|
+
}
|
|
79
|
+
invalidate() {
|
|
80
|
+
this.#image?.invalidate();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout blocks (UI-blocks spec §5.2): `stack`, `columns`, `grid`,
|
|
3
|
+
* `section` as pi-tui components.
|
|
4
|
+
*
|
|
5
|
+
* Columns/grid honour the compact fallbacks (spec §12.3): `columns`
|
|
6
|
+
* stack when the minimums don't fit; `grid` runs
|
|
7
|
+
* `max(1, floor(width / minItemWidth))` columns so a compact pane gets
|
|
8
|
+
* one. Every line every block emits is clamped to the pane
|
|
9
|
+
* ({@link truncate} — Review Focus 2).
|
|
10
|
+
*/
|
|
11
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { ColumnsBlock, GridBlock, SectionBlock, StackBlock } from "@sigrun/shared/blocks";
|
|
13
|
+
import { type BlockCtx } from "./context.js";
|
|
14
|
+
/**
|
|
15
|
+
* A component from a width-aware line builder. Each produced line is
|
|
16
|
+
* clamped to the render width (the invariant every block must keep).
|
|
17
|
+
*/
|
|
18
|
+
export declare class Lines implements Component {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(build: (width: number) => string[]);
|
|
21
|
+
render(width: number): string[];
|
|
22
|
+
invalidate(): void;
|
|
23
|
+
}
|
|
24
|
+
/** Render `children` vertically with `gap` blank lines between. */
|
|
25
|
+
export declare function renderStack(block: StackBlock, ctx: BlockCtx): Component;
|
|
26
|
+
/** Render children side by side, stacking when the minimums don't fit. */
|
|
27
|
+
export declare function renderColumns(block: ColumnsBlock, ctx: BlockCtx): Component;
|
|
28
|
+
/** Render children in as many columns as `minItemWidth` allows. */
|
|
29
|
+
export declare function renderGrid(block: GridBlock, ctx: BlockCtx): Component;
|
|
30
|
+
/** Render a titled, optionally toned group of children. */
|
|
31
|
+
export declare function renderSection(block: SectionBlock, ctx: BlockCtx): Component;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layout blocks (UI-blocks spec §5.2): `stack`, `columns`, `grid`,
|
|
3
|
+
* `section` as pi-tui components.
|
|
4
|
+
*
|
|
5
|
+
* Columns/grid honour the compact fallbacks (spec §12.3): `columns`
|
|
6
|
+
* stack when the minimums don't fit; `grid` runs
|
|
7
|
+
* `max(1, floor(width / minItemWidth))` columns so a compact pane gets
|
|
8
|
+
* one. Every line every block emits is clamped to the pane
|
|
9
|
+
* ({@link truncate} — Review Focus 2).
|
|
10
|
+
*/
|
|
11
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import { renderBlock } from "./render.js";
|
|
13
|
+
import { bindText, bindTone } from "./context.js";
|
|
14
|
+
import { SIZE_CELLS, pad, truncate } from "./width.js";
|
|
15
|
+
import { toneFg, toneMarker } from "./tones.js";
|
|
16
|
+
/** The blank line counts the `gap` tokens map to. */
|
|
17
|
+
const GAP_LINES = { s: 0, m: 1, l: 2 };
|
|
18
|
+
/** The section header rule character. */
|
|
19
|
+
const RULE = "─";
|
|
20
|
+
/**
|
|
21
|
+
* A component from a width-aware line builder. Each produced line is
|
|
22
|
+
* clamped to the render width (the invariant every block must keep).
|
|
23
|
+
*/
|
|
24
|
+
export class Lines {
|
|
25
|
+
#build;
|
|
26
|
+
constructor(build) {
|
|
27
|
+
this.#build = build;
|
|
28
|
+
}
|
|
29
|
+
render(width) {
|
|
30
|
+
return this.#build(width).map((line) => truncate(line, width));
|
|
31
|
+
}
|
|
32
|
+
invalidate() {
|
|
33
|
+
// Stateless render: lines re-assemble on the next render call.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/** Join child blocks vertically, a blank line between neighbours. */
|
|
37
|
+
function stacked(children, ctx, width, gap) {
|
|
38
|
+
const out = [];
|
|
39
|
+
for (const child of children) {
|
|
40
|
+
const lines = renderBlock(child, ctx).render(width);
|
|
41
|
+
if (out.length > 0 && lines.length > 0) {
|
|
42
|
+
for (let i = 0; i < gap; i++)
|
|
43
|
+
out.push("");
|
|
44
|
+
}
|
|
45
|
+
out.push(...lines);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
/** Render `children` vertically with `gap` blank lines between. */
|
|
50
|
+
export function renderStack(block, ctx) {
|
|
51
|
+
return new Lines((width) => stacked(block.children, ctx, width, GAP_LINES[block.gap ?? "m"]));
|
|
52
|
+
}
|
|
53
|
+
/** Render children side by side, stacking when the minimums don't fit. */
|
|
54
|
+
export function renderColumns(block, ctx) {
|
|
55
|
+
return new Lines((width) => {
|
|
56
|
+
const minimums = block.children.map((child) => child.minWidth === undefined ? SIZE_CELLS.s : SIZE_CELLS[child.minWidth]);
|
|
57
|
+
const total = minimums.reduce((a, b) => a + b, 0) + Math.max(0, block.children.length - 1);
|
|
58
|
+
// Compact fallback (spec §12.3): stack when the minimums don't fit.
|
|
59
|
+
if (width < total) {
|
|
60
|
+
return stacked(block.children.map((child) => child.block), ctx, width, 1);
|
|
61
|
+
}
|
|
62
|
+
// Side by side: render each child at a weighted slot, then zip
|
|
63
|
+
// the line arrays into rows (shorter columns pad with blanks).
|
|
64
|
+
const weights = block.children.map((child) => child.weight ?? 1);
|
|
65
|
+
const weightSum = weights.reduce((a, b) => a + b, 0);
|
|
66
|
+
const separators = block.children.length - 1;
|
|
67
|
+
const slots = weights.map((weight) => Math.max(1, Math.floor(((width - separators) * weight) / weightSum)));
|
|
68
|
+
const rendered = block.children.map((child, index) => renderBlock(child.block, ctx).render(slots[index]));
|
|
69
|
+
const height = Math.max(...rendered.map((lines) => lines.length));
|
|
70
|
+
const rows = [];
|
|
71
|
+
for (let row = 0; row < height; row++) {
|
|
72
|
+
rows.push(rendered
|
|
73
|
+
.map((lines, index) => pad(lines[row] ?? "", slots[index]))
|
|
74
|
+
.join(" "));
|
|
75
|
+
}
|
|
76
|
+
return rows;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/** Render children in as many columns as `minItemWidth` allows. */
|
|
80
|
+
export function renderGrid(block, ctx) {
|
|
81
|
+
return new Lines((width) => {
|
|
82
|
+
const minItem = SIZE_CELLS[block.minItemWidth];
|
|
83
|
+
const columns = Math.max(1, Math.floor(width / minItem));
|
|
84
|
+
const columnWidth = Math.max(1, Math.floor((width - (columns - 1)) / columns));
|
|
85
|
+
// Slot each child by its span ("full" spans every column).
|
|
86
|
+
const rows = [];
|
|
87
|
+
let current = [];
|
|
88
|
+
let used = 0;
|
|
89
|
+
const flush = () => {
|
|
90
|
+
if (current.length > 0)
|
|
91
|
+
rows.push(current);
|
|
92
|
+
current = [];
|
|
93
|
+
used = 0;
|
|
94
|
+
};
|
|
95
|
+
for (const child of block.children) {
|
|
96
|
+
let span = 1;
|
|
97
|
+
if (child.span === "full")
|
|
98
|
+
span = columns;
|
|
99
|
+
else if (child.span !== undefined)
|
|
100
|
+
span = Math.min(child.span, columns);
|
|
101
|
+
if (used > 0 && used + span > columns)
|
|
102
|
+
flush();
|
|
103
|
+
current.push({
|
|
104
|
+
span,
|
|
105
|
+
lines: renderBlock(child.block, ctx).render(columnWidth * span + (span - 1)),
|
|
106
|
+
});
|
|
107
|
+
used += span;
|
|
108
|
+
}
|
|
109
|
+
flush();
|
|
110
|
+
// Zip each row's cells into lines (shorter cells pad with blanks).
|
|
111
|
+
const out = [];
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const height = Math.max(...row.map((cell) => cell.lines.length));
|
|
114
|
+
const widths = row.map((cell) => columnWidth * cell.span + (cell.span - 1));
|
|
115
|
+
for (let line = 0; line < height; line++) {
|
|
116
|
+
out.push(row
|
|
117
|
+
.map((cell, index) => pad(cell.lines[line] ?? "", widths[index]))
|
|
118
|
+
.join(" "));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
/** Render a titled, optionally toned group of children. */
|
|
125
|
+
export function renderSection(block, ctx) {
|
|
126
|
+
return new Lines((width) => {
|
|
127
|
+
const title = bindText(block.title ?? "", ctx);
|
|
128
|
+
const subtitle = bindText(block.subtitle ?? "", ctx);
|
|
129
|
+
const out = [];
|
|
130
|
+
if (title !== "") {
|
|
131
|
+
if (block.tone !== undefined) {
|
|
132
|
+
const tone = bindTone(block.tone, ctx);
|
|
133
|
+
const head = `${toneFg(ctx.theme, tone)(toneMarker(tone, !ctx.unicode))} ${title}`;
|
|
134
|
+
const rule = RULE.repeat(Math.max(0, width - visibleWidth(head) - 1));
|
|
135
|
+
out.push(`${head} ${ctx.theme.dim(rule)}`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const rule = RULE.repeat(Math.max(0, width - visibleWidth(title) - 1));
|
|
139
|
+
out.push(`${title} ${ctx.theme.dim(rule)}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (subtitle !== "")
|
|
143
|
+
out.push(ctx.theme.dim(subtitle));
|
|
144
|
+
// `collapsed` is the author's INITIAL state (§5.2): children stay
|
|
145
|
+
// hidden until a later task's toggle expands them.
|
|
146
|
+
const children = block.collapsed
|
|
147
|
+
? []
|
|
148
|
+
: block.children.filter((child) => "name" in child === false);
|
|
149
|
+
const inner = width - 2;
|
|
150
|
+
const body = stacked(children, ctx, Math.max(1, inner), GAP_LINES.m);
|
|
151
|
+
for (const line of body)
|
|
152
|
+
out.push(line === "" ? "" : ` ${line}`);
|
|
153
|
+
return out;
|
|
154
|
+
});
|
|
155
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `log` block (UI-blocks spec §5.3): bound `{at?, level?, text}[]`
|
|
3
|
+
* lines in a block-local viewport of {@link LOG_VIEWPORT_ROWS} rows.
|
|
4
|
+
*
|
|
5
|
+
* With `follow` the viewport pins to the tail until the user scrolls
|
|
6
|
+
* up; scrolling back to the bottom re-arms the following (the
|
|
7
|
+
* transcript convention). Per-level tones default `error`→bad,
|
|
8
|
+
* `warn`→warn, `info`→neutral, `debug`→info (the muted token);
|
|
9
|
+
* `tones` overrides per level. `wrap` folds long lines instead of
|
|
10
|
+
* truncating them.
|
|
11
|
+
*/
|
|
12
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
13
|
+
import type { LogBlock } from "@sigrun/shared/blocks";
|
|
14
|
+
import { type BlockCtx } from "./context.js";
|
|
15
|
+
/** The viewport height in rows. */
|
|
16
|
+
export declare const LOG_VIEWPORT_ROWS = 10;
|
|
17
|
+
/** `log`: the follow-tail viewport. */
|
|
18
|
+
export declare class LogComponent implements Component {
|
|
19
|
+
#private;
|
|
20
|
+
constructor(block: LogBlock, ctx: BlockCtx);
|
|
21
|
+
render(width: number): string[];
|
|
22
|
+
handleInput(data: string): void;
|
|
23
|
+
invalidate(): void;
|
|
24
|
+
}
|