wip(beehive): isolate OpenTUI presentation and synthetic smoke

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-11 13:59:56 -04:00
parent ec3a22f8ce
commit d6b34118eb
6 changed files with 469 additions and 1 deletions

84
beehive/OPENTUI_WIP.md Normal file
View File

@@ -0,0 +1,84 @@
# OpenTUI implementation checkpoint — NOT an install candidate
Base: ec3a22f8ceb4d63d871287c8269a842184fc4ed9.
Branch: feat/beehive-opentui-57f9c8fa.
Normal launcher and CLI are deliberately unchanged. This is only the new renderer,
not the requested working management vertical slice. Do not install or publish it
as the owner candidate. The source-author and held Blessed worktrees are unchanged.
No Blessed dependency or source was copied into this worktree.
## Implemented
- Actual pinned @opentui/core 0.5.11, standalone pnpm lockfile.
- `src/opentui-screen.ts`: two always-visible destinations, selectable list/detail,
detail ScrollBox, action/help bar, focus traversal, text/multiline/hidden forms,
explicit typed confirmation, busy guard, small-terminal guard, resize and quit.
- Secret entry is application-owned volatile input, never an ordinary Input or
Textarea and never rendered, including length. Close/cancel clears its reference.
- Reusable honest unavailable notice and consistent signed-in/key-saved header.
- `test/opentui-screen.bun.ts`: actual OpenTUI test renderer, synthetic data only.
- `test/opentui-pty-fixture.ts`: actual alternate-screen fixture, no imports of
credentials/config/transport/provider/host factories. This is not manager routing.
## Validation of this checkpoint workstate
Artifacts: `/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/`.
Official Bun 1.4.2 darwin-aarch64 downloaded from the oven-sh/bun GitHub release;
zip verified against that release's published SHASUMS256.txt. This establishes
checksum equality, not independent signature verification. Runtime remains app-local
in the artifact directory; no global runtime or launcher changed.
- Node 24.15.0 `node node_modules/typescript/bin/tsc --noEmit`: passes.
- Bun 1.4.2 `bun test ./test/opentui-screen.bun.ts`: 1 pass, 0 fail. Covers real
rendered list selection, hidden typing/paste cancellation, multiline save,
60x24 -> 30x10 -> 60x24 resize, pending-input quit.
- `pty-smoke.py`: real PTY launch/navigation/forms/resize/quit; exit 0, exact tty
flags restored, alternate-screen enter/exit present, synthetic secret absent.
Raw transcript and JSON result retained. Not an installed launcher test.
- No full package suite run yet: functional integration is not complete. Run it
once on the completed functional state, not to label this renderer a candidate.
- Initial Bun test invocation without `./` matched no tests; original log retained.
- Important tooling incident: `pnpm run check` triggered pnpm 11 auto-install of
the root workspace (614 cached packages). No root tracked file changed, and no
global/Hermit bootstrap was requested. Avoid `pnpm run` here; direct pinned Node
invocation above bypassed auto-install. Do not repeat or clean unrelated files.
## Required continuation (no owner approval gate)
1. Complete typed manager controller and real operations. Keep manager/service and
credential-helper interpreter Node; use Bun for OpenTUI presentation only unless
a separately validated narrow boundary proves safe. Baseline helper explicitly
spawns `process.execPath`; merely importing existing manager under Bun silently
changes that interpreter and is not accepted. Held Blessed controller has useful
inventory/lifecycle ideas but synchronous credential calls are not suitable.
2. Wire existing host configuration/provisioning, owner sign-in, host inventory,
lifecycle/configuration, offline drafts and explicit profile/public publication.
Missing independent catalog/runtime architecture must remain honestly unavailable,
while existing supported domains remain usable. Never insert fixture rows into
production. Generated one-time reveal lifecycle is not implemented here.
3. Enforce mismatched Restart refusal in governing host admission, not just UI.
Baseline host.ts captures selected-next at restart; binding-change tests currently
expect the old runtime-switch behavior and need deliberate correction.
4. Complete mouse selection for scrolled lists/actions; current click mapping only
selects exact rows when the entire list fits, otherwise selects the current row.
Check focus containment and long-label layout. Small-terminal modal rendering
is not exhaustively assessed. This is prototype work, not a new review gate.
5. Add narrow Node/Bun bridge, fixture seam and launcher routing. Keep supported
advanced CLI accessible. Install a frozen isolated dedicated package and pinned
app-local runtime, then validate the actual installed launcher with explicit fake
credentials/transport (temporary HOME alone is unsafe on macOS).
6. Focused boundary regressions/typecheck, one final package suite, brief PTY smoke,
self-review, signed-off commit with verified Logan identity, authorized new-branch
publication (no PR/default mutation), exact remote pin, desktop installation and
bounded laptop recipe. Nothing has been published or installed by this checkpoint.
## Reproduce local renderer evidence only
```sh
cd /Users/loganj/.buzz/REPOS/beehive-opentui-57f9c8fa/beehive
/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node node_modules/typescript/bin/tsc --noEmit
/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun test ./test/opentui-screen.bun.ts
python3 /Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/pty-smoke.py
```

