@ashley-shrok/viewmodel-shell 0.4.3 → 0.4.5
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 +9 -1
- package/dist/tui-cli.js +17 -8
- package/dist/tui.d.ts +11 -0
- package/dist/tui.js +100 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,7 +56,15 @@ const shell = new ViewModelShell({
|
|
|
56
56
|
shell.load();
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
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 — one static frame, no alternate screen.
|
|
60
|
+
|
|
61
|
+
The TUI is built on [Ink](https://github.com/vadimdemedes/ink) and friends, declared as **optional** dependencies (`ink`, `react`, `ink-text-input`, `ink-select-input`) so web and server consumers are unaffected — they are never imported by the browser, server, or core entrypoints. `npx vms-tui` installs them automatically. **Project consumers using `TuiAdapter` programmatically must add all four explicitly** — optional dependencies are *not* pulled transitively when another project depends on this package (notably with `bun install`):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm i @ashley-shrok/viewmodel-shell ink react ink-text-input ink-select-input
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for what the terminal renders.
|
|
60
68
|
|
|
61
69
|
## Themes
|
|
62
70
|
|
package/dist/tui-cli.js
CHANGED
|
@@ -40,8 +40,9 @@ async function main() {
|
|
|
40
40
|
({ TuiAdapter, openExternal, classify } = await import("./tui.js"));
|
|
41
41
|
}
|
|
42
42
|
catch (err) {
|
|
43
|
-
process.stderr.write("vms-tui requires
|
|
44
|
-
"
|
|
43
|
+
process.stderr.write("vms-tui requires its optional peer packages: 'ink', 'react', " +
|
|
44
|
+
"'ink-text-input', 'ink-select-input'.\n" +
|
|
45
|
+
"Install them: npm i ink react ink-text-input ink-select-input\n" +
|
|
45
46
|
`(load error: ${err.message})\n`);
|
|
46
47
|
process.exitCode = 1;
|
|
47
48
|
return;
|
|
@@ -119,6 +120,14 @@ async function main() {
|
|
|
119
120
|
// onRedirect, so redirects chain.
|
|
120
121
|
let redirectFailed = false;
|
|
121
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.
|
|
130
|
+
const nonInteractive = !process.stdout.isTTY || !process.stdin.isTTY;
|
|
122
131
|
const handleRedirect = (url, fromEndpoint) => {
|
|
123
132
|
const c = classify(url, fromEndpoint);
|
|
124
133
|
if (c.kind === "same-origin") {
|
|
@@ -127,9 +136,9 @@ async function main() {
|
|
|
127
136
|
void currentShell.load(); // failures flow through the shared onError
|
|
128
137
|
return;
|
|
129
138
|
}
|
|
130
|
-
if (
|
|
131
|
-
// Non-
|
|
132
|
-
// SINGLE shutdown funnel (redirectFailed is read at the exit site).
|
|
139
|
+
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).
|
|
133
142
|
process.stderr.write(`vms-tui: cannot follow redirect (${c.kind}): ${url}\n`);
|
|
134
143
|
redirectFailed = true;
|
|
135
144
|
return;
|
|
@@ -167,9 +176,9 @@ async function main() {
|
|
|
167
176
|
loadFailed = true;
|
|
168
177
|
process.stderr.write(`vms-tui: ${err.message}\n`);
|
|
169
178
|
}
|
|
170
|
-
if (
|
|
171
|
-
// Non-
|
|
172
|
-
// and exit instead of hanging
|
|
179
|
+
if (nonInteractive) {
|
|
180
|
+
// Non-interactive (piped / CI / dead stdin): nothing to interact with —
|
|
181
|
+
// emit one static frame and exit instead of hanging forever. The delay
|
|
173
182
|
// lets Ink flush its throttled render. (Redirects can't fire here: load()
|
|
174
183
|
// ignores response.redirect and non-TTY performs no dispatch — so
|
|
175
184
|
// redirectFailed stays false unless a future code path dispatches.)
|
package/dist/tui.d.ts
CHANGED
|
@@ -58,6 +58,17 @@ export declare function classify(url: string, fromEndpoint: string): RedirectKin
|
|
|
58
58
|
export declare class TuiAdapter implements Adapter {
|
|
59
59
|
private instance;
|
|
60
60
|
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
|
+
private readonly viewport;
|
|
65
|
+
/** Set once ESC[?1049h was written, so dispose() emits the paired
|
|
66
|
+
* ESC[?1049l exactly once (idempotent restore — same discipline as the
|
|
67
|
+
* cursor restore Ink does on unmount). */
|
|
68
|
+
private altEntered;
|
|
69
|
+
constructor(opts?: {
|
|
70
|
+
viewport?: "fill" | "content";
|
|
71
|
+
});
|
|
61
72
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
62
73
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
63
74
|
* CLI's single idempotent shutdown. A TUI-internal seam between our two
|
package/dist/tui.js
CHANGED
|
@@ -615,8 +615,42 @@ function TableFilterInput(props) {
|
|
|
615
615
|
function App(props) {
|
|
616
616
|
const { vm, renderWith } = props;
|
|
617
617
|
const { isRawModeSupported } = useStdin();
|
|
618
|
-
const { write } = useStdout();
|
|
619
|
-
|
|
618
|
+
const { write, stdout } = useStdout();
|
|
619
|
+
// `=== true` is LOAD-BEARING, not defensive. Ink's App.isRawModeSupported()
|
|
620
|
+
// returns `this.props.stdin.isTTY`, which is `true` on a TTY but `undefined`
|
|
621
|
+
// (never `false`) on a non-TTY stdin (pipe / </dev/null / agent / CI). Ink's
|
|
622
|
+
// useInput skips raw mode ONLY when `options.isActive === false` (strict).
|
|
623
|
+
// So passing the raw `undefined` as isActive does NOT skip → Ink calls
|
|
624
|
+
// setRawMode → throws "Raw mode is not supported" and dumps a react error
|
|
625
|
+
// frame on the non-TTY path. Coercing to a real boolean makes the gate
|
|
626
|
+
// `{ isActive: false }`, which Ink honors → clean static render. (A TTY
|
|
627
|
+
// gives `true`; ink-testing-library gives `true` → tests unchanged.)
|
|
628
|
+
const interactive = isRawModeSupported === true;
|
|
629
|
+
// Viewport fill (0.4.5). On a real interactive terminal, occupy the whole
|
|
630
|
+
// window so layout presets — especially `sidebar`'s flexGrow main pane —
|
|
631
|
+
// can expand: the terminal analog of BrowserAdapter filling the viewport
|
|
632
|
+
// via CSS. Gated on the REAL process TTYs, NOT Ink's isRawModeSupported
|
|
633
|
+
// (which ink-testing-library forces `true`): under vitest
|
|
634
|
+
// `process.stdout.isTTY` is false ⇒ no wrap ⇒ every existing App and
|
|
635
|
+
// conformance frame stays byte-identical by construction (and the pure
|
|
636
|
+
// `renderTree` path never reaches here). Opt out with
|
|
637
|
+
// `new TuiAdapter({ viewport: "content" })`.
|
|
638
|
+
const realTTY = process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
639
|
+
const fillViewport = realTTY && props.viewport !== "content";
|
|
640
|
+
const [vp, setVp] = useState(() => ({
|
|
641
|
+
cols: stdout?.columns,
|
|
642
|
+
rows: stdout?.rows,
|
|
643
|
+
}));
|
|
644
|
+
useEffect(() => {
|
|
645
|
+
if (!fillViewport || !stdout)
|
|
646
|
+
return;
|
|
647
|
+
const onResize = () => setVp({ cols: stdout.columns, rows: stdout.rows });
|
|
648
|
+
onResize(); // sync to the live size on (re)mount
|
|
649
|
+
stdout.on("resize", onResize);
|
|
650
|
+
return () => {
|
|
651
|
+
stdout.off("resize", onResize);
|
|
652
|
+
};
|
|
653
|
+
}, [fillViewport, stdout]);
|
|
620
654
|
const [focusedKey, setFocusedKey] = useState(null);
|
|
621
655
|
const [copiedKey, setCopiedKey] = useState(null);
|
|
622
656
|
// User-typed field drafts, keyed by focus key. Mirrors BrowserAdapter's
|
|
@@ -958,15 +992,23 @@ function App(props) {
|
|
|
958
992
|
onFieldSubmit,
|
|
959
993
|
onTableFilter: tableFilter,
|
|
960
994
|
};
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
995
|
+
const content = props.interstitial != null ? (_jsx(Interstitial, { msg: props.interstitial })) : modal ? (
|
|
996
|
+
// Screen-ownership: render ONLY the modal, horizontally centered (the
|
|
997
|
+
// honest terminal "z-layer" — Ink has no z-index; base tree suppressed).
|
|
998
|
+
// Vertical centering deferred (no reliable terminal-height center in Ink;
|
|
999
|
+
// cosmetic, not load-bearing).
|
|
1000
|
+
_jsx(Box, { flexDirection: "column", alignItems: "center", children: renderWith(modal, rctx) })) : (renderWith(vm, rctx));
|
|
1001
|
+
// Fill the terminal so flexGrow children (the sidebar main pane) have a
|
|
1002
|
+
// terminal-sized parent to expand into. `width` is the load-bearing dim
|
|
1003
|
+
// (horizontal sidebar fill); `height` makes it a true full-screen surface
|
|
1004
|
+
// (paired with the adapter's alternate-screen). `cols` is always present on
|
|
1005
|
+
// a real TTY; if a host omits `rows`, height falls back to auto without
|
|
1006
|
+
// breaking width. When !fillViewport (every test; non-TTY; opt-out) this
|
|
1007
|
+
// returns `content` unchanged ⇒ byte-identical to pre-0.4.5.
|
|
1008
|
+
if (fillViewport && typeof vp.cols === "number" && vp.cols > 0) {
|
|
1009
|
+
return (_jsx(Box, { width: vp.cols, height: vp.rows, flexDirection: "column", children: content }));
|
|
1010
|
+
}
|
|
1011
|
+
return content;
|
|
970
1012
|
}
|
|
971
1013
|
/**
|
|
972
1014
|
* Phase 5b terminal adapter — single-line + multi-line editors + selects.
|
|
@@ -997,6 +1039,17 @@ function App(props) {
|
|
|
997
1039
|
export class TuiAdapter {
|
|
998
1040
|
instance;
|
|
999
1041
|
disposed = false;
|
|
1042
|
+
/** "fill" (default) = occupy the terminal + alternate-screen on a real
|
|
1043
|
+
* interactive TTY; "content" = legacy intrinsic-content size, no
|
|
1044
|
+
* alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
|
|
1045
|
+
viewport;
|
|
1046
|
+
/** Set once ESC[?1049h was written, so dispose() emits the paired
|
|
1047
|
+
* ESC[?1049l exactly once (idempotent restore — same discipline as the
|
|
1048
|
+
* cursor restore Ink does on unmount). */
|
|
1049
|
+
altEntered = false;
|
|
1050
|
+
constructor(opts) {
|
|
1051
|
+
this.viewport = opts?.viewport ?? "fill";
|
|
1052
|
+
}
|
|
1000
1053
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
1001
1054
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
1002
1055
|
* CLI's single idempotent shutdown. A TUI-internal seam between our two
|
|
@@ -1020,6 +1073,25 @@ export class TuiAdapter {
|
|
|
1020
1073
|
this.interstitial = null; // a fresh view supersedes any prior notice
|
|
1021
1074
|
const app = this.createApp(vm, onAction);
|
|
1022
1075
|
if (!this.instance) {
|
|
1076
|
+
// Enter the alternate-screen buffer BEFORE the first mount so every
|
|
1077
|
+
// frame draws there and the user's prior scrollback is untouched
|
|
1078
|
+
// (restored verbatim by dispose()'s paired ESC[?1049l). Gated on the
|
|
1079
|
+
// REAL process TTYs — the exact complement of the CLI's `nonInteractive`
|
|
1080
|
+
// — so the 0.4.4 non-TTY static one-shot (pipe/CI/agent) emits NO
|
|
1081
|
+
// escape, and unit tests (process.stdout.isTTY false under vitest)
|
|
1082
|
+
// never enter it. "content" opts out entirely.
|
|
1083
|
+
if (this.viewport !== "content" &&
|
|
1084
|
+
process.stdout.isTTY === true &&
|
|
1085
|
+
process.stdin.isTTY === true &&
|
|
1086
|
+
!this.altEntered) {
|
|
1087
|
+
try {
|
|
1088
|
+
process.stdout.write("\x1b[?1049h");
|
|
1089
|
+
this.altEntered = true;
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
/* if the enter write fails, dispose() must not emit the leave */
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1023
1095
|
// exitOnCtrlC:false — the CLI is the single owner of teardown; Ink must
|
|
1024
1096
|
// not race it. Ctrl-C is handled in App and routed via requestExit.
|
|
1025
1097
|
this.instance = inkRender(app, { exitOnCtrlC: false });
|
|
@@ -1032,7 +1104,7 @@ export class TuiAdapter {
|
|
|
1032
1104
|
}
|
|
1033
1105
|
/** The interactive element used by render() and by interaction unit tests. */
|
|
1034
1106
|
createApp(vm, onAction, opts) {
|
|
1035
|
-
return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
|
|
1107
|
+
return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
|
|
1036
1108
|
}
|
|
1037
1109
|
/** Render a full-screen loud notice through the SINGLE existing Ink instance
|
|
1038
1110
|
* (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
|
|
@@ -1472,6 +1544,21 @@ export class TuiAdapter {
|
|
|
1472
1544
|
if (this.disposed)
|
|
1473
1545
|
return;
|
|
1474
1546
|
this.disposed = true;
|
|
1475
|
-
this.instance?.unmount();
|
|
1547
|
+
this.instance?.unmount(); // Ink restores cursor/raw mode on the alt screen
|
|
1548
|
+
if (this.altEntered) {
|
|
1549
|
+
// THEN leave the alternate-screen buffer → the user's prior terminal
|
|
1550
|
+
// (scrollback, cursor) is restored verbatim. Reached on EVERY exit
|
|
1551
|
+
// path: the CLI funnels shutdown / SIGINT / SIGTERM / uncaught /
|
|
1552
|
+
// unhandledRejection / process 'exit' all through dispose(), so a
|
|
1553
|
+
// crash or kill cannot strand the user on the alt screen. Idempotent
|
|
1554
|
+
// (disposed guard + altEntered cleared).
|
|
1555
|
+
this.altEntered = false;
|
|
1556
|
+
try {
|
|
1557
|
+
process.stdout.write("\x1b[?1049l");
|
|
1558
|
+
}
|
|
1559
|
+
catch {
|
|
1560
|
+
/* stdout gone during teardown — nothing more we can do */
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1476
1563
|
}
|
|
1477
1564
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.5",
|
|
4
4
|
"description": "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. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|