@sigrun/tui 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/package.json +2 -2
  3. package/dist/actions.d.ts +0 -119
  4. package/dist/actions.js +0 -12
  5. package/dist/api.d.ts +0 -152
  6. package/dist/api.js +0 -189
  7. package/dist/blocks/braille.d.ts +0 -41
  8. package/dist/blocks/braille.js +0 -131
  9. package/dist/blocks/chart.d.ts +0 -20
  10. package/dist/blocks/chart.js +0 -397
  11. package/dist/blocks/collection/calendar.d.ts +0 -28
  12. package/dist/blocks/collection/calendar.js +0 -232
  13. package/dist/blocks/collection/card.d.ts +0 -36
  14. package/dist/blocks/collection/card.js +0 -128
  15. package/dist/blocks/collection/collection.d.ts +0 -9
  16. package/dist/blocks/collection/collection.js +0 -26
  17. package/dist/blocks/collection/grid.d.ts +0 -18
  18. package/dist/blocks/collection/grid.js +0 -72
  19. package/dist/blocks/collection/lanes.d.ts +0 -32
  20. package/dist/blocks/collection/lanes.js +0 -320
  21. package/dist/blocks/collection/list.d.ts +0 -30
  22. package/dist/blocks/collection/list.js +0 -273
  23. package/dist/blocks/collection/move.d.ts +0 -67
  24. package/dist/blocks/collection/move.js +0 -96
  25. package/dist/blocks/collection/shared.d.ts +0 -87
  26. package/dist/blocks/collection/shared.js +0 -177
  27. package/dist/blocks/collection/table.d.ts +0 -26
  28. package/dist/blocks/collection/table.js +0 -221
  29. package/dist/blocks/content.d.ts +0 -48
  30. package/dist/blocks/content.js +0 -253
  31. package/dist/blocks/context.d.ts +0 -96
  32. package/dist/blocks/context.js +0 -65
  33. package/dist/blocks/diff.d.ts +0 -21
  34. package/dist/blocks/diff.js +0 -25
  35. package/dist/blocks/ext-transport.d.ts +0 -70
  36. package/dist/blocks/ext-transport.js +0 -165
  37. package/dist/blocks/form/choice.d.ts +0 -54
  38. package/dist/blocks/form/choice.js +0 -175
  39. package/dist/blocks/form/fields.d.ts +0 -56
  40. package/dist/blocks/form/fields.js +0 -136
  41. package/dist/blocks/form/file.d.ts +0 -61
  42. package/dist/blocks/form/file.js +0 -121
  43. package/dist/blocks/form/form.d.ts +0 -53
  44. package/dist/blocks/form/form.js +0 -703
  45. package/dist/blocks/form/list.d.ts +0 -37
  46. package/dist/blocks/form/list.js +0 -70
  47. package/dist/blocks/form/values.d.ts +0 -20
  48. package/dist/blocks/form/values.js +0 -104
  49. package/dist/blocks/icons.d.ts +0 -14
  50. package/dist/blocks/icons.js +0 -74
  51. package/dist/blocks/image.d.ts +0 -39
  52. package/dist/blocks/image.js +0 -82
  53. package/dist/blocks/layout.d.ts +0 -31
  54. package/dist/blocks/layout.js +0 -155
  55. package/dist/blocks/log.d.ts +0 -24
  56. package/dist/blocks/log.js +0 -111
  57. package/dist/blocks/meter.d.ts +0 -16
  58. package/dist/blocks/meter.js +0 -50
  59. package/dist/blocks/page.d.ts +0 -54
  60. package/dist/blocks/page.js +0 -355
  61. package/dist/blocks/render.d.ts +0 -31
  62. package/dist/blocks/render.js +0 -145
  63. package/dist/blocks/surface-view.d.ts +0 -33
  64. package/dist/blocks/surface-view.js +0 -86
  65. package/dist/blocks/surface.d.ts +0 -75
  66. package/dist/blocks/surface.js +0 -468
  67. package/dist/blocks/tones.d.ts +0 -23
  68. package/dist/blocks/tones.js +0 -63
  69. package/dist/blocks/treeview.d.ts +0 -22
  70. package/dist/blocks/treeview.js +0 -121
  71. package/dist/blocks/width.d.ts +0 -32
  72. package/dist/blocks/width.js +0 -70
  73. package/dist/clipboard.d.ts +0 -20
  74. package/dist/clipboard.js +0 -19
  75. package/dist/editor-launch.d.ts +0 -34
  76. package/dist/editor-launch.js +0 -70
  77. package/dist/keymaps/emacs.d.ts +0 -18
  78. package/dist/keymaps/emacs.js +0 -172
  79. package/dist/keymaps/standard.d.ts +0 -17
  80. package/dist/keymaps/standard.js +0 -175
  81. package/dist/keymaps/types.d.ts +0 -43
  82. package/dist/keymaps/types.js +0 -28
  83. package/dist/keymaps/vim.d.ts +0 -48
  84. package/dist/keymaps/vim.js +0 -291
  85. package/dist/launch.d.ts +0 -149
  86. package/dist/launch.js +0 -446
  87. package/dist/sse.d.ts +0 -109
  88. package/dist/sse.js +0 -401
  89. package/dist/theme.d.ts +0 -51
  90. package/dist/theme.js +0 -119
  91. package/dist/ui/ambient.d.ts +0 -81
  92. package/dist/ui/ambient.js +0 -301
  93. package/dist/ui/app.d.ts +0 -245
  94. package/dist/ui/app.js +0 -1325
  95. package/dist/ui/composer.d.ts +0 -66
  96. package/dist/ui/composer.js +0 -125
  97. package/dist/ui/conversation-pane.d.ts +0 -123
  98. package/dist/ui/conversation-pane.js +0 -463
  99. package/dist/ui/help.d.ts +0 -15
  100. package/dist/ui/help.js +0 -80
  101. package/dist/ui/interactions.d.ts +0 -90
  102. package/dist/ui/interactions.js +0 -330
  103. package/dist/ui/picker.d.ts +0 -26
  104. package/dist/ui/picker.js +0 -130
  105. package/dist/ui/sidebar.d.ts +0 -99
  106. package/dist/ui/sidebar.js +0 -269
  107. package/dist/ui/status-bar.d.ts +0 -77
  108. package/dist/ui/status-bar.js +0 -146