View File

@@ -1 +1 @@
{"name":"@buzz/beehive","version":"0.0.1","private":true,"type":"module","engines":{"node":">=22.18"},"scripts":{"check":"tsc --noEmit","test":"node --test test/*.test.ts","start":"node src/cli.ts"},"dependencies":{"@napi-rs/keyring":"2.0.0","@noble/curves":"1.2.0","nostr-tools":"2.23.1","ws":"8.21.0"},"devDependencies":{"@types/node":"22.18.0","@types/ws":"8.18.1","typescript":"5.9.3"}}
{"name":"@buzz/beehive","version":"0.0.1","private":true,"type":"module","engines":{"node":">=22.18"},"scripts":{"check":"tsc --noEmit","test":"node --test test/*.test.ts","start":"node src/cli.ts"},"dependencies":{"@napi-rs/keyring":"2.0.0","@noble/curves":"1.2.0","@opentui/core":"0.5.11","nostr-tools":"2.23.1","ws":"8.21.0"},"devDependencies":{"@types/node":"22.18.0","@types/ws":"8.18.1","typescript":"5.9.3"}}

162
beehive/pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@noble/curves':
specifier: 1.2.0
version: 1.2.0
'@opentui/core':
specifier: 0.5.11
version: 0.5.11(typescript@5.9.3)(web-tree-sitter@0.25.10)
nostr-tools:
specifier: 2.23.1
version: 2.23.1(typescript@5.9.3)
@@ -133,6 +136,54 @@ packages:
resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==}
engines: {node: '>= 20.19.0'}
'@opentui/core-darwin-arm64@0.5.11':
resolution: {integrity: sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ==}
cpu: [arm64]
os: [darwin]
'@opentui/core-darwin-x64@0.5.11':
resolution: {integrity: sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g==}
cpu: [x64]
os: [darwin]
'@opentui/core-linux-arm64-musl@0.5.11':
resolution: {integrity: sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@opentui/core-linux-arm64@0.5.11':
resolution: {integrity: sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg==}
cpu: [arm64]
os: [linux]
'@opentui/core-linux-x64-musl@0.5.11':
resolution: {integrity: sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@opentui/core-linux-x64@0.5.11':
resolution: {integrity: sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w==}
cpu: [x64]
os: [linux]
'@opentui/core-win32-arm64@0.5.11':
resolution: {integrity: sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg==}
cpu: [arm64]
os: [win32]
'@opentui/core-win32-x64@0.5.11':
resolution: {integrity: sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ==}
cpu: [x64]
os: [win32]
'@opentui/core@0.5.11':
resolution: {integrity: sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ==}
engines: {bun: '>=1.3.0', node: '>=26.4.0'}
peerDependencies:
web-tree-sitter: 0.25.10
'@scure/base@2.0.0':
resolution: {integrity: sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w==}
@@ -148,6 +199,31 @@ packages:
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
ansi-regex@6.3.0:
resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==}
engines: {node: '>=12'}
bun-ffi-structs@0.3.1:
resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==}
peerDependencies:
typescript: ^5
diff@9.0.0:
resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==}
engines: {node: '>=0.3.1'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
get-east-asian-width@1.6.0:
resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==}
engines: {node: '>=18'}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
nostr-tools@2.23.1:
resolution: {integrity: sha512-Q5SJ1omrseBFXtLwqDhufpFLA6vX3rS/IuBCc974qaYX6YKGwEPxa/ZsyxruUOr+b+5EpWL2hFmCB5AueYrfBw==}
peerDependencies:
@@ -159,6 +235,14 @@ packages:
nostr-wasm@0.1.0:
resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@7.1.2:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -167,6 +251,14 @@ packages:
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
web-tree-sitter@0.25.10:
resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==}
peerDependencies:
'@types/emscripten': ^1.40.0
peerDependenciesMeta:
'@types/emscripten':
optional: true
ws@8.21.0:
resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==}
engines: {node: '>=10.0.0'}
@@ -246,6 +338,50 @@ snapshots:
'@noble/hashes@2.0.1': {}
'@opentui/core-darwin-arm64@0.5.11':
optional: true
'@opentui/core-darwin-x64@0.5.11':
optional: true
'@opentui/core-linux-arm64-musl@0.5.11':
optional: true
'@opentui/core-linux-arm64@0.5.11':
optional: true
'@opentui/core-linux-x64-musl@0.5.11':
optional: true
'@opentui/core-linux-x64@0.5.11':
optional: true
'@opentui/core-win32-arm64@0.5.11':
optional: true
'@opentui/core-win32-x64@0.5.11':
optional: true
'@opentui/core@0.5.11(typescript@5.9.3)(web-tree-sitter@0.25.10)':
dependencies:
bun-ffi-structs: 0.3.1(typescript@5.9.3)
diff: 9.0.0
marked: 17.0.1
string-width: 7.2.0
strip-ansi: 7.1.2
web-tree-sitter: 0.25.10
optionalDependencies:
'@opentui/core-darwin-arm64': 0.5.11
'@opentui/core-darwin-x64': 0.5.11
'@opentui/core-linux-arm64': 0.5.11
'@opentui/core-linux-arm64-musl': 0.5.11
'@opentui/core-linux-x64': 0.5.11
'@opentui/core-linux-x64-musl': 0.5.11
'@opentui/core-win32-arm64': 0.5.11
'@opentui/core-win32-x64': 0.5.11
transitivePeerDependencies:
- typescript
'@scure/base@2.0.0': {}
'@scure/bip32@2.0.1':
@@ -267,6 +403,20 @@ snapshots:
dependencies:
'@types/node': 22.18.0
ansi-regex@6.3.0: {}
bun-ffi-structs@0.3.1(typescript@5.9.3):
dependencies:
typescript: 5.9.3
diff@9.0.0: {}
emoji-regex@10.6.0: {}
get-east-asian-width@1.6.0: {}
marked@17.0.1: {}
nostr-tools@2.23.1(typescript@5.9.3):
dependencies:
'@noble/ciphers': 2.1.1
@@ -281,8 +431,20 @@ snapshots:
nostr-wasm@0.1.0: {}
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.6.0
strip-ansi: 7.1.2
strip-ansi@7.1.2:
dependencies:
ansi-regex: 6.3.0
typescript@5.9.3: {}
undici-types@6.21.0: {}
web-tree-sitter@0.25.10: {}
ws@8.21.0: {}

