@ashley-shrok/viewmodel-shell 0.4.4 → 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 +2 -0
- package/dist/tui.d.ts +11 -0
- package/dist/tui.js +90 -12
- 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
|
@@ -615,7 +615,7 @@ function TableFilterInput(props) {
|
|
|
615
615
|
function App(props) {
|
|
616
616
|
const { vm, renderWith } = props;
|
|
617
617
|
const { isRawModeSupported } = useStdin();
|
|
618
|
-
const { write } = useStdout();
|
|
618
|
+
const { write, stdout } = useStdout();
|
|
619
619
|
// `=== true` is LOAD-BEARING, not defensive. Ink's App.isRawModeSupported()
|
|
620
620
|
// returns `this.props.stdin.isTTY`, which is `true` on a TTY but `undefined`
|
|
621
621
|
// (never `false`) on a non-TTY stdin (pipe / </dev/null / agent / CI). Ink's
|
|
@@ -626,6 +626,31 @@ function App(props) {
|
|
|
626
626
|
// `{ isActive: false }`, which Ink honors → clean static render. (A TTY
|
|
627
627
|
// gives `true`; ink-testing-library gives `true` → tests unchanged.)
|
|
628
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]);
|
|
629
654
|
const [focusedKey, setFocusedKey] = useState(null);
|
|
630
655
|
const [copiedKey, setCopiedKey] = useState(null);
|
|
631
656
|
// User-typed field drafts, keyed by focus key. Mirrors BrowserAdapter's
|
|
@@ -967,15 +992,23 @@ function App(props) {
|
|
|
967
992
|
onFieldSubmit,
|
|
968
993
|
onTableFilter: tableFilter,
|
|
969
994
|
};
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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;
|
|
979
1012
|
}
|
|
980
1013
|
/**
|
|
981
1014
|
* Phase 5b terminal adapter — single-line + multi-line editors + selects.
|
|
@@ -1006,6 +1039,17 @@ function App(props) {
|
|
|
1006
1039
|
export class TuiAdapter {
|
|
1007
1040
|
instance;
|
|
1008
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
|
+
}
|
|
1009
1053
|
/** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
|
|
1010
1054
|
* mode, is delivered as input 0x03 and never raises SIGINT) reach the
|
|
1011
1055
|
* CLI's single idempotent shutdown. A TUI-internal seam between our two
|
|
@@ -1029,6 +1073,25 @@ export class TuiAdapter {
|
|
|
1029
1073
|
this.interstitial = null; // a fresh view supersedes any prior notice
|
|
1030
1074
|
const app = this.createApp(vm, onAction);
|
|
1031
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
|
+
}
|
|
1032
1095
|
// exitOnCtrlC:false — the CLI is the single owner of teardown; Ink must
|
|
1033
1096
|
// not race it. Ctrl-C is handled in App and routed via requestExit.
|
|
1034
1097
|
this.instance = inkRender(app, { exitOnCtrlC: false });
|
|
@@ -1041,7 +1104,7 @@ export class TuiAdapter {
|
|
|
1041
1104
|
}
|
|
1042
1105
|
/** The interactive element used by render() and by interaction unit tests. */
|
|
1043
1106
|
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) }));
|
|
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) }));
|
|
1045
1108
|
}
|
|
1046
1109
|
/** Render a full-screen loud notice through the SINGLE existing Ink instance
|
|
1047
1110
|
* (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
|
|
@@ -1481,6 +1544,21 @@ export class TuiAdapter {
|
|
|
1481
1544
|
if (this.disposed)
|
|
1482
1545
|
return;
|
|
1483
1546
|
this.disposed = true;
|
|
1484
|
-
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
|
+
}
|
|
1485
1563
|
}
|
|
1486
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",
|