From d6b34118eb3e610da00878f400cd59e93630cc79 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Fri, 11 Sep 2026 13:59:56 -0400 Subject: [PATCH] wip(beehive): isolate OpenTUI presentation and synthetic smoke Signed-off-by: Logan Johnson --- beehive/OPENTUI_WIP.md | 84 +++++++++++++++ beehive/package.json | 2 +- beehive/pnpm-lock.yaml | 162 ++++++++++++++++++++++++++++ beehive/src/opentui-screen.ts | 159 +++++++++++++++++++++++++++ beehive/test/opentui-pty-fixture.ts | 20 ++++ beehive/test/opentui-screen.bun.ts | 43 ++++++++ 6 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 beehive/OPENTUI_WIP.md create mode 100644 beehive/src/opentui-screen.ts create mode 100644 beehive/test/opentui-pty-fixture.ts create mode 100644 beehive/test/opentui-screen.bun.ts diff --git a/beehive/OPENTUI_WIP.md b/beehive/OPENTUI_WIP.md new file mode 100644 index 0000000000..631a13eb71 --- /dev/null +++ b/beehive/OPENTUI_WIP.md @@ -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 +``` diff --git a/beehive/package.json b/beehive/package.json index 9306ca9110..ae1cb229a1 100644 --- a/beehive/package.json +++ b/beehive/package.json @@ -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"}} diff --git a/beehive/pnpm-lock.yaml b/beehive/pnpm-lock.yaml index 2bd06d161a..74a44c5f70 100644 --- a/beehive/pnpm-lock.yaml +++ b/beehive/pnpm-lock.yaml @@ -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: {} diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts new file mode 100644 index 0000000000..9150db18bf --- /dev/null +++ b/beehive/src/opentui-screen.ts @@ -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 }; +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(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 { + 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(); + } +} diff --git a/beehive/test/opentui-pty-fixture.ts b/beehive/test/opentui-pty-fixture.ts new file mode 100644 index 0000000000..11cc924914 --- /dev/null +++ b/beehive/test/opentui-pty-fixture.ts @@ -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); diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts new file mode 100644 index 0000000000..b2c47ad1fe --- /dev/null +++ b/beehive/test/opentui-screen.bun.ts @@ -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(); } +});