@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/dist/actions.d.ts +119 -0
  3. package/dist/actions.js +12 -0
  4. package/dist/api.d.ts +209 -0
  5. package/dist/api.js +282 -0
  6. package/dist/blocks/braille.d.ts +41 -0
  7. package/dist/blocks/braille.js +131 -0
  8. package/dist/blocks/chart.d.ts +20 -0
  9. package/dist/blocks/chart.js +397 -0
  10. package/dist/blocks/chat-transport.d.ts +44 -0
  11. package/dist/blocks/chat-transport.js +116 -0
  12. package/dist/blocks/collection/calendar.d.ts +28 -0
  13. package/dist/blocks/collection/calendar.js +232 -0
  14. package/dist/blocks/collection/card.d.ts +36 -0
  15. package/dist/blocks/collection/card.js +128 -0
  16. package/dist/blocks/collection/collection.d.ts +9 -0
  17. package/dist/blocks/collection/collection.js +26 -0
  18. package/dist/blocks/collection/grid.d.ts +18 -0
  19. package/dist/blocks/collection/grid.js +72 -0
  20. package/dist/blocks/collection/lanes.d.ts +32 -0
  21. package/dist/blocks/collection/lanes.js +320 -0
  22. package/dist/blocks/collection/list.d.ts +30 -0
  23. package/dist/blocks/collection/list.js +273 -0
  24. package/dist/blocks/collection/move.d.ts +67 -0
  25. package/dist/blocks/collection/move.js +96 -0
  26. package/dist/blocks/collection/shared.d.ts +87 -0
  27. package/dist/blocks/collection/shared.js +177 -0
  28. package/dist/blocks/collection/table.d.ts +26 -0
  29. package/dist/blocks/collection/table.js +221 -0
  30. package/dist/blocks/content.d.ts +48 -0
  31. package/dist/blocks/content.js +253 -0
  32. package/dist/blocks/context.d.ts +117 -0
  33. package/dist/blocks/context.js +65 -0
  34. package/dist/blocks/diff.d.ts +21 -0
  35. package/dist/blocks/diff.js +25 -0
  36. package/dist/blocks/ext-transport.d.ts +70 -0
  37. package/dist/blocks/ext-transport.js +165 -0
  38. package/dist/blocks/form/choice.d.ts +54 -0
  39. package/dist/blocks/form/choice.js +175 -0
  40. package/dist/blocks/form/fields.d.ts +56 -0
  41. package/dist/blocks/form/fields.js +136 -0
  42. package/dist/blocks/form/file.d.ts +61 -0
  43. package/dist/blocks/form/file.js +121 -0
  44. package/dist/blocks/form/form.d.ts +66 -0
  45. package/dist/blocks/form/form.js +764 -0
  46. package/dist/blocks/form/list.d.ts +37 -0
  47. package/dist/blocks/form/list.js +70 -0
  48. package/dist/blocks/form/values.d.ts +20 -0
  49. package/dist/blocks/form/values.js +106 -0
  50. package/dist/blocks/icons.d.ts +14 -0
  51. package/dist/blocks/icons.js +74 -0
  52. package/dist/blocks/image.d.ts +39 -0
  53. package/dist/blocks/image.js +82 -0
  54. package/dist/blocks/interaction-transport.d.ts +42 -0
  55. package/dist/blocks/interaction-transport.js +79 -0
  56. package/dist/blocks/layout.d.ts +31 -0
  57. package/dist/blocks/layout.js +155 -0
  58. package/dist/blocks/log.d.ts +24 -0
  59. package/dist/blocks/log.js +111 -0
  60. package/dist/blocks/meter.d.ts +16 -0
  61. package/dist/blocks/meter.js +50 -0
  62. package/dist/blocks/page.d.ts +54 -0
  63. package/dist/blocks/page.js +360 -0
  64. package/dist/blocks/render.d.ts +31 -0
  65. package/dist/blocks/render.js +163 -0
  66. package/dist/blocks/surface-view.d.ts +41 -0
  67. package/dist/blocks/surface-view.js +137 -0
  68. package/dist/blocks/surface.d.ts +80 -0
  69. package/dist/blocks/surface.js +506 -0
  70. package/dist/blocks/tones.d.ts +23 -0
  71. package/dist/blocks/tones.js +63 -0
  72. package/dist/blocks/treeview.d.ts +22 -0
  73. package/dist/blocks/treeview.js +121 -0
  74. package/dist/blocks/width.d.ts +32 -0
  75. package/dist/blocks/width.js +70 -0
  76. package/dist/clipboard.d.ts +20 -0
  77. package/dist/clipboard.js +19 -0
  78. package/dist/editor-launch.d.ts +34 -0
  79. package/dist/editor-launch.js +70 -0
  80. package/dist/keymaps/emacs.d.ts +18 -0
  81. package/dist/keymaps/emacs.js +172 -0
  82. package/dist/keymaps/standard.d.ts +17 -0
  83. package/dist/keymaps/standard.js +175 -0
  84. package/dist/keymaps/types.d.ts +43 -0
  85. package/dist/keymaps/types.js +28 -0
  86. package/dist/keymaps/vim.d.ts +48 -0
  87. package/dist/keymaps/vim.js +291 -0
  88. package/dist/launch.d.ts +159 -0
  89. package/dist/launch.js +478 -0
  90. package/dist/sse.d.ts +112 -0
  91. package/dist/sse.js +418 -0
  92. package/dist/theme.d.ts +51 -0
  93. package/dist/theme.js +119 -0
  94. package/dist/ui/ambient.d.ts +78 -0
  95. package/dist/ui/ambient.js +264 -0
  96. package/dist/ui/app.d.ts +319 -0
  97. package/dist/ui/app.js +1539 -0
  98. package/dist/ui/composer.d.ts +66 -0
  99. package/dist/ui/composer.js +133 -0
  100. package/dist/ui/conversation-pane.d.ts +143 -0
  101. package/dist/ui/conversation-pane.js +615 -0
  102. package/dist/ui/help.d.ts +15 -0
  103. package/dist/ui/help.js +80 -0
  104. package/dist/ui/interactions.d.ts +91 -0
  105. package/dist/ui/interactions.js +477 -0
  106. package/dist/ui/picker.d.ts +26 -0
  107. package/dist/ui/picker.js +130 -0
  108. package/dist/ui/settings.d.ts +68 -0
  109. package/dist/ui/settings.js +254 -0
  110. package/dist/ui/sidebar.d.ts +99 -0
  111. package/dist/ui/sidebar.js +269 -0
  112. package/dist/ui/status-bar.d.ts +77 -0
  113. package/dist/ui/status-bar.js +146 -0
  114. package/package.json +4 -3
@@ -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
+ }