@sigrun/tui 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/README.md +9 -2
- package/dist/actions.d.ts +119 -0
- package/dist/actions.js +12 -0
- package/dist/api.d.ts +152 -0
- package/dist/api.js +189 -0
- package/dist/blocks/braille.d.ts +41 -0
- package/dist/blocks/braille.js +131 -0
- package/dist/blocks/chart.d.ts +20 -0
- package/dist/blocks/chart.js +397 -0
- package/dist/blocks/collection/calendar.d.ts +28 -0
- package/dist/blocks/collection/calendar.js +232 -0
- package/dist/blocks/collection/card.d.ts +36 -0
- package/dist/blocks/collection/card.js +128 -0
- package/dist/blocks/collection/collection.d.ts +9 -0
- package/dist/blocks/collection/collection.js +26 -0
- package/dist/blocks/collection/grid.d.ts +18 -0
- package/dist/blocks/collection/grid.js +72 -0
- package/dist/blocks/collection/lanes.d.ts +32 -0
- package/dist/blocks/collection/lanes.js +320 -0
- package/dist/blocks/collection/list.d.ts +30 -0
- package/dist/blocks/collection/list.js +273 -0
- package/dist/blocks/collection/move.d.ts +67 -0
- package/dist/blocks/collection/move.js +96 -0
- package/dist/blocks/collection/shared.d.ts +87 -0
- package/dist/blocks/collection/shared.js +177 -0
- package/dist/blocks/collection/table.d.ts +26 -0
- package/dist/blocks/collection/table.js +221 -0
- package/dist/blocks/content.d.ts +48 -0
- package/dist/blocks/content.js +253 -0
- package/dist/blocks/context.d.ts +96 -0
- package/dist/blocks/context.js +65 -0
- package/dist/blocks/diff.d.ts +21 -0
- package/dist/blocks/diff.js +25 -0
- package/dist/blocks/ext-transport.d.ts +70 -0
- package/dist/blocks/ext-transport.js +165 -0
- package/dist/blocks/form/choice.d.ts +54 -0
- package/dist/blocks/form/choice.js +175 -0
- package/dist/blocks/form/fields.d.ts +56 -0
- package/dist/blocks/form/fields.js +136 -0
- package/dist/blocks/form/file.d.ts +61 -0
- package/dist/blocks/form/file.js +121 -0
- package/dist/blocks/form/form.d.ts +53 -0
- package/dist/blocks/form/form.js +703 -0
- package/dist/blocks/form/list.d.ts +37 -0
- package/dist/blocks/form/list.js +70 -0
- package/dist/blocks/form/values.d.ts +20 -0
- package/dist/blocks/form/values.js +104 -0
- package/dist/blocks/icons.d.ts +14 -0
- package/dist/blocks/icons.js +74 -0
- package/dist/blocks/image.d.ts +39 -0
- package/dist/blocks/image.js +82 -0
- package/dist/blocks/layout.d.ts +31 -0
- package/dist/blocks/layout.js +155 -0
- package/dist/blocks/log.d.ts +24 -0
- package/dist/blocks/log.js +111 -0
- package/dist/blocks/meter.d.ts +16 -0
- package/dist/blocks/meter.js +50 -0
- package/dist/blocks/page.d.ts +54 -0
- package/dist/blocks/page.js +355 -0
- package/dist/blocks/render.d.ts +31 -0
- package/dist/blocks/render.js +145 -0
- package/dist/blocks/surface-view.d.ts +33 -0
- package/dist/blocks/surface-view.js +86 -0
- package/dist/blocks/surface.d.ts +75 -0
- package/dist/blocks/surface.js +468 -0
- package/dist/blocks/tones.d.ts +23 -0
- package/dist/blocks/tones.js +63 -0
- package/dist/blocks/treeview.d.ts +22 -0
- package/dist/blocks/treeview.js +121 -0
- package/dist/blocks/width.d.ts +32 -0
- package/dist/blocks/width.js +70 -0
- package/dist/clipboard.d.ts +20 -0
- package/dist/clipboard.js +19 -0
- package/dist/editor-launch.d.ts +34 -0
- package/dist/editor-launch.js +70 -0
- package/dist/keymaps/emacs.d.ts +18 -0
- package/dist/keymaps/emacs.js +172 -0
- package/dist/keymaps/standard.d.ts +17 -0
- package/dist/keymaps/standard.js +175 -0
- package/dist/keymaps/types.d.ts +43 -0
- package/dist/keymaps/types.js +28 -0
- package/dist/keymaps/vim.d.ts +48 -0
- package/dist/keymaps/vim.js +291 -0
- package/dist/launch.d.ts +149 -0
- package/dist/launch.js +446 -0
- package/dist/sse.d.ts +109 -0
- package/dist/sse.js +401 -0
- package/dist/theme.d.ts +51 -0
- package/dist/theme.js +119 -0
- package/dist/ui/ambient.d.ts +81 -0
- package/dist/ui/ambient.js +301 -0
- package/dist/ui/app.d.ts +245 -0
- package/dist/ui/app.js +1325 -0
- package/dist/ui/composer.d.ts +66 -0
- package/dist/ui/composer.js +125 -0
- package/dist/ui/conversation-pane.d.ts +123 -0
- package/dist/ui/conversation-pane.js +463 -0
- package/dist/ui/help.d.ts +15 -0
- package/dist/ui/help.js +80 -0
- package/dist/ui/interactions.d.ts +90 -0
- package/dist/ui/interactions.js +330 -0
- package/dist/ui/picker.d.ts +26 -0
- package/dist/ui/picker.js +130 -0
- package/dist/ui/sidebar.d.ts +99 -0
- package/dist/ui/sidebar.js +269 -0
- package/dist/ui/status-bar.d.ts +77 -0
- package/dist/ui/status-bar.js +146 -0
- package/package.json +49 -5
package/dist/launch.js
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
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;
|