From a83a47b19a2425b3a354aa7c3d9b83ed28e68b6a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 16 Sep 2026 08:49:42 -0400 Subject: [PATCH] feat(beehive): separate manager sections and enable guarded restart Signed-off-by: Logan Johnson --- beehive/UX_NAVIGATION_CHECKPOINT.md | 159 ++++++++++++++++++++++++ beehive/src/manager-controller.ts | 6 +- beehive/src/manager-navigation.ts | 29 +++++ beehive/src/manager-view.ts | 42 ++++--- beehive/src/opentui-screen.ts | 24 ++-- beehive/test/manager-controller.test.ts | 14 ++- beehive/test/manager-navigation.test.ts | 25 ++++ beehive/test/opentui-screen.bun.ts | 30 ++++- beehive/tools/ux-walkthrough.sh | 24 ++++ 9 files changed, 318 insertions(+), 35 deletions(-) create mode 100644 beehive/UX_NAVIGATION_CHECKPOINT.md create mode 100644 beehive/src/manager-navigation.ts create mode 100644 beehive/test/manager-navigation.test.ts create mode 100644 beehive/tools/ux-walkthrough.sh diff --git a/beehive/UX_NAVIGATION_CHECKPOINT.md b/beehive/UX_NAVIGATION_CHECKPOINT.md new file mode 100644 index 0000000000..49d4c684c8 --- /dev/null +++ b/beehive/UX_NAVIGATION_CHECKPOINT.md @@ -0,0 +1,159 @@ +# Revised UX checkpoint — incomplete + +This is a local implementation checkpoint for delegation 6d8d3485, **not the +requested end-to-end candidate**. Base: `176497874fb450818fddcad6b04ce0e619183f81`. +No push, export, installation or native rebuild was performed. + +## Implemented + +- Four top-level sections: Host, Agents, Harnesses, Providers. Keyboard arrows + cycle sections; header pointer targets use the same order, including 40-column + layout. Each section retains its exact selection; a removed selection is not + silently replaced by another target. +- Host displays host name, exact relay URL, state and saved/loaded revisions. + Its single Start/Stop control follows the verified service state; unknown + ownership still refuses Start. Existing awaited Stop is unchanged. +- Harnesses displays discovered adapters and saved runtime details. Opening the + section invokes existing bounded discovery. Refresh and Add runtime are here. +- Providers independently displays saved providers, Add provider and Models for + the selected provider. Details omit OS credential references. +- Agents exposes the existing signed management Restart operation, replacing the + legacy blanket refusal. Exact selection/revision/freshness, assigned host, + stopped/running phase and unresolved-operation gates precede submission. + Host Restart still owns preflight, awaited teardown, replay and cancellation. + +## Source/operation map + +`manager-navigation.ts` owns section order and selection retention; +`manager-view.ts` chooses section controls and captures selected requests; +`opentui-screen.ts` owns keyboard/pointer presentation. The Node +`manager-controller.ts` remains the sole UI management-request boundary. +`intents.ts`, `host.ts`, service custody/ownership and all management-wire limits +are unchanged. Saving provider/runtime configuration still does not alter an +active run. Existing Pi and Codex runtime paths were not changed. + +Current checkout Desktop sources inspected: +- `desktop/src-tauri/src/commands/agent_discovery/relay_directory.rs`: relay-self + membership authority, paginated kind 39002 membership and owner kind 30177 + coordinates, exact-agent profiles and policy verification. **Not integrated + into this checkpoint.** A broad kind-0 scan is not an equivalent directory. +- `desktop/src-tauri/src/managed_agents/discovery/{catalog,runtime_metadata}.rs` + and `desktop/src/features/agents/lib/agentConfigCore.ts`: capability-driven + harness/provider/model/effort fields and dependent-value policies. +- Existing Beehive `harness-discovery.ts`, `harness-contract.json`, + `runtime-form.ts`, `settings-credentials.ts`, `settings-runtime.ts` and native + helper contract remain in force. The packaged discovery contract is still + pinned to historical `78618804...`; no new current-Desktop parity claim. + No Desktop contributor rules changed; no Desktop files were edited. + +## Required work still missing + +1. **Global relay agent list**: Agents still uses private host reports, including + host/operation rows. Next: add a bounded, cancellable authenticated directory + adapter using the current Desktop consumer and explicit fixtures, then project + exact-key directory rows independently from management authority. +2. **Registration-first continuation**: Register is still identity-only. Runtime + choice, environment variables and automatic continuation are absent. Next: + introduce a typed operation/result state machine (the current renderer request + returns `void`, including failures), bind the exact nsec public key and saved + runtime atomically, then continue only after verified success and fresh + authority. Cancellation must discard continuation without claiming OS rollback. + Secret possession must never synthesize assignment/genesis authority. +3. **Move in this manager**: absent. Next: expose the existing signed Move + preparation/grant flow, capturing source and destination revisions; registration + of a local destination must finish before source ownership is consumed. Reuse + the real `move.test.ts`/named-Move fixtures for partial, stopped and unknown + results rather than chaining optimistic Stop and Start. +4. **Current Desktop discovery/provider parity**: existing provider setup and + runtime configuration are reused, not refreshed end to end. Next: compare the + packaged contract and provider/auth/model consumers with current Desktop; + preserve capability-specific Pi/Codex behavior and OS custody. +5. **Concise UI copy**: old prose, draft/publication actions and identity-only + registration copy remain. Next: remove stale actions/copy with the completed + agent workflow. Host name and relay are displayed, not newly editable. + +No unresolved product-authority question was identified; these are unfinished +implementation tasks, not external blockers. Do not schedule delivery acceptance +against this checkpoint as if they were complete. + +## Validation + +Artifact directory: +`/Users/loganj/.buzz/artifacts/beehive-ux-6d8d3485/` + +- Node 24.15.0 TypeScript check: passed (`typecheck-final.log`). +- Focused manager/navigation + real Restart integration: **9 passed** + (`focused.log`). The final full run also covers added stale/standby Restart + manager guards. +- Bun 1.4.2 renderer: **4 passed** (`renderer-final.log`): four-way keyboard and + pointer navigation, narrow header, selected details, disabled controls, + hidden entry, cancellation and editor ownership. +- One final default-concurrent package suite: **202 passed, 4 failed, 19 skipped**, + 225 tests, 32.344 seconds (`full-suite.log`). **Not green.** Failures: + - `acp.test.ts`: ACP operation timed out (additional to the brief's historical + three failures; cause not established). + - `admission-cancel.test.ts`: wrong-authority control elapsed 2787 > 2500ms; + log retains not-authority and accepted Start/Stop receipts. + - `broker.test.ts`: `ok` stimulus conversation response timeout. + - `disconnect.test.ts`: missing resistant readiness, null trace. + No timeout, concurrency, assertion or production containment relaxation, and no + full-suite rerun. Similar historical symptoms are not a waiver or diagnosis. +- Native unchanged: reuse supplied historical 700 pass / 0 fail / 1 ignored + evidence; not a new native run. No repository-wide CI claim. +- Real isolated 100x30 PTY walkthrough passed (`pty-final.log`, `walkthrough.json`): + all sections; cancelled registration and runtime form; provider cancellation + preserves settings byte-for-byte; provider Save creates a second synthetic + provider; selected-provider details and model listing; hidden keys absent; + clean exit. External credential/relay/provider/browser/harness/service actions + were explicitly blocked. This is not registration continuation or Move proof. + +Failed evidence is retained: `renderer.log` and `renderer-diagnostic.log` used +`pressKey('right')`, which sends literal text; corrected to the mock's +`pressArrow('right')`. `pty-first/` and `pty.log` retain the first failed save +probe: the driver assumed Home selects the first action; it does not. Corrected +only the driver to use bounded Up arrows. `full-suite.log` retains all failures. + +## Reusable reviewer walkthrough + +From this checkout, in a real terminal at least 100x30: + +```sh +sh beehive/tools/ux-walkthrough.sh +``` + +The script creates a fresh `beehive-pairing-cli-ux-` HOME, prints its path, +sets `BEEHIVE_TEST_CREDENTIAL_FILE` and `NODE_OPTIONS --import` to the extended +installed loader, seeds only synthetic host/provider data and starts the actual +Node controller/Bun renderer. It does not invoke the installed Beehive launcher. +Use only synthetic values. Every invocation creates a new fixture directory. + +1. Inspect Host name/relay/state and Start control. Real service operations are + blocked by the fixture; this is intentionally not a host lifecycle demo. +2. Right to Agents; `a`, Up to Register, Enter; Escape cancels hidden input. +3. Right to Harnesses; inspect Buzz Agent and Refresh. Add runtime traverses + Harness → Provider → Model → Runtime name. Escape cancels. +4. Right to Providers; inspect Review OpenAI. `a` selects controls; Up/Down and + Enter open Add provider or Models. Save a synthetic OpenAI key with `yes`. +5. Tab to list; Up/Down changes selected-provider details. Left/Right restores + each section's prior selection. `q` quits outside forms; Ctrl-Q always quits. + +For another machine, set `BEEHIVE_REVIEW_NODE` and `BEEHIVE_REVIEW_BUN` to direct +Node 24.15.0 and Bun 1.4.2 executables. The defaults point at existing local +runtime artifacts, not downloaded or rebuilt binaries. + +Evidence replay: `cat /06-providers.ansi` in a 100x30 terminal. +`01-host.txt` through `10-provider-selected.txt` are plain frame reconstructions +from the real PTY bytes (not screenshots); corresponding `.ansi` streams are the +raw evidence. `pty-walkthrough.py` is the saved automation, `frames.py` the decoder. + +Exact validation commands (Node/Bun variables are the script's defaults): + +```sh +NODE=/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node +BUN=/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun +cd beehive +"$NODE" node_modules/typescript/bin/tsc --noEmit +"$NODE" --test test/manager-controller.test.ts test/manager-navigation.test.ts test/restart.test.ts +"$BUN" test ./test/opentui-screen.bun.ts +"$NODE" --test test/*.test.ts +``` diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index d33c186eac..007efdeb22 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -166,7 +166,7 @@ export class ManagerController { const fresh = this.fresh(m); const blocked = !fresh ? 'The host report is old or the host cannot be reached. Wait for a recent report before you act.' : this.client?.status().some(o => o.request.host === m.host && o.request.agent === m.agent && !['completed','failed'].includes(o.state)) ? 'An operation has no confirmed result. Inspect operations before you act.' : ''; 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 ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') }, + disabled: { 'select-config': blocked, stop: blocked, restart: blocked || (m.body.assignedHost !== m.host || !['stopped', 'running'].includes(String(m.body.phase)) ? 'Restart requires a recent report from the assigned host.' : ''), start: blocked || (m.body.phase !== 'stopped' || m.body.actualRun || m.body.assignedHost !== m.host ? 'Start requires a recent report from the assigned host. It must report the agent stopped with no current run.' : '') }, evidence: JSON.stringify(m, null, 2), detail: `Agent ${short(m.agent)} Host ${short(m.host)} ${fresh ? 'Recent host report' : 'Current status unknown. The report is old or the host cannot be reached. Last report:'} @@ -329,10 +329,10 @@ A Stop result is not a recent host report that confirms the agent is stopped.` } if (!this.owner || !this.client) throw plain('Owner sign-in required'); const current = this.inventory.get(request.target ?? ''); if (!current || !this.fresh(current) || current.revision !== request.revision) throw plain('The selection changed, or the host report is old or unavailable. Select an agent with a recent report. No request was sent.'); - if (request.action === 'restart') throw plain('Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent. No request was sent.'); + if (request.action === 'restart' && (current.body.assignedHost !== current.host || !['stopped', 'running'].includes(String(current.body.phase)))) throw plain('Restart requires a recent report from the assigned host.'); if (request.action === 'start' && (current.body.phase !== 'stopped' || current.body.actualRun || current.body.assignedHost !== current.host)) throw plain('Start requires a recent report from the assigned host. It must report the agent stopped with no current run.'); if (this.client.status().some(o => o.request.host === current.host && o.request.agent === current.agent && !['completed','failed'].includes(o.state))) throw plain('An operation has no confirmed result. Inspect operations and check operation results before you act again.'); - const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {}); + const operation = message(request.action === 'select-config' ? 'save' : request.action as 'start' | 'stop' | 'restart', current.host, current.agent, current.revision, request.action === 'select-config' ? { configurationAction: 'select', name: v.name } : {}); this.client.submit(operation); this.status = `Operation ${operation.id}: ${plainOperationType(operation)} saved and pending.\nHost: ${current.host}\nAgent: ${current.agent}\nRelay publication does not confirm host acceptance. Inspect operations. Unknown does not mean stopped.`; } else if (request.action !== 'refresh') throw plain('Not available in this build'); diff --git a/beehive/src/manager-navigation.ts b/beehive/src/manager-navigation.ts new file mode 100644 index 0000000000..d4c850f18d --- /dev/null +++ b/beehive/src/manager-navigation.ts @@ -0,0 +1,29 @@ +import type { ManagerSnapshot, ManagerItem } from './manager-controller.ts'; + +/** Section order is shared by pointer hit testing and keyboard navigation. */ +export const managerSections = ['Host', 'Agents', 'Harnesses', 'Providers'] as const; + +/** Public presentation only. A catalog item never becomes management authority. */ +export function sectionRows(snapshot: ManagerSnapshot, section: number): ManagerItem[] { + const settings = snapshot.settings; + if (section === 0) return snapshot.local.map(row => row.id !== 'host' ? row : ({ ...row, detail: `Host name: ${row.label}\nRelay URL: ${snapshot.hostRelay ?? snapshot.routing?.relay ?? '—'}\nState: ${snapshot.service?.state ?? 'unknown'}\nSaved revision: ${settings?.revision ?? 0}\nLoaded revision: ${snapshot.service?.revision ?? '—'}` })); + if (section === 1) return snapshot.agents; + if (section === 2) return [ + ...(snapshot.harnesses ?? []).map(h => ({ id: `harness:${h.id}`, label: `${h.label} · ${h.state}`, detail: `Harness: ${h.label}\nState: ${h.state}\nExecutable: ${h.executable ?? '—'}\nCLI: ${h.cli ?? '—'}` })), + ...(settings?.runtimes ?? []).map(r => ({ id: `runtime:${r.id}`, label: r.name, detail: `Runtime: ${r.name}\nHarness: ${r.harness}\nProvider: ${settings?.providers.find(p => p.id === r.providerId)?.name ?? '—'}\nModel: ${r.model}\nEffort: ${r.effort ?? 'Inherit'}` })), + ]; + if (section === 3) return (settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` })); + return []; +} + +/** Exact per-section selection retention; removal never selects a replacement. */ +export class ManagerNavigation { + section = 0; + private selected = new Map(); + select(id: string) { this.selected.set(this.section, id); } + switch(section: number) { if (Number.isInteger(section) && section >= 0 && section < managerSections.length) this.section = section; } + selection(rows: ManagerItem[]) { + if (!this.selected.has(this.section) && rows.length) this.select(rows[0]!.id); + return this.selected.get(this.section) ?? ''; + } +} diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 627648f96f..c54aea0e98 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -1,3 +1,4 @@ +import { ManagerNavigation, sectionRows } from './manager-navigation.ts'; import { runtimeForm } from './runtime-form.ts'; import { createCliRenderer } from '@opentui/core'; import { createReadStream, createWriteStream } from 'node:fs'; @@ -9,6 +10,7 @@ const renderer = await createCliRenderer({ exitOnCtrlC: false }); const screen = new OpenTuiScreen(renderer); const output = createWriteStream('', { fd: 4 }); let snapshot: ManagerSnapshot = { local: [], agents: [], status: 'Connecting to Beehive…' }; +const navigation = new ManagerNavigation(); let scope = 0, selected = '', sequence = 0, lastStatus = ''; let pending: { id: number; resolve: () => void } | undefined; function request(action: string, values?: Record, target?: string, revision?: number) { @@ -51,19 +53,12 @@ function eligibility(action: string) { } function render() { screen.setOwner(snapshot.owner?.slice(-12)); - const hostRows = [ - { id: 'agents', label: 'Agents', detail: '' }, ...(snapshot.settings?.agents ?? []).map(a => ({ id: a.publicKey, label: a.profile?.name ?? a.publicKey, detail: '' })), - { id: 'providers', label: 'Providers', detail: '' }, ...(snapshot.settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: '' })), - { id: 'runtimes', label: 'Runtimes', detail: '' }, ...(snapshot.settings?.runtimes ?? []).map(r => ({ id: r.id, label: r.name, detail: '' })), - ]; - const rows = scope === 0 ? hostRows : snapshot.agents; - if (selected === 'empty') selected = ''; - if (!selected || (scope === 0 && selected === 'missing' && rows.some(r => r.id === 'host'))) selected = rows[0]?.id ?? ''; + const rows = sectionRows(snapshot, scope); + selected = navigation.selection(rows); const vanished = !!selected && !rows.some(row => row.id === selected); screen.setRelay(snapshot.hostRelay ?? snapshot.routing?.relay, snapshot.service?.relay ?? 'unknown', snapshot.relayName); const configured = snapshot.local.some(r => r.id === 'host'); - const actions: ManagerAction[] = scope === 0 ? [ - { label: 'Register agent', run: async () => { + const registerAction: ManagerAction = { label: 'Register agent', run: async () => { if (!configured) { await routing('configure'); return; } let secret = await screen.input('Register agent\nAgent nsec private key. Hidden. Saved only in Beehive’s OS credential store.', '', true); if (secret === undefined) return; @@ -72,7 +67,8 @@ function render() { if (!preview) { secret = ''; return; } if (await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Relay unavailable. Register without a profile?' : preview.profileState === 'none' ? 'No profile found.' : preview.profile?.about ?? ''}\nThis does not authorize or start an agent. Save?`)) await request('register-agent',{ secret }); secret = ''; - } }, + } }; + let actions: ManagerAction[] = scope !== 1 ? [ { label: 'Add provider', run: async () => { await request('provider-form'); const type = await screen.choose('Add provider',['OpenAI','Databricks v2']); if (!type) return; @@ -103,9 +99,9 @@ function render() { if (!await screen.confirm(`Use ${name} for the next start?\nHost and agent: ${row.id}\nRevision: ${row.revision}\nThis does not change the current run.`)) return; await request('select-config', { name }, row.id, row.revision); } }, - ...(['start','stop','restart'] as const).map(action => ({ label: action === 'restart' ? 'Restart — unavailable' : `${action === 'start' ? 'Start' : 'Stop'} selected agent`, disabled: action === 'restart' ? 'Restart can change the local setup. Use Stop. Inspect a recent host report that confirms the agent is stopped. Then use Start. No Restart request is sent.' : eligibility(action), run: async () => { + ...(['start','stop','restart'] as const).map(action => ({ label: action[0]!.toUpperCase() + action.slice(1), disabled: eligibility(action), run: async () => { const row = snapshot.agents.find(r => r.id === selected); if (!row) return; - if (!await screen.confirm(`${action === 'start' ? 'Start' : 'Stop'} this agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return; + if (!await screen.confirm(`${action[0]!.toUpperCase() + action.slice(1)} agent?\nHost and agent: ${row.id}\nRevision: ${row.revision}`)) return; await request(action, undefined, row.id, row.revision); } })), { label: 'Inspect operations', run: () => request('operations') }, @@ -116,14 +112,28 @@ function render() { { label: 'Sign in with saved owner key', run: () => routing('signin') }, { label: 'Import matching owner key and sign in', run: () => routing('signin', true) }, ]; + if (scope === 0) actions = actions.filter(a => a.label === (snapshot.service?.state === 'running' ? 'Stop' : 'Start')); + if (scope === 1) actions.unshift(registerAction); + if (scope === 2) actions = [ + ...actions.filter(a => a.label === 'Add runtime'), + { label: 'Refresh', run: () => request('runtime-form') }, + ]; + if (scope === 3) actions = [ + ...actions.filter(a => a.label === 'Add provider'), + { label: 'Models', disabled: rows.some(r => r.id === selected) ? undefined : 'Select provider', run: async () => { + const provider = selected; + await request('models', { provider }); + if (snapshot.models) await screen.choose('Models', snapshot.models); + } }, + ]; if (scope === 1 && !snapshot.owner) actions.push({ label: 'New private instructions draft…', run: async () => { const name = await screen.input('Private instructions · 1 of 2\nDraft name', '', false, false, v => v.trim() ? '' : 'Enter a draft name.'); if (name === undefined) return; const instructions = await screen.input(`Private instructions · 2 of 2\nDraft: ${name}\nSaved only on this computer. Not published or used by an agent. Do not enter passwords or keys.`, '', false, true, v => v.trim() ? '' : 'Enter instructions.'); if (instructions !== undefined) await request('draft', { name, instructions }); } }); if (scope !== 0) actions.push({ label: 'Quit Beehive', run: () => screen.close() }); - screen.show(vanished ? [{ id: selected, label: 'Selection no longer available', detail: 'This item is no longer available. Select a current item. No operation will use a replacement item.' }, ...rows] : scope === 0 ? hostRows : snapshot.agents.length ? snapshot.agents : [{ id: 'empty', label: snapshot.owner ? 'No host reports yet' : 'Owner sign-in required', detail: 'Sign in as owner to manage agents. This list uses private host reports, not a separate agent directory. An empty list or an offline host does not mean agents are stopped. Local Host and local drafts do not require sign-in.' }], actions, selected); + screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows.length ? rows : [{ id: 'empty', label: 'Empty', detail: '—' }], actions, selected); const notice = scope === 0 ? `Host: ${snapshot.service?.state ?? 'unknown'} · Saved: ${snapshot.settings?.revision ?? 0} · Loaded: ${snapshot.service?.revision ?? 'not confirmed'}\n${snapshot.status}` : snapshot.status; if (lastStatus !== notice) { lastStatus = notice; screen.notice(notice); } } -screen.onScope = value => { scope = value; selected = ''; render(); }; -screen.onSelect = id => { if (selected !== id) { selected = id; render(); } }; +screen.onScope = value => { navigation.switch(value); scope = navigation.section; render(); if (scope === 2 && !snapshot.harnesses) void request('runtime-form'); }; +screen.onSelect = id => { if (id === 'empty') return; if (selected !== id) { if (id !== 'empty') navigation.select(id); selected = id; render(); } }; const input = createInterface({ input: createReadStream('', { fd: 3 }) }); input.on('line', line => { try { diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index 97c83cfd44..a033279195 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -1,4 +1,5 @@ import { RGBA, BoxRenderable, InputRenderable, ScrollBoxRenderable, SelectRenderable, TextRenderable, TextareaRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; +import { managerSections } from './manager-navigation.ts'; import { stripVTControlCharacters } from 'node:util'; export type ManagerRow = { id: string; label: string; detail: string; evidence?: string }; @@ -42,7 +43,15 @@ export class OpenTuiScreen { constructor(readonly renderer: CliRenderer) { this.root = new BoxRenderable(renderer, { width: '100%', height: '100%', flexDirection: 'column', backgroundColor: bg }); renderer.root.add(this.root); - 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.header = new TextRenderable(renderer, { fg, height: 2, onMouseDown: event => { + if (event.y !== 0) return; + let start = this.renderer.width < 56 ? 0 : 8; + for (let index = 0; index < managerSections.length; index++) { + const width = managerSections[index]!.length + (this.scope === index ? 2 : 0); + if (event.x >= start && event.x < start + width) { this.switchScope(index); return; } + start += width + (this.renderer.width < 56 ? 1 : 3); + } + } }); this.root.add(this.header); const body = new BoxRenderable(renderer, { flexGrow: 1, flexDirection: 'row', minHeight: 1 }); this.root.add(body); @@ -88,7 +97,7 @@ export class OpenTuiScreen { private focusStyle() { this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`; - this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls'; + this.scroll.title = managerSections[this.scope] ?? 'Details'; for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; } } private narrow = false; @@ -100,18 +109,19 @@ export class OpenTuiScreen { private formWidth() { return this.scope === 0 ? Math.max(20, this.renderer.width - this.listFrame.width - 2) : Math.min(72, this.renderer.width - 4); } private formLeft() { return this.scope === 0 ? this.listFrame.width : Math.floor((this.renderer.width - this.formWidth()) / 2); } private resize() { + this.heading(); if (this.overlay && this.modal) { this.overlay.width = this.formWidth(); this.overlay.left = this.formLeft(); 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.scope !== 0 && (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(14, Math.floor(this.renderer.width * .28))); - this.scroll.title = this.scope === 0 ? 'Host settings' : 'Agent controls'; + this.scroll.title = managerSections[this.scope] ?? 'Details'; this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2; } private relayFooter = 'Relay: Not configured · disconnected'; setRelay(url?: string, state = 'unknown', name?: string) { this.relayFooter = url ? `Relay: ${name ? `${clean(name)} · ` : ''}${clean(url)} · ${state}` : 'Relay: Not configured · disconnected'; this.resize(); } - 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)}`; } + private heading() { this.header.content = `${this.renderer.width < 56 ? '' : 'BEEHIVE '}${managerSections.map((name, index) => this.scope === index ? `[${name}]` : name).join(this.renderer.width < 56 ? ' ' : ' | ')}\n${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; @@ -137,7 +147,7 @@ export class OpenTuiScreen { if (key.name === 'f3' && this.scope !== 0) { 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 === 'left' || key.name === 'right') { key.preventDefault(); this.switchScope((this.scope + (key.name === 'left' ? managerSections.length - 1 : 1)) % managerSections.length); } if (key.name === 'tab') { key.preventDefault(); this.focusIndex = this.focusIndex === 0 ? 2 : 0; [this.list, this.scroll, this.actions][this.focusIndex]!.focus(); this.focusStyle(); } if (key.name === 'f1') { key.preventDefault(); this.help(); } } @@ -154,7 +164,7 @@ export class OpenTuiScreen { if (this.closed) return; const prior = selected ?? this.rows[this.list.getSelectedIndex()]?.id; this.rows = rows; this.commands = actions; - this.detail.visible = this.scope !== 0; + this.detail.visible = true; const commandOptions = actions.map(a => ({ name: clean(a.label + (a.disabled ? ' — unavailable' : '')), description: '' })); if (JSON.stringify(this.actions.options) !== JSON.stringify(commandOptions)) this.actions.options = commandOptions; this.actions.height = Math.max(5,actions.length); @@ -184,7 +194,7 @@ export class OpenTuiScreen { } inspect() { if (this.scope === 0) return; const row = this.rows[this.list.getSelectedIndex()]; this.read('Technical details', row?.evidence ?? 'No technical details for this item.'); } private outcome() { this.read('Status · latest message', this.lastNotice || 'No status message yet.'); } - private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: switch Host settings and remote Agents.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); } + private help() { this.read('Help', 'Tab: switch list and controls. Arrows: select. Enter: open.\nLeft/Right: Host, Agents, Harnesses, Providers.\nEsc: cancel or back. q or Ctrl-C: quit. The host keeps running.\nHost Start and Stop control this computer’s service, not an agent.\nAgent execution still requires authenticated management authority.\nShortcuts do not run while you edit fields.'); } private read(title: string, content: string) { if (this.modal || this.closed) return; const box = new ScrollBoxRenderable(this.renderer, { position: 'absolute', top: 2, left: this.formLeft(), width: this.formWidth(), height: this.renderer.height - 4, border: true, title: title + ' · Esc close', backgroundColor: bg, scrollY: true }); diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 81d17d6c46..97a85576a8 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -67,17 +67,21 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat 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('restart',{ target, revision: 2 }); assert.match(f.snapshot.status,/selection changed/); assert.equal(f.submitted.length,0); await request('start',{ target, revision: 3 }); assert.equal(f.submitted.length,1); assert.equal(f.submitted[0]!.type,'start'); await f.controller.request({ id, action: 'start', target, revision: 3 }); assert.equal(f.submitted.length,1,'duplicate request cannot act twice'); - await request('restart',{ target, revision: 3 }); assert.match(f.snapshot.status,/No Restart request is sent/); assert.equal(f.submitted.length,1,'handler refuses even if directly invoked'); + await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.at(-1)!.type,'restart'); assert.equal(f.submitted.length,2); f.states([{ request: f.submitted[0], state: 'unknown' }]); - await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,1); + await request('stop',{ target, revision: 3 }); assert.match(f.snapshot.status,/no confirmed result/); assert.equal(f.submitted.length,2); + await request('restart',{ target, revision: 3 }); assert.equal(f.submitted.length,2,'unresolved operation blocks Restart'); f.states([{ request: f.submitted[0], state: 'completed' }]); - await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[1]!.type,'save'); - await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[2]!.type,'stop'); + await request('select-config',{ target, revision: 3, values: { name: 'Alternative' } }); assert.equal(f.submitted[2]!.type,'save'); + await request('stop',{ target, revision: 3 }); assert.equal(f.submitted[3]!.type,'stop'); f.receive({ ...m, revision: 4, body: { ...m.body, observedAt: Date.now()+1, phase: 'running', actualRun: { run: 'actual' } } }); await request('start',{ target, revision: 4 }); assert.match(f.snapshot.status,/recent report from the assigned host/); - await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,3); assert.equal(f.closed,1); + f.receive({ ...m, revision: 5, body: { ...m.body, observedAt: Date.now()+2, assignedHost: 'host-b' } }); + await request('restart',{ target, revision: 5 }); assert.match(f.snapshot.status,/assigned host/); assert.equal(f.submitted.length,4); + await request('signout'); assert.equal(f.snapshot.owner,undefined); assert.equal(f.submitted.length,4); assert.equal(f.closed,1); f.receive(m); assert.equal(f.controller.snapshot().agents.length,0,'late receive is fenced after signout'); } finally { f.cleanup(); } }); diff --git a/beehive/test/manager-navigation.test.ts b/beehive/test/manager-navigation.test.ts new file mode 100644 index 0000000000..f6aa5ea7f4 --- /dev/null +++ b/beehive/test/manager-navigation.test.ts @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { ManagerNavigation, managerSections, sectionRows } from '../src/manager-navigation.ts'; +import type { ManagerSnapshot } from '../src/manager-controller.ts'; + +test('four sections retain exact selections without retargeting removed items', () => { + assert.deepEqual(managerSections, ['Host', 'Agents', 'Harnesses', 'Providers']); + const navigation = new ManagerNavigation(); + const rows = ['a','b'].map(id => ({ id, label: id, detail: id })); + assert.equal(navigation.selection(rows), 'a'); + navigation.select('b'); navigation.switch(1); + assert.equal(navigation.selection(rows), 'a'); + navigation.switch(0); assert.equal(navigation.selection(rows), 'b'); + assert.equal(navigation.selection(rows.slice(0,1)), 'b'); + navigation.switch(9); assert.equal(navigation.section, 0); +}); + +test('section rows expose selected public fields, not credential references or other controls', () => { + const snapshot: ManagerSnapshot = { local: [{ id: 'host', label: 'This host', detail: '' }], agents: [], status: '', hostRelay: 'wss://fixture.invalid', service: { state: 'stopped' }, settings: { version: 1, revision: 1, agents: [], providers: [{ id: 'p', name: 'Provider A', type: 'openai', endpoint: 'https://api.openai.com/v1', key: { service: 'beehive', account: 'secret-reference' } }], runtimes: [{ id: 'r', name: 'Runtime A', harness: 'buzz-agent', providerId: 'p', model: 'gpt-5', effort: 'high', executable: '/fixture/harness' }] } }; + assert.match(sectionRows(snapshot,0)[0]!.detail, /Host name: This host\nRelay URL: wss:\/\/fixture.invalid\nState: stopped/); + assert.equal(sectionRows(snapshot,1).length, 0); + assert.match(sectionRows(snapshot,2)[0]!.detail, /Provider: Provider A\nModel: gpt-5\nEffort: high/); + assert.equal(sectionRows(snapshot,3)[0]!.id,'p'); + assert.ok(!JSON.stringify(sectionRows(snapshot,3)).includes('secret-reference')); +}); diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts index 823c3062e2..567784cade 100644 --- a/beehive/test/opentui-screen.bun.ts +++ b/beehive/test/opentui-screen.bun.ts @@ -3,16 +3,16 @@ import assert from 'node:assert/strict'; import { createTestRenderer } from '@opentui/core/testing'; import { OpenTuiScreen } from '../src/opentui-screen.ts'; -test('host settings has right-pane controls, no inspection menu, and hidden ordinary-key entry', async () => { +test('host shows selected details and controls with hidden ordinary-key entry', async () => { const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); const view = new OpenTuiScreen(ui.renderer); try { let calls = 0; - view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'PRIVATE DETAIL MUST NOT DISPLAY' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } }))); + view.show(['Agents','Providers','Runtimes'].map(label => ({ id: label,label,detail: 'Host name: Fixture' })),['Register agent','Add provider','Add runtime','Start','Stop'].map(label => ({ label,run: () => { calls++; } }))); view.setRelay('wss://fixture.invalid','disconnected'); await ui.renderOnce(); const frame = ui.captureCharFrame(); - assert.match(frame,/Host settings/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/); - assert.ok(!frame.includes('Inspect')); assert.ok(!frame.includes('PRIVATE DETAIL')); assert.ok(!frame.includes('Actions (a)')); + assert.match(frame,/\[Host\]/); assert.match(frame,/Register agent/); assert.match(frame,/wss:\/\/fixture.invalid.*disconnected/); + assert.ok(!frame.includes('Inspect')); assert.ok(frame.includes('Host name: Fixture')); assert.ok(!frame.includes('Actions (a)')); ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); assert.equal(calls,1); const secret = 'synthetic-aq?io-private-value'; const entered = view.input('Hidden fixture key','',true); @@ -55,3 +55,25 @@ test('footer shows the optional sanitized relay name before the exact URL and st assert.match(ui.captureCharFrame(),/Relay: Not configured · disconnected/); } finally { view.close(); } }); + + +test('four sections navigate in both directions and remain readable at narrow width', async () => { + const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); + const view = new OpenTuiScreen(ui.renderer); + const scopes: number[] = []; + view.onScope = scope => { scopes.push(scope); view.show([{ id: String(scope), label: 'Selected', detail: `Section ${scope}` }], []); }; + try { + for (const name of ['Agents', 'Harnesses', 'Providers', 'Host']) { + ui.mockInput.pressArrow('right'); await ui.renderOnce(); + assert.ok(ui.captureCharFrame().includes(`[${name}]`), JSON.stringify({name,scopes,frame:ui.captureCharFrame()})); + } + ui.mockInput.pressArrow('left'); await ui.renderOnce(); + assert.deepEqual(scopes, [1, 2, 3, 0, 3]); + ui.resize(40, 24); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /Host Agents Harnesses \[Providers\]/); + await ui.mockMouse.click(13,0); await ui.renderOnce(); + assert.equal(scopes.at(-1),2); + await ui.mockMouse.click(25,0); await ui.renderOnce(); + assert.equal(scopes.at(-1),3); + } finally { view.close(); } +}); diff --git a/beehive/tools/ux-walkthrough.sh b/beehive/tools/ux-walkthrough.sh new file mode 100644 index 0000000000..2880afb762 --- /dev/null +++ b/beehive/tools/ux-walkthrough.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Synthetic UI only: fresh file credentials and explicitly blocked external seams. +set -eu +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +NODE=${BEEHIVE_REVIEW_NODE:-/Users/loganj/Library/Caches/hermit/pkg/node-24.15.0/bin/node} +BUN=${BEEHIVE_REVIEW_BUN:-/Users/loganj/.buzz/artifacts/beehive-opentui-57f9c8fa/runtime/bun-darwin-aarch64/bun} +[ -x "$NODE" ] && [ -x "$BUN" ] || { echo 'Set BEEHIVE_REVIEW_NODE and BEEHIVE_REVIEW_BUN' >&2; exit 1; } +HOME=$(mktemp -d "${TMPDIR:-/tmp}/beehive-pairing-cli-ux-XXXXXX") +export HOME +export BEEHIVE_TEST_CREDENTIAL_FILE="$HOME/credentials.json" +export NODE_OPTIONS="--import=$ROOT/test/manager-installed-loader.ts" +export BEEHIVE_BUN="$BUN" +unset BUZZ_PRIVATE_KEY BUZZ_AUTH_TAG BUZZ_RELAY_URL BEEHIVE_REAL_BUZZ_ACP +cd "$ROOT" +"$NODE" --input-type=module <<'JS' +import { fixtureCredential } from './test/manager-installed-fixture.ts'; +import { publicKey } from './src/protocol.ts'; +const directory = `${process.env.HOME}/.beehive/host`; +const signal = new AbortController().signal; +await fixtureCredential({action:'configure',directory,label:'Review host',owner:publicKey('1'.repeat(64)),relay:'wss://fixture.invalid'},signal); +await fixtureCredential({action:'add-openai',directory,name:'Review OpenAI',secret:'synthetic-only'},signal); +JS +printf 'Synthetic review HOME: %s\n' "$HOME" >&2 +exec "$NODE" src/manager-entry.ts