@sigrun/tui 0.1.0 → 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.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/actions.d.ts +43 -2
  3. package/dist/api.d.ts +5 -0
  4. package/dist/api.js +5 -1
  5. package/dist/blocks/braille.d.ts +41 -0
  6. package/dist/blocks/braille.js +131 -0
  7. package/dist/blocks/chart.d.ts +20 -0
  8. package/dist/blocks/chart.js +397 -0
  9. package/dist/blocks/collection/calendar.d.ts +28 -0
  10. package/dist/blocks/collection/calendar.js +232 -0
  11. package/dist/blocks/collection/card.d.ts +36 -0
  12. package/dist/blocks/collection/card.js +128 -0
  13. package/dist/blocks/collection/collection.d.ts +9 -0
  14. package/dist/blocks/collection/collection.js +26 -0
  15. package/dist/blocks/collection/grid.d.ts +18 -0
  16. package/dist/blocks/collection/grid.js +72 -0
  17. package/dist/blocks/collection/lanes.d.ts +32 -0
  18. package/dist/blocks/collection/lanes.js +320 -0
  19. package/dist/blocks/collection/list.d.ts +30 -0
  20. package/dist/blocks/collection/list.js +273 -0
  21. package/dist/blocks/collection/move.d.ts +67 -0
  22. package/dist/blocks/collection/move.js +96 -0
  23. package/dist/blocks/collection/shared.d.ts +87 -0
  24. package/dist/blocks/collection/shared.js +177 -0
  25. package/dist/blocks/collection/table.d.ts +26 -0
  26. package/dist/blocks/collection/table.js +221 -0
  27. package/dist/blocks/content.d.ts +48 -0
  28. package/dist/blocks/content.js +253 -0
  29. package/dist/blocks/context.d.ts +96 -0
  30. package/dist/blocks/context.js +65 -0
  31. package/dist/blocks/diff.d.ts +21 -0
  32. package/dist/blocks/diff.js +25 -0
  33. package/dist/blocks/ext-transport.d.ts +70 -0
  34. package/dist/blocks/ext-transport.js +165 -0
  35. package/dist/blocks/form/choice.d.ts +54 -0
  36. package/dist/blocks/form/choice.js +175 -0
  37. package/dist/blocks/form/fields.d.ts +56 -0
  38. package/dist/blocks/form/fields.js +136 -0
  39. package/dist/blocks/form/file.d.ts +61 -0
  40. package/dist/blocks/form/file.js +121 -0
  41. package/dist/blocks/form/form.d.ts +53 -0
  42. package/dist/blocks/form/form.js +703 -0
  43. package/dist/blocks/form/list.d.ts +37 -0
  44. package/dist/blocks/form/list.js +70 -0
  45. package/dist/blocks/form/values.d.ts +20 -0
  46. package/dist/blocks/form/values.js +104 -0
  47. package/dist/blocks/icons.d.ts +14 -0
  48. package/dist/blocks/icons.js +74 -0
  49. package/dist/blocks/image.d.ts +39 -0
  50. package/dist/blocks/image.js +82 -0
  51. package/dist/blocks/layout.d.ts +31 -0
  52. package/dist/blocks/layout.js +155 -0
  53. package/dist/blocks/log.d.ts +24 -0
  54. package/dist/blocks/log.js +111 -0
  55. package/dist/blocks/meter.d.ts +16 -0
  56. package/dist/blocks/meter.js +50 -0
  57. package/dist/blocks/page.d.ts +54 -0
  58. package/dist/blocks/page.js +355 -0
  59. package/dist/blocks/render.d.ts +31 -0
  60. package/dist/blocks/render.js +145 -0
  61. package/dist/blocks/surface-view.d.ts +33 -0
  62. package/dist/blocks/surface-view.js +86 -0
  63. package/dist/blocks/surface.d.ts +75 -0
  64. package/dist/blocks/surface.js +468 -0
  65. package/dist/blocks/tones.d.ts +23 -0
  66. package/dist/blocks/tones.js +63 -0
  67. package/dist/blocks/treeview.d.ts +22 -0
  68. package/dist/blocks/treeview.js +121 -0
  69. package/dist/blocks/width.d.ts +32 -0
  70. package/dist/blocks/width.js +70 -0
  71. package/dist/keymaps/emacs.js +39 -0
  72. package/dist/keymaps/standard.js +45 -1
  73. package/dist/keymaps/vim.js +37 -0
  74. package/dist/launch.js +13 -0
  75. package/dist/sse.d.ts +8 -0
  76. package/dist/sse.js +27 -0
  77. package/dist/theme.d.ts +2 -2
  78. package/dist/theme.js +16 -2
  79. package/dist/ui/app.d.ts +36 -0
  80. package/dist/ui/app.js +84 -0
  81. package/dist/ui/interactions.js +2 -7
  82. package/dist/ui/sidebar.d.ts +25 -2
  83. package/dist/ui/sidebar.js +64 -0
  84. package/package.json +3 -3
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The TUI's extension-surface transport (UI-blocks U3 Task 5): implements
3
+ * the shared `SurfaceTransport` contract over the U3 routes (spec §7.2)
4
+ * as the TUI's remote client. One transport = one surface instance
5
+ * (slug + id + params); the wire `k` (hashed instance key) keys the
6
+ * change-event routing in {@link ExtSurfaceHub}.
7
+ *
8
+ * Like the webui twin, every fetch carries `X-Sigrun-Stream` once the
9
+ * launcher hands the stream id in (the watcher entry's stream);
10
+ * `image()` rejects until U7 ships the route. The channel owns
11
+ * refreshMs cadence, the keepalive, and visibility gating (U2).
12
+ */
13
+ import { createHash } from "node:crypto";
14
+ import { instanceKey } from "@sigrun/shared/blocks";
15
+ import { SigrunApiError } from "../api.js";
16
+ /**
17
+ * The wire `k` for one surface instance (spec §7.3), Node flavor:
18
+ * `sha256(slug + "\0" + id + "\0" + key)` hex. The TUI's transport
19
+ * computes it synchronously (the WebCrypto twin in `@sigrun/shared/
20
+ * blocks` is async — DOM-only).
21
+ */
22
+ export function surfaceKeyHash(slug, id, key) {
23
+ return createHash("sha256")
24
+ .update([slug, id, key].join("\u0000"))
25
+ .digest("hex");
26
+ }
27
+ /** The instance key for one transport's params (the default rule). */
28
+ export function transportKey(options) {
29
+ return instanceKey(options.params ?? {});
30
+ }
31
+ /** The base path for one surface instance (`?params=` omitted when `{}`). */
32
+ function surfacePath(options) {
33
+ const base = `/v1/ext/${encodeURIComponent(options.slug)}/surfaces/${encodeURIComponent(options.id)}`;
34
+ if (options.params === undefined || Object.keys(options.params).length === 0) {
35
+ return base;
36
+ }
37
+ return `${base}?params=${encodeURIComponent(JSON.stringify(options.params))}`;
38
+ }
39
+ function withQuery(path, key, value) {
40
+ return `${path}${path.includes("?") ? "&" : "?"}${key}=${value}`;
41
+ }
42
+ /** Create the TUI's extension-surface transport. */
43
+ export function createExtSurfaceTransport(options) {
44
+ const doFetch = options.fetch ??
45
+ ((input, init) => globalThis.fetch(input, init));
46
+ const baseUrl = options.baseUrl.replace(/\/+$/, "");
47
+ const listeners = new Set();
48
+ // Mutable: a reconnect's new stream id lands via setStreamId — every
49
+ // fetch resolves the header at CALL time.
50
+ let streamId = options.streamId;
51
+ const headers = () => ({
52
+ accept: "application/json",
53
+ ...(options.user === undefined || options.user.trim() === ""
54
+ ? {}
55
+ : { "x-sigrun-user": options.user.trim() }),
56
+ ...(options.token === undefined || options.token.trim() === ""
57
+ ? {}
58
+ : { authorization: `Bearer ${options.token.trim()}` }),
59
+ ...(streamId === undefined
60
+ ? {}
61
+ : { "x-sigrun-stream": streamId }),
62
+ });
63
+ const get = async (path) => {
64
+ const response = await doFetch(`${baseUrl}${path}`, {
65
+ method: "GET",
66
+ headers: headers(),
67
+ });
68
+ if (!response.ok) {
69
+ throw new SigrunApiError("GET", path, response.status, await response.json().catch(() => undefined));
70
+ }
71
+ return (await response.json());
72
+ };
73
+ const transport = {
74
+ load() {
75
+ return get(surfacePath(options));
76
+ },
77
+ fetchData(since) {
78
+ const path = `${surfacePath(options)}/data`;
79
+ return get(since === undefined ? path : withQuery(path, "since", String(since)));
80
+ },
81
+ async act(body) {
82
+ const response = await doFetch(`${baseUrl}${surfacePath(options)}/actions`, {
83
+ method: "POST",
84
+ headers: { ...headers(), "content-type": "application/json" },
85
+ body: JSON.stringify({ ...body, params: options.params ?? {} }),
86
+ });
87
+ return (await response.json());
88
+ },
89
+ async upload(body) {
90
+ const base = surfacePath(options);
91
+ const path = withQuery(`${baseUrl}${base}/uploads/${encodeURIComponent(body.field)}/${encodeURIComponent(body.name)}`, "params", encodeURIComponent(JSON.stringify(options.params ?? {})));
92
+ const response = await doFetch(path, {
93
+ method: "PUT",
94
+ headers: { ...headers(), "content-type": "application/octet-stream" },
95
+ // SAFETY: Node's fetch types Uint8Array<ArrayBuffer> narrower
96
+ // than the Uint8Array<ArrayBufferLike> the wire body carries;
97
+ // the runtime accepts any ArrayBuffer view.
98
+ body: body.bytes,
99
+ });
100
+ return (await response.json());
101
+ },
102
+ image() {
103
+ return Promise.reject(new Error("ext surface image route ships with U7"));
104
+ },
105
+ onChanged(listener) {
106
+ listeners.add(listener);
107
+ return () => {
108
+ listeners.delete(listener);
109
+ };
110
+ },
111
+ };
112
+ return {
113
+ transport,
114
+ setStreamId(next) {
115
+ streamId = next;
116
+ },
117
+ };
118
+ }
119
+ /**
120
+ * The change-event router (the launcher's dispatch seam): `attach`
121
+ * creates a transport whose `onChanged` subscribes into the hub under
122
+ * the instance's (slug, id, k); `dispatch` fans a `surface.changed`
123
+ * event out to the matching subscriptions only.
124
+ */
125
+ export class ExtSurfaceHub {
126
+ #subscriptions = new Set();
127
+ /** Create a transport whose `onChanged` subscribes into this hub. */
128
+ attach(options, keyHash) {
129
+ const base = createExtSurfaceTransport(options);
130
+ const hub = this;
131
+ const transport = {
132
+ load: () => base.transport.load(),
133
+ fetchData: (since) => base.transport.fetchData(since),
134
+ act: (body) => base.transport.act(body),
135
+ upload: (body) => base.transport.upload(body),
136
+ image: (src, opts) => base.transport.image(src, opts),
137
+ onChanged(listener) {
138
+ const subscription = {
139
+ slug: options.slug,
140
+ id: options.id,
141
+ keyHash,
142
+ listener,
143
+ };
144
+ hub.#subscriptions.add(subscription);
145
+ return () => {
146
+ hub.#subscriptions.delete(subscription);
147
+ };
148
+ },
149
+ };
150
+ return {
151
+ transport,
152
+ setStreamId: (next) => base.setStreamId(next),
153
+ };
154
+ }
155
+ /** Fire every subscription matching the event's (slug, id, k). */
156
+ dispatch(event) {
157
+ for (const subscription of this.#subscriptions) {
158
+ if (subscription.slug === event.slug &&
159
+ subscription.id === event.id &&
160
+ subscription.keyHash === event.k) {
161
+ subscription.listener({ rev: event.rev, treeRev: event.treeRev });
162
+ }
163
+ }
164
+ }
165
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The choice field (UI-blocks U2 Task 5, spec §5.6 — the port of U1's
3
+ * `ChoiceField.tsx` value rules): single value = the selected id; multi
4
+ * = the ids; `allowOther` adds a free-text row whose value is
5
+ * `{other: text}` (single) or the ids plus `{other: text}` as the last
6
+ * element (multi). The TUI shows the focused option's `preview`
7
+ * markdown BELOW the options (§5.6's TUI note) at every width. An
8
+ * `allowOther` field appends a final "Type something" row; typing on it
9
+ * edits the free text.
10
+ *
11
+ * v1 degradation note: `preview` renders via wrapPlain (plain wrapped
12
+ * text), not the markdown renderer — the preview stays one small
13
+ * dim block under the options.
14
+ */
15
+ import type { ChoiceField, ChoiceOption, JsonValue } from "@sigrun/shared/blocks";
16
+ import { type BlockCtx } from "../context.js";
17
+ import { EditBuffer } from "./fields.js";
18
+ /** The `allowOther` row label (spec §5.6; overridable via its label). */
19
+ export declare const OTHER_ROW_DEFAULT = "Type something";
20
+ /** The selected id list of a multi value (strings only — a trailing
21
+ * `{other}` object is not an id). */
22
+ export declare function optionIdList(value: JsonValue): readonly string[];
23
+ /** The free text of an `{other}` value (single) or a trailing `{other}`
24
+ * element (multi). */
25
+ export declare function otherText(value: JsonValue): string;
26
+ /** Resolve the option list (literal or binding). */
27
+ export declare function choiceOptions(field: ChoiceField, ctx: BlockCtx): readonly ChoiceOption[];
28
+ /** The per-field choice state: the option cursor and the other-text
29
+ * editor (value round-trips through the owning form's values). */
30
+ export declare class ChoiceState {
31
+ cursor: number;
32
+ other: EditBuffer;
33
+ }
34
+ /** The number of rows a choice renders: options plus the other row. */
35
+ export declare function choiceRowCount(field: ChoiceField, ctx: BlockCtx): number;
36
+ /** Render the option rows plus the preview of the focused option. */
37
+ export declare function choiceLines(opts: {
38
+ field: ChoiceField;
39
+ ctx: BlockCtx;
40
+ width: number;
41
+ value: JsonValue;
42
+ state: ChoiceState;
43
+ focused: boolean;
44
+ }): string[];
45
+ /** The choice value after pressing Enter/Space on `row` (the option
46
+ * cursor). Returns the next value, or `undefined` when the row is the
47
+ * other row (text typing handles it) or nothing changed. */
48
+ export declare function choiceActivate(field: ChoiceField, ctx: BlockCtx, value: JsonValue, row: number): JsonValue | undefined;
49
+ /** The choice value after the other text changed (empty text clears). */
50
+ export declare function choiceSetOther(field: ChoiceField, value: JsonValue, textValue: string): JsonValue;
51
+ /** The review-line text of a choice value (the stepper's review step). */
52
+ export declare function choiceValueText(field: ChoiceField, ctx: BlockCtx, value: JsonValue): string;
53
+ /** Keep the cursor inside the row count. */
54
+ export declare function clampChoiceCursor(field: ChoiceField, ctx: BlockCtx, state: ChoiceState): void;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * The choice field (UI-blocks U2 Task 5, spec §5.6 — the port of U1's
3
+ * `ChoiceField.tsx` value rules): single value = the selected id; multi
4
+ * = the ids; `allowOther` adds a free-text row whose value is
5
+ * `{other: text}` (single) or the ids plus `{other: text}` as the last
6
+ * element (multi). The TUI shows the focused option's `preview`
7
+ * markdown BELOW the options (§5.6's TUI note) at every width. An
8
+ * `allowOther` field appends a final "Type something" row; typing on it
9
+ * edits the free text.
10
+ *
11
+ * v1 degradation note: `preview` renders via wrapPlain (plain wrapped
12
+ * text), not the markdown renderer — the preview stays one small
13
+ * dim block under the options.
14
+ */
15
+ import { bindRaw } from "../context.js";
16
+ import { truncate, wrapPlain } from "../width.js";
17
+ import { EditBuffer } from "./fields.js";
18
+ /** The `allowOther` row label (spec §5.6; overridable via its label). */
19
+ export const OTHER_ROW_DEFAULT = "Type something";
20
+ /** The selected id list of a multi value (strings only — a trailing
21
+ * `{other}` object is not an id). */
22
+ export function optionIdList(value) {
23
+ if (!Array.isArray(value))
24
+ return [];
25
+ return value.filter((entry) => typeof entry === "string");
26
+ }
27
+ /** The free text of an `{other}` value (single) or a trailing `{other}`
28
+ * element (multi). */
29
+ export function otherText(value) {
30
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
31
+ const other = value.other;
32
+ if (typeof other === "string")
33
+ return other;
34
+ }
35
+ if (Array.isArray(value)) {
36
+ const last = value[value.length - 1];
37
+ if (typeof last === "object" && last !== null && !Array.isArray(last)) {
38
+ const other = last.other;
39
+ if (typeof other === "string")
40
+ return other;
41
+ }
42
+ }
43
+ return "";
44
+ }
45
+ /** Resolve the option list (literal or binding). */
46
+ export function choiceOptions(field, ctx) {
47
+ const resolved = bindRaw(field.options, ctx);
48
+ if (!Array.isArray(resolved))
49
+ return [];
50
+ // The wire contract (§5.6) declares ChoiceOption entries; a malformed
51
+ // entry renders as its raw label text, never a throw.
52
+ return resolved;
53
+ }
54
+ /** The per-field choice state: the option cursor and the other-text
55
+ * editor (value round-trips through the owning form's values). */
56
+ export class ChoiceState {
57
+ cursor = 0;
58
+ other = new EditBuffer("");
59
+ }
60
+ /** The number of rows a choice renders: options plus the other row. */
61
+ export function choiceRowCount(field, ctx) {
62
+ return choiceOptions(field, ctx).length + (field.allowOther !== undefined ? 1 : 0);
63
+ }
64
+ /** Render the option rows plus the preview of the focused option. */
65
+ export function choiceLines(opts) {
66
+ const { field, ctx, width, value, state, focused } = opts;
67
+ const options = choiceOptions(field, ctx);
68
+ const multi = field.multi === true;
69
+ const ids = optionIdList(value);
70
+ const other = otherText(value);
71
+ const singleSelected = typeof value === "string" ? value : "";
72
+ const singleOther = typeof value === "object" && value !== null && !Array.isArray(value);
73
+ const unicode = ctx.unicode;
74
+ const lines = [];
75
+ const radioOn = unicode ? "(*)" : "(*)";
76
+ const radioOff = "( )";
77
+ const checkOn = unicode ? "[x]" : "[x]";
78
+ const checkOff = "[ ]";
79
+ options.forEach((option, index) => {
80
+ const cursor = focused && index === state.cursor ? "> " : " ";
81
+ const box = multi
82
+ ? ids.includes(option.id)
83
+ ? checkOn
84
+ : checkOff
85
+ : singleSelected === option.id
86
+ ? radioOn
87
+ : radioOff;
88
+ const label = bindRaw(option.label, ctx);
89
+ const text = typeof label === "string" ? label : option.id;
90
+ const recommended = option.recommended === true ? (unicode ? " ★" : " *") : "";
91
+ let line = `${cursor}${box} ${text}${recommended}`;
92
+ const description = bindRaw(option.description, ctx);
93
+ if (typeof description === "string" && description !== "") {
94
+ line += ctx.theme.dim(` — ${description}`);
95
+ }
96
+ lines.push(truncate(line, width));
97
+ });
98
+ if (field.allowOther !== undefined) {
99
+ const index = options.length;
100
+ const cursor = focused && index === state.cursor ? "> " : " ";
101
+ const selected = multi ? other !== "" : singleOther;
102
+ const box = multi ? (selected ? checkOn : checkOff) : selected ? radioOn : radioOff;
103
+ const rowLabel = field.allowOther.label ?? OTHER_ROW_DEFAULT;
104
+ const typed = selected ? other : focused && index === state.cursor ? state.other.text : "";
105
+ lines.push(truncate(`${cursor}${box} ${rowLabel}: ${typed}`, width));
106
+ }
107
+ // Preview BELOW the options (§5.6's TUI note) while an option is focused.
108
+ const focusedOption = options[state.cursor];
109
+ if (focused && focusedOption?.preview !== undefined && focusedOption.preview !== "") {
110
+ for (const line of wrapPlain(focusedOption.preview, Math.max(1, width - 2))) {
111
+ lines.push(ctx.theme.dim(` ${line}`));
112
+ }
113
+ }
114
+ return lines;
115
+ }
116
+ /** The choice value after pressing Enter/Space on `row` (the option
117
+ * cursor). Returns the next value, or `undefined` when the row is the
118
+ * other row (text typing handles it) or nothing changed. */
119
+ export function choiceActivate(field, ctx, value, row) {
120
+ const options = choiceOptions(field, ctx);
121
+ const multi = field.multi === true;
122
+ if (row >= options.length)
123
+ return undefined; // the other row
124
+ const option = options[row];
125
+ if (option === undefined)
126
+ return undefined;
127
+ if (!multi)
128
+ return option.id;
129
+ const current = optionIdList(value);
130
+ const other = otherText(value);
131
+ const otherPart = other !== "" ? [{ other }] : [];
132
+ if (current.includes(option.id)) {
133
+ return [...current.filter((id) => id !== option.id), ...otherPart];
134
+ }
135
+ return [...current, option.id, ...otherPart];
136
+ }
137
+ /** The choice value after the other text changed (empty text clears). */
138
+ export function choiceSetOther(field, value, textValue) {
139
+ const multi = field.multi === true;
140
+ if (!multi) {
141
+ return textValue === "" ? "" : { other: textValue };
142
+ }
143
+ const current = optionIdList(value);
144
+ return textValue.trim() === ""
145
+ ? [...current]
146
+ : [...current, { other: textValue }];
147
+ }
148
+ /** The review-line text of a choice value (the stepper's review step). */
149
+ export function choiceValueText(field, ctx, value) {
150
+ const options = choiceOptions(field, ctx);
151
+ const labelOf = (id) => {
152
+ const option = options.find((candidate) => candidate.id === id);
153
+ const label = option === undefined ? undefined : bindRaw(option.label, ctx);
154
+ return typeof label === "string" && label !== "" ? label : id;
155
+ };
156
+ if (field.multi === true) {
157
+ const parts = optionIdList(value).map(labelOf);
158
+ const other = otherText(value);
159
+ if (other !== "")
160
+ parts.push(other);
161
+ return parts.join(", ");
162
+ }
163
+ if (typeof value === "string")
164
+ return value === "" ? "" : labelOf(value);
165
+ const other = otherText(value);
166
+ return other;
167
+ }
168
+ /** Keep the cursor inside the row count. */
169
+ export function clampChoiceCursor(field, ctx, state) {
170
+ const count = choiceRowCount(field, ctx);
171
+ if (state.cursor >= count)
172
+ state.cursor = Math.max(0, count - 1);
173
+ if (state.cursor < 0)
174
+ state.cursor = 0;
175
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Form field chrome (UI-blocks U2 Task 5, spec §5.6): the shared field
3
+ * row (label, value editor line, help, error) and the text editors the
4
+ * simple fields (text, number, date, datetime) drive. The TUI editor is
5
+ * a single editable line: characters insert at the caret, Backspace
6
+ * deletes, arrows move the caret; the caret marker is `▏` (ASCII `|`
7
+ * with ctx.unicode false).
8
+ *
9
+ * The field row renders labels BESIDE fields at regular/wide and ABOVE
10
+ * them at compact (spec §12.3's form row) — the row decides from the
11
+ * render width.
12
+ *
13
+ * Editing keys are spoken directly by the form component (it owns the
14
+ * focus and routes input); the app-level keymaps are untouched (their
15
+ * `surface`-focus keys govern collections, not text editing).
16
+ */
17
+ import type { Bindable, Field, JsonValue } from "@sigrun/shared/blocks";
18
+ import { type BlockCtx } from "../context.js";
19
+ /** The caret marker (spec §10.2's degradation rule). */
20
+ export declare function caretGlyph(unicode: boolean): string;
21
+ /** The width the label column takes at regular/wide (labels beside). */
22
+ export declare const LABEL_CELLS = 20;
23
+ /**
24
+ * A single-line text editor buffer: the value plus the caret. Shared
25
+ * by every text-shaped field (text, number raw, date, datetime,
26
+ * longtext non-code, choice's other text, list item editors).
27
+ */
28
+ export declare class EditBuffer {
29
+ #private;
30
+ constructor(initial: string);
31
+ get text(): string;
32
+ get caret(): number;
33
+ /** Handle one input chunk; returns true when the key was an edit. */
34
+ input(data: string): boolean;
35
+ /** The displayed line with the caret marker inserted. */
36
+ render(unicode?: boolean): string;
37
+ }
38
+ /** A field's label/help resolved to text. */
39
+ export declare function fieldLabel(field: Field, ctx: BlockCtx): string;
40
+ /** The value's display text (the editor seed; numbers stringify). */
41
+ export declare function valueText(value: JsonValue): string;
42
+ /** Render a bindable to text (labels, help). */
43
+ export declare function text(value: Bindable<string> | undefined, ctx: BlockCtx): string;
44
+ /**
45
+ * The field-row chrome: label (beside at regular/wide, above at
46
+ * compact), the editor lines, then help and the error (client or the
47
+ * server field error, §5.6) under the field.
48
+ */
49
+ export declare function fieldRowLines(opts: {
50
+ field: Field;
51
+ ctx: BlockCtx;
52
+ width: number;
53
+ /** The editor lines (the focused field's editor shows the caret). */
54
+ editor: readonly string[];
55
+ error?: string;
56
+ }): string[];
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Form field chrome (UI-blocks U2 Task 5, spec §5.6): the shared field
3
+ * row (label, value editor line, help, error) and the text editors the
4
+ * simple fields (text, number, date, datetime) drive. The TUI editor is
5
+ * a single editable line: characters insert at the caret, Backspace
6
+ * deletes, arrows move the caret; the caret marker is `▏` (ASCII `|`
7
+ * with ctx.unicode false).
8
+ *
9
+ * The field row renders labels BESIDE fields at regular/wide and ABOVE
10
+ * them at compact (spec §12.3's form row) — the row decides from the
11
+ * render width.
12
+ *
13
+ * Editing keys are spoken directly by the form component (it owns the
14
+ * focus and routes input); the app-level keymaps are untouched (their
15
+ * `surface`-focus keys govern collections, not text editing).
16
+ */
17
+ import { bindText } from "../context.js";
18
+ import { truncate, widthClassOf, wrapPlain } from "../width.js";
19
+ /** The caret marker (spec §10.2's degradation rule). */
20
+ export function caretGlyph(unicode) {
21
+ return unicode ? "▏" : "|";
22
+ }
23
+ /** The width the label column takes at regular/wide (labels beside). */
24
+ export const LABEL_CELLS = 20;
25
+ /**
26
+ * A single-line text editor buffer: the value plus the caret. Shared
27
+ * by every text-shaped field (text, number raw, date, datetime,
28
+ * longtext non-code, choice's other text, list item editors).
29
+ */
30
+ export class EditBuffer {
31
+ #text;
32
+ #caret;
33
+ constructor(initial) {
34
+ this.#text = initial;
35
+ this.#caret = initial.length;
36
+ }
37
+ get text() {
38
+ return this.#text;
39
+ }
40
+ get caret() {
41
+ return this.#caret;
42
+ }
43
+ /** Handle one input chunk; returns true when the key was an edit. */
44
+ input(data) {
45
+ if (data === "\x1b[D") {
46
+ // Left
47
+ if (this.#caret > 0)
48
+ this.#caret -= 1;
49
+ return true;
50
+ }
51
+ if (data === "\x1b[C") {
52
+ // Right
53
+ if (this.#caret < this.#text.length)
54
+ this.#caret += 1;
55
+ return true;
56
+ }
57
+ if (data === "\x7f" || data === "\b") {
58
+ if (this.#caret > 0) {
59
+ this.#text = this.#text.slice(0, this.#caret - 1) + this.#text.slice(this.#caret);
60
+ this.#caret -= 1;
61
+ }
62
+ return true;
63
+ }
64
+ // Printable input only (escape sequences and control keys are the
65
+ // form's navigation, not text).
66
+ if (data.length > 0 && !data.startsWith("\x1b") && !/[\x00-\x1f]/u.test(data)) {
67
+ this.#text = this.#text.slice(0, this.#caret) + data + this.#text.slice(this.#caret);
68
+ this.#caret += data.length;
69
+ return true;
70
+ }
71
+ return false;
72
+ }
73
+ /** The displayed line with the caret marker inserted. */
74
+ render(unicode = true) {
75
+ return (this.#text.slice(0, this.#caret) +
76
+ caretGlyph(unicode) +
77
+ this.#text.slice(this.#caret));
78
+ }
79
+ }
80
+ /** A field's label/help resolved to text. */
81
+ export function fieldLabel(field, ctx) {
82
+ return bindText(field.label, ctx) || field.name;
83
+ }
84
+ /** The value's display text (the editor seed; numbers stringify). */
85
+ export function valueText(value) {
86
+ if (typeof value === "string")
87
+ return value;
88
+ if (typeof value === "number" || typeof value === "boolean")
89
+ return String(value);
90
+ return "";
91
+ }
92
+ /** Render a bindable to text (labels, help). */
93
+ export function text(value, ctx) {
94
+ return bindText(value, ctx);
95
+ }
96
+ /**
97
+ * The field-row chrome: label (beside at regular/wide, above at
98
+ * compact), the editor lines, then help and the error (client or the
99
+ * server field error, §5.6) under the field.
100
+ */
101
+ export function fieldRowLines(opts) {
102
+ const { field, ctx, width } = opts;
103
+ const label = fieldLabel(field, ctx) + (field.required === true ? " *" : "");
104
+ const help = text(field.help, ctx);
105
+ const lines = [];
106
+ const beside = widthClassOf(width) !== "compact";
107
+ const labelText = truncate(label, beside ? LABEL_CELLS - 1 : width);
108
+ if (label !== "") {
109
+ if (beside) {
110
+ // Labels beside fields (§12.3): pad the label column, the
111
+ // editor line follows on the same row.
112
+ const first = opts.editor[0] ?? "";
113
+ lines.push(truncate(`${labelText.padEnd(LABEL_CELLS)}${first}`, width));
114
+ for (const extra of opts.editor.slice(1)) {
115
+ lines.push(truncate(`${" ".repeat(LABEL_CELLS)}${extra}`, width));
116
+ }
117
+ }
118
+ else {
119
+ lines.push(ctx.theme.bold(labelText));
120
+ for (const line of opts.editor)
121
+ lines.push(truncate(` ${line}`, width));
122
+ }
123
+ }
124
+ else {
125
+ for (const line of opts.editor)
126
+ lines.push(truncate(line, width));
127
+ }
128
+ if (help !== "") {
129
+ for (const line of wrapPlain(help, width))
130
+ lines.push(ctx.theme.dim(line));
131
+ }
132
+ if (opts.error !== undefined && opts.error !== "") {
133
+ lines.push(ctx.theme.fg("bad")(truncate(opts.error, width)));
134
+ }
135
+ return lines;
136
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The secret and file fields (UI-blocks U2 Task 5, spec §5.6).
3
+ *
4
+ * `secret` is write-only: it NEVER renders a value, only the bound
5
+ * status — `set (stored)` / `set (env)` / `not set` — and a masked
6
+ * editor (`•` per typed character; ASCII `*` with ctx.unicode false).
7
+ *
8
+ * `file` prompts for a path, checks `accept` (MIME patterns) and
9
+ * `maxBytes`, reads the file's bytes, dispatches `onUpload` (a
10
+ * `submit` action, turn false) with the file descriptor plus the base64
11
+ * bytes, and hands the bytes to the surface's upload seam
12
+ * (`uploadBytes`) when the host provides one (§7.2). A failed check
13
+ * shows its error under the field and dispatches nothing.
14
+ */
15
+ import type { FileField, JsonObject, SecretField } from "@sigrun/shared/blocks";
16
+ import { type BlockCtx } from "../context.js";
17
+ /** The secret status text (spec §5.6's exact strings). */
18
+ export declare function secretStatusText(status: unknown): string;
19
+ /** The status line of a secret field: the bound status (the field's
20
+ * name resolved against the form's bind, as U1's SecretField does). */
21
+ export declare function secretStatusLine(field: SecretField, formBind: string, ctx: BlockCtx): string;
22
+ /** The masked editor text: one dot per typed character. */
23
+ export declare function maskText(textValue: string, unicode: boolean): string;
24
+ /** Whether a file matches the accept patterns (comma-separated:
25
+ * `image/*`, `image/png`, `.png`) — U1's `acceptsMime` rule, extended
26
+ * with an extension fallback when the MIME guess is empty. */
27
+ export declare function acceptsMime(accept: string | undefined, file: {
28
+ type: string;
29
+ name: string;
30
+ }): boolean;
31
+ /** The MIME guess from a file name ("" when unknown). */
32
+ export declare function mimeOf(name: string): string;
33
+ /** The outcome of a file pick (checks + read + dispatch shape). */
34
+ export type FilePick = {
35
+ ok: true;
36
+ descriptor: {
37
+ name: string;
38
+ size: number;
39
+ type: string;
40
+ };
41
+ /** The raw bytes (the upload seam's payload). */
42
+ bytes: Uint8Array;
43
+ /** The descriptor plus base64 bytes (the dispatch payload's file). */
44
+ payloadFile: JsonObject;
45
+ } | {
46
+ ok: false;
47
+ error: string;
48
+ };
49
+ /**
50
+ * Check `accept`/`maxBytes`, read the file and build the descriptor +
51
+ * payload. A failure answers `{ok: false, error}` and nothing else
52
+ * happens (no dispatch).
53
+ */
54
+ export declare function pickFile(field: FileField, path: string, read?: (path: string) => Uint8Array, sizeOf?: (path: string) => number): FilePick;
55
+ /** The per-field secret/file editor state (the path/masked buffer). */
56
+ export declare class FileFieldState {
57
+ error: string | undefined;
58
+ uploaded: readonly string[];
59
+ }
60
+ /** The status line a file field shows after an upload. */
61
+ export declare function fileStatusLine(state: FileFieldState, width: number): string | undefined;