mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
wip(beehive): isolate OpenTUI presentation and synthetic smoke
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
84
beehive/OPENTUI_WIP.md
Normal file
84
beehive/OPENTUI_WIP.md
Normal 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
|
||||
```
|
||||
@@ -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
162
beehive/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
159
beehive/src/opentui-screen.ts
Normal file
159
beehive/src/opentui-screen.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
20
beehive/test/opentui-pty-fixture.ts
Normal file
20
beehive/test/opentui-pty-fixture.ts
Normal 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);
|
||||
43
beehive/test/opentui-screen.bun.ts
Normal file
43
beehive/test/opentui-screen.bun.ts
Normal 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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user