@ashley-shrok/viewmodel-shell 0.5.0 → 0.7.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/README.md +10 -6
- package/dist/browser.js +4 -1
- package/dist/index.d.ts +2 -0
- package/dist/server.js +6 -1
- package/dist/tui-cli.js +85 -98
- package/dist/tui.d.ts +64 -147
- package/dist/tui.js +1046 -1548
- package/package.json +9 -11
- package/styles/default.css +17 -3
package/README.md
CHANGED
|
@@ -51,13 +51,15 @@ The download endpoint stays auth-gated and the server authorizes in the action h
|
|
|
51
51
|
|
|
52
52
|
## Terminal (TUI)
|
|
53
53
|
|
|
54
|
-
The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
|
|
54
|
+
The same backend renders in a terminal — same wire, no backend change, with a real lazygit-style UX: mouse clicks, wheel scroll, per-pane focus cycle, and a context-aware status bar. Point the CLI at any ViewModel Shell endpoint:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
|
|
57
|
+
bunx vms-tui https://your-app.example/api/tasks
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
The action endpoint is derived by convention (`<endpoint>/action`).
|
|
60
|
+
The action endpoint is derived by convention (`<endpoint>/action`). The TUI requires the [Bun](https://bun.sh) runtime — install once via `curl -fsSL https://bun.sh/install | bash` (or any installer on bun.sh). Browser and server consumers are unaffected; only `/tui` + `vms-tui` need Bun.
|
|
61
|
+
|
|
62
|
+
Wire it programmatically, exactly like `BrowserAdapter`:
|
|
61
63
|
|
|
62
64
|
```ts
|
|
63
65
|
import { ViewModelShell } from "@ashley-shrok/viewmodel-shell";
|
|
@@ -72,12 +74,14 @@ const shell = new ViewModelShell({
|
|
|
72
74
|
shell.load();
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
|
|
77
|
+
**Interaction model.** Every `section`, top-level `list`, and top-level `table` is its own scrollable focus pane with a border. Tab/Shift-Tab cycles focus across panes; ↑↓ PgUp/PgDn scroll inside the focused pane; click any button, checkbox, link, copy-button, table header, or table row to act on it. Enter activates the focused pane's primary actionable (first button → dispatch, first link → navigate, first copy-button → OSC-52 copy). Space toggles the focused pane's first checkbox-with-action. When the pane has a text field, Enter submits the enclosing form (Field's `<input onSubmit>`) and Space is a normal character.
|
|
78
|
+
|
|
79
|
+
On an interactive terminal the app **fills the screen** via the alternate-screen buffer — a vim/htop-style takeover that re-flows on resize and restores your prior terminal verbatim on exit (every exit path: quit, Ctrl-C, SIGTERM, crash). Opt out with `new TuiAdapter({ viewport: "content" })` for intrinsic content size and no screen takeover. Non-interactive runs (pipe / CI / agent / `</dev/null`) are unaffected.
|
|
76
80
|
|
|
77
|
-
The TUI is built on [
|
|
81
|
+
The TUI is built on [OpenTUI](https://github.com/anomalyco/opentui), declared as **optional** dependencies (`@opentui/core`, `@opentui/react`, `react@19`) so web and server consumers are unaffected — they are never imported by the browser, server, or core entrypoints. `bunx vms-tui` installs them automatically. **Project consumers using `TuiAdapter` programmatically must add all three explicitly** — optional dependencies are *not* pulled transitively:
|
|
78
82
|
|
|
79
83
|
```bash
|
|
80
|
-
|
|
84
|
+
bun add @ashley-shrok/viewmodel-shell @opentui/core @opentui/react react
|
|
81
85
|
```
|
|
82
86
|
|
|
83
87
|
See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for what the terminal renders.
|
package/dist/browser.js
CHANGED
|
@@ -186,7 +186,10 @@ export class BrowserAdapter {
|
|
|
186
186
|
}
|
|
187
187
|
page(n, parent, on) {
|
|
188
188
|
const el = document.createElement("div");
|
|
189
|
-
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}
|
|
189
|
+
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${
|
|
190
|
+
// 0.7.0 (#13) — width override: "wide" or "full" opt-in via a closed
|
|
191
|
+
// union. Omitted = no modifier class (existing 1080px cap holds).
|
|
192
|
+
n.width ? ` vms-page--${n.width}` : ""}`;
|
|
190
193
|
if (n.title) {
|
|
191
194
|
const h = document.createElement("h1");
|
|
192
195
|
h.className = "vms-page__title";
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface PageNode {
|
|
|
41
41
|
density?: "comfortable" | "compact";
|
|
42
42
|
/** Layout preset arranging direct children. Omitted or "stack" = current vertical flow (no modifier class). "split" (equal 2-up), "cards" (uniform grid), "sidebar" (thin + wide app shell) emit .vms-page--{value}. Closed union (D-01/D-02; sidebar D-28). */
|
|
43
43
|
layout?: "stack" | "split" | "cards" | "sidebar";
|
|
44
|
+
/** Page-shell max-width override. Omitted = framework default cap (`--vms-page-max`, 1080px). "wide" = `--vms-page-max-wide` (1440px default), for data-heavy pages with wide tables. "full" = uncapped (max-width: none), for full-bleed dashboards. TUI ignores this (terminals fill naturally). Closed union (D-13 / issue #13). */
|
|
45
|
+
width?: "wide" | "full";
|
|
44
46
|
children: ViewNode[];
|
|
45
47
|
}
|
|
46
48
|
export interface SectionNode {
|
package/dist/server.js
CHANGED
|
@@ -19,7 +19,12 @@ export function parseFormDataAction(formData) {
|
|
|
19
19
|
const state = JSON.parse(stateRaw);
|
|
20
20
|
const files = {};
|
|
21
21
|
for (const [key, value] of formData.entries()) {
|
|
22
|
-
|
|
22
|
+
// Narrow via typeof, NOT `instanceof File`: @types/node@22.19+ declares
|
|
23
|
+
// its own `File` interface alongside DOM's, and the TS narrowing for
|
|
24
|
+
// `instanceof File` ambiguates between the two on
|
|
25
|
+
// `FormDataEntryValue = string | File`. `typeof !== "string"` narrows
|
|
26
|
+
// the union to File unambiguously and is identical at runtime.
|
|
27
|
+
if (key !== "_action" && key !== "_state" && typeof value !== "string") {
|
|
23
28
|
files[key] = value;
|
|
24
29
|
}
|
|
25
30
|
}
|
package/dist/tui-cli.js
CHANGED
|
@@ -1,18 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// vms-tui — drive any ViewModel Shell backend from a terminal.
|
|
3
3
|
//
|
|
4
|
-
// vms-tui <endpoint-url>
|
|
5
|
-
// e.g. vms-tui http://localhost:3000/api/tasks
|
|
4
|
+
// bunx vms-tui <endpoint-url>
|
|
5
|
+
// e.g. bunx vms-tui http://localhost:3000/api/tasks
|
|
6
6
|
//
|
|
7
7
|
// Convention: actions POST to `<endpoint>/action` (matches the demos:
|
|
8
|
-
// GET /api/tasks + POST /api/tasks/action).
|
|
8
|
+
// GET /api/tasks + POST /api/tasks/action).
|
|
9
9
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// Runtime requirement (0.6.0+): Bun. The shebang above is Node so that an
|
|
11
|
+
// accidental `npx vms-tui …` prints a clear "needs Bun" message instead of
|
|
12
|
+
// crashing inside an FFI-laden OpenTUI import. Under `bunx`, `typeof Bun`
|
|
13
|
+
// is defined and the script proceeds normally.
|
|
13
14
|
const USAGE = "Usage: vms-tui <endpoint-url>\n" +
|
|
14
|
-
" e.g. vms-tui http://localhost:3000/api/tasks\n" +
|
|
15
|
-
" Actions POST to <endpoint>/action
|
|
15
|
+
" e.g. bunx vms-tui http://localhost:3000/api/tasks\n" +
|
|
16
|
+
" Actions POST to <endpoint>/action.\n" +
|
|
17
|
+
" Requires Bun runtime: https://bun.sh/install";
|
|
18
|
+
// ── Bun runtime guard ─────────────────────────────────────────────────────
|
|
19
|
+
// vms-tui's TUI substrate (OpenTUI) uses Bun's FFI APIs and is not yet
|
|
20
|
+
// Node-compatible. Failing fast with a clear message here beats a confusing
|
|
21
|
+
// ESM resolution stack trace deeper in the import chain.
|
|
22
|
+
if (typeof Bun === "undefined") {
|
|
23
|
+
process.stderr.write("vms-tui requires the Bun runtime.\n" +
|
|
24
|
+
" Install: curl -fsSL https://bun.sh/install | bash\n" +
|
|
25
|
+
" Then run: bunx vms-tui <endpoint-url>\n" +
|
|
26
|
+
"(see https://bun.sh for other installers)\n");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
16
29
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
30
|
async function main() {
|
|
18
31
|
const arg = process.argv[2];
|
|
@@ -22,7 +35,6 @@ async function main() {
|
|
|
22
35
|
return;
|
|
23
36
|
}
|
|
24
37
|
try {
|
|
25
|
-
// Validate; rejects garbage early instead of failing deep in fetch.
|
|
26
38
|
void new URL(arg);
|
|
27
39
|
}
|
|
28
40
|
catch {
|
|
@@ -31,55 +43,44 @@ async function main() {
|
|
|
31
43
|
return;
|
|
32
44
|
}
|
|
33
45
|
const endpoint = arg;
|
|
34
|
-
//
|
|
35
|
-
//
|
|
46
|
+
// Dynamic import — under Bun, this resolves to the OpenTUI-backed adapter.
|
|
47
|
+
// Under Node we'd never get here (the Bun guard above exits first).
|
|
36
48
|
let TuiAdapter;
|
|
37
|
-
let openExternal;
|
|
38
|
-
let classify;
|
|
39
49
|
try {
|
|
40
|
-
({ TuiAdapter
|
|
50
|
+
({ TuiAdapter } = await import("./tui.js"));
|
|
41
51
|
}
|
|
42
52
|
catch (err) {
|
|
43
|
-
process.stderr.write("vms-tui
|
|
44
|
-
"'
|
|
45
|
-
"
|
|
53
|
+
process.stderr.write("vms-tui: failed to load the TUI adapter — make sure '@opentui/core' " +
|
|
54
|
+
"and '@opentui/react' are installed.\n" +
|
|
55
|
+
" bun install @opentui/core @opentui/react react\n" +
|
|
46
56
|
`(load error: ${err.message})\n`);
|
|
47
57
|
process.exitCode = 1;
|
|
48
58
|
return;
|
|
49
59
|
}
|
|
60
|
+
// Dynamically import ViewModelShell (the framework core; not Bun-specific)
|
|
61
|
+
// alongside, to keep the import topology clean.
|
|
62
|
+
const { ViewModelShell } = await import("./index.js");
|
|
50
63
|
const adapter = new TuiAdapter();
|
|
51
64
|
let loadFailed = false;
|
|
52
65
|
let exiting = false;
|
|
53
|
-
// Single, idempotent teardown owned by the CLI.
|
|
54
|
-
//
|
|
55
|
-
// Ink registers `signal-exit` and resolving its waitUntilExit() promise
|
|
56
|
-
// (which adapter.dispose() → Ink unmount does) lets main()'s tail resume.
|
|
57
|
-
// Without the `exiting` guard that tail would set process.exitCode = 0 and
|
|
58
|
-
// race the real signal code away (observed: SIGINT exited 0, not 130).
|
|
59
|
-
// So: set the code FIRST, restore the terminal, then exit — and let the
|
|
60
|
-
// first caller win; any later caller (incl. the resumed await-tail) no-ops.
|
|
61
66
|
const shutdown = (code) => {
|
|
62
67
|
if (exiting)
|
|
63
68
|
return;
|
|
64
69
|
exiting = true;
|
|
65
70
|
process.exitCode = code;
|
|
66
71
|
try {
|
|
67
|
-
adapter.dispose();
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
/* best-effort, idempotent */
|
|
72
|
+
adapter.dispose();
|
|
71
73
|
}
|
|
74
|
+
catch { /* idempotent */ }
|
|
72
75
|
process.exit(code);
|
|
73
76
|
};
|
|
74
77
|
process.once("SIGINT", () => shutdown(130));
|
|
75
78
|
process.once("SIGTERM", () => shutdown(143));
|
|
76
79
|
process.once("uncaughtException", (err) => {
|
|
77
80
|
try {
|
|
78
|
-
adapter.dispose();
|
|
79
|
-
}
|
|
80
|
-
catch {
|
|
81
|
-
/* idempotent */
|
|
81
|
+
adapter.dispose();
|
|
82
82
|
}
|
|
83
|
+
catch { /* idempotent */ }
|
|
83
84
|
process.stderr.write(`vms-tui: ${err?.stack ?? String(err)}\n`);
|
|
84
85
|
shutdown(1);
|
|
85
86
|
});
|
|
@@ -87,74 +88,45 @@ async function main() {
|
|
|
87
88
|
try {
|
|
88
89
|
adapter.dispose();
|
|
89
90
|
}
|
|
90
|
-
catch {
|
|
91
|
-
/* idempotent */
|
|
92
|
-
}
|
|
91
|
+
catch { /* idempotent */ }
|
|
93
92
|
process.stderr.write(`vms-tui: ${String(reason)}\n`);
|
|
94
93
|
shutdown(1);
|
|
95
94
|
});
|
|
96
|
-
// Final safety net: any other exit path still restores the terminal.
|
|
97
95
|
process.once("exit", () => {
|
|
98
96
|
try {
|
|
99
97
|
adapter.dispose();
|
|
100
98
|
}
|
|
101
|
-
catch {
|
|
102
|
-
/* idempotent */
|
|
103
|
-
}
|
|
99
|
+
catch { /* idempotent */ }
|
|
104
100
|
});
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
// fires for a *programmatic* kill -INT). Route that keyboard Ctrl-C, caught
|
|
109
|
-
// in the adapter's input handler, into this same idempotent shutdown so the
|
|
110
|
-
// terminal is always restored. SIGTERM and programmatic SIGINT are
|
|
111
|
-
// unaffected by raw mode and keep working via the handlers above. (The
|
|
112
|
-
// keep-alive below is now redundant on the TTY path — Ink's resumed raw
|
|
113
|
-
// stdin holds the loop — but is harmless and kept as belt-and-suspenders.)
|
|
114
|
-
adapter.setRequestExit((code) => shutdown(code));
|
|
115
|
-
// Phase 4: server-initiated redirects via the existing core onRedirect seam
|
|
116
|
-
// (no new wire). A ViewModelShell's endpoint is immutable and load() takes
|
|
117
|
-
// no URL, so a same-origin redirect is followed by building a FRESH shell
|
|
118
|
-
// that reuses the SAME single adapter (Ink rerenders in place — no remount,
|
|
119
|
-
// teardown topology unchanged). connect()'s options carry this same
|
|
120
|
-
// onRedirect, so redirects chain.
|
|
121
|
-
let redirectFailed = false;
|
|
122
|
-
let currentShell;
|
|
123
|
-
// Non-interactive iff EITHER stream is not a TTY. Checking stdin too (not
|
|
124
|
-
// just stdout) is load-bearing: with the tui.tsx isActive fix the App's
|
|
125
|
-
// useInput is correctly inert on non-TTY stdin, so nothing holds the event
|
|
126
|
-
// loop — if stdout were a TTY but stdin a pipe (`vms-tui url </dev/null`
|
|
127
|
-
// from an interactive shell), a stdout-only guard would fall through to the
|
|
128
|
-
// keep-alive + waitUntilExit() and HANG forever (no input can ever arrive).
|
|
129
|
-
// One static frame + exit is the only correct behavior when stdin is dead.
|
|
101
|
+
// Non-interactive: render one frame, then exit. Either stream not being a
|
|
102
|
+
// TTY is the signal — a stdout TTY with a piped stdin would hang forever
|
|
103
|
+
// (no input can ever arrive), so we check both.
|
|
130
104
|
const nonInteractive = !process.stdout.isTTY || !process.stdin.isTTY;
|
|
105
|
+
// ── Redirect handling (B1: minimal) ────────────────────────────────────
|
|
106
|
+
// Same-origin: rebuild the shell against the new endpoint and reload, so
|
|
107
|
+
// the polling/state cycle continues seamlessly. Different-origin or
|
|
108
|
+
// invalid: delegate to adapter.navigate (which spawns xdg-open / open
|
|
109
|
+
// / start), or, on non-TTY, print a loud stderr line. B5 will re-add
|
|
110
|
+
// the in-app interstitial UX the Ink CLI had.
|
|
111
|
+
let currentShell;
|
|
131
112
|
const handleRedirect = (url, fromEndpoint) => {
|
|
132
|
-
const c =
|
|
113
|
+
const c = classifyRedirect(url, fromEndpoint);
|
|
133
114
|
if (c.kind === "same-origin") {
|
|
134
|
-
currentShell.stopPolling();
|
|
115
|
+
currentShell.stopPolling();
|
|
135
116
|
currentShell = connect(c.endpoint);
|
|
136
|
-
void currentShell.load();
|
|
117
|
+
void currentShell.load();
|
|
137
118
|
return;
|
|
138
119
|
}
|
|
139
120
|
if (nonInteractive) {
|
|
140
|
-
// Non-interactive: never spawn a browser; loud stderr + nonzero exit via
|
|
141
|
-
// the SINGLE shutdown funnel (redirectFailed is read at the exit site).
|
|
142
121
|
process.stderr.write(`vms-tui: cannot follow redirect (${c.kind}): ${url}\n`);
|
|
143
|
-
|
|
122
|
+
loadFailed = true;
|
|
144
123
|
return;
|
|
145
124
|
}
|
|
146
125
|
if (c.kind === "different-origin") {
|
|
147
|
-
|
|
148
|
-
const fb = () => adapter.showInterstitial(`${detail}\n\n(no browser could be launched — open it manually)`);
|
|
149
|
-
if (openExternal(c.url, fb)) {
|
|
150
|
-
adapter.showInterstitial(`${detail}\n\n(opening in your browser…)`);
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
fb();
|
|
154
|
-
}
|
|
126
|
+
adapter.navigate(c.url);
|
|
155
127
|
}
|
|
156
128
|
else {
|
|
157
|
-
|
|
129
|
+
process.stderr.write(`vms-tui: invalid redirect URL: ${JSON.stringify(url)}\n`);
|
|
158
130
|
}
|
|
159
131
|
};
|
|
160
132
|
const connect = (ep) => new ViewModelShell({
|
|
@@ -163,7 +135,6 @@ async function main() {
|
|
|
163
135
|
adapter,
|
|
164
136
|
onError: (err) => {
|
|
165
137
|
loadFailed = true;
|
|
166
|
-
// A stderr line guarantees visibility even before the first render.
|
|
167
138
|
process.stderr.write(`vms-tui: ${err.message}\n`);
|
|
168
139
|
},
|
|
169
140
|
onRedirect: (url) => handleRedirect(url, ep),
|
|
@@ -177,26 +148,42 @@ async function main() {
|
|
|
177
148
|
process.stderr.write(`vms-tui: ${err.message}\n`);
|
|
178
149
|
}
|
|
179
150
|
if (nonInteractive) {
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
// redirectFailed stays false unless a future code path dispatches.)
|
|
185
|
-
await delay(80);
|
|
186
|
-
return shutdown(loadFailed || redirectFailed ? 1 : 0);
|
|
151
|
+
// Let OpenTUI's renderer flush one frame, then exit. 100ms is generous;
|
|
152
|
+
// OpenTUI's render pipeline is microtask-fast.
|
|
153
|
+
await delay(100);
|
|
154
|
+
return shutdown(loadFailed ? 1 : 0);
|
|
187
155
|
}
|
|
188
|
-
// Load/connection failure → nothing rendered; exit now rather than wait for
|
|
189
|
-
// a Ctrl-C the user has no reason to send.
|
|
190
156
|
if (loadFailed) {
|
|
191
157
|
return shutdown(1);
|
|
192
158
|
}
|
|
193
|
-
// TTY + rendered: keep the frame up until the user quits (Ctrl-C).
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
159
|
+
// TTY + rendered: keep the frame up until the user quits (Ctrl-C / SIGTERM).
|
|
160
|
+
// OpenTUI's renderer owns the keep-alive (its async event loop holds the
|
|
161
|
+
// process), so we just await a never-resolving promise — SIGINT/SIGTERM
|
|
162
|
+
// handlers funnel into shutdown().
|
|
163
|
+
await new Promise(() => { });
|
|
164
|
+
}
|
|
165
|
+
function classifyRedirect(target, fromEndpoint) {
|
|
166
|
+
let from;
|
|
167
|
+
try {
|
|
168
|
+
from = new URL(fromEndpoint);
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
return { kind: "invalid", reason: "current endpoint is not a valid URL" };
|
|
172
|
+
}
|
|
173
|
+
let to;
|
|
174
|
+
try {
|
|
175
|
+
to = new URL(target, from);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return { kind: "invalid", reason: "redirect target is not a valid URL" };
|
|
179
|
+
}
|
|
180
|
+
if (to.protocol !== "http:" && to.protocol !== "https:") {
|
|
181
|
+
return { kind: "invalid", reason: `unsupported protocol: ${to.protocol}` };
|
|
182
|
+
}
|
|
183
|
+
if (to.origin === from.origin) {
|
|
184
|
+
return { kind: "same-origin", endpoint: to.toString() };
|
|
185
|
+
}
|
|
186
|
+
return { kind: "different-origin", url: to.toString() };
|
|
201
187
|
}
|
|
202
188
|
void main();
|
|
189
|
+
export {};
|
package/dist/tui.d.ts
CHANGED
|
@@ -1,158 +1,75 @@
|
|
|
1
|
-
import type { ReactElement } from "react";
|
|
2
1
|
import type { Adapter, ActionEvent, ViewNode } from "./index.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* invokes `onSpawnError`. Callers map BOTH false and onSpawnError to the loud
|
|
13
|
-
* interstitial, so a redirect is never silently lost. Exported so a unit test
|
|
14
|
-
* can drive it with a stubbed node:child_process. */
|
|
15
|
-
export declare function openExternal(url: string, onSpawnError?: () => void): boolean;
|
|
16
|
-
/** How the vms-tui CLI's default onRedirect classifies a server redirect.
|
|
17
|
-
* No new wire — pure URL/origin analysis against the current endpoint. A
|
|
18
|
-
* relative path resolves against the endpoint ⇒ same-origin by construction;
|
|
19
|
-
* an absolute URL is same-origin iff its `origin` matches (origin compare,
|
|
20
|
-
* never a string prefix — `http://evil/?x=http://good` must NOT pass). */
|
|
21
|
-
export type RedirectKind = {
|
|
22
|
-
kind: "same-origin";
|
|
23
|
-
endpoint: string;
|
|
24
|
-
} | {
|
|
25
|
-
kind: "different-origin";
|
|
26
|
-
url: string;
|
|
27
|
-
} | {
|
|
28
|
-
kind: "invalid";
|
|
29
|
-
url: string;
|
|
30
|
-
};
|
|
31
|
-
export declare function classify(url: string, fromEndpoint: string): RedirectKind;
|
|
32
|
-
/**
|
|
33
|
-
* Phase 5b terminal adapter — single-line + multi-line editors + selects.
|
|
34
|
-
*
|
|
35
|
-
* Every node renders byte-identically to Phase 1/2 when UNFOCUSED and on the
|
|
36
|
-
* pure `renderTree`/non-TTY path (NO_CTX → interactive:false → no editor). On
|
|
37
|
-
* a TTY the shell's view is interactive: a self-managed focus ring
|
|
38
|
-
* (Tab/Shift-Tab/arrows), Enter/Space dispatch, focus continuity; button /
|
|
39
|
-
* checkbox (`{checked}`) / tabs (`{value}`) / link / copy-button (OSC 52) /
|
|
40
|
-
* form-submit. The focused single-line `field` is an editable
|
|
41
|
-
* `ink-text-input`; the focused `textarea`/`code` field is the contained
|
|
42
|
-
* `MultilineEditor` (Enter→newline; submit via the ring's submit button;
|
|
43
|
-
* `code` adds a dim language label, no literal-tab). The focused `select` is
|
|
44
|
-
* an `ink-select-input` list; the focused `select-multiple` is the contained
|
|
45
|
-
* `MultiSelectInput` (Space toggles; comma-joined wire value; submit via the
|
|
46
|
-
* ring's submit button). Typed drafts survive poll/dispatch re-renders unless
|
|
47
|
-
* the field disappears or the server changes its value — mirroring
|
|
48
|
-
* BrowserAdapter; selects are additionally server-authoritative on ANY server
|
|
49
|
-
* re-render (excluded from draft preservation); password is masked;
|
|
50
|
-
* form-`checkbox` toggles on Space. The still-deferred tier (`file`/`modal`/
|
|
51
|
-
* `table`) arrives in later phases and still renders a visible fail-loud
|
|
52
|
-
* placeholder — never blank, never silent.
|
|
53
|
-
*
|
|
54
|
-
* LEAF MODULE: never imported by src/index.ts / src/browser.ts /
|
|
55
|
-
* src/server.ts, so `ink`/`react` never enter the web or server dependency
|
|
56
|
-
* graph (a unit test asserts this).
|
|
57
|
-
*/
|
|
2
|
+
interface TuiOpts {
|
|
3
|
+
/** "fill" (default) takes the full terminal via alternate-screen buffer;
|
|
4
|
+
* "content" renders intrinsic content size in the inline scrollback. */
|
|
5
|
+
viewport?: "fill" | "content";
|
|
6
|
+
/** Sidebar rail fraction (0.15–0.6; default 1/3). When `page.layout === "sidebar"`
|
|
7
|
+
* (and the same on `section.layout`), the FIRST child occupies this fraction
|
|
8
|
+
* of the available width; the remainder fills with the rest of the children. */
|
|
9
|
+
sidebarFraction?: number;
|
|
10
|
+
}
|
|
58
11
|
export declare class TuiAdapter implements Adapter {
|
|
59
|
-
private
|
|
12
|
+
private renderer;
|
|
13
|
+
private root;
|
|
14
|
+
private pending;
|
|
15
|
+
private initPromise;
|
|
60
16
|
private disposed;
|
|
61
|
-
/** "fill" (default) = occupy the terminal + alternate-screen on a real
|
|
62
|
-
* interactive TTY; "content" = legacy intrinsic-content size, no
|
|
63
|
-
* alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
|
|
64
17
|
private readonly viewport;
|
|
65
|
-
/** `sidebar` rail width as a fraction of the terminal (fill path only).
|
|
66
|
-
* Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
|
|
67
|
-
* the layout. Adapter-level styling knob (NOT wire — appearance, not
|
|
68
|
-
* arrangement). */
|
|
69
18
|
private readonly sidebarFraction;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
private
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
*
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
private lastVm;
|
|
91
|
-
private lastOnAction;
|
|
92
|
-
setRequestExit(fn: (code: number) => void): void;
|
|
19
|
+
private readonly sessionStore;
|
|
20
|
+
private focusedPaneIndex;
|
|
21
|
+
private lastPaneCount;
|
|
22
|
+
private readonly fieldValues;
|
|
23
|
+
private readonly fieldWireValues;
|
|
24
|
+
private copiedKey;
|
|
25
|
+
private copiedTimer;
|
|
26
|
+
constructor(opts?: TuiOpts);
|
|
27
|
+
private setFieldValue;
|
|
28
|
+
/** Resolve a field's display value at render time. The draft-preservation
|
|
29
|
+
* contract (AGENTS.md) is "user edits survive re-renders UNLESS the server
|
|
30
|
+
* explicitly sets a new value for that field." Three states:
|
|
31
|
+
* 1. First time we see this field name — initialize the wire baseline
|
|
32
|
+
* WITHOUT clobbering any pre-existing edit value (matters when a test
|
|
33
|
+
* primes fieldValues via setFieldValue before the first render).
|
|
34
|
+
* 2. Wire value unchanged since last render — preserve the edit value.
|
|
35
|
+
* 3. Wire value differs from the prior wire baseline — server intent
|
|
36
|
+
* change → reset the edit value to match. */
|
|
37
|
+
private copy;
|
|
38
|
+
private resolveFieldValue;
|
|
93
39
|
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
40
|
+
private init;
|
|
41
|
+
private flushPending;
|
|
42
|
+
private navigateForLinks;
|
|
43
|
+
/** Test-only: read the current edit value for a named field (for asserting
|
|
44
|
+
* draft preservation across server re-renders). */
|
|
45
|
+
_peekFieldValue(name: string): string | undefined;
|
|
46
|
+
/** Test-only: read the current "copied" key for asserting that activation
|
|
47
|
+
* set the feedback state. Returns null when no button is currently flashing. */
|
|
48
|
+
_peekCopiedKey(): string | null;
|
|
49
|
+
private cycleFocus;
|
|
50
|
+
/** B5 — keyboard activation of the focused pane's primary actionable.
|
|
51
|
+
*
|
|
52
|
+
* Mode mapping:
|
|
53
|
+
* "enter" → dispatch the first button.action / link → navigate /
|
|
54
|
+
* copy-button → copy(). No-op if the pane has inputs
|
|
55
|
+
* (FieldView's <input onSubmit> handles Enter for forms).
|
|
56
|
+
* "space" → toggle the first checkbox (dispatch action with
|
|
57
|
+
* {checked: !node.checked}). No-op if the pane has inputs
|
|
58
|
+
* (Space is a printable character in text fields).
|
|
59
|
+
*
|
|
60
|
+
* Always reads pane state from the CURRENT vm (this.pending), so a key
|
|
61
|
+
* pressed mid-render still acts on the structure the user can see.
|
|
62
|
+
*/
|
|
63
|
+
private activatePane;
|
|
64
|
+
/** Teardown — restore terminal cleanly. Idempotent. */
|
|
65
|
+
dispose(): void;
|
|
112
66
|
navigate(url: string): void;
|
|
113
|
-
/** Write-only client storage. `session` → in-memory (process lifetime).
|
|
114
|
-
* `local` → a SYNCHRONOUS XDG state-file write — synchronous is mandatory:
|
|
115
|
-
* the core applies side-effects before the redirect branch (adapter-seam
|
|
116
|
-
* Case E), so the token must land before navigation. Fail-loud, never
|
|
117
|
-
* swallow, never re-throw into core (push() has no try/catch): an I/O error
|
|
118
|
-
* or an unparseable EXISTING store surfaces the loud interstitial + a
|
|
119
|
-
* stderr line and does NOT clobber a possibly-unrelated user file. */
|
|
120
67
|
storage(scope: "local" | "session", key: string, value: string): void;
|
|
121
|
-
/** Save an authenticated-download blob to the terminal user's filesystem.
|
|
122
|
-
* Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
|
|
123
|
-
* process.cwd(). The chosen path is printed to stderr so the operator
|
|
124
|
-
* can find it. The filename is sanitized (no path components / no `..`)
|
|
125
|
-
* so a hostile/buggy server can't escape the download dir; this is the
|
|
126
|
-
* TUI counterpart to the browser's built-in download-name sanitization. */
|
|
127
68
|
saveFile(data: Blob, filename: string, _contentType: string): Promise<void>;
|
|
128
|
-
/** Test-only: read back a session value. The wire contract has no
|
|
129
|
-
* read; this exists solely so
|
|
130
|
-
* (
|
|
69
|
+
/** Test-only: read back a session value. The wire contract has no
|
|
70
|
+
* storage read; this exists solely so unit tests can prove the write
|
|
71
|
+
* landed (parity with the Ink adapter's _peekSession). */
|
|
131
72
|
_peekSession(key: string): string | undefined;
|
|
132
|
-
/** Pure ViewNode → Ink element, UNFOCUSED (NO_CTX). Used by static unit
|
|
133
|
-
* tests; byte-identical to Phase 1 output. */
|
|
134
|
-
renderTree(vm: ViewNode): ReactElement;
|
|
135
|
-
/** Spacing rhythm. compact collapses gaps/padding (mirrors .vms--compact). */
|
|
136
|
-
private spacing;
|
|
137
|
-
/**
|
|
138
|
-
* React key: the roadmap's identity heuristic — explicit id/name where
|
|
139
|
-
* present, else positional index. Render-only; never a wire field.
|
|
140
|
-
*/
|
|
141
|
-
private keyOf;
|
|
142
|
-
/** Fail-loud placeholder — single source of the phase string. */
|
|
143
|
-
private unsupported;
|
|
144
|
-
/** Focused-node affordance. Unfocused → returns the element unchanged, so
|
|
145
|
-
* the static path stays byte-identical to Phase 1. Focused → a leading
|
|
146
|
-
* cyan caret (deterministic + information-honest; ▸ U+25B8 is distinct
|
|
147
|
-
* from the list-item markers › / ·). */
|
|
148
|
-
private focusWrap;
|
|
149
|
-
/** Shipped list-item variant → marker + child tint (fail-soft: unknown ok). */
|
|
150
|
-
private listItemStyle;
|
|
151
|
-
/** page & section share the 4 layout presets. Information-honest, not pixel. */
|
|
152
|
-
private layoutContainer;
|
|
153
|
-
private renderNode;
|
|
154
|
-
/** Resolves when the Ink app unmounts; immediate if nothing was rendered. */
|
|
155
|
-
waitUntilExit(): Promise<void>;
|
|
156
|
-
/** Idempotent terminal restore — unmount restores cursor/raw mode. */
|
|
157
|
-
dispose(): void;
|
|
158
73
|
}
|
|
74
|
+
export declare function renderTree(vm: ViewNode): React.ReactNode;
|
|
75
|
+
export {};
|