@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/launch.d.ts
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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 { type Component, type Terminal, type TuiAltScreenOptions } from "@earendil-works/pi-tui";
|
|
40
|
+
import { type AccentName, type ModeName, type PaletteName } from "@sigrun/shared/themes";
|
|
41
|
+
import { type FetchLike } from "./api.js";
|
|
42
|
+
/** The input-listener signature pi-tui's TUI uses. */
|
|
43
|
+
export type TuiInputListenerFn = (data: string) => {
|
|
44
|
+
consume: boolean;
|
|
45
|
+
} | undefined;
|
|
46
|
+
/** The screen surface the launcher drives (a TuiAltScreen satisfies it). */
|
|
47
|
+
export interface TuiScreen extends Component {
|
|
48
|
+
setLayoutRoot(component: Component | undefined): void;
|
|
49
|
+
setFocus(component: Component | null): void;
|
|
50
|
+
start(): void;
|
|
51
|
+
stop(options?: {
|
|
52
|
+
preserveScreen?: boolean;
|
|
53
|
+
}): void;
|
|
54
|
+
requestRender(force?: boolean): void;
|
|
55
|
+
addInputListener(listener: TuiInputListenerFn): () => void;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The screen-constructor seam (task C1b): matches TuiAltScreen's
|
|
59
|
+
* constructor signature so tests can record the arguments without a real
|
|
60
|
+
* terminal. Production uses `TuiAltScreen` itself.
|
|
61
|
+
*/
|
|
62
|
+
export type TuiScreenFactory = (terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string, options?: TuiAltScreenOptions) => TuiScreen;
|
|
63
|
+
export interface LaunchTuiShellOptions {
|
|
64
|
+
/** The server's base URL (`SigrunClient`'s `baseUrl`). */
|
|
65
|
+
readonly baseUrl: string;
|
|
66
|
+
/** The `X-Sigrun-User` identity; absent = the server's naive mode. */
|
|
67
|
+
readonly user?: string;
|
|
68
|
+
/**
|
|
69
|
+
* The bearer token (Task 7): `authorization: Bearer` on every client
|
|
70
|
+
* request and the stream connect. The launcher sources it from env
|
|
71
|
+
* `SIGRUN_TOKEN` — a credential never rides argv.
|
|
72
|
+
*/
|
|
73
|
+
readonly token?: string;
|
|
74
|
+
/** The UI refresh's tui.json fields (C1a threads them in from the CLI). */
|
|
75
|
+
readonly palette?: PaletteName;
|
|
76
|
+
readonly accent?: AccentName;
|
|
77
|
+
readonly mode?: ModeName;
|
|
78
|
+
/** The keymap (C3); defaults to the standard one. */
|
|
79
|
+
readonly keymap?: "standard" | "vim" | "emacs";
|
|
80
|
+
/** The tui.json `editor` field (C1); a vim-family value feeds edit-in-vim. */
|
|
81
|
+
readonly editor?: string;
|
|
82
|
+
/** TEST SEAM: the screen constructor (defaults to TuiAltScreen). */
|
|
83
|
+
readonly screen?: TuiScreenFactory;
|
|
84
|
+
/** TEST SEAM: the terminal (defaults to ProcessTerminal). */
|
|
85
|
+
readonly terminal?: Terminal;
|
|
86
|
+
/** TEST SEAM: the boot fetch (defaults to the ambient fetch). */
|
|
87
|
+
readonly fetchFn?: FetchLike;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The stream-health transition notices (fix wave F5): state-deduped, so a
|
|
91
|
+
* reconnect storm shows ONE "disconnected" and ONE "reconnected", never a
|
|
92
|
+
* pane full of retries. Exported for the unit test (the shell itself needs
|
|
93
|
+
* a real terminal).
|
|
94
|
+
*
|
|
95
|
+
* Review F1 (Task 5 fix wave): the watcher's `onError` carries the SSE
|
|
96
|
+
* client's MESSAGE, and a 401 is terminal there — the auth failure must
|
|
97
|
+
* surface VERBATIM, never collapsed into "stream: disconnected" (a
|
|
98
|
+
* token revoked mid-session is a credential problem no reconnect fixes).
|
|
99
|
+
* The auth notice latches: after it, further errors stay silent and an
|
|
100
|
+
* `onOpen` announces nothing (there was no real reconnection to report).
|
|
101
|
+
*/
|
|
102
|
+
export declare function createStreamHealthNotices(notify: (message: string) => void): {
|
|
103
|
+
onError(message: string): void;
|
|
104
|
+
onOpen(): void;
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Run the shell against `baseUrl` until the user exits. Boot failures
|
|
108
|
+
* (the initial refresh rejecting) propagate — the CLI prints and exits 1.
|
|
109
|
+
*/
|
|
110
|
+
export declare function launchTuiShell(options: LaunchTuiShellOptions): Promise<void>;
|
|
111
|
+
/**
|
|
112
|
+
* The flags {@link tuiMain} accepts. The CLI passes the fields it
|
|
113
|
+
* resolved (tui.json + its own flags) through these; a standalone
|
|
114
|
+
* invocation may pass any subset.
|
|
115
|
+
*/
|
|
116
|
+
export interface TuiLaunchArgs {
|
|
117
|
+
readonly server?: string;
|
|
118
|
+
readonly user?: string;
|
|
119
|
+
readonly palette?: PaletteName;
|
|
120
|
+
readonly accent?: AccentName;
|
|
121
|
+
readonly mode?: ModeName;
|
|
122
|
+
readonly keymap?: "standard" | "vim" | "emacs";
|
|
123
|
+
readonly editor?: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Parse the bin's argv (post-`node` slices): every entry must be a known
|
|
127
|
+
* flag followed by a value. A validator returning `undefined` means the
|
|
128
|
+
* VALUE was malformed for that flag (not a parse-usage error) — it is
|
|
129
|
+
* reported naming the flag's expected form.
|
|
130
|
+
*/
|
|
131
|
+
export declare function parseTuiLaunchArgs(argv: readonly string[]): TuiLaunchArgs;
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the bin's launch options from its parsed flags + environment
|
|
134
|
+
* (Task 7): server (`--server` > `SIGRUN_SERVER` > the localhost default),
|
|
135
|
+
* user (`--user` > `SIGRUN_USER`), token (`SIGRUN_TOKEN` ONLY — there is
|
|
136
|
+
* deliberately no `--token` flag; a credential never rides argv), and
|
|
137
|
+
* the tui.json UI fields the CLI threads through as flags.
|
|
138
|
+
*/
|
|
139
|
+
export declare function launchOptionsFrom(args: TuiLaunchArgs, env: NodeJS.ProcessEnv): LaunchTuiShellOptions;
|
|
140
|
+
/**
|
|
141
|
+
* The dedicated startup-failure message for a server still in its
|
|
142
|
+
* unclaimed state (Task 7): a standalone `sigrun-tui` that hits the
|
|
143
|
+
* setup gate exits with the `sigrun attach` onboarding hint instead of
|
|
144
|
+
* a bare 403. A 401 (Task 5) maps to the auth-failure hint — the
|
|
145
|
+
* `SIGRUN_TOKEN` is missing, expired, or revoked, and the fix is a new
|
|
146
|
+
* token in the webui's Settings → API tokens section. Any other failure
|
|
147
|
+
* maps to `undefined` (the generic path).
|
|
148
|
+
*/
|
|
149
|
+
export declare function tuiStartupMessage(error: unknown): string | undefined;
|
|
150
|
+
/**
|
|
151
|
+
* The bin's `main`: resolve the launch options (flags + env, see
|
|
152
|
+
* {@link launchOptionsFrom}) and run {@link launchTuiShell}. Returns the
|
|
153
|
+
* process exit code (0 clean; 1 on a usage error or a boot failure,
|
|
154
|
+
* with the message printed — the setup-gate failure carries its own
|
|
155
|
+
* `sigrun attach` onboarding hint).
|
|
156
|
+
*/
|
|
157
|
+
export declare function tuiMain(argv?: readonly string[], env?: NodeJS.ProcessEnv, io?: {
|
|
158
|
+
error(text: string): void;
|
|
159
|
+
}): Promise<number>;
|
package/dist/launch.js
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
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 { AUTH_FAILURE_MESSAGE, authFailureMessage, 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
|
+
* Review F1 (Task 5 fix wave): the watcher's `onError` carries the SSE
|
|
78
|
+
* client's MESSAGE, and a 401 is terminal there — the auth failure must
|
|
79
|
+
* surface VERBATIM, never collapsed into "stream: disconnected" (a
|
|
80
|
+
* token revoked mid-session is a credential problem no reconnect fixes).
|
|
81
|
+
* The auth notice latches: after it, further errors stay silent and an
|
|
82
|
+
* `onOpen` announces nothing (there was no real reconnection to report).
|
|
83
|
+
*/
|
|
84
|
+
export function createStreamHealthNotices(notify) {
|
|
85
|
+
let down = false;
|
|
86
|
+
let authFailed = false;
|
|
87
|
+
return {
|
|
88
|
+
onError(message) {
|
|
89
|
+
if (authFailed)
|
|
90
|
+
return;
|
|
91
|
+
if (message === AUTH_FAILURE_MESSAGE) {
|
|
92
|
+
authFailed = true;
|
|
93
|
+
notify(message);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (down)
|
|
97
|
+
return;
|
|
98
|
+
down = true;
|
|
99
|
+
notify("stream: disconnected");
|
|
100
|
+
},
|
|
101
|
+
onOpen() {
|
|
102
|
+
// After the auth failure the stream is dead by design (the SSE
|
|
103
|
+
// client stops on a 401): an open here would be a different
|
|
104
|
+
// stream's noise — never report a bogus "reconnected".
|
|
105
|
+
if (authFailed || !down)
|
|
106
|
+
return;
|
|
107
|
+
down = false;
|
|
108
|
+
notify("stream: reconnected");
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Run the shell against `baseUrl` until the user exits. Boot failures
|
|
114
|
+
* (the initial refresh rejecting) propagate — the CLI prints and exits 1.
|
|
115
|
+
*/
|
|
116
|
+
export async function launchTuiShell(options) {
|
|
117
|
+
const client = new SigrunClient({
|
|
118
|
+
baseUrl: options.baseUrl,
|
|
119
|
+
...(options.user === undefined ? {} : { user: options.user }),
|
|
120
|
+
...(options.token === undefined ? {} : { token: options.token }),
|
|
121
|
+
...(options.fetchFn === undefined ? {} : { fetch: options.fetchFn }),
|
|
122
|
+
});
|
|
123
|
+
const terminal = options.terminal ?? new ProcessTerminal();
|
|
124
|
+
// The alt-screen screen consults the global keybindings at construction
|
|
125
|
+
// (its input listener), so the disables must be installed first.
|
|
126
|
+
installKeybindings();
|
|
127
|
+
const createScreen = options.screen ??
|
|
128
|
+
((t, hw, log, opts) => new TuiAltScreen(t, hw, log, opts));
|
|
129
|
+
const tui = createScreen(terminal, false, undefined, {
|
|
130
|
+
mouse: true,
|
|
131
|
+
copyOnSelect: true,
|
|
132
|
+
});
|
|
133
|
+
const theme = createTuiTheme({
|
|
134
|
+
palette: options.palette ?? "polar-night",
|
|
135
|
+
accent: options.accent ?? "baltic-amber",
|
|
136
|
+
mode: options.mode ?? "dark",
|
|
137
|
+
colorMode: getTerminalColorMode(),
|
|
138
|
+
});
|
|
139
|
+
const app = new TuiApp(client, {
|
|
140
|
+
// The app needs pi-tui's TUI surface for the Editor + repaint
|
|
141
|
+
// pings; the constructed screen provides it.
|
|
142
|
+
// SAFETY: TuiAltScreen implements TUI's render/input surface (pi-tui's
|
|
143
|
+
// own TUI is exactly this shape); the seam type only narrows tests.
|
|
144
|
+
tui: tui,
|
|
145
|
+
watchOptions: {
|
|
146
|
+
baseUrl: options.baseUrl,
|
|
147
|
+
...(options.user === undefined ? {} : { user: options.user }),
|
|
148
|
+
...(options.token === undefined ? {} : { token: options.token }),
|
|
149
|
+
},
|
|
150
|
+
...(options.editor === undefined ? {} : { editor: options.editor }),
|
|
151
|
+
theme,
|
|
152
|
+
});
|
|
153
|
+
// Fix wave F5: surface stream transport health as transition notices.
|
|
154
|
+
const streamHealth = createStreamHealthNotices((message) => app.pane.showNotice(message));
|
|
155
|
+
app.onWatchError = streamHealth.onError;
|
|
156
|
+
app.onWatchOpen = streamHealth.onOpen;
|
|
157
|
+
// The fullscreen layout root (task C1b): the app owns the components
|
|
158
|
+
// (TuiApp.render stays as the non-mounted fallback path); the launcher
|
|
159
|
+
// composes them here. The pane sits in the follow-end PRIMARY scroll
|
|
160
|
+
// view (the wheel + page keys target it); the status bar's mode chip
|
|
161
|
+
// rides the theme's accent background (StatusBar owns that styling).
|
|
162
|
+
const statusBar = new StatusBar({ theme });
|
|
163
|
+
// C3/C4/C5: the keymap comes from the tui.json `keymap` option
|
|
164
|
+
// (standard is the default). The vim `:` Tab completion merges the
|
|
165
|
+
// slash catalog — a shared array the keymap reads live, filled by the
|
|
166
|
+
// fetch below.
|
|
167
|
+
const vimCatalogNames = [];
|
|
168
|
+
const keymap = options.keymap === "vim"
|
|
169
|
+
? createVimKeymap({ catalogNames: vimCatalogNames })
|
|
170
|
+
: options.keymap === "emacs"
|
|
171
|
+
? createEmacsKeymap()
|
|
172
|
+
: createStandardKeymap();
|
|
173
|
+
// C5: the bar's hint cluster is the keymap's `hints(ctx)` — the app
|
|
174
|
+
// re-polls it on every focus change (and after each key below, so a
|
|
175
|
+
// vim mode change repaints too). Boot paints the composer-focused
|
|
176
|
+
// steady state.
|
|
177
|
+
app.hintsProvider = () => keymap.hints(app.keyContext());
|
|
178
|
+
statusBar.set({
|
|
179
|
+
mode: "attach",
|
|
180
|
+
conversation: "",
|
|
181
|
+
hints: keymap.hints({ focus: "composer", busy: false, autocompleteOpen: false, overlayOpen: false }),
|
|
182
|
+
});
|
|
183
|
+
// C3: the app flashes copy/configure/help/search through the bar.
|
|
184
|
+
app.setStatusBar(statusBar);
|
|
185
|
+
// C5: the `help` action opens the key reference overlay for the
|
|
186
|
+
// active keymap (the app owns the overlayOpen bookkeeping).
|
|
187
|
+
// SAFETY: same TuiAltScreen-as-TUI surface as the TuiApp seam above.
|
|
188
|
+
app.helpOverlay = (onClose) => showHelp(tui, keymap, { onClose });
|
|
189
|
+
if (keymap.name === "vim") {
|
|
190
|
+
statusBar.set({ ...statusBar.getState(), mode: keymap.modeLabel() ?? "attach" });
|
|
191
|
+
const vim = keymap;
|
|
192
|
+
// Post edit-in-vim the keymap follows the composer focus back to INSERT.
|
|
193
|
+
app.enterInsertMode = () => vim.enterInsertMode();
|
|
194
|
+
// The `:` Tab completion also completes the slash catalog (fire-and-
|
|
195
|
+
// forget: completion works on the vim table until the fetch lands).
|
|
196
|
+
void app
|
|
197
|
+
.slashCommandNames()
|
|
198
|
+
.then((names) => vimCatalogNames.push(...names))
|
|
199
|
+
.catch(() => { }); // a failed catalog fetch degrades completion only
|
|
200
|
+
}
|
|
201
|
+
const body = new HStack([
|
|
202
|
+
{
|
|
203
|
+
component: app.sidebar,
|
|
204
|
+
basis: SIDEBAR_BASIS,
|
|
205
|
+
visible: (viewport) => viewport.width >= SIDEBAR_MIN_WIDTH,
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
// The main slot (U4 Task 4 round-2): the conversation pane in
|
|
209
|
+
// the steady state, the settings view while one is open — the
|
|
210
|
+
// slot swaps, so settings renders in the body's own column.
|
|
211
|
+
component: new ScrollView(app.mainContent, {
|
|
212
|
+
follow: "end",
|
|
213
|
+
primary: true,
|
|
214
|
+
overscroll: "chain",
|
|
215
|
+
}),
|
|
216
|
+
grow: 1,
|
|
217
|
+
},
|
|
218
|
+
]);
|
|
219
|
+
const root = new VStack([
|
|
220
|
+
{ component: body, grow: 1 },
|
|
221
|
+
{ component: app.interactions, basis: "auto" },
|
|
222
|
+
{ component: app.ambient, basis: "auto" },
|
|
223
|
+
{ component: app.editor, basis: "auto" },
|
|
224
|
+
{ component: statusBar, basis: 1 },
|
|
225
|
+
]);
|
|
226
|
+
tui.setLayoutRoot(root);
|
|
227
|
+
await app.refresh();
|
|
228
|
+
// U3 Task 5: the sidebar's Views group loads with the conversations
|
|
229
|
+
// (one /v1/extensions GET per boot; failures fail quiet).
|
|
230
|
+
void app.sidebar.refreshViews().catch(() => { });
|
|
231
|
+
// The host events subscription (core hardening W3 Task 4, spec §7.4):
|
|
232
|
+
// ONE stream per process replaces the poll timers — `conversation.*`
|
|
233
|
+
// refetches the sidebar listing, `interaction.*` kicks the prompter,
|
|
234
|
+
// `ambient.changed` refetches the ambient chrome. A transport failure
|
|
235
|
+
// surfaces as a pane notice (the same posture as the conversation
|
|
236
|
+
// watcher's stream-health wiring); the events stream only NUDGES, so
|
|
237
|
+
// a dropped frame heals on the next event.
|
|
238
|
+
const unwatchEvents = watchEvents({
|
|
239
|
+
onEvent: (event) => {
|
|
240
|
+
if (event.type === "conversation.created" ||
|
|
241
|
+
event.type === "conversation.changed") {
|
|
242
|
+
void app.refresh().catch(() => { }); // a failed refetch fails quiet
|
|
243
|
+
}
|
|
244
|
+
else if (event.type === "interaction.pending" ||
|
|
245
|
+
event.type === "interaction.settled") {
|
|
246
|
+
app.interactions.kick();
|
|
247
|
+
}
|
|
248
|
+
else if (event.type === "ambient.changed") {
|
|
249
|
+
app.ambient.kick(event.conversationId);
|
|
250
|
+
}
|
|
251
|
+
else if (event.type === "surface.changed") {
|
|
252
|
+
// U3 Task 5: fan the EXT surface event out to the open view's
|
|
253
|
+
// transport (the hub matches slug+id+k). The CHAT shape (U5
|
|
254
|
+
// Task 2/4: conversationId+entryId, no k) names a block in a
|
|
255
|
+
// conversation — it fans to the app's ChatSurfaceHub, which
|
|
256
|
+
// the inline block surfaces subscribe through.
|
|
257
|
+
if ("slug" in event) {
|
|
258
|
+
app.dispatchSurfaceChanged(event);
|
|
259
|
+
}
|
|
260
|
+
else if ("conversationId" in event) {
|
|
261
|
+
app.dispatchChatSurfaceChanged(event);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
onError: (message) => app.pane.showNotice(message),
|
|
266
|
+
// U3 Task 5 (§7.3): the hello frame's streamId is the watcher
|
|
267
|
+
// entry's stream — open ext-view transports send it as
|
|
268
|
+
// X-Sigrun-Stream. A reconnect's new id re-registers on the
|
|
269
|
+
// next fetch.
|
|
270
|
+
onHello: (streamId) => app.setEventsStreamId(streamId),
|
|
271
|
+
}, {
|
|
272
|
+
baseUrl: options.baseUrl,
|
|
273
|
+
...(options.user === undefined ? {} : { user: options.user }),
|
|
274
|
+
...(options.token === undefined ? {} : { token: options.token }),
|
|
275
|
+
});
|
|
276
|
+
const done = new Promise((resolve) => {
|
|
277
|
+
app.onQuit = resolve;
|
|
278
|
+
tui.addInputListener((data) => {
|
|
279
|
+
// The seam prompts (answer mode, the search overlay) own their
|
|
280
|
+
// keys before the keymap sees them.
|
|
281
|
+
if (app.handleKeybinding(data)) {
|
|
282
|
+
return { consume: true };
|
|
283
|
+
}
|
|
284
|
+
// C3: the keymap decides — dispatch, consume, or pass through
|
|
285
|
+
// to the focused component (the composer owns the rest).
|
|
286
|
+
const result = keymap.handle(data, app.keyContext());
|
|
287
|
+
if (result === "pass")
|
|
288
|
+
return undefined;
|
|
289
|
+
if (result !== "consume")
|
|
290
|
+
void app.dispatch(result);
|
|
291
|
+
// C4: the vim keymap's mode chip + ":" command line repaint after
|
|
292
|
+
// each key (the app polls — the keymap owns the buffer).
|
|
293
|
+
if (keymap.name === "vim") {
|
|
294
|
+
const vim = keymap;
|
|
295
|
+
app.renderCommandLine(vim.getCommandLine(), vim.modeLabel());
|
|
296
|
+
}
|
|
297
|
+
// C5: a key may change the keymap's hint state (vim mode, focus
|
|
298
|
+
// via dispatch) — re-poll the hints after every handled key.
|
|
299
|
+
app.refreshHints();
|
|
300
|
+
return { consume: true };
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
tui.setFocus(app.editor);
|
|
304
|
+
tui.start();
|
|
305
|
+
try {
|
|
306
|
+
await done;
|
|
307
|
+
}
|
|
308
|
+
finally {
|
|
309
|
+
unwatchEvents();
|
|
310
|
+
app.dispose();
|
|
311
|
+
tui.stop();
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The `sigrun-tui` bin's usage line (parse failures print it + the
|
|
316
|
+
* reason, exit 1).
|
|
317
|
+
*/
|
|
318
|
+
const TUI_USAGE_TEXT = `usage: sigrun-tui [--server <url>] [--user <name>]
|
|
319
|
+
[--palette <name>] [--accent <name>] [--mode dark|light]
|
|
320
|
+
[--keymap standard|vim|emacs] [--editor <command>]`;
|
|
321
|
+
/** Every flag {@link parseTuiLaunchArgs} accepts, with its validator. */
|
|
322
|
+
const TUI_FLAGS = {
|
|
323
|
+
"--server": (value) => (value === "" ? undefined : value),
|
|
324
|
+
"--user": (value) => (value === "" ? undefined : value),
|
|
325
|
+
"--palette": (value) => (isPaletteName(value) ? value : undefined),
|
|
326
|
+
"--accent": (value) => (isAccentName(value) ? value : undefined),
|
|
327
|
+
"--mode": (value) => value === "dark" || value === "light" ? value : undefined,
|
|
328
|
+
"--keymap": (value) => value === "standard" || value === "vim" || value === "emacs"
|
|
329
|
+
? value
|
|
330
|
+
: undefined,
|
|
331
|
+
"--editor": (value) => (value === "" ? undefined : value),
|
|
332
|
+
};
|
|
333
|
+
/**
|
|
334
|
+
* Parse the bin's argv (post-`node` slices): every entry must be a known
|
|
335
|
+
* flag followed by a value. A validator returning `undefined` means the
|
|
336
|
+
* VALUE was malformed for that flag (not a parse-usage error) — it is
|
|
337
|
+
* reported naming the flag's expected form.
|
|
338
|
+
*/
|
|
339
|
+
export function parseTuiLaunchArgs(argv) {
|
|
340
|
+
const args = {};
|
|
341
|
+
let index = 0;
|
|
342
|
+
while (index < argv.length) {
|
|
343
|
+
const flag = argv[index];
|
|
344
|
+
const parse = TUI_FLAGS[flag];
|
|
345
|
+
if (parse === undefined) {
|
|
346
|
+
throw new Error(`unknown argument ${JSON.stringify(flag)}\n${TUI_USAGE_TEXT}`);
|
|
347
|
+
}
|
|
348
|
+
const raw = argv[index + 1];
|
|
349
|
+
if (raw === undefined || raw.startsWith("--")) {
|
|
350
|
+
throw new Error(`${flag} requires a value\n${TUI_USAGE_TEXT}`);
|
|
351
|
+
}
|
|
352
|
+
const value = parse(raw);
|
|
353
|
+
if (value === undefined) {
|
|
354
|
+
throw new Error(`${flag} does not accept ${JSON.stringify(raw)}\n${TUI_USAGE_TEXT}`);
|
|
355
|
+
}
|
|
356
|
+
// SAFETY: each TUI_FLAGS entry's validator only returns the value
|
|
357
|
+
// type its key names (the Record's value union is exactly the
|
|
358
|
+
// per-flag literal unions of TuiLaunchArgs).
|
|
359
|
+
args[flag.slice(2)] = value;
|
|
360
|
+
index += 2;
|
|
361
|
+
}
|
|
362
|
+
return args;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The standalone default server URL: `http://localhost:$SIGRUN_PORT`
|
|
366
|
+
* (3000 when unset). A malformed `SIGRUN_PORT` fails loud naming the
|
|
367
|
+
* variable — the same posture as the core CLI's port parsing.
|
|
368
|
+
*/
|
|
369
|
+
function defaultBaseUrl(env) {
|
|
370
|
+
const raw = env.SIGRUN_PORT;
|
|
371
|
+
let port = 3000;
|
|
372
|
+
if (raw !== undefined && raw !== "") {
|
|
373
|
+
port = Number(raw);
|
|
374
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
375
|
+
throw new Error(`SIGRUN_PORT must be a positive integer, got ${raw}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return `http://localhost:${port}`;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Resolve the bin's launch options from its parsed flags + environment
|
|
382
|
+
* (Task 7): server (`--server` > `SIGRUN_SERVER` > the localhost default),
|
|
383
|
+
* user (`--user` > `SIGRUN_USER`), token (`SIGRUN_TOKEN` ONLY — there is
|
|
384
|
+
* deliberately no `--token` flag; a credential never rides argv), and
|
|
385
|
+
* the tui.json UI fields the CLI threads through as flags.
|
|
386
|
+
*/
|
|
387
|
+
export function launchOptionsFrom(args, env) {
|
|
388
|
+
const token = env.SIGRUN_TOKEN?.trim();
|
|
389
|
+
return {
|
|
390
|
+
baseUrl: args.server ?? env.SIGRUN_SERVER ?? defaultBaseUrl(env),
|
|
391
|
+
...(args.user === undefined && env.SIGRUN_USER === undefined
|
|
392
|
+
? {}
|
|
393
|
+
: { user: (args.user ?? env.SIGRUN_USER) }),
|
|
394
|
+
...(token === undefined || token === "" ? {} : { token }),
|
|
395
|
+
...(args.palette === undefined ? {} : { palette: args.palette }),
|
|
396
|
+
...(args.accent === undefined ? {} : { accent: args.accent }),
|
|
397
|
+
...(args.mode === undefined ? {} : { mode: args.mode }),
|
|
398
|
+
...(args.keymap === undefined ? {} : { keymap: args.keymap }),
|
|
399
|
+
...(args.editor === undefined ? {} : { editor: args.editor }),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* The dedicated startup-failure message for a server still in its
|
|
404
|
+
* unclaimed state (Task 7): a standalone `sigrun-tui` that hits the
|
|
405
|
+
* setup gate exits with the `sigrun attach` onboarding hint instead of
|
|
406
|
+
* a bare 403. A 401 (Task 5) maps to the auth-failure hint — the
|
|
407
|
+
* `SIGRUN_TOKEN` is missing, expired, or revoked, and the fix is a new
|
|
408
|
+
* token in the webui's Settings → API tokens section. Any other failure
|
|
409
|
+
* maps to `undefined` (the generic path).
|
|
410
|
+
*/
|
|
411
|
+
export function tuiStartupMessage(error) {
|
|
412
|
+
if (error instanceof SigrunApiError && error.status === 403) {
|
|
413
|
+
const body = error.body;
|
|
414
|
+
if (body?.error === "setup_required") {
|
|
415
|
+
return "server is not set up — run `sigrun attach` to onboard";
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return authFailureMessage(error);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The bin's `main`: resolve the launch options (flags + env, see
|
|
422
|
+
* {@link launchOptionsFrom}) and run {@link launchTuiShell}. Returns the
|
|
423
|
+
* process exit code (0 clean; 1 on a usage error or a boot failure,
|
|
424
|
+
* with the message printed — the setup-gate failure carries its own
|
|
425
|
+
* `sigrun attach` onboarding hint).
|
|
426
|
+
*/
|
|
427
|
+
export async function tuiMain(argv = process.argv.slice(2), env = process.env, io = { error: (text) => console.error(text) }) {
|
|
428
|
+
let args;
|
|
429
|
+
try {
|
|
430
|
+
args = parseTuiLaunchArgs(argv);
|
|
431
|
+
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
io.error(error instanceof Error ? error.message : String(error));
|
|
434
|
+
return 1;
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
await launchTuiShell(launchOptionsFrom(args, env));
|
|
438
|
+
return 0;
|
|
439
|
+
}
|
|
440
|
+
catch (error) {
|
|
441
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
442
|
+
const startup = tuiStartupMessage(error);
|
|
443
|
+
io.error(startup === undefined
|
|
444
|
+
? message.startsWith("sigrun-tui:")
|
|
445
|
+
? message
|
|
446
|
+
: `sigrun-tui: ${message}`
|
|
447
|
+
: `sigrun-tui: ${startup}`);
|
|
448
|
+
return 1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Script detection (the bin entry): only run main when node invoked THIS
|
|
452
|
+
// file (dist/launch.js) — an import (tests, the old in-process path)
|
|
453
|
+
// leaves it inert. npm bin entries are SYMLINKS (…/.bin/sigrun-tui → the
|
|
454
|
+
// package's dist/launch.js): node realpaths the main module by default
|
|
455
|
+
// (import.meta.url is the REAL path) while process.argv[1] keeps the
|
|
456
|
+
// INVOKED path — the comparison must realpath the argv side, or a
|
|
457
|
+
// .bin/npx invocation never matches and the bin exits 0 silently.
|
|
458
|
+
/** Realpath argv[1], or undefined when it is not a file on disk (an
|
|
459
|
+
* argv[1] that doesn't exist can never be this module — e.g. exotic
|
|
460
|
+
* wrapper invocations). */
|
|
461
|
+
function invokedEntryPath() {
|
|
462
|
+
if (process.argv[1] === undefined)
|
|
463
|
+
return undefined;
|
|
464
|
+
try {
|
|
465
|
+
return realpathSync(process.argv[1]);
|
|
466
|
+
}
|
|
467
|
+
catch {
|
|
468
|
+
return undefined;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
const invokedEntry = invokedEntryPath();
|
|
472
|
+
const invokedAsBin = invokedEntry !== undefined &&
|
|
473
|
+
import.meta.url === pathToFileURL(invokedEntry).href;
|
|
474
|
+
if (invokedAsBin) {
|
|
475
|
+
void tuiMain().then((code) => {
|
|
476
|
+
process.exit(code);
|
|
477
|
+
});
|
|
478
|
+
}
|