@sigrun/tui 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -1
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +209 -0
- package/dist/api.js +282 -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/chat-transport.d.ts +44 -0
- package/dist/blocks/chat-transport.js +116 -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 +117 -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 +66 -0
- package/dist/blocks/form/form.js +764 -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 +106 -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/interaction-transport.d.ts +42 -0
- package/dist/blocks/interaction-transport.js +79 -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 +360 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +163 -0
- package/dist/blocks/surface-view.d.ts +41 -0
- package/dist/blocks/surface-view.js +137 -0
- package/dist/blocks/surface.d.ts +80 -0
- package/dist/blocks/surface.js +506 -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 +159 -0
- package/dist/launch.js +478 -0
- package/dist/sse.d.ts +112 -0
- package/dist/sse.js +418 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +78 -0
- package/dist/ui/ambient.js +264 -0
- package/dist/ui/app.d.ts +319 -0
- package/dist/ui/app.js +1539 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +133 -0
- package/dist/ui/conversation-pane.d.ts +143 -0
- package/dist/ui/conversation-pane.js +615 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +91 -0
- package/dist/ui/interactions.js +477 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/settings.d.ts +68 -0
- package/dist/ui/settings.js +254 -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 +4 -3
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
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 401 on (re)connect is TERMINAL (Task 5): the auth-failure
|
|
26
|
+
* string surfaces once and the watcher stops — a revoked credential
|
|
27
|
+
* cannot recover by reconnecting, so no retry ladder fires. A malformed
|
|
28
|
+
* frame reports
|
|
29
|
+
* `onError` and the stream STAYS OPEN — one bad frame must never kill the
|
|
30
|
+
* transport.
|
|
31
|
+
*/
|
|
32
|
+
import { type ConversationViewWire } from "./api.js";
|
|
33
|
+
import type { HostEventWire } from "@sigrun/shared/views";
|
|
34
|
+
export interface WatchHandlers {
|
|
35
|
+
/** A `snapshot` frame arrived (the full view on connect/re-attach). */
|
|
36
|
+
readonly onSnapshot?: (view: ConversationViewWire) => void;
|
|
37
|
+
/**
|
|
38
|
+
* A full revision arrived — either the connect snapshot or the fold of
|
|
39
|
+
* every `delta` since it. The view is ALWAYS a full immutable
|
|
40
|
+
* `ConversationViewWire`: replace, never accumulate.
|
|
41
|
+
*/
|
|
42
|
+
readonly onView?: (view: ConversationViewWire) => void;
|
|
43
|
+
/**
|
|
44
|
+
* A failure: a malformed frame (stream stays open) or a transport
|
|
45
|
+
* error (a reconnect follows unless `unwatch` ran). Carries a
|
|
46
|
+
* human-readable description — fail loud, never silent.
|
|
47
|
+
*/
|
|
48
|
+
readonly onError?: (message: string) => void;
|
|
49
|
+
/** The underlying connection (re)opened with an OK response. */
|
|
50
|
+
readonly onOpen?: () => void;
|
|
51
|
+
}
|
|
52
|
+
export interface WatchOptions {
|
|
53
|
+
/**
|
|
54
|
+
* The server's origin (e.g. `http://127.0.0.1:PORT`) — required; the
|
|
55
|
+
* TUI is a remote client.
|
|
56
|
+
*/
|
|
57
|
+
readonly baseUrl: string;
|
|
58
|
+
/** `X-Sigrun-User` identity header (Task 2); omitted when absent/empty. */
|
|
59
|
+
readonly user?: string;
|
|
60
|
+
/**
|
|
61
|
+
* The bearer token (Task 7): `authorization: Bearer <token>` on the
|
|
62
|
+
* stream connect; omitted when absent/empty/whitespace.
|
|
63
|
+
*/
|
|
64
|
+
readonly token?: string;
|
|
65
|
+
/** Backoff ladder base in ms (default 250; tests shrink it). */
|
|
66
|
+
readonly reconnectBaseMs?: number;
|
|
67
|
+
/** Backoff cap in ms (default 4000). */
|
|
68
|
+
readonly reconnectMaxMs?: number;
|
|
69
|
+
/** Injected for tests; defaults to the ambient `fetch`. */
|
|
70
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
71
|
+
}
|
|
72
|
+
/** The watcher handle: `unwatch()` closes the stream, idempotent. */
|
|
73
|
+
export type Unwatch = () => void;
|
|
74
|
+
/**
|
|
75
|
+
* Watch one conversation's stream. Returns `unwatch()`. Reconnects on
|
|
76
|
+
* stream drop; a reconnect's fresh snapshot replaces state wholesale.
|
|
77
|
+
*/
|
|
78
|
+
export declare function watchConversation(conversationId: number, handlers: WatchHandlers, options: WatchOptions): Unwatch;
|
|
79
|
+
/**
|
|
80
|
+
* The host events reader (core hardening W3 Task 4, spec §7.4): a
|
|
81
|
+
* Node-side SSE client over `GET /v1/events` carrying `HostEventWire`
|
|
82
|
+
* frames (`event: <type>` + single-line `data:` JSON). The TUI's launch
|
|
83
|
+
* wiring subscribes ONCE per process: `conversation.*` events refetch the
|
|
84
|
+
* conversation list, `interaction.*` kicks the prompter, `ambient.changed`
|
|
85
|
+
* refetches the ambient chrome — the old list/interaction/ambient poll
|
|
86
|
+
* timers are gone.
|
|
87
|
+
*
|
|
88
|
+
* Same transport discipline as `watchConversation`: backoff ladder
|
|
89
|
+
* (250ms doubling, 4s cap, ±30% jitter), one `onError` per failed
|
|
90
|
+
* connection, give up after 10 consecutive failures, malformed frames
|
|
91
|
+
* report + keep the stream open. There is no replay — a reconnect's
|
|
92
|
+
* first post-connect frame suffices because the handlers REFETCH (the
|
|
93
|
+
* list/interaction/ambient GETs), never merge.
|
|
94
|
+
*/
|
|
95
|
+
export interface EventsHandlers {
|
|
96
|
+
/** A host event arrived (already JSON-parsed). */
|
|
97
|
+
readonly onEvent: (event: HostEventWire) => void;
|
|
98
|
+
/**
|
|
99
|
+
* The hello frame's streamId (UI-blocks U3 Task 4, spec §7.3's W3
|
|
100
|
+
* amendment): fired on EVERY (re)connect — a reconnect's hello
|
|
101
|
+
* carries a NEW id, and the channel re-fetches every visible surface
|
|
102
|
+
* with it (re-registering the watcher entries). Surface fetches send
|
|
103
|
+
* the current id as `X-Sigrun-Stream`.
|
|
104
|
+
*/
|
|
105
|
+
readonly onHello?: (streamId: string) => void;
|
|
106
|
+
/** A failure: malformed frame (stays open) or transport error (reconnects). */
|
|
107
|
+
readonly onError?: (message: string) => void;
|
|
108
|
+
/** The underlying connection (re)opened with an OK response. */
|
|
109
|
+
readonly onOpen?: () => void;
|
|
110
|
+
}
|
|
111
|
+
/** Subscribe to the global events stream. Returns the unsubscribe. */
|
|
112
|
+
export declare function watchEvents(handlers: EventsHandlers, options: WatchOptions): Unwatch;
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
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 401 on (re)connect is TERMINAL (Task 5): the auth-failure
|
|
26
|
+
* string surfaces once and the watcher stops — a revoked credential
|
|
27
|
+
* cannot recover by reconnecting, so no retry ladder fires. A malformed
|
|
28
|
+
* frame reports
|
|
29
|
+
* `onError` and the stream STAYS OPEN — one bad frame must never kill the
|
|
30
|
+
* transport.
|
|
31
|
+
*/
|
|
32
|
+
import { AUTH_FAILURE_MESSAGE } from "./api.js";
|
|
33
|
+
import { applyDelta } from "@sigrun/shared/view-delta";
|
|
34
|
+
/** Parse a frame; `undefined` when the payload is not an object. */
|
|
35
|
+
function parseFrame(data) {
|
|
36
|
+
let parsed;
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(data);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
return parsed;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* An incremental SSE frame parser: feed it chunks; it emits complete
|
|
50
|
+
* frames (`{event, data}`). `:` lines are comments; `data:` accumulates
|
|
51
|
+
* until a blank line; the field-name space after `:` is stripped once.
|
|
52
|
+
*/
|
|
53
|
+
function createFrameParser(emit) {
|
|
54
|
+
let buffer = "";
|
|
55
|
+
let event = "";
|
|
56
|
+
let data = [];
|
|
57
|
+
function dispatchLine(line) {
|
|
58
|
+
if (line === "") {
|
|
59
|
+
if (data.length > 0) {
|
|
60
|
+
emit({ event, data: data.join("\n") });
|
|
61
|
+
}
|
|
62
|
+
event = "";
|
|
63
|
+
data = [];
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (line.startsWith(":")) {
|
|
67
|
+
return; // a comment (the heartbeat `: ping`)
|
|
68
|
+
}
|
|
69
|
+
const colon = line.indexOf(":");
|
|
70
|
+
const field = colon === -1 ? line : line.slice(0, colon);
|
|
71
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
72
|
+
if (value.startsWith(" ")) {
|
|
73
|
+
value = value.slice(1);
|
|
74
|
+
}
|
|
75
|
+
if (field === "event") {
|
|
76
|
+
event = value;
|
|
77
|
+
}
|
|
78
|
+
else if (field === "data") {
|
|
79
|
+
data.push(value);
|
|
80
|
+
}
|
|
81
|
+
// `id:` (and unknown fields) are accepted and ignored — every frame
|
|
82
|
+
// is a full revision, so the revision cursor is never needed.
|
|
83
|
+
}
|
|
84
|
+
return (chunk) => {
|
|
85
|
+
buffer += chunk;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const newline = buffer.indexOf("\n");
|
|
88
|
+
if (newline === -1)
|
|
89
|
+
return;
|
|
90
|
+
let line = buffer.slice(0, newline);
|
|
91
|
+
buffer = buffer.slice(newline + 1);
|
|
92
|
+
if (line.endsWith("\r")) {
|
|
93
|
+
line = line.slice(0, -1);
|
|
94
|
+
}
|
|
95
|
+
dispatchLine(line);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Watch one conversation's stream. Returns `unwatch()`. Reconnects on
|
|
101
|
+
* stream drop; a reconnect's fresh snapshot replaces state wholesale.
|
|
102
|
+
*/
|
|
103
|
+
export function watchConversation(conversationId, handlers, options) {
|
|
104
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
105
|
+
const baseMs = options.reconnectBaseMs ?? 250;
|
|
106
|
+
const maxMs = options.reconnectMaxMs ?? 4_000;
|
|
107
|
+
/** Give up after this many consecutive failed reconnects. */
|
|
108
|
+
const maxAttempts = 10;
|
|
109
|
+
const user = options.user?.trim();
|
|
110
|
+
const token = options.token?.trim();
|
|
111
|
+
const headers = {
|
|
112
|
+
accept: "text/event-stream",
|
|
113
|
+
...(user === undefined || user === "" ? {} : { "x-sigrun-user": user }),
|
|
114
|
+
...(token === undefined || token === ""
|
|
115
|
+
? {}
|
|
116
|
+
: { authorization: `Bearer ${token}` }),
|
|
117
|
+
};
|
|
118
|
+
const url = `${options.baseUrl}/v1/conversations/${conversationId}/stream`;
|
|
119
|
+
let closed = false;
|
|
120
|
+
let attempts = 0;
|
|
121
|
+
let timer;
|
|
122
|
+
let abort;
|
|
123
|
+
/** Per-attempt onError dedupe (fix wave A10); cleared on open. */
|
|
124
|
+
let reportedTransportError = false;
|
|
125
|
+
/**
|
|
126
|
+
* The held view: the last snapshot, folded with every accepted delta
|
|
127
|
+
* since. `undefined` before the first snapshot — a delta arriving then
|
|
128
|
+
* is malformed (there is nothing to fold into).
|
|
129
|
+
*/
|
|
130
|
+
let currentView;
|
|
131
|
+
function scheduleReconnect() {
|
|
132
|
+
attempts += 1;
|
|
133
|
+
const base = Math.min(baseMs * 2 ** (attempts - 1), maxMs);
|
|
134
|
+
// ±30% jitter: correlated drops must not reconnect in lockstep.
|
|
135
|
+
const delay = base * (1 + (Math.random() * 0.6 - 0.3));
|
|
136
|
+
timer = setTimeout(() => {
|
|
137
|
+
timer = undefined;
|
|
138
|
+
void connect();
|
|
139
|
+
}, delay);
|
|
140
|
+
}
|
|
141
|
+
async function connect() {
|
|
142
|
+
if (closed)
|
|
143
|
+
return;
|
|
144
|
+
if (attempts >= maxAttempts) {
|
|
145
|
+
// Give up (fix wave A3): the 10th consecutive failure was just
|
|
146
|
+
// reported by its caller; emit ONE final notice and stop — no
|
|
147
|
+
// 11th fetch fires.
|
|
148
|
+
handlers.onError?.(`giving up after ${maxAttempts} failed reconnects`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
abort = controller;
|
|
153
|
+
let response;
|
|
154
|
+
try {
|
|
155
|
+
response = await fetchImpl(url, {
|
|
156
|
+
method: "GET",
|
|
157
|
+
headers,
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (closed)
|
|
163
|
+
return;
|
|
164
|
+
handlers.onError?.(`conversation stream connect failed: ${error.message}`);
|
|
165
|
+
scheduleReconnect();
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (!response.ok || response.body === null) {
|
|
169
|
+
if (closed)
|
|
170
|
+
return;
|
|
171
|
+
// A 401 (Task 5) is terminal: a credential cannot recover by
|
|
172
|
+
// reconnecting — surface the auth-failure string ONCE and stop
|
|
173
|
+
// (never a silent retry ladder against a revoked token).
|
|
174
|
+
if (response.status === 401) {
|
|
175
|
+
handlers.onError?.(AUTH_FAILURE_MESSAGE);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
handlers.onError?.(`conversation stream responded with status ${response.status}`);
|
|
179
|
+
scheduleReconnect();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
attempts = 0;
|
|
183
|
+
// The open clears the per-attempt dedupe (fix wave A10): the next
|
|
184
|
+
// failed connection gets its own single onError.
|
|
185
|
+
reportedTransportError = false;
|
|
186
|
+
handlers.onOpen?.();
|
|
187
|
+
const parser = createFrameParser((frame) => {
|
|
188
|
+
if (closed)
|
|
189
|
+
return;
|
|
190
|
+
const parsed = parseFrame(frame.data);
|
|
191
|
+
if (parsed === undefined) {
|
|
192
|
+
handlers.onError?.(`malformed ${frame.event === "" ? "message" : frame.event} frame (not a view object)`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (frame.event === "snapshot") {
|
|
196
|
+
// A fresh snapshot SUBSUMES anything missed — the fold re-bases
|
|
197
|
+
// on it wholesale; nothing here merges.
|
|
198
|
+
currentView = parsed;
|
|
199
|
+
handlers.onSnapshot?.(currentView);
|
|
200
|
+
}
|
|
201
|
+
else if (frame.event === "delta") {
|
|
202
|
+
if (currentView === undefined) {
|
|
203
|
+
handlers.onError?.("malformed delta frame (arrived before any snapshot)");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
// Fold, then surface the FULL revision: the consumer's contract
|
|
207
|
+
// stays "replace, never accumulate".
|
|
208
|
+
currentView = applyDelta(currentView, parsed);
|
|
209
|
+
handlers.onView?.(currentView);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
handlers.onError?.(`unknown stream event "${frame.event}"`);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
const decoder = new TextDecoder();
|
|
216
|
+
try {
|
|
217
|
+
const reader = response.body.getReader();
|
|
218
|
+
for (;;) {
|
|
219
|
+
const { done, value } = await reader.read();
|
|
220
|
+
if (done)
|
|
221
|
+
break;
|
|
222
|
+
parser(decoder.decode(value, { stream: true }));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (closed)
|
|
227
|
+
return;
|
|
228
|
+
// Dedupe (fix wave A10): a read failure and the close that
|
|
229
|
+
// follows it are ONE connection failure — one onError.
|
|
230
|
+
if (!reportedTransportError) {
|
|
231
|
+
reportedTransportError = true;
|
|
232
|
+
handlers.onError?.(`conversation stream read failed: ${error.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!closed) {
|
|
236
|
+
if (!reportedTransportError) {
|
|
237
|
+
reportedTransportError = true;
|
|
238
|
+
handlers.onError?.("conversation stream closed by the server");
|
|
239
|
+
}
|
|
240
|
+
scheduleReconnect();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
void connect();
|
|
244
|
+
return () => {
|
|
245
|
+
if (closed)
|
|
246
|
+
return;
|
|
247
|
+
closed = true;
|
|
248
|
+
if (timer !== undefined) {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
timer = undefined;
|
|
251
|
+
}
|
|
252
|
+
abort?.abort();
|
|
253
|
+
abort = undefined;
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Loose parse: the launch wiring only branches on `type`. */
|
|
257
|
+
function parseEvent(data) {
|
|
258
|
+
let parsed;
|
|
259
|
+
try {
|
|
260
|
+
parsed = JSON.parse(data);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (typeof parsed !== "object" ||
|
|
266
|
+
parsed === null ||
|
|
267
|
+
Array.isArray(parsed) ||
|
|
268
|
+
typeof parsed.type !== "string") {
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
return parsed;
|
|
272
|
+
}
|
|
273
|
+
/** Subscribe to the global events stream. Returns the unsubscribe. */
|
|
274
|
+
export function watchEvents(handlers, options) {
|
|
275
|
+
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
276
|
+
const baseMs = options.reconnectBaseMs ?? 250;
|
|
277
|
+
const maxMs = options.reconnectMaxMs ?? 4_000;
|
|
278
|
+
const maxAttempts = 10;
|
|
279
|
+
const user = options.user?.trim();
|
|
280
|
+
const token = options.token?.trim();
|
|
281
|
+
const headers = {
|
|
282
|
+
accept: "text/event-stream",
|
|
283
|
+
...(user === undefined || user === "" ? {} : { "x-sigrun-user": user }),
|
|
284
|
+
...(token === undefined || token === ""
|
|
285
|
+
? {}
|
|
286
|
+
: { authorization: `Bearer ${token}` }),
|
|
287
|
+
};
|
|
288
|
+
const url = `${options.baseUrl}/v1/events`;
|
|
289
|
+
let closed = false;
|
|
290
|
+
let attempts = 0;
|
|
291
|
+
let timer;
|
|
292
|
+
let abort;
|
|
293
|
+
let reportedTransportError = false;
|
|
294
|
+
function scheduleReconnect() {
|
|
295
|
+
attempts += 1;
|
|
296
|
+
const base = Math.min(baseMs * 2 ** (attempts - 1), maxMs);
|
|
297
|
+
const delay = base * (1 + (Math.random() * 0.6 - 0.3));
|
|
298
|
+
timer = setTimeout(() => {
|
|
299
|
+
timer = undefined;
|
|
300
|
+
void connect();
|
|
301
|
+
}, delay);
|
|
302
|
+
}
|
|
303
|
+
async function connect() {
|
|
304
|
+
if (closed)
|
|
305
|
+
return;
|
|
306
|
+
if (attempts >= maxAttempts) {
|
|
307
|
+
handlers.onError?.(`events stream: giving up after ${maxAttempts} failed reconnects`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const controller = new AbortController();
|
|
311
|
+
abort = controller;
|
|
312
|
+
let response;
|
|
313
|
+
try {
|
|
314
|
+
response = await fetchImpl(url, {
|
|
315
|
+
method: "GET",
|
|
316
|
+
headers,
|
|
317
|
+
signal: controller.signal,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
if (closed)
|
|
322
|
+
return;
|
|
323
|
+
handlers.onError?.(`events stream connect failed: ${error.message}`);
|
|
324
|
+
scheduleReconnect();
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
if (!response.ok || response.body === null) {
|
|
328
|
+
if (closed)
|
|
329
|
+
return;
|
|
330
|
+
// A 401 (Task 5) is terminal — same rule as the conversation
|
|
331
|
+
// stream: the auth-failure string ONCE, never a retry ladder.
|
|
332
|
+
if (response.status === 401) {
|
|
333
|
+
handlers.onError?.(AUTH_FAILURE_MESSAGE);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
handlers.onError?.(`events stream responded with status ${response.status}`);
|
|
337
|
+
scheduleReconnect();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
attempts = 0;
|
|
341
|
+
reportedTransportError = false;
|
|
342
|
+
handlers.onOpen?.();
|
|
343
|
+
const parser = createFrameParser((frame) => {
|
|
344
|
+
if (closed)
|
|
345
|
+
return;
|
|
346
|
+
// The hello frame (U3 Task 4) is connection-local: it names
|
|
347
|
+
// THIS stream for the watcher registry — surface fetches send
|
|
348
|
+
// the id as `X-Sigrun-Stream`; a NEW id (reconnect) re-fires
|
|
349
|
+
// so the channel re-registers. It never reaches onEvent (the
|
|
350
|
+
// launch wiring's switch would ignore it anyway — event types
|
|
351
|
+
// are an open set). Parsed loosely: the hello payload carries
|
|
352
|
+
// `streamId`, not a `type` member.
|
|
353
|
+
if (frame.event === "hello") {
|
|
354
|
+
let parsed;
|
|
355
|
+
try {
|
|
356
|
+
parsed = JSON.parse(frame.data);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
handlers.onError?.("malformed hello frame (not JSON)");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
// SAFETY: the JSON.parse output is unknown; the read below
|
|
363
|
+
// narrows `streamId` by typeof before use.
|
|
364
|
+
const streamId = parsed.streamId;
|
|
365
|
+
if (typeof streamId === "string") {
|
|
366
|
+
handlers.onHello?.(streamId);
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
handlers.onError?.("malformed hello frame (no streamId)");
|
|
370
|
+
}
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
const event = parseEvent(frame.data);
|
|
374
|
+
if (event === undefined) {
|
|
375
|
+
handlers.onError?.(`malformed ${frame.event === "" ? "message" : frame.event} frame (not an event object)`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
handlers.onEvent(event);
|
|
379
|
+
});
|
|
380
|
+
const decoder = new TextDecoder();
|
|
381
|
+
try {
|
|
382
|
+
const reader = response.body.getReader();
|
|
383
|
+
for (;;) {
|
|
384
|
+
const { done, value } = await reader.read();
|
|
385
|
+
if (done)
|
|
386
|
+
break;
|
|
387
|
+
parser(decoder.decode(value, { stream: true }));
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
catch (error) {
|
|
391
|
+
if (closed)
|
|
392
|
+
return;
|
|
393
|
+
if (!reportedTransportError) {
|
|
394
|
+
reportedTransportError = true;
|
|
395
|
+
handlers.onError?.(`events stream read failed: ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (!closed) {
|
|
399
|
+
if (!reportedTransportError) {
|
|
400
|
+
reportedTransportError = true;
|
|
401
|
+
handlers.onError?.("events stream closed by the server");
|
|
402
|
+
}
|
|
403
|
+
scheduleReconnect();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
void connect();
|
|
407
|
+
return () => {
|
|
408
|
+
if (closed)
|
|
409
|
+
return;
|
|
410
|
+
closed = true;
|
|
411
|
+
if (timer !== undefined) {
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
timer = undefined;
|
|
414
|
+
}
|
|
415
|
+
abort?.abort();
|
|
416
|
+
abort = undefined;
|
|
417
|
+
};
|
|
418
|
+
}
|
package/dist/theme.d.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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;
|