View File

@@ -0,0 +1,159 @@
import { BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core';
import { stripVTControlCharacters } from 'node:util';
export type ManagerRow = { id: string; label: string; detail: string };
export type ManagerAction = { label: string; run: () => void | Promise<void> };
const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '');
/** Presentation only: never imports credentials, configuration, transport or host code.
* Bun owns this renderer; the manager/service interpreter can remain Node. */
export class OpenTuiScreen {
private readonly root: BoxRenderable;
private readonly header: TextRenderable;
private readonly list: SelectRenderable;
private readonly detail: TextRenderable;
private readonly scroll: ScrollBoxRenderable;
private readonly actions: SelectRenderable;
private readonly status: TextRenderable;
private readonly small: TextRenderable;
private rows: ManagerRow[] = [];
private commands: ManagerAction[] = [];
private focusIndex = 0;
private scope = 0;
private owner = 'signed out';
private busy = false;
private closed = false;
private modal?: { cancel: () => void; key: (key: KeyEvent) => void; paste: (text: string) => void };
private finish!: () => void;
readonly done = new Promise<void>(resolve => { this.finish = resolve; });
onScope: (index: number) => void = () => {};
onSelect: (id: string) => void = () => {};
constructor(readonly renderer: CliRenderer) {
this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: '#101820' });
renderer.root.add(this.root);
this.header = new TextRenderable(renderer, { height: 2, fg: '#72d9ef', onMouseDown: event => this.switchScope(event.x < renderer.width / 2 ? 0 : 1) });
this.root.add(this.header);
const body = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'row', minHeight: 1 });
this.root.add(body);
this.list = new SelectRenderable(renderer, { width: '32%', height: '100%', showDescription: false, showScrollIndicator: true, selectedBackgroundColor: '#245272', focusedBackgroundColor: '#172d3b', onMouseDown: event => {
this.focusIndex = 0; this.list.focus();
const delta = event.y - this.list.y;
const current = this.list.getSelectedIndex();
// Select's scrolling offset is private; use relative distance from its visible selection only for unscrolled lists.
if (this.rows.length <= this.list.height && delta >= 0) this.list.setSelectedIndex(Math.min(this.rows.length - 1, delta));
else this.list.selectCurrent();
} });
body.add(this.list);
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, height: '100%', border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
body.add(this.scroll);
this.detail = new TextRenderable(renderer, { width: '100%', content: 'No items.', selectable: false });
this.scroll.add(this.detail);
this.actions = new SelectRenderable(renderer, { height: 5, showDescription: false, showScrollIndicator: true, selectedBackgroundColor: '#245272', onMouseDown: event => {
this.focusIndex = 2; this.actions.focus();
const delta = event.y - this.actions.y;
if (this.commands.length <= this.actions.height && delta >= 0) this.actions.setSelectedIndex(Math.min(this.commands.length - 1, delta));
this.actions.selectCurrent();
} });
this.root.add(this.actions);
this.status = new TextRenderable(renderer, { height: 2, fg: '#ffce75', content: 'Tab pane · arrows select · Enter action · F1 help · Ctrl-Q quit (not Stop)' });
this.root.add(this.status);
this.small = new TextRenderable(renderer, { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg: '#101820', content: 'Terminal too small. Resize to 40 × 16. Nothing submitted. Ctrl-Q quits.', visible: false });
renderer.root.add(this.small);
this.list.on('selectionChanged', (index: number) => {
const row = this.rows[index]; this.detail.content = clean(row?.detail ?? 'No items.'); this.scroll.scrollTo(0);
if (row) this.onSelect(row.id);
});
this.actions.on('itemSelected', (index: number) => { void this.run(index); });
renderer.keyInput.on('keypress', key => this.key(key));
renderer.keyInput.on('paste', event => {
if (!this.modal) return;
this.modal.paste(new TextDecoder().decode(event.bytes));
// Ordinary editor paste is handled by its focused renderable.
});
renderer.on('resize', () => this.resize());
renderer.on('destroy', () => this.close());
this.list.focus(); this.heading(); this.resize();
}
private resize() { this.small.visible = this.renderer.width < 40 || this.renderer.height < 16; }
private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\nOwner · ${clean(this.owner)}`; }
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
private switchScope(scope: number) {
if (this.modal || this.busy || this.small.visible || this.closed) return;
this.scope = scope; this.heading(); this.onScope(scope);
}
private key(key: KeyEvent) {
if (key.ctrl && (key.name === 'q' || key.name === 'c')) { key.preventDefault(); this.close(); return; }
if (this.small.visible) { key.preventDefault(); return; }
if (this.modal) { this.modal.key(key); return; }
if (key.name === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope(1 - this.scope); }
if (key.name === 'tab') { key.preventDefault(); this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); }
if (key.name === 'f1') { key.preventDefault(); this.notice('Left/Right: Local Host / Agents · Tab: panes · arrows/PgDn/wheel: scroll · Enter: action · Esc: cancel form · Ctrl-Q: quit, never Stop'); }
}
private async run(index: number) {
const action = this.commands[index];
if (!action || this.busy || this.modal || this.small.visible || this.closed) return;
this.busy = true; this.notice('Working… Esc cancels an open form. Quit is not Stop.');
try { await action.run(); }
catch { this.notice('Action failed. State may be unchanged or outcome unknown; inspect the operation before retrying.'); }
finally { this.busy = false; }
}
show(rows: ManagerRow[], actions: ManagerAction[], selected?: string) {
if (this.closed) return;
const prior = selected ?? this.rows[this.list.getSelectedIndex()]?.id;
this.rows = rows; this.commands = actions;
this.list.options = rows.map(row => ({ name: clean(row.label), description: '', value: row.id }));
const index = Math.max(0, rows.findIndex(row => row.id === prior));
this.list.setSelectedIndex(index); this.detail.content = clean(rows[index]?.detail ?? 'No items.');
this.actions.options = actions.map(action => ({ name: clean(action.label), description: '' }));
}
notice(text: string) { if (!this.closed) this.status.content = clean(text); }
unavailable(feature: string) { this.notice(`${feature}: Not available in this build. No request made. Local Host and existing host observations remain separate.`); }
/** Secret bytes never enter an Input/Textarea, history, selection or text buffer.
* Single-use modal storage is cleared on every settlement, including quit. */
input(label: string, value = '', secret = false, multiline = false): Promise<string | undefined> {
if (this.closed || this.modal) return Promise.resolve(undefined);
return new Promise(resolve => {
const box = new BoxRenderable(this.renderer, { position: 'absolute', top: 2, left: 0, width: '100%', height: '80%', border: true, backgroundColor: '#172d3b', flexDirection: 'column', padding: 1, title: secret ? 'Hidden key input' : 'Edit' });
this.renderer.root.add(box);
box.add(new TextRenderable(this.renderer, { content: clean(label), flexShrink: 0 }));
let hidden = secret ? value : '';
const entry = secret ? undefined : multiline
? new TextareaRenderable(this.renderer, { initialValue: value, flexGrow: 1, width: '100%' })
: new InputRenderable(this.renderer, { value, maxLength: 8192, width: '100%' });
if (entry) { box.add(entry); entry.focus(); }
else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { content: 'Input hidden — no characters or length displayed.', height: 2 })); }
box.add(new TextRenderable(this.renderer, { content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : 'Enter accept · Esc cancel', height: 2 }));
let settled = false;
const finish = (answer?: string) => {
if (settled) return; settled = true; hidden = '';
if (entry instanceof InputRenderable) entry.value = '';
else entry?.setText('');
this.modal = undefined; box.destroyRecursively();
if (!this.closed) [this.list, this.scroll, this.actions][this.focusIndex]!.focus();
resolve(answer);
};
this.modal = {
cancel: () => finish(),
paste: text => { if (secret && hidden.length + text.length <= 4096) hidden += text.replace(/[\r\n]/g, ''); },
key: key => {
if (key.name === 'escape') { key.preventDefault(); finish(); return; }
if ((!multiline && key.name === 'return') || (multiline && key.ctrl && key.name === 's')) {
key.preventDefault(); finish(secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText); return;
}
if (!secret) return;
key.preventDefault();
if (key.name === 'backspace') hidden = hidden.slice(0, -1);
else if (key.ctrl && key.name === 'u') hidden = '';
else if (!key.ctrl && !key.meta && key.sequence.length === 1 && key.sequence >= ' ' && hidden.length < 4096) hidden += key.sequence;
},
};
});
}
async confirm(label: string) { return await this.input(`${label}\nType yes to confirm. Empty Enter is Cancel.`) === 'yes'; }
close() {
if (this.closed) return; this.closed = true; this.modal?.cancel(); this.renderer.destroy(); this.finish();
}
}

View File

@@ -0,0 +1,20 @@
import { createCliRenderer } from '@opentui/core';
import { OpenTuiScreen } from '../src/opentui-screen.ts';
// Explicit presentation-only fixture. No production config, credential, relay,
// provider or host module is imported, regardless of HOME or inherited key vars.
const renderer = await createCliRenderer({ exitOnCtrlC: false, screenMode: 'alternate-screen', consoleMode: 'disabled', openConsoleOnError: false });
const view = new OpenTuiScreen(renderer);
function show(scope = 0) {
view.show([{ id: 'fixture', label: scope ? 'Synthetic host observation' : 'Synthetic Local Host', detail: scope ? 'Agents: fixture only. Independent catalog: Not available in this build. No relay request is made.' : 'This is a synthetic presentation fixture. No host started. No native credentials or transport constructed.' }], [
{ label: 'Edit public label', run: async () => { const answer = await view.input('Public label', 'Fixture'); view.notice(answer === undefined ? 'Cancelled; no write.' : `Fixture accepted: ${answer}`); } },
{ label: 'Hidden fixture key', run: async () => { const answer = await view.input('Synthetic key only', '', true); view.notice(answer === undefined ? 'Cancelled; volatile input cleared.' : 'Synthetic input accepted; not persisted.'); } },
{ label: 'Quit manager (not Stop)', run: () => view.close() },
]);
}
view.onScope = show;
const stop = () => view.close();
process.on('SIGINT', stop); process.on('SIGTERM', stop);
show();
await view.done;
process.off('SIGINT', stop); process.off('SIGTERM', stop);

View File

@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createTestRenderer } from '@opentui/core/testing';
import { OpenTuiScreen } from '../src/opentui-screen.ts';
test('actual OpenTUI selection, hidden entry, multiline save, resize and quit', async () => {
const ui = await createTestRenderer({ width: 60, height: 24, exitOnCtrlC: false });
const view = new OpenTuiScreen(ui.renderer);
try {
let selected = '';
view.onSelect = id => { selected = id; };
view.show([{ id: 'a', label: 'Synthetic A', detail: 'First detail' }, { id: 'b', label: 'Synthetic B', detail: 'Second detail' }], []);
await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
ui.mockInput.pressArrow('down');
await ui.renderOnce();
assert.equal(selected, 'b');
assert.match(ui.captureCharFrame(), /Second detail/);
const secret = 'synthetic-secret-not-a-real-key';
const entered = view.input('Fixture secret', '', true);
await ui.mockInput.typeText(secret);
await ui.renderOnce();
assert.ok(!ui.captureCharFrame().includes(secret));
ui.mockInput.pressEnter();
assert.equal(await entered, secret);
const cancelled = view.input('Cancel this secret', '', true);
await ui.mockInput.pasteBracketedText(secret);
ui.mockInput.pressEscape();
assert.equal(await cancelled, undefined);
const edit = view.input('Private draft', 'line one', false, true);
ui.mockInput.pressKey('s', { ctrl: true });
assert.equal(await edit, 'line one');
ui.resize(30, 10); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Terminal too small/);
ui.resize(60, 24); await ui.renderOnce();
assert.match(ui.captureCharFrame(), /Local Host.*Agents/);
const quitting = view.input('Quit clears volatile input', '', true);
await ui.mockInput.typeText(secret);
ui.mockInput.pressKey('q', { ctrl: true });
await view.done;
assert.equal(await quitting, undefined);
} finally { view.close(); }
});