@ashley-shrok/viewmodel-shell 0.4.4 → 0.4.6
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 +2 -0
- package/dist/tui.d.ts +11 -0
- package/dist/tui.js +103 -16
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,8 @@ const shell = new ViewModelShell({
|
|
|
56
56
|
shell.load();
|
|
57
57
|
```
|
|
58
58
|
|
|
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
|
+
|
|
59
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`):
|
|
60
62
|
|
|
61
63
|
```bash
|
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
|
@@ -12,6 +12,7 @@ const NO_CTX = {
|
|
|
12
12
|
copiedKey: null,
|
|
13
13
|
focusKey: () => undefined,
|
|
14
14
|
interactive: false,
|
|
15
|
+
fill: false,
|
|
15
16
|
draft: () => undefined,
|
|
16
17
|
};
|
|
17
18
|
/** OSC 52 clipboard write — the terminal-native analog of the clipboard API.
|
|
@@ -615,7 +616,7 @@ function TableFilterInput(props) {
|
|
|
615
616
|
function App(props) {
|
|
616
617
|
const { vm, renderWith } = props;
|
|
617
618
|
const { isRawModeSupported } = useStdin();
|
|
618
|
-
const { write } = useStdout();
|
|
619
|
+
const { write, stdout } = useStdout();
|
|
619
620
|
// `=== true` is LOAD-BEARING, not defensive. Ink's App.isRawModeSupported()
|
|
620
621
|
// returns `this.props.stdin.isTTY`, which is `true` on a TTY but `undefined`
|
|
621
622
|
// (never `false`) on a non-TTY stdin (pipe / </dev/null / agent / CI). Ink's
|
|
@@ -626,6 +627,31 @@ function App(props) {
|
|
|
626
627
|
// `{ isActive: false }`, which Ink honors → clean static render. (A TTY
|
|
627
628
|
// gives `true`; ink-testing-library gives `true` → tests unchanged.)
|
|
628
629
|
const interactive = isRawModeSupported === true;
|
|
630
|
+
// Viewport fill (0.4.5). On a real interactive terminal, occupy the whole
|
|
631
|
+
// window so layout presets — especially `sidebar`'s flexGrow main pane —
|
|
632
|
+
// can expand: the terminal analog of BrowserAdapter filling the viewport
|
|
633
|
+
// via CSS. Gated on the REAL process TTYs, NOT Ink's isRawModeSupported
|
|
634
|
+
// (which ink-testing-library forces `true`): under vitest
|
|
635
|
+
// `process.stdout.isTTY` is false ⇒ no wrap ⇒ every existing App and
|
|
636
|
+
// conformance frame stays byte-identical by construction (and the pure
|
|
637
|
+
// `renderTree` path never reaches here). Opt out with
|
|
638
|
+
// `new TuiAdapter({ viewport: "content" })`.
|
|
639
|
+
const realTTY = process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
640
|
+
const fillViewport = realTTY && props.viewport !== "content";
|
|
641
|
+
const [vp, setVp] = useState(() => ({
|
|
642
|
+
cols: stdout?.columns,
|
|
643
|
+
rows: stdout?.rows,
|
|
644
|
+
}));
|
|
645
|
+
useEffect(() => {
|
|
646
|
+
if (!fillViewport || !stdout)
|
|
647
|
+
return;
|
|
648
|
+
const onResize = () => setVp({ cols: stdout.columns, rows: stdout.rows });
|
|
649
|
+
onResize(); // sync to the live size on (re)mount
|
|
650
|
+
stdout.on("resize", onResize);
|
|
651
|
+
return () => {
|
|
652
|
+
stdout.off("resize", onResize);
|
|
653
|
+
};
|
|
654
|
+
}, [fillViewport, stdout]);
|
|
629
655
|
const [focusedKey, setFocusedKey] = useState(null);
|
|
630
656
|
const [copiedKey, setCopiedKey] = useState(null);
|
|
631
657
|
// User-typed field drafts, keyed by focus key. Mirrors BrowserAdapter's
|
|
@@ -962,20 +988,29 @@ function App(props) {
|
|
|
962
988
|
copiedKey,
|
|
963
989
|
focusKey: (o) => map.get(o),
|
|
964
990
|
interactive,
|
|
991
|
+
fill: fillViewport,
|
|
965
992
|
draft: (k) => draftFor(k),
|
|
966
993
|
onFieldChange: (k, v) => setDraft((s) => ({ ...s, [k]: v })),
|
|
967
994
|
onFieldSubmit,
|
|
968
995
|
onTableFilter: tableFilter,
|
|
969
996
|
};
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
997
|
+
const content = props.interstitial != null ? (_jsx(Interstitial, { msg: props.interstitial })) : modal ? (
|
|
998
|
+
// Screen-ownership: render ONLY the modal, horizontally centered (the
|
|
999
|
+
// honest terminal "z-layer" — Ink has no z-index; base tree suppressed).
|
|
1000
|
+
// Vertical centering deferred (no reliable terminal-height center in Ink;
|
|
1001
|
+
// cosmetic, not load-bearing).
|
|
1002
|
+
_jsx(Box, { flexDirection: "column", alignItems: "center", children: renderWith(modal, rctx) })) : (renderWith(vm, rctx));
|
|
1003
|
+
// Fill the terminal so flexGrow children (the sidebar main pane) have a
|
|
1004
|
+
// terminal-sized parent to expand into. `width` is the load-bearing dim
|
|
1005
|
+
// (horizontal sidebar fill); `height` makes it a true full-screen surface
|
|
1006
|
+
// (paired with the adapter's alternate-screen). `cols` is always present on
|
|
1007
|
+
// a real TTY; if a host omits `rows`, height falls back to auto without
|
|
1008
|
+
// breaking width. When !fillViewport (every test; non-TTY; opt-out) this
|
|
1009
|
+
// returns `content` unchanged ⇒ byte-identical to pre-0.4.5.
|
|
1010
|
+
if (fillViewport && typeof vp.cols === "number" && vp.cols > 0) {
|
|
1011
|
+
return (_jsx(Box, { width: vp.cols, height: vp.rows, flexDirection: "column", children: content }));
|
|
1012
|
+
}
|
|
1013
|
+
return content;
|
|
979
1014
|
}
|
|
980
1015
|
/**
|
|
981
1016
|
* Phase 5b terminal adapter — single-line + multi-line editors + selects.
|
|
@@ -1006,6 +1041,17 @@ function App(props) {
|
|
|
1006
1041
|
export class TuiAdapter {
|
|
1007
1042
|
instance;
|
|
1008
1043
|
disposed = false;
|
|
1044
|
+
/** "fill" (default) = occupy the terminal + alternate-screen on a real
|
|
1045
|
+
* interactive TTY; "content" = legacy intrinsic-content size, no
|
|
1046
|
+
* alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
|
|
1047
|
+
viewport;
|
|
1048
|
+
/** Set once ESC[?1049h was written, so dispose() emits the paired
|
|
1049
|
+
* ESC[?1049l exactly once (idempotent restore — same discipline as the
|
|
1050
|
+
* cursor restore Ink does on unmount). */
|
|
1051
|
+
altEntered = false;
|
|
1052
|
+
constructor(opts) {
|
|
1053
|
+
this.viewport = opts?.viewport ?? "fill";
|
|
1054
|
+
}
|
|
1009
1055
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
1010
1056
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
1011
1057
|
* CLI's single idempotent shutdown. A TUI-internal seam between our two
|
|
@@ -1029,6 +1075,25 @@ export class TuiAdapter {
|
|
|
1029
1075
|
this.interstitial = null; // a fresh view supersedes any prior notice
|
|
1030
1076
|
const app = this.createApp(vm, onAction);
|
|
1031
1077
|
if (!this.instance) {
|
|
1078
|
+
// Enter the alternate-screen buffer BEFORE the first mount so every
|
|
1079
|
+
// frame draws there and the user's prior scrollback is untouched
|
|
1080
|
+
// (restored verbatim by dispose()'s paired ESC[?1049l). Gated on the
|
|
1081
|
+
// REAL process TTYs — the exact complement of the CLI's `nonInteractive`
|
|
1082
|
+
// — so the 0.4.4 non-TTY static one-shot (pipe/CI/agent) emits NO
|
|
1083
|
+
// escape, and unit tests (process.stdout.isTTY false under vitest)
|
|
1084
|
+
// never enter it. "content" opts out entirely.
|
|
1085
|
+
if (this.viewport !== "content" &&
|
|
1086
|
+
process.stdout.isTTY === true &&
|
|
1087
|
+
process.stdin.isTTY === true &&
|
|
1088
|
+
!this.altEntered) {
|
|
1089
|
+
try {
|
|
1090
|
+
process.stdout.write("\x1b[?1049h");
|
|
1091
|
+
this.altEntered = true;
|
|
1092
|
+
}
|
|
1093
|
+
catch {
|
|
1094
|
+
/* if the enter write fails, dispose() must not emit the leave */
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1032
1097
|
// exitOnCtrlC:false — the CLI is the single owner of teardown; Ink must
|
|
1033
1098
|
// not race it. Ctrl-C is handled in App and routed via requestExit.
|
|
1034
1099
|
this.instance = inkRender(app, { exitOnCtrlC: false });
|
|
@@ -1041,7 +1106,7 @@ export class TuiAdapter {
|
|
|
1041
1106
|
}
|
|
1042
1107
|
/** The interactive element used by render() and by interaction unit tests. */
|
|
1043
1108
|
createApp(vm, onAction, opts) {
|
|
1044
|
-
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) }));
|
|
1109
|
+
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) }));
|
|
1045
1110
|
}
|
|
1046
1111
|
/** Render a full-screen loud notice through the SINGLE existing Ink instance
|
|
1047
1112
|
* (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
|
|
@@ -1182,9 +1247,16 @@ export class TuiAdapter {
|
|
|
1182
1247
|
layoutContainer(layout, children, density, rctx) {
|
|
1183
1248
|
const sp = this.spacing(density);
|
|
1184
1249
|
const kids = (children ?? []).map((c, i) => this.renderNode(c, this.keyOf(c, i), density, undefined, rctx));
|
|
1250
|
+
// 0.4.6 — when filling a real terminal, the layout spine must carry the
|
|
1251
|
+
// terminal width into the panes. Probed fact: Yoga align-stretch ALONE
|
|
1252
|
+
// does NOT fill here; an explicit width:"100%" on the content wrapper
|
|
1253
|
+
// does. `cards` is intentionally excluded (a uniform small-tile grid by
|
|
1254
|
+
// design — force-filling defeats the preset). fill=false on the static /
|
|
1255
|
+
// non-TTY path ⇒ {} ⇒ byte-identical to pre-0.4.6.
|
|
1256
|
+
const fillW = rctx.fill ? { width: "100%" } : {};
|
|
1185
1257
|
switch (layout) {
|
|
1186
1258
|
case "split":
|
|
1187
|
-
return (_jsx(Box, { flexDirection: "row", gap: sp.gap || 1, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: el }, i))) }));
|
|
1259
|
+
return (_jsx(Box, { flexDirection: "row", gap: sp.gap || 1, ...fillW, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: rctx.fill ? (_jsx(Box, { width: "100%", flexDirection: "column", children: el })) : (el) }, i))) }));
|
|
1188
1260
|
case "cards":
|
|
1189
1261
|
return (_jsx(Box, { flexDirection: "row", flexWrap: "wrap", gap: sp.gap || 1, children: kids.map((el, i) => (_jsx(Box, { flexGrow: 0, flexShrink: 1, flexBasis: 28, minWidth: 20, children: el }, i))) }));
|
|
1190
1262
|
case "sidebar": {
|
|
@@ -1194,10 +1266,10 @@ export class TuiAdapter {
|
|
|
1194
1266
|
if (rest.length === 0) {
|
|
1195
1267
|
return (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }));
|
|
1196
1268
|
}
|
|
1197
|
-
return (_jsxs(Box, { flexDirection: "row", gap: sp.gap || 1, children: [_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }), _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: _jsx(Box, { flexDirection: "column", gap: sp.gap, children: rest }) })] }));
|
|
1269
|
+
return (_jsxs(Box, { flexDirection: "row", gap: sp.gap || 1, ...fillW, children: [_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }), _jsx(Box, { flexGrow: 1, flexShrink: 1, flexBasis: 0, children: _jsx(Box, { flexDirection: "column", gap: sp.gap, ...fillW, children: rest }) })] }));
|
|
1198
1270
|
}
|
|
1199
1271
|
default: // "stack" / undefined
|
|
1200
|
-
return (_jsx(Box, { flexDirection: "column", gap: sp.gap, children: kids }));
|
|
1272
|
+
return (_jsx(Box, { flexDirection: "column", gap: sp.gap, ...fillW, children: kids }));
|
|
1201
1273
|
}
|
|
1202
1274
|
}
|
|
1203
1275
|
// ── the node switch ──────────────────────────────────────────────────────
|
|
@@ -1206,7 +1278,7 @@ export class TuiAdapter {
|
|
|
1206
1278
|
case "page": {
|
|
1207
1279
|
const d = node.density === "compact" ? "compact" : "comfortable";
|
|
1208
1280
|
const sp = this.spacing(d);
|
|
1209
|
-
return (_jsxs(Box, { flexDirection: "column", gap: sp.gap, children: [node.title ? (_jsx(Box, { children: _jsx(Text, { bold: true, underline: true, children: node.title }) })) : null, this.layoutContainer(node.layout, node.children ?? [], d, rctx)] }, key));
|
|
1281
|
+
return (_jsxs(Box, { flexDirection: "column", gap: sp.gap, ...(rctx.fill ? { width: "100%", flexGrow: 1 } : {}), children: [node.title ? (_jsx(Box, { children: _jsx(Text, { bold: true, underline: true, children: node.title }) })) : null, this.layoutContainer(node.layout, node.children ?? [], d, rctx)] }, key));
|
|
1210
1282
|
}
|
|
1211
1283
|
case "section": {
|
|
1212
1284
|
const sp = this.spacing(density);
|
|
@@ -1481,6 +1553,21 @@ export class TuiAdapter {
|
|
|
1481
1553
|
if (this.disposed)
|
|
1482
1554
|
return;
|
|
1483
1555
|
this.disposed = true;
|
|
1484
|
-
this.instance?.unmount();
|
|
1556
|
+
this.instance?.unmount(); // Ink restores cursor/raw mode on the alt screen
|
|
1557
|
+
if (this.altEntered) {
|
|
1558
|
+
// THEN leave the alternate-screen buffer → the user's prior terminal
|
|
1559
|
+
// (scrollback, cursor) is restored verbatim. Reached on EVERY exit
|
|
1560
|
+
// path: the CLI funnels shutdown / SIGINT / SIGTERM / uncaught /
|
|
1561
|
+
// unhandledRejection / process 'exit' all through dispose(), so a
|
|
1562
|
+
// crash or kill cannot strand the user on the alt screen. Idempotent
|
|
1563
|
+
// (disposed guard + altEntered cleared).
|
|
1564
|
+
this.altEntered = false;
|
|
1565
|
+
try {
|
|
1566
|
+
process.stdout.write("\x1b[?1049l");
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
/* stdout gone during teardown — nothing more we can do */
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1485
1572
|
}
|
|
1486
1573
|
}
|
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.6",
|
|
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",
|