@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 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
- if (props.interstitial != null)
971
- return _jsx(Interstitial, { msg: props.interstitial });
972
- if (modal)
973
- // Screen-ownership: render ONLY the modal, horizontally centered (the
974
- // honest terminal "z-layer" — Ink has no z-index; base tree suppressed).
975
- // Vertical centering deferred (no reliable terminal-height center in Ink;
976
- // cosmetic, not load-bearing).
977
- return (_jsx(Box, { flexDirection: "column", alignItems: "center", children: renderWith(modal, rctx) }));
978
- return renderWith(vm, rctx);
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.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",