diff --git a/beehive/OPENTUI_WIP.md b/beehive/OPENTUI_WIP.md index ce31ac1bd8..f922f2114b 100644 --- a/beehive/OPENTUI_WIP.md +++ b/beehive/OPENTUI_WIP.md @@ -117,3 +117,19 @@ with pnpm 11.4.0 `install --ignore-workspace --ignore-scripts --frozen-lockfile` Node 24.15.0 and Bun 1.4.2 executables at `beehive/runtime/node` and `beehive/runtime/bun`. Link `~/.local/bin/beehive` to the exported package's `bin/beehive`, not to a worktree. See the immutable delivery recipe for the exact pin. + +## Local visual-polish candidate (not installed) + +The subsequent presentation candidate replaces default JSON walls with Node-built +public summaries and F3 technical evidence; uses ≥88×24 split / narrow Enter-to-detail; +puts contextual guarded commands in F2; retains permanent keys with F4 scrollable +outcomes; and uses compact validated inputs plus a named configuration chooser. +The installed `6390e594` exports are deliberately unchanged pending independent +implementation-vs-design review. + +Evidence and the corrected external design are under +`/Users/loganj/.buzz/artifacts/beehive-opentui-polish-6390e594/implementation/`. +Read `IMPLEMENTATION.md` there for the eight-point self-review, scoped deviations, +actual PTY comparisons versus offscreen fixtures, exact runtime commands and limits. +This candidate does not claim exhaustive design conformance, real relay inventory, +light-terminal visual proof, or safe scrolled-action mouse activation. diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index a6433d9c1e..3d29a041a5 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -12,7 +12,15 @@ import { message, type Message } from './protocol.ts'; import { profileDrafts, editProfileDraft } from './profile-drafts.ts'; export type ManagerRequest = { id: number; action: string; values?: Record; target?: string; revision?: number }; -export type ManagerSnapshot = { local: { id: string; label: string; detail: string }[]; agents: { id: string; label: string; detail: string; revision: number; configurations: string[] }[]; owner?: string; status: string }; +export type ManagerItem = { id: string; label: string; detail: string; evidence?: string; disabled?: Record }; +export type ManagerSnapshot = { local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string }; +const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s; +const describe = (v: unknown): string => { + if (v === undefined || v === null) return 'Not reported'; + if (typeof v !== 'object') return typeof v === 'string' && /^[0-9a-f]{32,}$/i.test(v) ? short(v) : String(v); + if (!Object.keys(v).length) return 'Not reported'; + return Object.entries(v).map(([k, value]) => `${k}: ${describe(value)}`).join('\n'); +}; /** One bounded Node helper per explicit credential operation. Completion waits for exit. * Cancellation cannot undo OS persistence; callers must inspect before retrying. */ @@ -66,15 +74,61 @@ export class ManagerController { try { if (existsSync(join(this.hostDirectory, 'host-identity.json'))) { const identity = readHostIdentityPublic(this.hostDirectory); - local.push({ id: 'host', label: identity.pairing.label, detail: `THIS COMPUTER — configuration, not service liveness\n${JSON.stringify(identity.pairing, null, 2)}\nStart foreground service separately: beehive host --owner-present\nLocal provisioning: beehive local-setup ${this.hostDirectory}\nProvision from a binding file: beehive provision-agent (existing CLI).\nService launch and binding authoring forms: Not available in this build. Existing binding/genesis provisioning is available below.` }); + local.push({ id: 'host', label: identity.pairing.label, evidence: JSON.stringify(identity.pairing, null, 2), detail: `${identity.pairing.label} +Configuration: saved +Relay: ${identity.pairing.relay} +Owner: ${short(identity.pairing.owner)} +Service: not checked by this view + +Registration is not Start authority. + +Next steps +F2: provision from prepared binding/genesis files. +Quit manager before foreground CLI use: +beehive host --owner-present +Service launch and binding authoring forms are unavailable.` }); if (existsSync(join(this.hostDirectory, 'setup.json'))) for (const slot of installationPublicSlots(this.hostDirectory)) local.push({ id: slot.agent, label: slot.agent, detail: `Local public slot (not current run status)\nAgent ${slot.agent}\nBindings: ${Object.keys(slot.bindings).join(', ')}` }); } else local.push({ id: 'missing', label: 'Configure this computer', detail: 'No local host configuration. Save owner PUBLIC npub and relay; host identity is created in OS credentials. No owner sign-in, agent or service Start.' }); } catch (error) { local.push({ id: 'error', label: 'Retained configuration needs attention', detail: String(error) + '\nPreserved; no automatic reset.' }); } - const agents: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => ({ id, label: `${m.agent.slice(0, 12)} · ${m.host.slice(0, 12)} · ${this.fresh(m) ? m.body.phase : 'UNKNOWN/stale'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object), detail: `HOST OBSERVATION — not an independent agent catalog\n${this.fresh(m) ? 'Recent report' : 'UNKNOWN / historical, not stopped'}\nActual run and selected-next are separate.\n${JSON.stringify(m, null, 2)}` })); - for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${id.slice(0, 12)}`, revision: m.revision, configurations: [], detail: `Infrastructure availability is not agent authorization or global liveness.\n${JSON.stringify(m, null, 2)}` }); - for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${operation.request.type} · ${operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], detail: `OPERATION RECORD — not current host state\n${JSON.stringify(operation, null, 2)}\nUnknown is not stopped. Reconcile queries receipts without resubmitting. Policy retry remains available in existing CLI.` }); - return { local, agents, owner: this.owner, status: this.status }; + const agents: ManagerSnapshot['agents'] = [...this.inventory].map(([id, m]) => { + const fresh = this.fresh(m); + const blocked = !fresh ? 'Host stale or unreachable. Refresh before acting.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'Unresolved operation: inspect receipts before acting.' : ''; + return { id, label: `Agent ${short(m.agent)} · ${fresh ? m.body.phase : 'UNKNOWN'}`, revision: m.revision, configurations: Object.keys((m.body.configurations ?? {}) as object), + disabled: { 'select-config': blocked, stop: blocked, start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Requires fresh assigned stopped report without an actual run.' : '') }, + evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)} +Host ${short(m.host)} +${fresh ? 'Recent host report' : 'UNKNOWN — stale or unreachable; last reported below'} +Reported phase: ${m.body.phase} +Start authority: ${m.body.assignedHost === m.host ? 'Assigned to this host' : 'Not assigned to this host'} + +Actual run (reported) +${m.body.actualRun ? describe(m.body.actualRun) : 'No actual run in this report'} + +Selected for next Start +${describe(m.body.selectedNext)} +Choosing next does not change the actual run.` }; + }); + for (const [id, m] of this.offers) agents.push({ id: `host:${id}`, label: `Host ${short(id)}`, revision: m.revision, configurations: [], evidence: JSON.stringify(m, null, 2), detail: `Host availability report +Host ${short(id)} +${this.fresh(m) ? 'Recent host report' : 'UNKNOWN — stale or unreachable'} +${describe(m.body)} +Availability is not agent Start authority.` }); + for (const operation of this.client?.status() ?? []) agents.push({ id: `operation:${operation.request.id}`, label: `${operation.request.type} · ${operation.state} · ${operation.request.id.slice(0,8)}`, revision: operation.request.revision, configurations: [], evidence: JSON.stringify(operation, null, 2), detail: `Operation: ${operation.request.type} +State: ${operation.state} +Host: ${short(operation.request.host)} +Agent: ${short(operation.request.agent)} +Captured revision: ${operation.request.revision} +Publication: ${describe(operation.publication)} +Receipt/result: ${describe(operation.result)} + +Publication is not acceptance. +Outcome unknown? Check receipts; do not submit again. +A Stop receipt is not a fresh stopped inventory report.` }); + let routing: ReturnType; + try { routing = readControllerConfig(this.ownerDirectory); } catch { /* Invalid retained routing is refused by sign-in; never reset here. */ } + return { local, agents, routing: routing ? { owner: routing.owner, relay: routing.relay } : undefined, owner: this.owner, status: this.status }; } + private fresh(m: Message) { return Boolean(this.client?.connected && Date.now() - Number(m.body.observedAt) <= 6000); } refresh() { if (!this.closed) this.changed(this.snapshot()); } cancel() { this.generation++; this.active?.abort(); } @@ -91,7 +145,7 @@ export class ManagerController { const owner = ownerPublicInput(v.owner ?? ''); if (!/^(wss|ws):\/\//.test(v.relay ?? '')) throw Error('Management relay URL required'); await this.credential({ action: 'configure', directory: this.hostDirectory, label: hostname().slice(0,128), owner, relay: v.relay }, abort.signal); - check(); this.status = 'Host configuration saved. Not serving; no agents started.'; + check(); this.status = 'Configuration saved. This action did not start a service or agent.'; } else if (request.action === 'provision') { await this.credential({ action: 'provision', directory: this.hostDirectory, binding: v.binding, genesis: v.genesis, secret: v.secret }, abort.signal); check(); this.status = 'Local agent provisioned stopped using existing binding/genesis APIs. No Start or relay publication.'; diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 0253174337..f1a0302c35 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -8,37 +8,55 @@ const renderer = await createCliRenderer({ exitOnCtrlC: false }); const screen = new OpenTuiScreen(renderer); const output = createWriteStream('', { fd: 4 }); let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting Node controller…' }; -let scope = 0, selected = '', sequence = 0; +let scope = 0, selected = '', sequence = 0, lastStatus = ''; let pending: { id: number; resolve: () => void } | undefined; function request(action: string, values?: Record, target?: string, revision?: number) { if (pending) return Promise.resolve(); const id = ++sequence; const completion = new Promise(resolve => { pending = { id, resolve }; }); const message: ManagerRequest = { id, action, values, target, revision }; + screen.setPending(true); screen.notice(`Working: ${action}… Esc cancels waiting, not submitted remote work or committed OS writes. Inspect before retry.`); output.write(JSON.stringify(message) + '\n'); return completion; } async function routing(action: string, secret = false) { - const owner = await screen.input('Owner PUBLIC npub/hex (blank uses retained controller owner)'); - if (owner === undefined) return; - const relay = await screen.input('Management relay URL (blank uses retained controller relay)'); - if (relay === undefined) return; + const retained = action !== 'configure' ? snapshot.routing : undefined; + let owner = retained?.owner ?? '', relay = retained?.relay ?? ''; + if (!retained) { + let step = 0; + while (step < 2) { + const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Owner sign-in'} · 1 of 3\nOwner PUBLIC npub/hex (required)` : 'Configure routing · 2 of 3\nManagement relay URL (ws:// or wss://)'; + const answer = await screen.input(label, step === 0 ? owner : relay, false, false, value => step === 0 ? /^(?:[0-9a-fA-F]{64}|npub1[023456789acdefghjklmnpqrstuvwxyz]{58})$/.test(value.trim()) ? '' : 'Enter a public 64-hex key or npub.' : /^wss?:\/\//.test(value) ? '' : 'Enter a ws:// or wss:// relay URL.', step === 1 ? value => { relay = value; } : undefined); + if (answer === undefined) return; + if (answer === '\0back') { step = 0; continue; } + if (step === 0) owner = answer; else relay = answer; + step++; + } + } const values: Record = { owner, relay }; if (secret) { const key = await screen.input('Matching owner private key, 64 hex. Saved only in this computer’s OS credentials. Never sent to a host.', '', true); if (key === undefined) return; values.secret = key; } - if (!await screen.confirm(action === 'configure' ? 'Create this host identity in OS credentials? No owner key, agent or Start.' : 'Access this computer’s owner OS credential? Permission may prompt. Import is verified against configured owner.')) { delete values.secret; return; } + if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate host identity in OS credentials? No agent or Start.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Retained routing (read-only). ' : ''}Access owner OS credential? Permission may prompt. Matching import only.`)) { delete values.secret; return; } await request(action, values); delete values.secret; } +function eligibility(action: string) { + const row = snapshot.agents.find(r => r.id === selected); + if (!snapshot.owner) return 'Owner sign-in required.'; + if (!row?.disabled) return 'Select an agent observation.'; + return row.disabled[action] || (action === 'select-config' && !row.configurations.length ? 'No existing configurations reported.' : undefined); +} function render() { screen.setOwner(snapshot.owner?.slice(-12)); const rows = scope === 0 ? snapshot.local : snapshot.agents; - if (!rows.some(row => row.id === selected)) selected = rows[0]?.id ?? ''; + if (selected === 'empty') selected = ''; + if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? ''; + const vanished = !!selected && !rows.some(row => row.id === selected); const actions: ManagerAction[] = scope === 0 ? [ - { label: 'Configure missing Local Host', run: () => routing('configure') }, - { label: 'Provision agent from existing binding/genesis', run: async () => { + snapshot.local.some(r => r.id === 'host') ? { label: 'Host configuration saved — inspect', run: () => screen.inspect() } : { label: 'Configure this computer', disabled: snapshot.local.some(r => r.id === 'error') ? 'Retained configuration needs attention — preserved, not reset.' : undefined, run: () => routing('configure') }, + { label: 'Provision from prepared binding/genesis files', disabled: snapshot.local.some(r => r.id === 'host') ? undefined : 'Configure this computer first.', run: async () => { const binding = await screen.input('Absolute existing local binding JSON path (no identity fields)'); if (binding === undefined) return; const genesis = await screen.input('Absolute owner-authorized agent genesis JSON path'); if (genesis === undefined) return; const secret = await screen.input('Matching AGENT private key, 64 hex. Stored in host OS credentials, not the owner key.', '', true); if (secret === undefined) return; @@ -47,19 +65,19 @@ function render() { } }, { label: 'Refresh local inspection', run: () => request('refresh') }, { label: 'Save private instructions draft', run: async () => { - const name = await screen.input('Profile name'); if (name === undefined) return; - const instructions = await screen.input('Private behavior instructions (not credentials)', '', false, true); if (instructions === undefined) return; + const name = await screen.input('New private instruction draft · 1 of 2\nProfile name', '', false, false, v => v.trim() ? '' : 'A name is required.'); if (name === undefined) return; + const instructions = await screen.input(`Draft: ${name}\nLocal only — not published or applied. Private instructions (not credentials)`, '', false, true, v => v.trim() ? '' : 'Instructions are required.'); if (instructions === undefined) return; await request('draft', { name, instructions }); } }, { label: 'Provisioning / service commands', run: () => screen.notice('Forms: Not available in this build. Existing CLI: beehive local-setup ~/.beehive/host; beehive provision-agent; beehive host --owner-present. Quit this manager before foreground CLI use.') }, ] : snapshot.owner ? [ - { label: 'Select existing next configuration', run: async () => { + { label: 'Choose next configuration…', disabled: eligibility('select-config'), run: async () => { const row = snapshot.agents.find(r => r.id === selected); if (!row) return; - const name = await screen.input(`Existing configurations: ${row.configurations.join(', ')}\nExact name`); if (name === undefined) return; - if (!await screen.confirm(`Select ${name} for ${row.label}? Actual run unchanged.`)) return; + const name = await screen.choose('Choose existing next configuration', row.configurations); if (name === undefined) return; + if (!await screen.confirm(`Select ${name} for exact host/agent ${row.id} at revision ${row.revision}? Actual run unchanged.`)) return; await request('select-config', { name }, row.id, row.revision); } }, - ...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — not available (binding safety)' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, run: async () => { + ...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — not available (binding safety)' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.' : eligibility(action), run: async () => { const row = snapshot.agents.find(r => r.id === selected); if (!row) return; if (!await screen.confirm(`${action.toUpperCase()} exact agent/host ${row.id} at revision ${row.revision}?`)) return; await request(action, undefined, row.id, row.revision); @@ -72,23 +90,23 @@ function render() { { label: 'Sign in with saved owner key', run: () => routing('signin') }, { label: 'Import matching owner key and sign in', run: () => routing('signin', true) }, ]; - screen.show(scope === 0 ? snapshot.local : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host observations yet' : 'Owner sign-in required', detail: 'Agents management is owner-authorized. Inventory comes from privately advertising hosts, NOT an independent relay agent catalog. Empty/offline observations do not prove agents stopped. Local Host and offline drafts do not require sign-in.' }], actions, selected); - screen.notice(snapshot.status); + if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instruction draft…', run: async () => { const name = await screen.input('New private instruction draft · 1 of 2\nName', '', false, false, v => v.trim() ? '' : 'Name required.'); if (name === undefined) return; const instructions = await screen.input(`Draft: ${name}\nLocal only — not published or applied`, '', false, true, v => v.trim() ? '' : 'Instructions required.'); if (instructions !== undefined) await request('draft', { name, instructions }); } }); + actions.push({ label: 'Quit manager (not Stop)', run: () => screen.close() }); + screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'Selection no longer available. Choose a current observation; no operation is targeted at a replacement row.' }, ...rows] : scope === 0 ? snapshot.local : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host observations yet' : 'Owner sign-in required', detail: 'Agents management is owner-authorized. Inventory comes from privately advertising hosts, NOT an independent relay agent catalog. Empty/offline observations do not prove agents stopped. Local Host and offline drafts do not require sign-in.' }], actions, selected); + if (lastStatus !== snapshot.status) { lastStatus = snapshot.status; screen.notice(snapshot.status); } } screen.onScope = value => { scope = value; selected = ''; render(); }; -screen.onSelect = id => { selected = id; }; +screen.onSelect = id => { if (selected !== id) { selected = id; render(); } }; const input = createInterface({ input: createReadStream('', { fd: 3 }) }); input.on('line', line => { try { const value = JSON.parse(line); if (value.snapshot) { snapshot = value.snapshot; render(); } - if (value.complete === pending?.id) { const old = pending; pending = undefined; old?.resolve(); } + if (value.complete === pending?.id) { const old = pending; pending = undefined; screen.setPending(false); old?.resolve(); } } catch { screen.notice('Controller response unavailable. Quit and inspect retained state.'); } }); input.on('close', () => screen.close()); -renderer.keyInput.on('keypress', key => { - if (key.name === 'escape' && pending) output.write(JSON.stringify({ cancel: pending.id }) + '\n'); -}); +screen.onCancel = () => { if (pending) output.write(JSON.stringify({ cancel: pending.id }) + '\n'); }; await screen.done; output.end(JSON.stringify({ quit: true }) + '\n'); input.close(); process.exit(0); diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index 7fc5912f91..847be5e5f7 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -1,8 +1,9 @@ -import { BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; +import { RGBA, 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 }; +export type ManagerRow = { id: string; label: string; detail: string; evidence?: string }; +export type ManagerAction = { label: string; disabled?: string; run: () => void | Promise }; +const fg = RGBA.defaultForeground(), bg = RGBA.defaultBackground(); const clean = (value: string) => stripVTControlCharacters(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/g, ''); /** Presentation only: never imports credentials, configuration, transport or host code. @@ -11,6 +12,7 @@ export class OpenTuiScreen { private readonly root: BoxRenderable; private readonly header: TextRenderable; private readonly list: SelectRenderable; + private readonly listFrame: BoxRenderable; private readonly detail: TextRenderable; private readonly scroll: ScrollBoxRenderable; private readonly actions: SelectRenderable; @@ -23,6 +25,8 @@ export class OpenTuiScreen { private owner = 'signed out'; private busy = false; private closed = false; + private overlay?: BoxRenderable; + private compactOverlay = false; private modal?: { cancel: () => void; key: (key: KeyEvent) => void; paste: (text: string) => void }; private finish!: () => void; readonly done = new Promise(resolve => { this.finish = resolve; }); @@ -30,13 +34,14 @@ export class OpenTuiScreen { onSelect: (id: string) => void = () => {}; constructor(readonly renderer: CliRenderer) { - this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: '#101820' }); + this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg }); 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.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => { if (event.y !== 0) return; const end = this.scope === 0 ? 20 : 18; if (event.x >= 8 && event.x < end) this.switchScope(0); else if (event.x >= end + 3 && event.x < end + 3 + (this.scope === 1 ? 8 : 6)) this.switchScope(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.listFrame = new BoxRenderable(renderer, { width: 28, height: '100%', border: true, title: '[List]' }); body.add(this.listFrame); + this.list = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, width: '100%', height: '100%', showDescription: false, showScrollIndicator: true, showSelectionIndicator: true, onMouseScroll: event => { if (event.scroll?.direction === 'up') this.list.moveUp(); else if (event.scroll?.direction === 'down') this.list.moveDown(); }, onMouseDown: event => { this.focusIndex = 0; this.list.focus(); const delta = event.y - this.list.y; // Pinned OpenTUI does not expose its scrolling offset. Never map a click @@ -44,29 +49,24 @@ export class OpenTuiScreen { if (this.rows.length <= this.list.height && delta >= 0) this.list.setSelectedIndex(Math.min(this.rows.length - 1, delta)); else this.notice('Scrolled list: use arrows to select the exact row. Mouse selection is not available in this build.'); } }); - body.add(this.list); + this.listFrame.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.detail = new TextRenderable(renderer, { fg, 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(); - } else this.notice('Scrolled actions: use arrows then Enter. Mouse activation is not available in this build.'); - } }); + this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 2, showDescription: false, showSelectionIndicator: true, options: [{name: 'Actions (F2)', description: ''}, {name: 'Inspect (F3)', description: ''}], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(1, event.y - this.actions.y))); 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.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() }); 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 }); + this.root.add(new TextRenderable(renderer, { fg, height: 1, content: 'F1 Keys F2 Actions F3 Inspect ^Q Quit' })); + this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, 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.focusStyle(); if (row) this.onSelect(row.id); }); - this.actions.on('itemSelected', (index: number) => { void this.run(index); }); + this.actions.on('itemSelected', (index: number) => { if (index === 0) this.drawer(); else this.inspect(); }); + this.list.on('itemSelected', () => { if (this.narrow) { this.drilled = true; this.resize(); this.focusIndex = 1; this.scroll.focus(); } }); renderer.keyInput.on('keypress', key => this.key(key)); renderer.keyInput.on('paste', event => { if (!this.modal) return; @@ -75,27 +75,54 @@ export class OpenTuiScreen { }); renderer.on('resize', () => this.resize()); renderer.on('destroy', () => this.close()); + for (const widget of [this.list, this.scroll, this.actions]) { widget.on('focused', () => this.focusStyle()); widget.on('blurred', () => this.focusStyle()); } 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)}`; } + private focusStyle() { + this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`; + this.scroll.title = `${this.scroll.focused ? '[Details]' : 'Details'}${this.narrow ? ' · Esc back' : ''}`; + for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; } + } + private narrow = false; + private drilled = false; + private pending = false; + private lastNotice = ''; + onCancel: () => void = () => {}; + setPending(value: boolean) { this.pending = value; } + private resize() { + if (this.overlay && this.modal) { this.overlay.width = Math.min(72, this.renderer.width - 4); this.overlay.left = Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2); this.overlay.height = this.compactOverlay ? Math.min(14, this.renderer.height - 4) : this.renderer.height - 4; } + this.small.visible = this.renderer.width < 40 || this.renderer.height < 16; + this.narrow = this.renderer.width < 88 || this.renderer.height < 24; + this.listFrame.visible = !this.narrow || !this.drilled; + this.scroll.visible = !this.narrow || this.drilled; + this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(24, Math.floor(this.renderer.width * .28))); + this.scroll.title = this.narrow ? '[Details] · Esc back' : 'Details'; + } + private heading() { this.header.content = `BEEHIVE ${this.scope === 0 ? '[Local Host]' : 'Local Host'} | ${this.scope === 1 ? '[Agents]' : 'Agents'}\n${this.scope === 0 ? 'This computer · no owner sign-in needed' : 'Owner · ' + 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); + this.scope = scope; this.drilled = false; this.resize(); 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 (key.name === 'escape' && this.pending) { key.preventDefault(); this.onCancel(); return; } if (this.modal) { this.modal.key(key); return; } + if (key.name === 'escape' && this.drilled) { key.preventDefault(); this.drilled = false; this.resize(); this.focusIndex = 0; this.list.focus(); } + if (key.name === 'f2') { key.preventDefault(); this.drawer(); return; } + if (key.name === 'f3') { key.preventDefault(); this.inspect(); return; } + if (key.name === 'f4') { key.preventDefault(); this.outcome(); return; } + if (this.focusIndex === 0 && ['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(this.list, key.name); } 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'); } + if (key.name === 'tab') { key.preventDefault(); this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; if (this.narrow && this.focusIndex === (this.drilled ? 0 : 1)) this.focusIndex = (this.focusIndex + (key.shift ? 2 : 1)) % 3; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); } + if (key.name === 'f1') { key.preventDefault(); this.read('Help', 'F2 Actions · F3 public Inspect · F4 full Outcome · Left/Right: scope · Tab/Shift-Tab: panes · arrows/PgDn/Home/End/wheel: navigate · Enter: open detail/action · Esc: cancel pending first, then dismiss/back · Ctrl-Q: quit, never Stop. Scrolled list/action mouse selection unavailable: use keyboard. No new provider, catalog, publication or Restart capability. Quit before foreground CLI use.'); } } - private async run(index: number) { - const action = this.commands[index]; - if (!action || this.busy || this.modal || this.small.visible || this.closed) return; + private async run(index: number, captured?: ManagerAction) { + const action = captured ?? this.commands[index]; + if (!action || this.pending || this.busy || this.modal || this.small.visible || this.closed) return; + if (action.disabled) { this.notice(action.disabled); 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.'); } @@ -105,29 +132,73 @@ export class OpenTuiScreen { 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 options = rows.map(row => ({ name: clean(row.label), description: '', value: row.id })); + if (JSON.stringify(this.list.options) !== JSON.stringify(options)) this.list.options = options; 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: '' })); + this.list.setSelectedIndex(index); this.detail.content = clean(rows[index]?.detail ?? 'No items.'); this.focusStyle(); + } - notice(text: string) { if (!this.closed) this.status.content = clean(text); } + notice(text: string) { if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } } unavailable(feature: string) { this.notice(`${feature}: Not available in this build. No request made. Local Host and existing host observations remain separate.`); } + choose(title: string, names: string[]): Promise { + if (this.modal || !names.length) return Promise.resolve(undefined); + return new Promise(resolve => { + const box = new BoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: title + ' · Esc cancel', backgroundColor: bg }); + const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, width: '100%', height: '100%', showDescription: false, showSelectionIndicator: true, options: names.map(name => ({ name: clean(name), description: '' })) }); + this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box); box.add(list); list.focus(); + const finish = (name?: string) => { this.modal = undefined; box.destroyRecursively(); this.actions.focus(); resolve(name); }; + list.on('itemSelected', (i: number) => finish(names[i])); + this.modal = { cancel: () => finish(), paste: () => {}, key: key => { if (key.name === 'escape') { key.preventDefault(); finish(); } else if (['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(list, key.name); } } }; + }); + } + private navigate(list: SelectRenderable, key: string) { + const n = list.options.length, current = list.getSelectedIndex(); + list.setSelectedIndex(Math.max(0, Math.min(n - 1, key === 'home' ? 0 : key === 'end' ? n - 1 : current + (key === 'pageup' ? -1 : 1) * Math.max(1, list.height)))); + } + inspect() { const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details · public evidence', row?.evidence ?? 'Inspect unavailable: no technical evidence for this row.'); } + private outcome() { this.read('Outcome · current attempt', this.lastNotice || 'No outcome yet.'); } + private read(title: string, content: string) { + if (this.modal || this.closed) return; + const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true }); + this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box); box.add(new TextRenderable(this.renderer, { fg, content: clean(content), width: '100%', selectable: true })); box.focus(); + const cancel = () => { this.modal = undefined; box.destroyRecursively(); [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); }; + this.modal = { cancel, paste: () => {}, key: key => { if (key.name === 'escape') { key.preventDefault(); cancel(); } } }; + } + private drawer() { + if (this.modal || this.closed) return; + const commands = this.commands.slice(); + const box = new BoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: this.renderer.height - 4, border: true, title: '[Actions] · Esc close', backgroundColor: bg, flexDirection: 'column', padding: 1 }); + this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box); + box.add(new TextRenderable(this.renderer, { fg, content: clean(this.rows[this.list.getSelectedIndex()]?.label ?? 'No selection'), height: 2 })); + const reason = new TextRenderable(this.renderer, { fg, height: 4 }); + const list = new SelectRenderable(this.renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, flexGrow: 1, showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: commands.map(a => ({ name: clean((a.disabled || this.pending ? '(unavailable) ' : '') + a.label), description: '' })), onMouseScroll: event => { if (event.scroll?.direction === 'up') list.moveUp(); else if (event.scroll?.direction === 'down') list.moveDown(); }, onMouseDown: () => this.notice('Actions: use arrows then Enter. Mouse activation is unavailable; no request sent.') }); + box.add(list); box.add(reason); list.focus(); + const explain = () => { reason.content = this.pending ? 'Working… Esc cancels waiting. Submitted remote work or OS writes may already have completed; inspect before retry.' : clean(commands[list.getSelectedIndex()]?.disabled || 'Enter opens action · Esc closes · arrows / PgDn scroll'); }; + explain(); list.on('selectionChanged', explain); + const cancel = () => { this.modal = undefined; box.destroyRecursively(); [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); }; + list.on('itemSelected', (i: number) => { if (this.pending || commands[i]?.disabled) { explain(); return; } cancel(); void this.run(i, commands[i]); }); + this.modal = { cancel, paste: () => {}, key: key => { if (key.name === 'escape') { key.preventDefault(); cancel(); } else if (['home','end','pageup','pagedown'].includes(key.name)) { key.preventDefault(); this.navigate(list, key.name); } } }; + } + /** 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 { + input(label: string, value = '', secret = false, multiline = false, validate?: (value: string) => string, back?: (value: string) => void): 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 })); + const box = new BoxRenderable(this.renderer, { position: 'absolute', top: 2, left: Math.floor((this.renderer.width - Math.min(72, this.renderer.width - 4)) / 2), width: Math.min(72, this.renderer.width - 4), height: multiline ? this.renderer.height - 4 : Math.min(this.renderer.height - 4, 14), border: true, backgroundColor: bg, flexDirection: 'column', padding: 1, title: secret ? 'Hidden key input' : multiline ? 'Private draft · Ctrl-S saves' : clean(label.split('\n')[0]!).slice(0, 45) }); + this.overlay = box; this.compactOverlay = false; this.renderer.root.add(box); + this.compactOverlay = !multiline; + const prompt = new ScrollBoxRenderable(this.renderer, { width: '100%', flexGrow: multiline ? 0 : 1, height: multiline ? 3 : undefined, scrollY: true }); + prompt.add(new TextRenderable(this.renderer, { fg, content: clean(label), width: '100%' })); box.add(prompt); 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 })); + : new InputRenderable(this.renderer, { value, maxLength: 8192, width: '100%', backgroundColor: fg, textColor: bg, focusedBackgroundColor: fg, focusedTextColor: bg }); + if (entry) { const field = new BoxRenderable(this.renderer, { border: !multiline, height: multiline ? undefined : 3, flexShrink: 0, flexGrow: multiline ? 1 : 0, width: '100%' }); field.add(entry); box.add(field); entry.focus(); } + else { this.list.blur(); this.actions.blur(); this.scroll.blur(); box.add(new TextRenderable(this.renderer, { fg, content: 'Input hidden — no characters or length displayed.', height: 2 })); } + box.add(new TextRenderable(this.renderer, { fg, content: multiline ? 'Ctrl-S save · Esc cancel (unsaved text discarded)' : back ? 'Enter next · Shift-Tab back · Esc cancel' : 'Enter accept · Esc cancel · Ctrl-↑/↓ text', height: 2 })); + const error = new TextRenderable(this.renderer, { fg, height: 2, flexShrink: 0 }); box.add(error); let settled = false; const finish = (answer?: string) => { if (settled) return; settled = true; hidden = ''; @@ -141,9 +212,11 @@ export class OpenTuiScreen { cancel: () => finish(), paste: text => { if (secret && hidden.length + text.length <= 4096) hidden += text.replace(/[\r\n]/g, ''); }, key: key => { + if (key.ctrl && ['up','down'].includes(key.name)) { key.preventDefault(); prompt.scrollBy(key.name === 'up' ? -3 : 3); return; } + if (back && !secret && key.name === 'tab' && key.shift) { key.preventDefault(); back(entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''); finish('\0back'); return; } 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; + key.preventDefault(); const answer = secret ? hidden : entry instanceof InputRenderable ? entry.value : entry?.plainText ?? ''; const invalid = validate?.(answer); if (invalid) { error.content = secret ? 'Invalid key. Re-enter.' : clean(invalid); if (secret) hidden = ''; return; } finish(answer); return; } if (!secret) return; key.preventDefault(); diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 079998b166..b887afcc93 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -39,6 +39,9 @@ test('manager config save is real/keyless owner routing; retained configuration await f.controller.request({ id: 1, action: 'configure', values: { owner, relay: 'wss://example.invalid' } }); assert.ok(existsSync(join(f.home,'.beehive','host','host-identity.json'))); assert.match(f.snapshot.status,/saved/); + assert.match(f.snapshot.local[0]!.detail, /Service: not checked/); + assert.ok(!f.snapshot.local[0]!.detail.includes('{')); + assert.match(f.snapshot.local[0]!.evidence!, /nonce/); assert.equal(f.backend.read(credentialReference('owner',owner)),null); await f.controller.request({ id: 2, action: 'configure', values: { owner, relay: 'wss://other.invalid' } }); assert.match(f.snapshot.status,/already exists/); @@ -58,6 +61,10 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat await request('signin',{ values: { owner, relay: 'wss://example.invalid', secret } }); assert.equal(f.snapshot.owner,owner); const m = message('inventory','host-a','agent-a',3,{ observedAt: Date.now(), phase: 'stopped', actualRun: null, assignedHost: 'host-a', configurations: { default: {} }, selectedNext: {} }); f.receive(m); + assert.match(f.snapshot.agents[0]!.detail, /Actual run \(reported\)/); + assert.match(f.snapshot.agents[0]!.detail, /Selected for next Start/); + assert.ok(!f.snapshot.agents[0]!.detail.includes('{')); + assert.equal(f.snapshot.agents[0]!.disabled!.start, ''); const target = JSON.stringify(['host-a','agent-a']); await request('start',{ target, revision: 2 }); assert.match(f.snapshot.status,/Selection changed/); await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start'); diff --git a/beehive/test/opentui-capture.bun.ts b/beehive/test/opentui-capture.bun.ts new file mode 100644 index 0000000000..45e8585821 --- /dev/null +++ b/beehive/test/opentui-capture.bun.ts @@ -0,0 +1,16 @@ +import { createTestRenderer } from '@opentui/core/testing'; +import { OpenTuiScreen } from '../src/opentui-screen.ts'; +import { writeFileSync } from 'node:fs'; +const output = process.argv[2]; +if (!output) throw Error('Pass existing output directory with trailing slash'); +const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); +const view = new OpenTuiScreen(ui.renderer); +const save = async (name: string) => { await ui.renderOnce(); writeFileSync(output + name + '.txt', ui.captureCharFrame()); writeFileSync(output + name + '-spans.json', JSON.stringify(ui.captureSpans())); }; +try { + ui.mockInput.pressArrow('right'); + view.show([{ id: 'synthetic-agent', label: 'Agent 12345678…abcdef', detail: 'Agent 12345678…abcdef\nHost abcdef12…123456\nUNKNOWN — stale or unreachable; last reported below\nReported phase: running\n\nActual run (reported)\nConfiguration: daily\nModel: Not reported\n\nSelected for next Start\nConfiguration: careful\nChoosing next does not change the actual run.', evidence: '{"syntheticPublicEvidence":true}' }], [{ label: 'Start agent', disabled: 'Host stale or unreachable. Refresh before acting.', run: () => { throw Error('Must never run'); } }, { label: 'Restart', disabled: 'Backend Restart may switch bindings. Use Stop, inspect accepted stopped report, then Start. No Restart request is sent.', run: () => { throw Error('Must never run'); } }]); + await save('fixture-agent'); + view.setPending(true); view.notice('Working: synthetic request… Esc cancels waiting, not committed OS writes or remote work.'); + ui.mockInput.pressKey('F2'); await save('fixture-pending'); + ui.mockInput.pressKey('q', { ctrl: true }); +} finally { view.close(); } diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts index b2c47ad1fe..8470f09cbd 100644 --- a/beehive/test/opentui-screen.bun.ts +++ b/beehive/test/opentui-screen.bun.ts @@ -15,6 +15,8 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit', ui.mockInput.pressArrow('down'); await ui.renderOnce(); assert.equal(selected, 'b'); + assert.ok(!ui.captureCharFrame().includes('Second detail')); + ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Second detail/); const secret = 'synthetic-secret-not-a-real-key'; const entered = view.input('Fixture secret', '', true); @@ -41,3 +43,50 @@ test('actual OpenTUI selection, hidden entry, multiline save, resize and quit', assert.equal(await quitting, undefined); } finally { view.close(); } }); + +test('drawer guards, persistent keys, pending Esc priority, list navigation and validated input', async () => { + const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); + const view = new OpenTuiScreen(ui.renderer); + try { + let calls = 0, cancelled = 0, selected = ''; + view.onSelect = id => { selected = id; }; + view.onCancel = () => { cancelled++; }; + view.show(Array.from({length: 25}, (_, i) => ({id: String(i), label: `Agent ${i}`, detail: `Summary ${i}`, evidence: `PUBLIC ${i}`})), [ + {label: 'Start', disabled: 'Requires fresh stopped report.', run: () => { calls++; }}, + ...Array.from({length: 10}, (_, i) => ({label: `Action ${i}`, run: () => { calls++; }})), + ]); + ui.mockInput.pressKey('HOME'); await ui.renderOnce(); + await ui.mockMouse.scroll(4, 5, 'down'); await ui.renderOnce(); + assert.equal(selected, '1'); + ui.mockInput.pressKey('END'); await ui.renderOnce(); + assert.equal(selected, '24'); + await ui.mockMouse.click(4, 5); await ui.renderOnce(); + assert.equal(selected, '24', 'scrolled click cannot select wrong row'); + ui.mockInput.pressEnter(); assert.equal(calls, 0); + ui.mockInput.pressKey('F2'); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /\(unavailable\) Start/); + assert.match(ui.captureCharFrame(), /Requires fresh stopped report/); + ui.mockInput.pressEnter(); assert.equal(calls, 0); + ui.mockInput.pressKey('END'); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /Action 9/); + ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); + view.setPending(true); view.notice('Working: synthetic pending request'); + ui.mockInput.pressKey('F2'); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /Esc cancels waiting/); + assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/); + ui.mockInput.pressEnter(); assert.equal(calls, 0); + ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); assert.equal(cancelled, 1); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /\[Actions\]/); + view.setPending(false); ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); + ui.mockInput.pressKey('F3'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /PUBLIC 24/); + ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); + const input = view.input('Required public field', '', false, false, value => value ? '' : 'Required; enter a value.'); + ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Required; enter a value/); + await ui.mockInput.typeText('retained public input'); + ui.resize(60,24); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /retained public input/); + ui.mockInput.pressEnter(); assert.equal(await input, 'retained public input'); + view.notice('FAILED: a long outcome\n' + 'Details\n'.repeat(40)); + ui.mockInput.pressKey('F4'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /FAILED: a long outcome/); + assert.match(ui.captureCharFrame(), /F1 Keys F2 Actions F3 Inspect/); + } finally { view.close(); } +});