@ashley-shrok/viewmodel-shell 0.4.2 → 0.4.3
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 +26 -1
- package/dist/tui-cli.d.ts +2 -0
- package/dist/tui-cli.js +193 -0
- package/dist/tui.d.ts +134 -0
- package/dist/tui.js +1477 -0
- package/package.json +23 -3
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime.
|
|
4
4
|
|
|
5
|
-
The server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. The browser never owns application state — every interaction dispatches a semantic action to a single POST endpoint and the server returns the next state plus a fresh view.
|
|
5
|
+
The server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to the DOM (or, with the same wire, a terminal). The browser never owns application state — every interaction dispatches a semantic action to a single POST endpoint and the server returns the next state plus a fresh view.
|
|
6
6
|
|
|
7
7
|
The frontend is backend-agnostic: it speaks a small JSON contract over a single POST endpoint that takes `multipart/form-data` (with `_action` and `_state` fields). A .NET reference backend ships in the repo, but any language can produce the same contract.
|
|
8
8
|
|
|
@@ -33,6 +33,31 @@ If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [Gi
|
|
|
33
33
|
|
|
34
34
|
For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
|
|
35
35
|
|
|
36
|
+
## Terminal (TUI)
|
|
37
|
+
|
|
38
|
+
The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx vms-tui https://your-app.example/api/tasks
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The action endpoint is derived by convention (`<endpoint>/action`). Or wire it programmatically, exactly like `BrowserAdapter`:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { ViewModelShell } from "@ashley-shrok/viewmodel-shell";
|
|
48
|
+
import { TuiAdapter } from "@ashley-shrok/viewmodel-shell/tui";
|
|
49
|
+
|
|
50
|
+
const shell = new ViewModelShell({
|
|
51
|
+
endpoint: "/api/tasks",
|
|
52
|
+
actionEndpoint: "/api/tasks/action",
|
|
53
|
+
adapter: new TuiAdapter(),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
shell.load();
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The TUI is built on [Ink](https://github.com/vadimdemedes/ink), declared as an **optional** dependency: CLI/`npx` use installs it automatically, while web and server consumers are unaffected — it is never imported by the browser, server, or core entrypoints. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for what the terminal renders.
|
|
60
|
+
|
|
36
61
|
## Themes
|
|
37
62
|
|
|
38
63
|
The base stylesheet ships a **light** default (purple accent). To use a different look — including the prior dark-purple default — import a theme file on top:
|
package/dist/tui-cli.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// vms-tui — drive any ViewModel Shell backend from a terminal.
|
|
3
|
+
//
|
|
4
|
+
// vms-tui <endpoint-url>
|
|
5
|
+
// e.g. vms-tui http://localhost:3000/api/tasks
|
|
6
|
+
//
|
|
7
|
+
// Convention: actions POST to `<endpoint>/action` (matches the demos:
|
|
8
|
+
// GET /api/tasks + POST /api/tasks/action). A future flag can override it.
|
|
9
|
+
//
|
|
10
|
+
// Pure entrypoint — never imported as a library (the importable surface is
|
|
11
|
+
// TuiAdapter via the "./tui" export), so no import.meta.main guard is needed.
|
|
12
|
+
import { ViewModelShell } from "./index.js";
|
|
13
|
+
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.";
|
|
16
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
async function main() {
|
|
18
|
+
const arg = process.argv[2];
|
|
19
|
+
if (!arg || arg === "-h" || arg === "--help") {
|
|
20
|
+
process.stderr.write(USAGE + "\n");
|
|
21
|
+
process.exitCode = arg ? 0 : 2;
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
// Validate; rejects garbage early instead of failing deep in fetch.
|
|
26
|
+
void new URL(arg);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
process.stderr.write(`vms-tui: invalid URL: ${arg}\n${USAGE}\n`);
|
|
30
|
+
process.exitCode = 2;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const endpoint = arg;
|
|
34
|
+
// Guarded dynamic import: if the optional `ink`/`react` deps are absent,
|
|
35
|
+
// fail loud with an install hint instead of an ESM resolution stack trace.
|
|
36
|
+
let TuiAdapter;
|
|
37
|
+
let openExternal;
|
|
38
|
+
let classify;
|
|
39
|
+
try {
|
|
40
|
+
({ TuiAdapter, openExternal, classify } = await import("./tui.js"));
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
process.stderr.write("vms-tui requires the optional 'ink' and 'react' packages.\n" +
|
|
44
|
+
"Install them: npm i ink react\n" +
|
|
45
|
+
`(load error: ${err.message})\n`);
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const adapter = new TuiAdapter();
|
|
50
|
+
let loadFailed = false;
|
|
51
|
+
let exiting = false;
|
|
52
|
+
// Single, idempotent teardown owned by the CLI.
|
|
53
|
+
//
|
|
54
|
+
// Ink registers `signal-exit` and resolving its waitUntilExit() promise
|
|
55
|
+
// (which adapter.dispose() → Ink unmount does) lets main()'s tail resume.
|
|
56
|
+
// Without the `exiting` guard that tail would set process.exitCode = 0 and
|
|
57
|
+
// race the real signal code away (observed: SIGINT exited 0, not 130).
|
|
58
|
+
// So: set the code FIRST, restore the terminal, then exit — and let the
|
|
59
|
+
// first caller win; any later caller (incl. the resumed await-tail) no-ops.
|
|
60
|
+
const shutdown = (code) => {
|
|
61
|
+
if (exiting)
|
|
62
|
+
return;
|
|
63
|
+
exiting = true;
|
|
64
|
+
process.exitCode = code;
|
|
65
|
+
try {
|
|
66
|
+
adapter.dispose(); // Ink unmount → cursor/raw-mode restored
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* best-effort, idempotent */
|
|
70
|
+
}
|
|
71
|
+
process.exit(code);
|
|
72
|
+
};
|
|
73
|
+
process.once("SIGINT", () => shutdown(130));
|
|
74
|
+
process.once("SIGTERM", () => shutdown(143));
|
|
75
|
+
process.once("uncaughtException", (err) => {
|
|
76
|
+
try {
|
|
77
|
+
adapter.dispose(); // restore BEFORE printing so the message is readable
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* idempotent */
|
|
81
|
+
}
|
|
82
|
+
process.stderr.write(`vms-tui: ${err?.stack ?? String(err)}\n`);
|
|
83
|
+
shutdown(1);
|
|
84
|
+
});
|
|
85
|
+
process.once("unhandledRejection", (reason) => {
|
|
86
|
+
try {
|
|
87
|
+
adapter.dispose();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* idempotent */
|
|
91
|
+
}
|
|
92
|
+
process.stderr.write(`vms-tui: ${String(reason)}\n`);
|
|
93
|
+
shutdown(1);
|
|
94
|
+
});
|
|
95
|
+
// Final safety net: any other exit path still restores the terminal.
|
|
96
|
+
process.once("exit", () => {
|
|
97
|
+
try {
|
|
98
|
+
adapter.dispose();
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* idempotent */
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
// Phase 2: the adapter now uses Ink input hooks → Ink puts stdin in raw
|
|
105
|
+
// mode, which clears ISIG, so a *keyboard* Ctrl-C is delivered as input
|
|
106
|
+
// byte 0x03 and never raises SIGINT (the process.once("SIGINT") above only
|
|
107
|
+
// fires for a *programmatic* kill -INT). Route that keyboard Ctrl-C, caught
|
|
108
|
+
// in the adapter's input handler, into this same idempotent shutdown so the
|
|
109
|
+
// terminal is always restored. SIGTERM and programmatic SIGINT are
|
|
110
|
+
// unaffected by raw mode and keep working via the handlers above. (The
|
|
111
|
+
// keep-alive below is now redundant on the TTY path — Ink's resumed raw
|
|
112
|
+
// stdin holds the loop — but is harmless and kept as belt-and-suspenders.)
|
|
113
|
+
adapter.setRequestExit((code) => shutdown(code));
|
|
114
|
+
// Phase 4: server-initiated redirects via the existing core onRedirect seam
|
|
115
|
+
// (no new wire). A ViewModelShell's endpoint is immutable and load() takes
|
|
116
|
+
// no URL, so a same-origin redirect is followed by building a FRESH shell
|
|
117
|
+
// that reuses the SAME single adapter (Ink rerenders in place — no remount,
|
|
118
|
+
// teardown topology unchanged). connect()'s options carry this same
|
|
119
|
+
// onRedirect, so redirects chain.
|
|
120
|
+
let redirectFailed = false;
|
|
121
|
+
let currentShell;
|
|
122
|
+
const handleRedirect = (url, fromEndpoint) => {
|
|
123
|
+
const c = classify(url, fromEndpoint);
|
|
124
|
+
if (c.kind === "same-origin") {
|
|
125
|
+
currentShell.stopPolling(); // no timer armed today; defends a future one
|
|
126
|
+
currentShell = connect(c.endpoint);
|
|
127
|
+
void currentShell.load(); // failures flow through the shared onError
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!process.stdout.isTTY) {
|
|
131
|
+
// Non-TTY: never spawn a browser; loud stderr + nonzero exit via the
|
|
132
|
+
// SINGLE shutdown funnel (redirectFailed is read at the exit site).
|
|
133
|
+
process.stderr.write(`vms-tui: cannot follow redirect (${c.kind}): ${url}\n`);
|
|
134
|
+
redirectFailed = true;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (c.kind === "different-origin") {
|
|
138
|
+
const detail = `This app asked to open an external URL:\n\n ${c.url}`;
|
|
139
|
+
const fb = () => adapter.showInterstitial(`${detail}\n\n(no browser could be launched — open it manually)`);
|
|
140
|
+
if (openExternal(c.url, fb)) {
|
|
141
|
+
adapter.showInterstitial(`${detail}\n\n(opening in your browser…)`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
fb();
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
adapter.showInterstitial(`This app returned an invalid redirect:\n\n ${JSON.stringify(url)}`);
|
|
149
|
+
}
|
|
150
|
+
};
|
|
151
|
+
const connect = (ep) => new ViewModelShell({
|
|
152
|
+
endpoint: ep,
|
|
153
|
+
actionEndpoint: `${ep.replace(/\/+$/, "")}/action`,
|
|
154
|
+
adapter,
|
|
155
|
+
onError: (err) => {
|
|
156
|
+
loadFailed = true;
|
|
157
|
+
// A stderr line guarantees visibility even before the first render.
|
|
158
|
+
process.stderr.write(`vms-tui: ${err.message}\n`);
|
|
159
|
+
},
|
|
160
|
+
onRedirect: (url) => handleRedirect(url, ep),
|
|
161
|
+
});
|
|
162
|
+
currentShell = connect(endpoint);
|
|
163
|
+
try {
|
|
164
|
+
await currentShell.load();
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
loadFailed = true;
|
|
168
|
+
process.stderr.write(`vms-tui: ${err.message}\n`);
|
|
169
|
+
}
|
|
170
|
+
if (!process.stdout.isTTY) {
|
|
171
|
+
// Non-TTY (piped / CI): nothing to interact with — emit one static frame
|
|
172
|
+
// and exit instead of hanging on a Ctrl-C that can never come. The delay
|
|
173
|
+
// lets Ink flush its throttled render. (Redirects can't fire here: load()
|
|
174
|
+
// ignores response.redirect and non-TTY performs no dispatch — so
|
|
175
|
+
// redirectFailed stays false unless a future code path dispatches.)
|
|
176
|
+
await delay(80);
|
|
177
|
+
return shutdown(loadFailed || redirectFailed ? 1 : 0);
|
|
178
|
+
}
|
|
179
|
+
// Load/connection failure → nothing rendered; exit now rather than wait for
|
|
180
|
+
// a Ctrl-C the user has no reason to send.
|
|
181
|
+
if (loadFailed) {
|
|
182
|
+
return shutdown(1);
|
|
183
|
+
}
|
|
184
|
+
// TTY + rendered: keep the frame up until the user quits (Ctrl-C). Ink's
|
|
185
|
+
// resumed raw stdin holds the loop (Phase 2+), but the no-op timer is kept
|
|
186
|
+
// as belt-and-suspenders; the SIGINT/SIGTERM handlers own exit + restore.
|
|
187
|
+
// (waitUntilExit() wins the race if Ink unmounts for another reason.)
|
|
188
|
+
const keepAlive = setInterval(() => { }, 1 << 30);
|
|
189
|
+
await adapter.waitUntilExit();
|
|
190
|
+
clearInterval(keepAlive);
|
|
191
|
+
shutdown(0);
|
|
192
|
+
}
|
|
193
|
+
void main();
|
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { ReactElement } from "react";
|
|
2
|
+
import type { Adapter, ActionEvent, ViewNode } from "./index.js";
|
|
3
|
+
/** OSC 52 clipboard write — the terminal-native analog of the clipboard API.
|
|
4
|
+
* Works over SSH (no local clipboard dependency). Exported for a direct,
|
|
5
|
+
* deterministic unit test of the byte format. */
|
|
6
|
+
export declare function osc52(text: string): string;
|
|
7
|
+
/** Hand a URL to a real browser — the terminal analog of a redirect. Zero-dep
|
|
8
|
+
* (node:child_process). Opener order: $BROWSER → platform default. Detached +
|
|
9
|
+
* unref so a launched browser can never hold or block the Node event loop
|
|
10
|
+
* (teardown discipline). Returns false only if spawn threw *synchronously*
|
|
11
|
+
* (no opener attempted); an async spawn failure (e.g. ENOENT — no xdg-open)
|
|
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
|
+
*/
|
|
58
|
+
export declare class TuiAdapter implements Adapter {
|
|
59
|
+
private instance;
|
|
60
|
+
private disposed;
|
|
61
|
+
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
62
|
+
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
63
|
+
* CLI's single idempotent shutdown. A TUI-internal seam between our two
|
|
64
|
+
* leaf files — NOT part of the core Adapter interface. */
|
|
65
|
+
private requestExit;
|
|
66
|
+
/** session storage — in-memory, process lifetime (browser sessionStorage
|
|
67
|
+
* analog for a single-process CLI). Write-only per the wire contract. */
|
|
68
|
+
private sessionStore;
|
|
69
|
+
/** When non-null, App renders the loud Interstitial instead of the vm. */
|
|
70
|
+
private interstitial;
|
|
71
|
+
/** Last rendered vm/onAction — so showInterstitial can rerender through the
|
|
72
|
+
* SAME single Ink instance (never a 2nd inkRender). */
|
|
73
|
+
private lastVm;
|
|
74
|
+
private lastOnAction;
|
|
75
|
+
setRequestExit(fn: (code: number) => void): void;
|
|
76
|
+
render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
|
|
77
|
+
/** The interactive element used by render() and by interaction unit tests. */
|
|
78
|
+
createApp(vm: ViewNode, onAction: (a: ActionEvent) => void, opts?: {
|
|
79
|
+
requestExit?: (code: number) => void;
|
|
80
|
+
}): ReactElement;
|
|
81
|
+
/** Render a full-screen loud notice through the SINGLE existing Ink instance
|
|
82
|
+
* (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
|
|
83
|
+
* diff / the ESC[?25h restore). No-op once disposed — closes the
|
|
84
|
+
* redirect-during-teardown rerender-after-unmount race. Process stays
|
|
85
|
+
* alive; the CLI owns exit (Ctrl-C via App's existing useInput → shutdown).
|
|
86
|
+
* Mounts the instance if a notice somehow precedes the first render (that
|
|
87
|
+
* is still the FIRST inkRender, not a second). */
|
|
88
|
+
showInterstitial(msg: string): void;
|
|
89
|
+
/** Standalone redirect fallback. Only reached when a consumer wires
|
|
90
|
+
* TuiAdapter WITHOUT ShellOptions.onRedirect — core precedence means the
|
|
91
|
+
* vms-tui CLI's onRedirect suppresses this (proven by adapter-seam C/F).
|
|
92
|
+
* Origin-blind (the adapter has no shell/endpoint ref): hand off to a
|
|
93
|
+
* browser, else the loud interstitial. Never silent, never throws into
|
|
94
|
+
* core. No-op once disposed. */
|
|
95
|
+
navigate(url: string): void;
|
|
96
|
+
/** Write-only client storage. `session` → in-memory (process lifetime).
|
|
97
|
+
* `local` → a SYNCHRONOUS XDG state-file write — synchronous is mandatory:
|
|
98
|
+
* the core applies side-effects before the redirect branch (adapter-seam
|
|
99
|
+
* Case E), so the token must land before navigation. Fail-loud, never
|
|
100
|
+
* swallow, never re-throw into core (push() has no try/catch): an I/O error
|
|
101
|
+
* or an unparseable EXISTING store surfaces the loud interstitial + a
|
|
102
|
+
* stderr line and does NOT clobber a possibly-unrelated user file. */
|
|
103
|
+
storage(scope: "local" | "session", key: string, value: string): void;
|
|
104
|
+
/** Test-only: read back a session value. The wire contract has no storage
|
|
105
|
+
* read; this exists solely so a unit test can prove the write landed
|
|
106
|
+
* (same rationale as exporting osc52 for a deterministic test). */
|
|
107
|
+
_peekSession(key: string): string | undefined;
|
|
108
|
+
/** Pure ViewNode → Ink element, UNFOCUSED (NO_CTX). Used by static unit
|
|
109
|
+
* tests; byte-identical to Phase 1 output. */
|
|
110
|
+
renderTree(vm: ViewNode): ReactElement;
|
|
111
|
+
/** Spacing rhythm. compact collapses gaps/padding (mirrors .vms--compact). */
|
|
112
|
+
private spacing;
|
|
113
|
+
/**
|
|
114
|
+
* React key: the roadmap's identity heuristic — explicit id/name where
|
|
115
|
+
* present, else positional index. Render-only; never a wire field.
|
|
116
|
+
*/
|
|
117
|
+
private keyOf;
|
|
118
|
+
/** Fail-loud placeholder — single source of the phase string. */
|
|
119
|
+
private unsupported;
|
|
120
|
+
/** Focused-node affordance. Unfocused → returns the element unchanged, so
|
|
121
|
+
* the static path stays byte-identical to Phase 1. Focused → a leading
|
|
122
|
+
* cyan caret (deterministic + information-honest; ▸ U+25B8 is distinct
|
|
123
|
+
* from the list-item markers › / ·). */
|
|
124
|
+
private focusWrap;
|
|
125
|
+
/** Shipped list-item variant → marker + child tint (fail-soft: unknown ok). */
|
|
126
|
+
private listItemStyle;
|
|
127
|
+
/** page & section share the 4 layout presets. Information-honest, not pixel. */
|
|
128
|
+
private layoutContainer;
|
|
129
|
+
private renderNode;
|
|
130
|
+
/** Resolves when the Ink app unmounts; immediate if nothing was rendered. */
|
|
131
|
+
waitUntilExit(): Promise<void>;
|
|
132
|
+
/** Idempotent terminal restore — unmount restores cursor/raw mode. */
|
|
133
|
+
dispose(): void;
|
|
134
|
+
}
|