@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/launch.js DELETED
@@ -1,446 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * The TUI entry the CLI's default `launchTui` runs (Task 6): boot the
4
- * pi-tui fullscreen alt-screen renderer (task C1b — the terminal scrollback
5
- * is gone for the session; the app owns the viewport), mount the layout
6
- * root, and resolve when the user exits (ctrl+c). The CLI's `run`
7
- * lifecycle owns the shutdown from there — this promise resolving IS the
8
- * exit signal. There is no main-screen fallback.
9
- *
10
- * As of build-and-webui Task 2 this file is ALSO the package's bin
11
- * (`sigrun-tui` → `dist/launch.js`): the CLI no longer imports
12
- * `launchTuiShell` in-process — it spawns this bin as a child process
13
- * (`node <resolved dist/launch.js>`), passing the resolved server URL,
14
- * user, and tui.json UI fields as `--flags`. Run standalone, the bin
15
- * resolves them itself: `--server`/`SIGRUN_SERVER` (default
16
- * `http://localhost:$SIGRUN_PORT` or 3000) and `--user`/`SIGRUN_USER`.
17
- *
18
- * Layout (task C1b): `tui.setLayoutRoot(new VStack([...]))` — the body row
19
- * (`HStack`: the sidebar at basis 28, visible only at width ≥ 80, and the
20
- * pane wrapped in the follow-end PRIMARY ScrollView growing to fill), then
21
- * the interaction + ambient seam strips (basis "auto" — zero height when
22
- * empty), the composer Editor (basis "auto"), and the status bar pinned to
23
- * one line at the bottom (basis 1).
24
- *
25
- * Keybindings: BEFORE the screen is constructed, the alt-screen built-ins
26
- * the action layer will own are disabled (search, search next/previous,
27
- * previous/next prompt — the built-ins search raw lines and jump OSC 133
28
- * marks sigrun never emits); pageUp/pageDown/ctrl+home/ctrl+end scrolling
29
- * and the wheel stay. The shell's global input listener keeps ctrl+c
30
- * (exit), ctrl+o (answer mode), and Esc (abort a live run); task C3's
31
- * action layer reintroduces navigation — until then the composer is the
32
- * focus and the mouse drives the sidebar.
33
- *
34
- * Test seams (the shell itself needs a real terminal): `screen` injects
35
- * the screen constructor (tests assert TuiAltScreen's arguments), and
36
- * `terminal`/`fetchFn` inject the terminal and boot fetch. Production
37
- * callers pass none of them.
38
- */
39
- import { getTerminalColorMode, HStack, KeybindingsManager, ProcessTerminal, ScrollView, setKeybindings, TUI_KEYBINDINGS, TuiAltScreen, VStack, } from "@earendil-works/pi-tui";
40
- import { isAccentName, isPaletteName, } from "@sigrun/shared/themes";
41
- import { realpathSync } from "node:fs";
42
- import { pathToFileURL } from "node:url";
43
- import { SigrunClient, SigrunApiError } from "./api.js";
44
- import { watchEvents } from "./sse.js";
45
- import { createStandardKeymap } from "./keymaps/standard.js";
46
- import { createVimKeymap } from "./keymaps/vim.js";
47
- import { createEmacsKeymap } from "./keymaps/emacs.js";
48
- import { createTuiTheme } from "./theme.js";
49
- import { TuiApp } from "./ui/app.js";
50
- import { showHelp } from "./ui/help.js";
51
- import { StatusBar } from "./ui/status-bar.js";
52
- /** The sidebar's fixed column basis, and the width gate below which it hides. */
53
- const SIDEBAR_BASIS = 28;
54
- const SIDEBAR_MIN_WIDTH = 80;
55
- /**
56
- * Install the shell's keybinding set (task C1b): the alt-screen built-ins
57
- * for search and prompt jumps go dark — the action layer owns them (they
58
- * must target turns and move focus; the built-ins search raw lines and
59
- * jump OSC 133 marks sigrun never emits). Page/line/home/end scrolling
60
- * and the wheel stay. Must run BEFORE the screen is constructed.
61
- */
62
- function installKeybindings() {
63
- setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS, {
64
- "tui.altScreen.search": [],
65
- "tui.altScreen.searchNext": [],
66
- "tui.altScreen.searchPrevious": [],
67
- "tui.altScreen.previousPrompt": [],
68
- "tui.altScreen.nextPrompt": [],
69
- }));
70
- }
71
- /**
72
- * The stream-health transition notices (fix wave F5): state-deduped, so a
73
- * reconnect storm shows ONE "disconnected" and ONE "reconnected", never a
74
- * pane full of retries. Exported for the unit test (the shell itself needs
75
- * a real terminal).
76
- */
77
- export function createStreamHealthNotices(notify) {
78
- let down = false;
79
- return {
80
- onError() {
81
- if (down)
82
- return;
83
- down = true;
84
- notify("stream: disconnected");
85
- },
86
- onOpen() {
87
- if (!down)
88
- return;
89
- down = false;
90
- notify("stream: reconnected");
91
- },
92
- };
93
- }
94
- /**
95
- * Run the shell against `baseUrl` until the user exits. Boot failures
96
- * (the initial refresh rejecting) propagate — the CLI prints and exits 1.
97
- */
98
- export async function launchTuiShell(options) {
99
- const client = new SigrunClient({
100
- baseUrl: options.baseUrl,
101
- ...(options.user === undefined ? {} : { user: options.user }),
102
- ...(options.token === undefined ? {} : { token: options.token }),
103
- ...(options.fetchFn === undefined ? {} : { fetch: options.fetchFn }),
104
- });
105
- const terminal = options.terminal ?? new ProcessTerminal();
106
- // The alt-screen screen consults the global keybindings at construction
107
- // (its input listener), so the disables must be installed first.
108
- installKeybindings();
109
- const createScreen = options.screen ??
110
- ((t, hw, log, opts) => new TuiAltScreen(t, hw, log, opts));
111
- const tui = createScreen(terminal, false, undefined, {
112
- mouse: true,
113
- copyOnSelect: true,
114
- });
115
- const theme = createTuiTheme({
116
- palette: options.palette ?? "polar-night",
117
- accent: options.accent ?? "baltic-amber",
118
- mode: options.mode ?? "dark",
119
- colorMode: getTerminalColorMode(),
120
- });
121
- const app = new TuiApp(client, {
122
- // The app needs pi-tui's TUI surface for the Editor + repaint
123
- // pings; the constructed screen provides it.
124
- // SAFETY: TuiAltScreen implements TUI's render/input surface (pi-tui's
125
- // own TUI is exactly this shape); the seam type only narrows tests.
126
- tui: tui,
127
- watchOptions: {
128
- baseUrl: options.baseUrl,
129
- ...(options.user === undefined ? {} : { user: options.user }),
130
- ...(options.token === undefined ? {} : { token: options.token }),
131
- },
132
- ...(options.editor === undefined ? {} : { editor: options.editor }),
133
- theme,
134
- });
135
- // Fix wave F5: surface stream transport health as transition notices.
136
- const streamHealth = createStreamHealthNotices((message) => app.pane.showNotice(message));
137
- app.onWatchError = streamHealth.onError;
138
- app.onWatchOpen = streamHealth.onOpen;
139
- // The fullscreen layout root (task C1b): the app owns the components
140
- // (TuiApp.render stays as the non-mounted fallback path); the launcher
141
- // composes them here. The pane sits in the follow-end PRIMARY scroll
142
- // view (the wheel + page keys target it); the status bar's mode chip
143
- // rides the theme's accent background (StatusBar owns that styling).
144
- const statusBar = new StatusBar({ theme });
145
- // C3/C4/C5: the keymap comes from the tui.json `keymap` option
146
- // (standard is the default). The vim `:` Tab completion merges the
147
- // slash catalog — a shared array the keymap reads live, filled by the
148
- // fetch below.
149
- const vimCatalogNames = [];
150
- const keymap = options.keymap === "vim"
151
- ? createVimKeymap({ catalogNames: vimCatalogNames })
152
- : options.keymap === "emacs"
153
- ? createEmacsKeymap()
154
- : createStandardKeymap();
155
- // C5: the bar's hint cluster is the keymap's `hints(ctx)` — the app
156
- // re-polls it on every focus change (and after each key below, so a
157
- // vim mode change repaints too). Boot paints the composer-focused
158
- // steady state.
159
- app.hintsProvider = () => keymap.hints(app.keyContext());
160
- statusBar.set({
161
- mode: "attach",
162
- conversation: "",
163
- hints: keymap.hints({ focus: "composer", busy: false, autocompleteOpen: false, overlayOpen: false }),
164
- });
165
- // C3: the app flashes copy/configure/help/search through the bar.
166
- app.setStatusBar(statusBar);
167
- // C5: the `help` action opens the key reference overlay for the
168
- // active keymap (the app owns the overlayOpen bookkeeping).
169
- // SAFETY: same TuiAltScreen-as-TUI surface as the TuiApp seam above.
170
- app.helpOverlay = (onClose) => showHelp(tui, keymap, { onClose });
171
- if (keymap.name === "vim") {
172
- statusBar.set({ ...statusBar.getState(), mode: keymap.modeLabel() ?? "attach" });
173
- const vim = keymap;
174
- // Post edit-in-vim the keymap follows the composer focus back to INSERT.
175
- app.enterInsertMode = () => vim.enterInsertMode();
176
- // The `:` Tab completion also completes the slash catalog (fire-and-
177
- // forget: completion works on the vim table until the fetch lands).
178
- void app
179
- .slashCommandNames()
180
- .then((names) => vimCatalogNames.push(...names))
181
- .catch(() => { }); // a failed catalog fetch degrades completion only
182
- }
183
- const body = new HStack([
184
- {
185
- component: app.sidebar,
186
- basis: SIDEBAR_BASIS,
187
- visible: (viewport) => viewport.width >= SIDEBAR_MIN_WIDTH,
188
- },
189
- {
190
- component: new ScrollView(app.pane, {
191
- follow: "end",
192
- primary: true,
193
- overscroll: "chain",
194
- }),
195
- grow: 1,
196
- },
197
- ]);
198
- const root = new VStack([
199
- { component: body, grow: 1 },
200
- { component: app.interactions, basis: "auto" },
201
- { component: app.ambient, basis: "auto" },
202
- { component: app.editor, basis: "auto" },
203
- { component: statusBar, basis: 1 },
204
- ]);
205
- tui.setLayoutRoot(root);
206
- await app.refresh();
207
- // U3 Task 5: the sidebar's Views group loads with the conversations
208
- // (one /v1/extensions GET per boot; failures fail quiet).
209
- void app.sidebar.refreshViews().catch(() => { });
210
- // The host events subscription (core hardening W3 Task 4, spec §7.4):
211
- // ONE stream per process replaces the poll timers — `conversation.*`
212
- // refetches the sidebar listing, `interaction.*` kicks the prompter,
213
- // `ambient.changed` refetches the ambient chrome. A transport failure
214
- // surfaces as a pane notice (the same posture as the conversation
215
- // watcher's stream-health wiring); the events stream only NUDGES, so
216
- // a dropped frame heals on the next event.
217
- const unwatchEvents = watchEvents({
218
- onEvent: (event) => {
219
- if (event.type === "conversation.created" ||
220
- event.type === "conversation.changed") {
221
- void app.refresh().catch(() => { }); // a failed refetch fails quiet
222
- }
223
- else if (event.type === "interaction.pending" ||
224
- event.type === "interaction.settled") {
225
- app.interactions.kick();
226
- }
227
- else if (event.type === "ambient.changed") {
228
- app.ambient.kick(event.conversationId);
229
- }
230
- else if (event.type === "surface.changed") {
231
- // U3 Task 5: fan the surface event out to the open
232
- // view's transport (the hub matches slug+id+k).
233
- app.dispatchSurfaceChanged(event);
234
- }
235
- },
236
- onError: (message) => app.pane.showNotice(message),
237
- // U3 Task 5 (§7.3): the hello frame's streamId is the watcher
238
- // entry's stream — open ext-view transports send it as
239
- // X-Sigrun-Stream. A reconnect's new id re-registers on the
240
- // next fetch.
241
- onHello: (streamId) => app.setEventsStreamId(streamId),
242
- }, {
243
- baseUrl: options.baseUrl,
244
- ...(options.user === undefined ? {} : { user: options.user }),
245
- ...(options.token === undefined ? {} : { token: options.token }),
246
- });
247
- const done = new Promise((resolve) => {
248
- app.onQuit = resolve;
249
- tui.addInputListener((data) => {
250
- // The seam prompts (answer mode, the search overlay) own their
251
- // keys before the keymap sees them.
252
- if (app.handleKeybinding(data)) {
253
- return { consume: true };
254
- }
255
- // C3: the keymap decides — dispatch, consume, or pass through
256
- // to the focused component (the composer owns the rest).
257
- const result = keymap.handle(data, app.keyContext());
258
- if (result === "pass")
259
- return undefined;
260
- if (result !== "consume")
261
- void app.dispatch(result);
262
- // C4: the vim keymap's mode chip + ":" command line repaint after
263
- // each key (the app polls — the keymap owns the buffer).
264
- if (keymap.name === "vim") {
265
- const vim = keymap;
266
- app.renderCommandLine(vim.getCommandLine(), vim.modeLabel());
267
- }
268
- // C5: a key may change the keymap's hint state (vim mode, focus
269
- // via dispatch) — re-poll the hints after every handled key.
270
- app.refreshHints();
271
- return { consume: true };
272
- });
273
- });
274
- tui.setFocus(app.editor);
275
- tui.start();
276
- try {
277
- await done;
278
- }
279
- finally {
280
- unwatchEvents();
281
- app.dispose();
282
- tui.stop();
283
- }
284
- }
285
- /**
286
- * The `sigrun-tui` bin's usage line (parse failures print it + the
287
- * reason, exit 1).
288
- */
289
- const TUI_USAGE_TEXT = `usage: sigrun-tui [--server <url>] [--user <name>]
290
- [--palette <name>] [--accent <name>] [--mode dark|light]
291
- [--keymap standard|vim|emacs] [--editor <command>]`;
292
- /** Every flag {@link parseTuiLaunchArgs} accepts, with its validator. */
293
- const TUI_FLAGS = {
294
- "--server": (value) => (value === "" ? undefined : value),
295
- "--user": (value) => (value === "" ? undefined : value),
296
- "--palette": (value) => (isPaletteName(value) ? value : undefined),
297
- "--accent": (value) => (isAccentName(value) ? value : undefined),
298
- "--mode": (value) => value === "dark" || value === "light" ? value : undefined,
299
- "--keymap": (value) => value === "standard" || value === "vim" || value === "emacs"
300
- ? value
301
- : undefined,
302
- "--editor": (value) => (value === "" ? undefined : value),
303
- };
304
- /**
305
- * Parse the bin's argv (post-`node` slices): every entry must be a known
306
- * flag followed by a value. A validator returning `undefined` means the
307
- * VALUE was malformed for that flag (not a parse-usage error) — it is
308
- * reported naming the flag's expected form.
309
- */
310
- export function parseTuiLaunchArgs(argv) {
311
- const args = {};
312
- let index = 0;
313
- while (index < argv.length) {
314
- const flag = argv[index];
315
- const parse = TUI_FLAGS[flag];
316
- if (parse === undefined) {
317
- throw new Error(`unknown argument ${JSON.stringify(flag)}\n${TUI_USAGE_TEXT}`);
318
- }
319
- const raw = argv[index + 1];
320
- if (raw === undefined || raw.startsWith("--")) {
321
- throw new Error(`${flag} requires a value\n${TUI_USAGE_TEXT}`);
322
- }
323
- const value = parse(raw);
324
- if (value === undefined) {
325
- throw new Error(`${flag} does not accept ${JSON.stringify(raw)}\n${TUI_USAGE_TEXT}`);
326
- }
327
- // SAFETY: each TUI_FLAGS entry's validator only returns the value
328
- // type its key names (the Record's value union is exactly the
329
- // per-flag literal unions of TuiLaunchArgs).
330
- args[flag.slice(2)] = value;
331
- index += 2;
332
- }
333
- return args;
334
- }
335
- /**
336
- * The standalone default server URL: `http://localhost:$SIGRUN_PORT`
337
- * (3000 when unset). A malformed `SIGRUN_PORT` fails loud naming the
338
- * variable — the same posture as the core CLI's port parsing.
339
- */
340
- function defaultBaseUrl(env) {
341
- const raw = env.SIGRUN_PORT;
342
- let port = 3000;
343
- if (raw !== undefined && raw !== "") {
344
- port = Number(raw);
345
- if (!Number.isInteger(port) || port <= 0) {
346
- throw new Error(`SIGRUN_PORT must be a positive integer, got ${raw}`);
347
- }
348
- }
349
- return `http://localhost:${port}`;
350
- }
351
- /**
352
- * Resolve the bin's launch options from its parsed flags + environment
353
- * (Task 7): server (`--server` > `SIGRUN_SERVER` > the localhost default),
354
- * user (`--user` > `SIGRUN_USER`), token (`SIGRUN_TOKEN` ONLY — there is
355
- * deliberately no `--token` flag; a credential never rides argv), and
356
- * the tui.json UI fields the CLI threads through as flags.
357
- */
358
- export function launchOptionsFrom(args, env) {
359
- const token = env.SIGRUN_TOKEN?.trim();
360
- return {
361
- baseUrl: args.server ?? env.SIGRUN_SERVER ?? defaultBaseUrl(env),
362
- ...(args.user === undefined && env.SIGRUN_USER === undefined
363
- ? {}
364
- : { user: (args.user ?? env.SIGRUN_USER) }),
365
- ...(token === undefined || token === "" ? {} : { token }),
366
- ...(args.palette === undefined ? {} : { palette: args.palette }),
367
- ...(args.accent === undefined ? {} : { accent: args.accent }),
368
- ...(args.mode === undefined ? {} : { mode: args.mode }),
369
- ...(args.keymap === undefined ? {} : { keymap: args.keymap }),
370
- ...(args.editor === undefined ? {} : { editor: args.editor }),
371
- };
372
- }
373
- /**
374
- * The dedicated startup-failure message for a server still in its
375
- * unclaimed state (Task 7): a standalone `sigrun-tui` that hits the
376
- * setup gate exits with the `sigrun attach` onboarding hint instead of
377
- * a bare 403. Any other failure maps to `undefined` (the generic path).
378
- */
379
- export function tuiStartupMessage(error) {
380
- if (error instanceof SigrunApiError && error.status === 403) {
381
- const body = error.body;
382
- if (body?.error === "setup_required") {
383
- return "server is not set up — run `sigrun attach` to onboard";
384
- }
385
- }
386
- return undefined;
387
- }
388
- /**
389
- * The bin's `main`: resolve the launch options (flags + env, see
390
- * {@link launchOptionsFrom}) and run {@link launchTuiShell}. Returns the
391
- * process exit code (0 clean; 1 on a usage error or a boot failure,
392
- * with the message printed — the setup-gate failure carries its own
393
- * `sigrun attach` onboarding hint).
394
- */
395
- export async function tuiMain(argv = process.argv.slice(2), env = process.env, io = { error: (text) => console.error(text) }) {
396
- let args;
397
- try {
398
- args = parseTuiLaunchArgs(argv);
399
- }
400
- catch (error) {
401
- io.error(error instanceof Error ? error.message : String(error));
402
- return 1;
403
- }
404
- try {
405
- await launchTuiShell(launchOptionsFrom(args, env));
406
- return 0;
407
- }
408
- catch (error) {
409
- const message = error instanceof Error ? error.message : String(error);
410
- const startup = tuiStartupMessage(error);
411
- io.error(startup === undefined
412
- ? message.startsWith("sigrun-tui:")
413
- ? message
414
- : `sigrun-tui: ${message}`
415
- : `sigrun-tui: ${startup}`);
416
- return 1;
417
- }
418
- }
419
- // Script detection (the bin entry): only run main when node invoked THIS
420
- // file (dist/launch.js) — an import (tests, the old in-process path)
421
- // leaves it inert. npm bin entries are SYMLINKS (…/.bin/sigrun-tui → the
422
- // package's dist/launch.js): node realpaths the main module by default
423
- // (import.meta.url is the REAL path) while process.argv[1] keeps the
424
- // INVOKED path — the comparison must realpath the argv side, or a
425
- // .bin/npx invocation never matches and the bin exits 0 silently.
426
- /** Realpath argv[1], or undefined when it is not a file on disk (an
427
- * argv[1] that doesn't exist can never be this module — e.g. exotic
428
- * wrapper invocations). */
429
- function invokedEntryPath() {
430
- if (process.argv[1] === undefined)
431
- return undefined;
432
- try {
433
- return realpathSync(process.argv[1]);
434
- }
435
- catch {
436
- return undefined;
437
- }
438
- }
439
- const invokedEntry = invokedEntryPath();
440
- const invokedAsBin = invokedEntry !== undefined &&
441
- import.meta.url === pathToFileURL(invokedEntry).href;
442
- if (invokedAsBin) {
443
- void tuiMain().then((code) => {
444
- process.exit(code);
445
- });
446
- }
package/dist/sse.d.ts DELETED
@@ -1,109 +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 type { ConversationViewWire } from "./api.js";
30
- import type { HostEventWire } from "@sigrun/shared/views";
31
- export interface WatchHandlers {
32
- /** A `snapshot` frame arrived (the full view on connect/re-attach). */
33
- readonly onSnapshot?: (view: ConversationViewWire) => void;
34
- /**
35
- * A full revision arrived — either the connect snapshot or the fold of
36
- * every `delta` since it. The view is ALWAYS a full immutable
37
- * `ConversationViewWire`: replace, never accumulate.
38
- */
39
- readonly onView?: (view: ConversationViewWire) => void;
40
- /**
41
- * A failure: a malformed frame (stream stays open) or a transport
42
- * error (a reconnect follows unless `unwatch` ran). Carries a
43
- * human-readable description — fail loud, never silent.
44
- */
45
- readonly onError?: (message: string) => void;
46
- /** The underlying connection (re)opened with an OK response. */
47
- readonly onOpen?: () => void;
48
- }
49
- export interface WatchOptions {
50
- /**
51
- * The server's origin (e.g. `http://127.0.0.1:PORT`) — required; the
52
- * TUI is a remote client.
53
- */
54
- readonly baseUrl: string;
55
- /** `X-Sigrun-User` identity header (Task 2); omitted when absent/empty. */
56
- readonly user?: string;
57
- /**
58
- * The bearer token (Task 7): `authorization: Bearer <token>` on the
59
- * stream connect; omitted when absent/empty/whitespace.
60
- */
61
- readonly token?: string;
62
- /** Backoff ladder base in ms (default 250; tests shrink it). */
63
- readonly reconnectBaseMs?: number;
64
- /** Backoff cap in ms (default 4000). */
65
- readonly reconnectMaxMs?: number;
66
- /** Injected for tests; defaults to the ambient `fetch`. */
67
- readonly fetch?: typeof globalThis.fetch;
68
- }
69
- /** The watcher handle: `unwatch()` closes the stream, idempotent. */
70
- export type Unwatch = () => void;
71
- /**
72
- * Watch one conversation's stream. Returns `unwatch()`. Reconnects on
73
- * stream drop; a reconnect's fresh snapshot replaces state wholesale.
74
- */
75
- export declare function watchConversation(conversationId: number, handlers: WatchHandlers, options: WatchOptions): Unwatch;
76
- /**
77
- * The host events reader (core hardening W3 Task 4, spec §7.4): a
78
- * Node-side SSE client over `GET /v1/events` carrying `HostEventWire`
79
- * frames (`event: <type>` + single-line `data:` JSON). The TUI's launch
80
- * wiring subscribes ONCE per process: `conversation.*` events refetch the
81
- * conversation list, `interaction.*` kicks the prompter, `ambient.changed`
82
- * refetches the ambient chrome — the old list/interaction/ambient poll
83
- * timers are gone.
84
- *
85
- * Same transport discipline as `watchConversation`: backoff ladder
86
- * (250ms doubling, 4s cap, ±30% jitter), one `onError` per failed
87
- * connection, give up after 10 consecutive failures, malformed frames
88
- * report + keep the stream open. There is no replay — a reconnect's
89
- * first post-connect frame suffices because the handlers REFETCH (the
90
- * list/interaction/ambient GETs), never merge.
91
- */
92
- export interface EventsHandlers {
93
- /** A host event arrived (already JSON-parsed). */
94
- readonly onEvent: (event: HostEventWire) => void;
95
- /**
96
- * The hello frame's streamId (UI-blocks U3 Task 4, spec §7.3's W3
97
- * amendment): fired on EVERY (re)connect — a reconnect's hello
98
- * carries a NEW id, and the channel re-fetches every visible surface
99
- * with it (re-registering the watcher entries). Surface fetches send
100
- * the current id as `X-Sigrun-Stream`.
101
- */
102
- readonly onHello?: (streamId: string) => void;
103
- /** A failure: malformed frame (stays open) or transport error (reconnects). */
104
- readonly onError?: (message: string) => void;
105
- /** The underlying connection (re)opened with an OK response. */
106
- readonly onOpen?: () => void;
107
- }
108
- /** Subscribe to the global events stream. Returns the unsubscribe. */
109
- export declare function watchEvents(handlers: EventsHandlers, options: WatchOptions): Unwatch;