package/dist/sse.js DELETED
@@ -1,401 +0,0 @@
1
- /**
2
- * The TUI's §5 conversation stream watcher (Task 4; delta frames in core
3
- * hardening W3 Task 3, spec §7.3 ruling 11): a Node-side SSE client over
4
- * `GET /v1/conversations/:id/stream` carrying `snapshot` and `delta` frames.
5
- *
6
- * This watcher is the TUI's delta folder: `snapshot` REPLACES the held view
7
- * wholesale, `delta` folds into it via the shared `applyDelta`, and every
8
- * accepted frame surfaces to the consumer as a FULL immutable
9
- * `ConversationViewWire` revision — the consumer replaces, never
10
- * accumulates. The server sends a fresh `snapshot` on EVERY connect, so a
11
- * reconnect's snapshot SUBSUMES any frame missed while disconnected — no
12
- * replay, no stale merge (Review Focus 1).
13
- *
14
- * Node 22 global fetch + manual frame parsing: `:` lines are comments,
15
- * `data:` lines accumulate until a blank line dispatches the frame;
16
- * multi-line `data:` joins with `\n`.
17
- *
18
- * Transport failures close the reader and reconnect with exponential
19
- * backoff (base 250ms doubling, capped at 4s, ±30% jitter). Reconnects
20
- * give up after 10 consecutive failures with ONE final `onError`; the
21
- * counter resets on every successful open and at watch start. Transport
22
- * failures (read error, mid-stream close) emit at most one `onError` per
23
- * connection (a read failure and the ensuing close are the same failure);
24
- * a non-OK status on (re)connect reports per attempt — it never opens a
25
- * connection. A malformed frame reports
26
- * `onError` and the stream STAYS OPEN — one bad frame must never kill the
27
- * transport.
28
- */
29
- import { applyDelta } from "@sigrun/shared/view-delta";
30
- /** Parse a frame; `undefined` when the payload is not an object. */
31
- function parseFrame(data) {
32
- let parsed;
33
- try {
34
- parsed = JSON.parse(data);
35
- }
36
- catch {
37
- return undefined;
38
- }
39
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
40
- return undefined;
41
- }
42
- return parsed;
43
- }
44
- /**
45
- * An incremental SSE frame parser: feed it chunks; it emits complete
46
- * frames (`{event, data}`). `:` lines are comments; `data:` accumulates
47
- * until a blank line; the field-name space after `:` is stripped once.
48
- */
49
- function createFrameParser(emit) {
50
- let buffer = "";
51
- let event = "";
52
- let data = [];
53
- function dispatchLine(line) {
54
- if (line === "") {
55
- if (data.length > 0) {
56
- emit({ event, data: data.join("\n") });
57
- }
58
- event = "";
59
- data = [];
60
- return;
61
- }
62
- if (line.startsWith(":")) {
63
- return; // a comment (the heartbeat `: ping`)
64
- }
65
- const colon = line.indexOf(":");
66
- const field = colon === -1 ? line : line.slice(0, colon);
67
- let value = colon === -1 ? "" : line.slice(colon + 1);
68
- if (value.startsWith(" ")) {
69
- value = value.slice(1);
70
- }
71
- if (field === "event") {
72
- event = value;
73
- }
74
- else if (field === "data") {
75
- data.push(value);
76
- }
77
- // `id:` (and unknown fields) are accepted and ignored — every frame
78
- // is a full revision, so the revision cursor is never needed.
79
- }
80
- return (chunk) => {
81
- buffer += chunk;
82
- for (;;) {
83
- const newline = buffer.indexOf("\n");
84
- if (newline === -1)
85
- return;
86
- let line = buffer.slice(0, newline);
87
- buffer = buffer.slice(newline + 1);
88
- if (line.endsWith("\r")) {
89
- line = line.slice(0, -1);
90
- }
91
- dispatchLine(line);
92
- }
93
- };
94
- }
95
- /**
96
- * Watch one conversation's stream. Returns `unwatch()`. Reconnects on
97
- * stream drop; a reconnect's fresh snapshot replaces state wholesale.
98
- */
99
- export function watchConversation(conversationId, handlers, options) {
100
- const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
101
- const baseMs = options.reconnectBaseMs ?? 250;
102
- const maxMs = options.reconnectMaxMs ?? 4_000;
103
- /** Give up after this many consecutive failed reconnects. */
104
- const maxAttempts = 10;
105
- const user = options.user?.trim();
106
- const token = options.token?.trim();
107
- const headers = {
108
- accept: "text/event-stream",
109
- ...(user === undefined || user === "" ? {} : { "x-sigrun-user": user }),
110
- ...(token === undefined || token === ""
111
- ? {}
112
- : { authorization: `Bearer ${token}` }),
113
- };
114
- const url = `${options.baseUrl}/v1/conversations/${conversationId}/stream`;
115
- let closed = false;
116
- let attempts = 0;
117
- let timer;
118
- let abort;
119
- /** Per-attempt onError dedupe (fix wave A10); cleared on open. */
120
- let reportedTransportError = false;
121
- /**
122
- * The held view: the last snapshot, folded with every accepted delta
123
- * since. `undefined` before the first snapshot — a delta arriving then
124
- * is malformed (there is nothing to fold into).
125
- */
126
- let currentView;
127
- function scheduleReconnect() {
128
- attempts += 1;
129
- const base = Math.min(baseMs * 2 ** (attempts - 1), maxMs);
130
- // ±30% jitter: correlated drops must not reconnect in lockstep.
131
- const delay = base * (1 + (Math.random() * 0.6 - 0.3));
132
- timer = setTimeout(() => {
133
- timer = undefined;
134
- void connect();
135
- }, delay);
136
- }
137
- async function connect() {
138
- if (closed)
139
- return;
140
- if (attempts >= maxAttempts) {
141
- // Give up (fix wave A3): the 10th consecutive failure was just
142
- // reported by its caller; emit ONE final notice and stop — no
143
- // 11th fetch fires.
144
- handlers.onError?.(`giving up after ${maxAttempts} failed reconnects`);
145
- return;
146
- }
147
- const controller = new AbortController();
148
- abort = controller;
149
- let response;
150
- try {
151
- response = await fetchImpl(url, {
152
- method: "GET",
153
- headers,
154
- signal: controller.signal,
155
- });
156
- }
157
- catch (error) {
158
- if (closed)
159
- return;
160
- handlers.onError?.(`conversation stream connect failed: ${error.message}`);
161
- scheduleReconnect();
162
- return;
163
- }
164
- if (!response.ok || response.body === null) {
165
- if (closed)
166
- return;
167
- handlers.onError?.(`conversation stream responded with status ${response.status}`);
168
- scheduleReconnect();
169
- return;
170
- }
171
- attempts = 0;
172
- // The open clears the per-attempt dedupe (fix wave A10): the next
173
- // failed connection gets its own single onError.
174
- reportedTransportError = false;
175
- handlers.onOpen?.();
176
- const parser = createFrameParser((frame) => {
177
- if (closed)
178
- return;
179
- const parsed = parseFrame(frame.data);
180
- if (parsed === undefined) {
181
- handlers.onError?.(`malformed ${frame.event === "" ? "message" : frame.event} frame (not a view object)`);
182
- return;
183
- }
184
- if (frame.event === "snapshot") {
185
- // A fresh snapshot SUBSUMES anything missed — the fold re-bases
186
- // on it wholesale; nothing here merges.
187
- currentView = parsed;
188
- handlers.onSnapshot?.(currentView);
189
- }
190
- else if (frame.event === "delta") {
191
- if (currentView === undefined) {
192
- handlers.onError?.("malformed delta frame (arrived before any snapshot)");
193
- return;
194
- }
195
- // Fold, then surface the FULL revision: the consumer's contract
196
- // stays "replace, never accumulate".
197
- currentView = applyDelta(currentView, parsed);
198
- handlers.onView?.(currentView);
199
- }
200
- else {
201
- handlers.onError?.(`unknown stream event "${frame.event}"`);
202
- }
203
- });
204
- const decoder = new TextDecoder();
205
- try {
206
- const reader = response.body.getReader();
207
- for (;;) {
208
- const { done, value } = await reader.read();
209
- if (done)
210
- break;
211
- parser(decoder.decode(value, { stream: true }));
212
- }
213
- }
214
- catch (error) {
215
- if (closed)
216
- return;
217
- // Dedupe (fix wave A10): a read failure and the close that
218
- // follows it are ONE connection failure — one onError.
219
- if (!reportedTransportError) {
220
- reportedTransportError = true;
221
- handlers.onError?.(`conversation stream read failed: ${error.message}`);
222
- }
223
- }
224
- if (!closed) {
225
- if (!reportedTransportError) {
226
- reportedTransportError = true;
227
- handlers.onError?.("conversation stream closed by the server");
228
- }
229
- scheduleReconnect();
230
- }
231
- }
232
- void connect();
233
- return () => {
234
- if (closed)
235
- return;
236
- closed = true;
237
- if (timer !== undefined) {
238
- clearTimeout(timer);
239
- timer = undefined;
240
- }
241
- abort?.abort();
242
- abort = undefined;
243
- };
244
- }
245
- /** Loose parse: the launch wiring only branches on `type`. */
246
- function parseEvent(data) {
247
- let parsed;
248
- try {
249
- parsed = JSON.parse(data);
250
- }
251
- catch {
252
- return undefined;
253
- }
254
- if (typeof parsed !== "object" ||
255
- parsed === null ||
256
- Array.isArray(parsed) ||
257
- typeof parsed.type !== "string") {
258
- return undefined;
259
- }
260
- return parsed;
261
- }
262
- /** Subscribe to the global events stream. Returns the unsubscribe. */
263
- export function watchEvents(handlers, options) {
264
- const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
265
- const baseMs = options.reconnectBaseMs ?? 250;
266
- const maxMs = options.reconnectMaxMs ?? 4_000;
267
- const maxAttempts = 10;
268
- const user = options.user?.trim();
269
- const token = options.token?.trim();
270
- const headers = {
271
- accept: "text/event-stream",
272
- ...(user === undefined || user === "" ? {} : { "x-sigrun-user": user }),
273
- ...(token === undefined || token === ""
274
- ? {}
275
- : { authorization: `Bearer ${token}` }),
276
- };
277
- const url = `${options.baseUrl}/v1/events`;
278
- let closed = false;
279
- let attempts = 0;
280
- let timer;
281
- let abort;
282
- let reportedTransportError = false;
283
- function scheduleReconnect() {
284
- attempts += 1;
285
- const base = Math.min(baseMs * 2 ** (attempts - 1), maxMs);
286
- const delay = base * (1 + (Math.random() * 0.6 - 0.3));
287
- timer = setTimeout(() => {
288
- timer = undefined;
289
- void connect();
290
- }, delay);
291
- }
292
- async function connect() {
293
- if (closed)
294
- return;
295
- if (attempts >= maxAttempts) {
296
- handlers.onError?.(`events stream: giving up after ${maxAttempts} failed reconnects`);
297
- return;
298
- }
299
- const controller = new AbortController();
300
- abort = controller;
301
- let response;
302
- try {
303
- response = await fetchImpl(url, {
304
- method: "GET",
305
- headers,
306
- signal: controller.signal,
307
- });
308
- }
309
- catch (error) {
310
- if (closed)
311
- return;
312
- handlers.onError?.(`events stream connect failed: ${error.message}`);
313
- scheduleReconnect();
314
- return;
315
- }
316
- if (!response.ok || response.body === null) {
317
- if (closed)
318
- return;
319
- handlers.onError?.(`events stream responded with status ${response.status}`);
320
- scheduleReconnect();
321
- return;
322
- }
323
- attempts = 0;
324
- reportedTransportError = false;
325
- handlers.onOpen?.();
326
- const parser = createFrameParser((frame) => {
327
- if (closed)
328
- return;
329
- // The hello frame (U3 Task 4) is connection-local: it names
330
- // THIS stream for the watcher registry — surface fetches send
331
- // the id as `X-Sigrun-Stream`; a NEW id (reconnect) re-fires
332
- // so the channel re-registers. It never reaches onEvent (the
333
- // launch wiring's switch would ignore it anyway — event types
334
- // are an open set). Parsed loosely: the hello payload carries
335
- // `streamId`, not a `type` member.
336
- if (frame.event === "hello") {
337
- let parsed;
338
- try {
339
- parsed = JSON.parse(frame.data);
340
- }
341
- catch {
342
- handlers.onError?.("malformed hello frame (not JSON)");
343
- return;
344
- }
345
- // SAFETY: the JSON.parse output is unknown; the read below
346
- // narrows `streamId` by typeof before use.
347
- const streamId = parsed.streamId;
348
- if (typeof streamId === "string") {
349
- handlers.onHello?.(streamId);
350
- }
351
- else {
352
- handlers.onError?.("malformed hello frame (no streamId)");
353
- }
354
- return;
355
- }
356
- const event = parseEvent(frame.data);
357
- if (event === undefined) {
358
- handlers.onError?.(`malformed ${frame.event === "" ? "message" : frame.event} frame (not an event object)`);
359
- return;
360
- }
361
- handlers.onEvent(event);
362
- });
363
- const decoder = new TextDecoder();
364
- try {
365
- const reader = response.body.getReader();
366
- for (;;) {
367
- const { done, value } = await reader.read();
368
- if (done)
369
- break;
370
- parser(decoder.decode(value, { stream: true }));
371
- }
372
- }
373
- catch (error) {
374
- if (closed)
375
- return;
376
- if (!reportedTransportError) {
377
- reportedTransportError = true;
378
- handlers.onError?.(`events stream read failed: ${error.message}`);
379
- }
380
- }
381
- if (!closed) {
382
- if (!reportedTransportError) {
383
- reportedTransportError = true;
384
- handlers.onError?.("events stream closed by the server");
385
- }
386
- scheduleReconnect();
387
- }
388
- }
389
- void connect();
390
- return () => {
391
- if (closed)
392
- return;
393
- closed = true;
394
- if (timer !== undefined) {
395
- clearTimeout(timer);
396
- timer = undefined;
397
- }
398
- abort?.abort();
399
- abort = undefined;
400
- };
401
- }
package/dist/theme.d.ts DELETED
@@ -1,51 +0,0 @@
1
- /**
2
- * The TUI theme (Task C1, UI refresh): wraps the shared themes module's
3
- * palettes in pi-tui's color pipeline (parseColor → foregroundAnsi /
4
- * backgroundAnsi) so components style text through token names instead
5
- * of raw SGR sequences. The palette DATA lives in one place
6
- * (@sigrun/shared/themes, shared with the webui — zero duplication);
7
- * this module owns only the terminal mapping.
8
- *
9
- * Degradation: `colorMode` threads through foregroundAnsi/backgroundAnsi,
10
- * so a 256color terminal gets `38;5;N` (pi-tui's nearest-256 mapping)
11
- * instead of truecolor `38;2;r;g;b`. The launcher resolves the mode at
12
- * boot via `getTerminalColorMode()`; tests pass it explicitly.
13
- *
14
- * The `code` token is CSS `rgba(...)` (the shared data's overlay chip):
15
- * pi-tui's parseColor takes hex/`rgb()` only, so it is alpha-composited
16
- * over the mode's `bg` token here — the same visual the webui gets from
17
- * layering the overlay on the background.
18
- */
19
- import { type TerminalColorMode } from "@earendil-works/pi-tui";
20
- import { type AccentName, type ModeName, type PaletteName, type PaletteTokens } from "@sigrun/shared/themes";
21
- /** A palette token, the accent colour, or a state colour (warn/bad). */
22
- export type TuiToken = keyof PaletteTokens | "accent" | "warn" | "bad";
23
- /**
24
- * Terminal styling surface: token-named fg/bg plus the plain attribute
25
- * helpers. `fg`/`bg` return string wrappers so call sites compose
26
- * (`theme.fg("muted")(text)`) without holding SGR sequences.
27
- */
28
- export interface TuiTheme {
29
- fg(token: TuiToken): (s: string) => string;
30
- bg(token: TuiToken): (s: string) => string;
31
- bold(s: string): string;
32
- dim(s: string): string;
33
- italic(s: string): string;
34
- }
35
- export interface TuiThemeOptions {
36
- readonly palette: PaletteName;
37
- readonly accent: AccentName;
38
- readonly mode: ModeName;
39
- readonly colorMode: TerminalColorMode;
40
- }
41
- /**
42
- * Build the theme. Precomputes each token's fg/bg SGR (pi-tui's cached
43
- * sequence idiom — `styleTextWithAnsi` without the dependency) and wraps
44
- * it in reset-terminated spans.
45
- */
46
- export declare function createTuiTheme(options: TuiThemeOptions): TuiTheme;
47
- /**
48
- * The no-colour fallback (tests, non-TTY sinks): every method is the
49
- * identity — the structure renders identically without SGR sequences.
50
- */
51
- export declare function plainTuiTheme(): TuiTheme;
package/dist/theme.js DELETED
@@ -1,119 +0,0 @@
1
- /**
2
- * The TUI theme (Task C1, UI refresh): wraps the shared themes module's
3
- * palettes in pi-tui's color pipeline (parseColor → foregroundAnsi /
4
- * backgroundAnsi) so components style text through token names instead
5
- * of raw SGR sequences. The palette DATA lives in one place
6
- * (@sigrun/shared/themes, shared with the webui — zero duplication);
7
- * this module owns only the terminal mapping.
8
- *
9
- * Degradation: `colorMode` threads through foregroundAnsi/backgroundAnsi,
10
- * so a 256color terminal gets `38;5;N` (pi-tui's nearest-256 mapping)
11
- * instead of truecolor `38;2;r;g;b`. The launcher resolves the mode at
12
- * boot via `getTerminalColorMode()`; tests pass it explicitly.
13
- *
14
- * The `code` token is CSS `rgba(...)` (the shared data's overlay chip):
15
- * pi-tui's parseColor takes hex/`rgb()` only, so it is alpha-composited
16
- * over the mode's `bg` token here — the same visual the webui gets from
17
- * layering the overlay on the background.
18
- */
19
- import { backgroundAnsi, foregroundAnsi, parseColor, } from "@earendil-works/pi-tui";
20
- import { ACCENTS, PALETTES, } from "@sigrun/shared/themes";
21
- const RESET = "\u001b[0m";
22
- const attr = (code, s) => `\u001b[${code}m${s}${RESET}`;
23
- /** Alpha-composite a CSS `rgba(r,g,b,a)` over an opaque base. */
24
- function compositeOver(rgba, base) {
25
- const m = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+)\s*)?\)$/.exec(rgba.replace(/\s/g, ""));
26
- if (!m)
27
- return base;
28
- const [, rs, gs, bs, as = "1"] = m;
29
- const [r, g, b, a] = [Number(rs), Number(gs), Number(bs), Number(as)];
30
- const [br, bg, bb] = hexToRgb(base);
31
- const mix = (fg, bgv) => Math.round(fg * a + bgv * (1 - a));
32
- return rgbToHex(mix(r, br), mix(g, bg), mix(b, bb));
33
- }
34
- function hexToRgb(hex) {
35
- const value = hex.replace("#", "");
36
- return [
37
- parseInt(value.slice(0, 2), 16),
38
- parseInt(value.slice(2, 4), 16),
39
- parseInt(value.slice(4, 6), 16),
40
- ];
41
- }
42
- function rgbToHex(r, g, b) {
43
- const two = (n) => n.toString(16).padStart(2, "0");
44
- return `#${two(r)}${two(g)}${two(b)}`;
45
- }
46
- /**
47
- * The state colours (warnings + errors), defined per mode like the
48
- * webui's `--warn`/`--danger` (webui/src/app.css — they are NOT palette
49
- * members in either channel). The TUI's tone table (blocks/tones.ts)
50
- * paints `warn`/`bad` tones with them.
51
- */
52
- const STATE_COLORS = {
53
- warn: { dark: "#d9a35a", light: "#8a6116" },
54
- bad: { dark: "#e0707a", light: "#b3363f" },
55
- };
56
- /** Resolve a token to a parseable colour for the palette+mode. */
57
- function resolveToken(tokens, accentHex, mode, token) {
58
- if (token === "accent")
59
- return accentHex;
60
- if (token === "warn" || token === "bad")
61
- return STATE_COLORS[token][mode];
62
- const value = tokens[token];
63
- // `code` is the rgba overlay chip; composite over the mode's bg.
64
- if (value.startsWith("rgba"))
65
- return compositeOver(value, tokens.bg);
66
- return value;
67
- }
68
- /**
69
- * Build the theme. Precomputes each token's fg/bg SGR (pi-tui's cached
70
- * sequence idiom — `styleTextWithAnsi` without the dependency) and wraps
71
- * it in reset-terminated spans.
72
- */
73
- export function createTuiTheme(options) {
74
- const tokens = PALETTES[options.palette][options.mode];
75
- const accentHex = ACCENTS[options.accent][options.mode];
76
- const mode = options.colorMode;
77
- const fgSequences = new Map();
78
- const bgSequences = new Map();
79
- for (const token of [
80
- "bg",
81
- "surface",
82
- "surface2",
83
- "border",
84
- "text",
85
- "muted",
86
- "faint",
87
- "ok",
88
- "code",
89
- "accent",
90
- "warn",
91
- "bad",
92
- ]) {
93
- const color = parseColor(resolveToken(tokens, accentHex, options.mode, token));
94
- fgSequences.set(token, foregroundAnsi(color, mode));
95
- bgSequences.set(token, backgroundAnsi(color, mode));
96
- }
97
- const span = (seq, s) => `${seq}${s}${RESET}`;
98
- return {
99
- fg: (token) => (s) => span(fgSequences.get(token) ?? "", s),
100
- bg: (token) => (s) => span(bgSequences.get(token) ?? "", s),
101
- bold: (s) => attr("1", s),
102
- dim: (s) => attr("2", s),
103
- italic: (s) => attr("3", s),
104
- };
105
- }
106
- /**
107
- * The no-colour fallback (tests, non-TTY sinks): every method is the
108
- * identity — the structure renders identically without SGR sequences.
109
- */
110
- export function plainTuiTheme() {
111
- const identity = (s) => s;
112
- return {
113
- fg: () => identity,
114
- bg: () => identity,
115
- bold: identity,
116
- dim: identity,
117
- italic: identity,
118
- };
119
- }
@@ -1,81 +0,0 @@
1
- /**
2
- * The ambient seam's TUI renderer (Task 8 — the parity rule): ambient
3
- * surfaces placed in the TUI's chrome per the seam's `place` field. Spec
4
- * §8 leaves the place→chrome mapping to the builder; the TUI's mapping:
5
- *
6
- * - `status` → the statusline: one row, directly above the composer (the
7
- * last row of the app body). Multiple status records join with " · ".
8
- * - `footer` → the footer zone: one summary line per record, above the
9
- * statusline.
10
- * - `panel` → the panel zone: the full rendering (line, rows, gauge,
11
- * ledger), above the footer.
12
- *
13
- * Genericity (the seam's whole point): the chrome renders the closed
14
- * primitives and knows NOTHING about what a surface means — no "todo",
15
- * no "goal". `details` renders as one collapsed JSON line, never
16
- * interpreted. The rendering mirrors the webui's AmbientPanel: rows are
17
- * severity-sorted (0 severe .. 2 quiet, ties by id) with quiet rows
18
- * folded into a "+N quiet" summary; metrics format via the record's
19
- * `format` hint; gauges render label/phase/elapsed plus a text percent
20
- * bar; ledgers render newest-first. Ambient surfaces are read-only (the
21
- * seam routes actions through interaction records).
22
- *
23
- * Fetching (W3 Task 4 — the timer cadence is RETIRED): on conversation
24
- * connect, and on `ambient.changed` host events for the live
25
- * conversation (the app wires `watchEvents` to {@link kick}; an event
26
- * for another conversation is ignored). A record's declared `refreshMs`
27
- * no longer drives a poll — the extension that declares it owns its
28
- * writes, and each write emits the event. Fetch failures are QUIET
29
- * (best-effort chrome; stream transport health is reported separately by
30
- * the watcher's error/open transition notices — the launcher's
31
- * stream-health wiring).
32
- *
33
- * Expiry: `expires: "idle"` surfaces render only while the conversation
34
- * has a live run ({@link setBusy}, driven by the stream's `steer.busy`);
35
- * they disappear when the conversation goes idle and reappear on the next
36
- * busy frame while the record is still served. `expires: "never"` records
37
- * stay until the server stops serving them (replacement-state reads).
38
- *
39
- * Segments: a line's `segments` render as their texts joined with spaces
40
- * in AUTHORED order — the segment `place` zones (left/middle/right) are
41
- * ignored in the statusline join (the TUI chrome has no zone layout; the
42
- * webui owns zoned placement).
43
- */
44
- import type { Component } from "@earendil-works/pi-tui";
45
- import type { AmbientWire, SigrunClient } from "../api.js";
46
- export declare class AmbientChrome implements Component {
47
- #private;
48
- /** Rendered-state changes (the app requests a repaint). */
49
- onChanged?: () => void;
50
- constructor(client: SigrunClient);
51
- /** The latest served surfaces (before the idle filter). */
52
- get records(): readonly AmbientWire[];
53
- /**
54
- * Retarget the chrome: reset state and fetch immediately (the brief's
55
- * "fetch on connect"). `undefined` clears the chrome. (W3 Task 4: no
56
- * timer cadence — `ambient.changed` host events own the refreshes.)
57
- */
58
- setConversation(id: number | undefined): void;
59
- /**
60
- * Refetch when an `ambient.changed` host event names the LIVE
61
- * conversation (the app's events wiring calls this; other
62
- * conversations' events are ignored).
63
- */
64
- kick(conversationId: number): void;
65
- /**
66
- * The conversation's run state (the app feeds it from stream frames).
67
- * `expires: "idle"` surfaces render only while busy.
68
- */
69
- setBusy(busy: boolean): void;
70
- /** The statusline: the status records' summaries joined " · ". */
71
- statusline(): string;
72
- /** The footer zone: one summary line per footer record. */
73
- footerLines(): readonly string[];
74
- /** The panel zone: the full per-record rendering, blocks joined. */
75
- panelLines(): readonly string[];
76
- /** Detach (the app calls this on dispose): stale fetches never land. */
77
- dispose(): void;
78
- /** The chrome: panel blocks, then footer lines, then the statusline. */
79
- render(_width: number): string[];
80
- invalidate(): void;
81
- }