From 1157e98f7f5b7b290f41b3db437f4cb0cfa9727f Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 16 Sep 2026 11:22:38 -0400 Subject: [PATCH] fix(beehive): name action targets and retire transient UX state Signed-off-by: Logan Johnson --- beehive/UX_CORRECTIONS.md | 70 ++++++++++++++++++++++++ beehive/src/manager-controller.ts | 4 +- beehive/src/manager-view.ts | 40 ++++++++------ beehive/src/opentui-screen.ts | 19 +++++-- beehive/src/runtime-form.ts | 10 ++-- beehive/test/manager-controller.test.ts | 5 ++ beehive/test/opentui-screen.bun.ts | 33 +++++++++++ beehive/test/runtime-integration.test.ts | 2 +- 8 files changed, 153 insertions(+), 30 deletions(-) create mode 100644 beehive/UX_CORRECTIONS.md diff --git a/beehive/UX_CORRECTIONS.md b/beehive/UX_CORRECTIONS.md new file mode 100644 index 0000000000..889f268059 --- /dev/null +++ b/beehive/UX_CORRECTIONS.md @@ -0,0 +1,70 @@ +# Manager UX corrections (F2–F5) + +Presentation names are snapshot metadata only: command targets, revisions, +assignment and host-local enrollment authority remain unchanged. Confirmations +share agent/host presentation for Start, Stop, Restart, Move and saved choices. +Prompt first lines are short task titles; field guidance follows in the body. +Temporary notices retire only while still current, composing across nested +requests without erasing a newer result. Truly empty lists have zero options; +Controls remain available and retained real selections keep their existing policy. + +## Short repeatable UX check + +Run `beehive/tools/ux-walkthrough.sh` in a **100×30 terminal**. This uses the explicit +synthetic loader, fresh `beehive-pairing-cli-` credential file, private transport +and owned processes, and fences installed service/provider/native sign-in calls. +Do not substitute an ordinary manager launch or HOME-only isolation. + +1. Agents while signed out: blank list, **0/0**, non-selectable “No items.”; + arrows and Enter create no selection. Register/sign-in Controls stay visible. +2. Register → Runtime → hidden key → Esc: idle status returns; settings unchanged. +3. Sign in: title “Sign in”, shortened owner identity; Esc returns idle. Sign in + again using the fixture's saved key; the real agent row replaces the empty list. +4. Start: title “Start agent?”, **Review agent / Review host**, not target JSON. + Esc returns idle. Start → yes → Runtime → Esc retains the continuation result + and leaves settings unchanged. +5. Focus Controls; wheel Details; refresh unchanged data: Controls remain visible, + Details scroll stays put, and visible action clicks route to those actions. + Provider/runtime forms use “Save provider”, “Save runtime”, “Custom model” and + “Environment” titles, with context below, not a truncated sentence in the title. + +Never enter live credentials. The fixture help supplies the canonical synthetic +nsec if testing registration beyond the cancellation checks above. + +## Evidence for this delta + +Workspace artifacts: `/Users/loganj/.buzz/artifacts/beehive-ux-f2-f5/`. + +- **F2:** `f17-start-confirm.txt` / `.ansi` show named agent and local start host; + `f08-signin-confirm.txt` shows shortened owner. Controller regression checks + offer label projection and exact unchanged target JSON; existing lifecycle + gates in that test still execute against the exact target/revision. +- **F3:** `f08`, `f17` show short titles in real PTY. Runtime form regression binds + Save runtime, Custom model and Environment title/body labels to production forms. + Other confirmation call sites share the same short-title convention. +- **F4:** `f07`, `f09`, `f18` show idle after cancellation; `f20` retains the + continuation result. PTY verifies byte-identical settings after cancels. + Renderer regression exercises nested notice retirement, newer result retention + and the real action runner's completion cleanup. +- **F5:** `f02`–`f04` show zero rows through arrow/Enter. Renderer regression covers + zero callbacks, accessible Controls, and transition back to real rows. +- **F1 retained:** renderer's original scroll/overflow-click/visibility regressions + pass; `f12`–`f16` retain real PTY Controls and pointer/wheel evidence. + +Direct Node **24.15.0** TypeScript passed. Focused controller/runtime/navigation: +**24 passed**. Pinned Bun **1.4.2** renderer: **8 passed**. Real synthetic PTY: +**20 checkpoints**, cancellation settings unchanged, clean exit **0**. +One final default-concurrent package suite: **245 passed, 1 failed, 19 skipped** +(43.507s). The unchanged broker test failed with “Conversation response timed out; +check relay admission and local sign-in”; full output is preserved in +`full-suite.log`. No deadlines/assertions weakened and no full-suite rerun. +No installed-opt-in rerun, live credential/provider/relay/browser operations, +service replacement, push, or repository-wide `just ci` claim. Earlier unchanged +provider/enrollment/Move/native evidence remains separately attributed in +`beehive-parity-b5cd2aa0` and `beehive-ux-25a9e2b7`. + +The first PTY driver attempt incorrectly expected the Sign in title and Esc hint +on one line. Its stream/logs remain in `driver-first/`; only that observation +pattern was corrected, with the original deadline retained. The initial Bun +invocation lacked the required `./` path prefix and discovered no tests; the +explicit-path invocation above executed all eight tests. diff --git a/beehive/src/manager-controller.ts b/beehive/src/manager-controller.ts index 39968280bf..42427626ab 100644 --- a/beehive/src/manager-controller.ts +++ b/beehive/src/manager-controller.ts @@ -31,7 +31,7 @@ export type ManagerResult = | { state: 'failed' | 'cancelled' | 'ignored'; reason: string }; export type ManagerRequest = { id: number; action: string; values?: Record; target?: string; revision?: number }; -export type ManagerItem = { startTarget?: string; startRevision?: number; target?: string; id: string; label: string; detail: string; evidence?: string; disabled?: Record }; +export type ManagerItem = { names?: { agent?: string; host?: string; startHost?: string }; startTarget?: string; startRevision?: number; target?: string; id: string; label: string; detail: string; evidence?: string; disabled?: Record }; export type MoveDestination = { target: string; revision: number; label: string; reason?: string }; export type ManagerSnapshot = { managementRelay?: 'connected' | 'disconnected'; diagnostics?: ManagerItem[]; local: ManagerItem[]; agents: (ManagerItem & { revision: number; configurations: string[]; destinations?: MoveDestination[] })[]; routing?: { owner: string; relay: string }; owner?: string; status: string; settings?: Settings; service?: ServiceStatus; hostRelay?: string; relayName?: string; profilePreview?: RegisteredAgent; models?: string[]; modelLabels?: Record; runtimeExecutable?: string; harnesses?: DetectedHarness[]; databricksHost?: string }; const short = (s: string) => s.length > 22 ? `${s.slice(0,8)}…${s.slice(-6)}` : s; @@ -206,7 +206,7 @@ This choice does not change the current run.` }; try { registered = readSettings(this.hostDirectory).agents.some(a => a.publicKey === agent.publicKey); } catch { /* Configuration error is shown in Local Host. */ } const local = this.localEnrollmentTarget(agent.publicKey); const unavailable = 'No unique assigned host report. Execution authority is unknown.'; - return { startTarget: local, startRevision: local ? -1 : undefined, destinations: report ? this.moveDestinations(report) : [], id:agent.publicKey,target:shown?.id,label:`${agent.name} · ${shown ? this.fresh(report!) ? report!.body.phase : 'Unknown' : 'Unknown'}`,revision:shown?.revision ?? -1,configurations:shown?.configurations ?? [],disabled: { ...(shown?.disabled ?? { start:unavailable,stop:unavailable,restart:unavailable,move:unavailable,'select-config':unavailable }), ...(local ? {start:''} : {}) },evidence:JSON.stringify({ discovery:agent,report },null,2),detail:`${agent.name} + return { names: { agent: agent.name && agent.name !== agent.publicKey ? agent.name : short(agent.publicKey), host: report ? String((this.offers.get(report.host)?.body.configuration as {label?: string})?.label || short(report.host)) : undefined, startHost: local ? readHostIdentityPublic(this.hostDirectory).pairing.label : undefined }, startTarget: local, startRevision: local ? -1 : undefined, destinations: report ? this.moveDestinations(report) : [], id:agent.publicKey,target:shown?.id,label:`${agent.name} · ${shown ? this.fresh(report!) ? report!.body.phase : 'Unknown' : 'Unknown'}`,revision:shown?.revision ?? -1,configurations:shown?.configurations ?? [],disabled: { ...(shown?.disabled ?? { start:unavailable,stop:unavailable,restart:unavailable,move:unavailable,'select-config':unavailable }), ...(local ? {start:''} : {}) },evidence:JSON.stringify({ discovery:agent,report },null,2),detail:`${agent.name} Agent: ${agent.publicKey} Registered here: ${registered ? 'Yes' : 'No'} Relay presence: ${agent.status} (discovery only) diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 2fe88371c6..f156742041 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -4,8 +4,12 @@ import { createCliRenderer } from '@opentui/core'; import { createReadStream, createWriteStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { OpenTuiScreen, type ManagerAction } from './opentui-screen.ts'; -import type { ManagerRequest, ManagerSnapshot, ManagerResult } from './manager-controller.ts'; +import type { ManagerItem, ManagerRequest, ManagerSnapshot, ManagerResult } from './manager-controller.ts'; +function shortIdentity(value: string) { return value.length > 20 ? `${value.slice(0,8)}…${value.slice(-8)}` : value; } +function targetNames(row: ManagerItem, start = false) { + return `Agent: ${row.names?.agent || shortIdentity(row.id)}\nHost: ${(start ? row.names?.startHost : undefined) || row.names?.host || 'Unknown'}`; +} const renderer = await createCliRenderer({ exitOnCtrlC: false }); const screen = new OpenTuiScreen(renderer); const output = createWriteStream('', { fd: 4 }); @@ -18,9 +22,9 @@ function request(action: string, values?: Record, target?: string const id = ++sequence; const completion = new Promise(resolve => { pending = { id, resolve }; }); const message: ManagerRequest = { id, action, values, target, revision }; - screen.setPending(true); screen.notice(action === 'add-databricks' ? 'Signing in through your browser… Esc cancels the native helper. An OS credential write or browser window may remain; cancelled sign-in will not save a provider.' : 'Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.'); + screen.setPending(true); const retire = screen.temporaryNotice(action === 'add-databricks' ? 'Signing in through your browser… Esc cancels the native helper. An OS credential write or browser window may remain; cancelled sign-in will not save a provider.' : 'Working… Esc stops waiting. Remote work and saved changes are not cancelled. Inspect before you try again.'); output.write(JSON.stringify(message) + '\n'); - return completion; + return completion.finally(retire); } async function routing(action: string, secret = false) { const retained = action !== 'configure' ? snapshot.routing : undefined; @@ -28,7 +32,7 @@ async function routing(action: string, secret = false) { if (!retained) { let step = 0; while (step < 2) { - const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Sign in as owner'} · 1 of 3\nOwner public key (npub or 64 hex characters). Do not enter a private key.` : 'Relay · 2 of 3\nRelay URL (ws:// or wss://)'; + const label = step === 0 ? `${action === 'configure' ? 'Configure this computer' : 'Sign in'} · 1 of 3\nOwner public key (npub or 64 hex characters). Do not enter a private key.` : 'Relay · 2 of 3\nRelay 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 an npub or a public key with 64 hex characters.' : /^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; } @@ -38,11 +42,11 @@ async function routing(action: string, secret = false) { } const values: Record = { owner, relay }; if (secret) { - const key = await screen.input('Owner private key (64 hex characters). It must match the owner public key. Saved in this computer’s secure credential store. Never sent to a host.', '', true); + const key = await screen.input('Import owner key\nMatching private key (64 hex characters) · OS credential store', '', true); if (key === undefined) return; values.secret = key; } - if (!await screen.confirm(action === 'configure' ? `Configure this computer · 3 of 3\nOwner: ${owner}\nRelay: ${relay}\nCreate a host identity in this computer’s secure credential store? This does not create an agent or start a host.` : `Owner: ${owner}\nRelay: ${relay}\n${retained ? 'Saved owner and relay cannot change here.\n' : ''}Access the owner key in this computer’s secure credential store? The system may ask for permission. An imported key must match the owner.`)) { delete values.secret; return; } + if (!await screen.confirm(`${action === 'configure' ? 'Configure this computer' : 'Sign in'}\nOwner: ${shortIdentity(owner)}\nRelay: ${relay}\n${action === 'configure' ? 'Create host identity in the OS credential store?' : 'Access owner key in the OS credential store?'}`)) { delete values.secret; return; } await request(action, values); delete values.secret; } async function registerForm(continuation?: string, agent?: string) { @@ -58,7 +62,7 @@ async function registerForm(continuation?: string, agent?: string) { const result = await request('profile-preview',{secret,...(continuation ? {continuation} : {})}); if (result.state !== 'completed') { settled = true; return; } const preview = snapshot.profilePreview; if (!preview) return; - if (!await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Profile unavailable; public key verified locally.' : preview.profileState === 'none' ? 'No relay profile found.' : ''}\nRuntime: ${runtime.name}\nHarness: ${runtime.harness} · Model: ${runtime.model}\nProvider: ${snapshot.settings?.providers.find(p => p.id === runtime.providerId)?.name ?? 'Unknown'}\nEffort: ${runtime.effort ?? 'Inherit'} · Environment: ${Object.keys(runtime.environment ?? {}).join(', ') || 'None'}\n${continuation ? 'Register and continue Start on the selected host?' : 'Save registration? This does not assign or start the agent.'}`)) return; + if (!await screen.confirm(`Register agent\n${preview.profile?.name ?? 'Name unavailable'}\n${preview.publicKey}\n${preview.profileState === 'unavailable' ? 'Profile unavailable; public key verified locally.' : preview.profileState === 'none' ? 'No relay profile found.' : ''}\nRuntime: ${runtime.name}\nHarness: ${runtime.harness} · Model: ${runtime.model}\nProvider: ${snapshot.settings?.providers.find(p => p.id === runtime.providerId)?.name ?? 'Unknown'}\nEffort: ${runtime.effort ?? 'Inherit'} · Environment: ${Object.keys(runtime.environment ?? {}).join(', ') || 'None'}\n${continuation ? 'Register and continue Start on the selected host?' : 'Save registration?'}`)) return; await request('register-agent',{secret,runtime:runtime.id,...(continuation ? {continuation} : {})}); settled = true; } finally { secret = ''; if (continuation && !settled) await request('retire-continuation'); } } @@ -87,25 +91,25 @@ function render() { if (type === 'Databricks v2') { const endpoint = await screen.input('Databricks workspace URL',snapshot.databricksHost ?? ''); if (!endpoint) return; const name = await screen.input('Provider name','Databricks'); if (!name) return; - if (await screen.confirm(`Sign in to ${name} in your browser? Tokens stay in Beehive’s OS store. No agent will start.`)) await request('add-databricks',{ name,endpoint }); + if (await screen.confirm(`Sign in to provider\n${name}\nBrowser sign-in · Tokens saved in the OS credential store`)) await request('add-databricks',{ name,endpoint }); return; } const providerType = type === 'Anthropic' ? 'anthropic' : type === 'OpenRouter' ? 'openrouter' : type === 'OpenAI-compatible' ? 'openai-compat' : 'openai'; const endpoint = providerType === 'openai-compat' ? await screen.input('HTTPS endpoint') : providerType === 'anthropic' ? 'https://api.anthropic.com' : providerType === 'openrouter' ? 'https://openrouter.ai/api/v1' : 'https://api.openai.com/v1'; if (!endpoint) return; const wire = providerType === 'openai-compat' ? await screen.choose('API',['auto','chat','responses']) : undefined; if (providerType === 'openai-compat' && !wire) return; const name = await screen.input('Provider name',type); if (!name) return; - let secret = await screen.input(`${type} API key · Hidden · OS credential store`,'',true); if (!secret) return; - if (await screen.confirm(`Save provider ${name}? No agent will start.`)) await request('add-provider',{ name, secret, type:providerType, endpoint, ...(wire ? {wire} : {}) }); + let secret = await screen.input(`Provider API key\n${type} · Hidden · OS credential store`,'',true); if (!secret) return; + if (await screen.confirm(`Save provider\n${name}`)) await request('add-provider',{ name, secret, type:providerType, endpoint, ...(wire ? {wire} : {}) }); secret = ''; } }, { label: 'Add runtime', run: () => runtimeForm(screen, () => snapshot, request) }, { label: 'Start', disabled: snapshot.service?.state === 'unknown' ? 'Host ownership is unknown. No process will be adopted.' : undefined, run: async () => { if (!configured) { await routing('configure'); return; } - if (await screen.confirm('Start the local host service? It keeps running when you quit. The OS may ask for key access. This does not start an agent.')) await request('host-start'); + if (await screen.confirm('Start host\nLocal host service · Keeps running when you quit')) await request('host-start'); } }, { label: 'Stop', disabled: snapshot.service?.state !== 'running' ? 'A verified running host instance is required.' : undefined, run: async () => { const instance = snapshot.service?.instance; if (!instance) return; - if (await screen.confirm('Stop this host and its running agents?')) await request('host-stop',{ instance }); + if (await screen.confirm('Stop host\nStop this host and its running agents?')) await request('host-stop',{ instance }); } }, ] : snapshot.owner ? [ { label: 'Refresh agents', run: () => request('directory-refresh') }, @@ -115,17 +119,17 @@ function render() { const labels = runtimes.map(r => `${r.name} · ${r.model} · ${r.id}`); const choice = await screen.choose('Runtime for next start',labels); if (choice === undefined) return; const runtime = runtimes[labels.indexOf(choice)]; if (!runtime) return; - if (await screen.confirm(`Save ${runtime.name} for next Start? Current run is unchanged.`)) await request('select-runtime',{runtime:runtime.id},row.target,row.revision); + if (await screen.confirm(`Save runtime\n${runtime.name} · Next Start\n${targetNames(row)}`)) await request('select-runtime',{runtime:runtime.id},row.target,row.revision); } }, { label: 'Choose configuration…', disabled: eligibility('select-config'), run: async () => { const row = snapshot.agents.find(r => r.id === selected); if (!row) return; const name = await screen.choose('Configuration for next start', row.configurations); if (name === undefined) return; - if (!await screen.confirm(`Use ${name} for the next start?\nHost and agent: ${row.target ?? 'Unknown'}\nRevision: ${row.revision}\nThis does not change the current run.`)) return; + if (!await screen.confirm(`Save configuration\n${name} · Next Start\n${targetNames(row)}\nRevision: ${row.revision}`)) return; await request('select-config', { name }, row.target, row.revision); } }, ...(['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[0]!.toUpperCase() + action.slice(1)} agent?\nHost and agent: ${action === 'start' ? row.startTarget ?? row.target ?? 'Unknown' : row.target ?? 'Unknown'}\nRevision: ${row.revision}`)) return; + if (!await screen.confirm(`${action[0]!.toUpperCase() + action.slice(1)} agent?\n${targetNames(row, action === 'start')}\nRevision: ${row.revision}`)) return; const result = await request(action, undefined, action === 'start' ? row.startTarget ?? row.target : row.target, action === 'start' ? row.startRevision ?? row.revision : row.revision); if (result.state === 'registration-required') await registerForm(result.continuation,result.agent); } })), @@ -137,7 +141,7 @@ function render() { const chosen = await screen.choose('Move · destination host / runtime',labels); if (chosen === undefined) return; const destination = destinations[labels.indexOf(chosen)]; if (!destination) return; if (destination.reason) { screen.notice(destination.reason); return; } - if (!await screen.confirm(`Move agent to ${destination.label}? + if (!await screen.confirm(`Move agent\n${targetNames(row)}\nDestination: ${destination.label} The source stops before destination launch. Workspace, session and credentials stay on their hosts.`)) return; await request('move',{destination:destination.target,destinationRevision:String(destination.revision)},row.target,row.revision); } }, @@ -165,12 +169,12 @@ The source stops before destination launch. Workspace, session and credentials s ]; 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 unavailable', detail: 'Unavailable' }, ...rows] : rows.length ? rows : [{ id: 'empty', label: 'Empty', detail: '—' }], actions, selected); + screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows, 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 => { 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(); } }; +screen.onSelect = id => { if (selected !== id) { 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 95bee2491b..a844c015d6 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -127,7 +127,7 @@ export class OpenTuiScreen { } private focusStyle() { - this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`; + this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.rows.length ? this.list.getSelectedIndex() + 1 : 0}/${this.rows.length}`; this.scroll.title = managerSections[this.scope] ?? 'Details'; this.controlsFrame.title = this.actions.focused ? '[Controls]' : 'Controls'; for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; } @@ -193,10 +193,10 @@ export class OpenTuiScreen { 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. Quitting does not stop agents.'); + this.busy = true; const retire = this.temporaryNotice('Working…'); try { await action.run(); } catch { this.notice('Action failed. The result is unknown. Inspect the operation before you try again.'); } - finally { this.busy = false; } + finally { retire(); this.busy = false; } } show(rows: ManagerRow[], actions: ManagerAction[], selected?: string) { if (this.closed) return; @@ -212,7 +212,18 @@ export class OpenTuiScreen { this.list.setSelectedIndex(index); this.detail.content = clean(rows[index]?.detail ?? 'No items.'); this.focusStyle(); } - notice(text: string) { if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } } + private noticeToken: object = {}; + /** Retire only this notice, restoring the enclosing scope or idle status. */ + temporaryNotice(text: string) { + const previous = this.lastNotice, previousToken = this.noticeToken; + this.notice(text); + const token = this.noticeToken; + return () => { + if (this.noticeToken !== token || this.closed) return; + this.notice(previous); this.noticeToken = previousToken; + }; + } + notice(text: string) { this.noticeToken = {}; if (!this.closed) { this.lastNotice = clean(text); this.status.content = this.lastNotice; } } unavailable(feature: string) { this.notice(`${feature} is unavailable in this build. No request was sent. Local Host shows this computer. Agents shows host reports.`); } choose(title: string, names: string[]): Promise { diff --git a/beehive/src/runtime-form.ts b/beehive/src/runtime-form.ts index b8e48f8baf..890f4f2e7b 100644 --- a/beehive/src/runtime-form.ts +++ b/beehive/src/runtime-form.ts @@ -14,7 +14,7 @@ export async function runtimeForm(screen: { const harnesses = snapshot().harnesses ?? []; if (!harnesses.length) { screen.notice(snapshot().status); return; } const labels = harnesses.map(h => `${h.label} · ${h.providers.length ? 'Available' : h.state === 'available' ? 'Provider integration unavailable' : h.state}`); - const harness = await screen.choose('Harness · detection is not sign-in', labels); if (!harness) return; + const harness = await screen.choose('Harness', labels); if (!harness) return; const selected = harnesses[labels.indexOf(harness)]; if (!selected?.providers.length) { screen.notice(selected?.reason ?? 'Unsupported harness'); return; } const providers = (snapshot().settings?.providers ?? []).filter(p => selected.providers.includes(p.type)); @@ -26,14 +26,14 @@ export async function runtimeForm(screen: { const models = snapshot().models; const labelsById = snapshot().modelLabels ?? {}; const modelLabels = (models ?? []).map(id=>labelsById[id] && labelsById[id] !== id ? `${labelsById[id]} · ${id}` : id); - const choiceModel = await screen.choose(models ? 'Model' : 'Model listing unavailable · Custom model allowed', [...modelLabels, 'Custom model']); if (!choiceModel) return; - const model = choiceModel === 'Custom model' ? await screen.input('Exact model ID. Custom does not verify provider access.') : models?.[modelLabels.indexOf(choiceModel)]; if (!model) return; + const choiceModel = await screen.choose(models ? 'Model' : 'Model · Custom model', [...modelLabels, 'Custom model']); if (!choiceModel) return; + const model = choiceModel === 'Custom model' ? await screen.input('Custom model\nExact model ID') : models?.[modelLabels.indexOf(choiceModel)]; if (!model) return; const efforts = selected.id === 'buzz-agent' ? runtimeEfforts(provider.type, model) : selected.id === 'pi' ? piEfforts(provider.type, model) : []; const effort = efforts.length ? await screen.choose('Effort · new runs only', ['Inherit', ...efforts]) : 'Inherit'; if (!effort) return; - const entry = await screen.input('Environment · JSON name/value map · no secrets', '{}'); if (entry === undefined) return; + const entry = await screen.input('Environment\nJSON name/value map · no secrets', '{}'); if (entry === undefined) return; let environment: string; try { environment = JSON.stringify(runtimeEnvironment(JSON.parse(entry))); } catch { screen.notice('Invalid environment. Use nonsecret name/value pairs; identity, provider and loader variables are reserved. Nothing saved.'); return; } const name = await screen.input('Runtime name', model); if (!name) return; - if (await screen.confirm(`Save ${name}?\n${selected.label} · ${provider.name} · ${model}\n${efforts.length ? `Effort: ${effort}` : 'No supported effort control for this model.'}\nEnvironment: ${Object.keys(JSON.parse(environment)).join(', ') || 'None'}\nSaved for new runs. Running agents do not change.`)) await request('add-runtime', { name, model, environment, provider: provider.id, harness: selected.id, ...(effort !== 'Inherit' ? { effort } : {}) }); + if (await screen.confirm(`Save runtime\n${name}\n${selected.label} · ${provider.name} · ${model}\n${efforts.length ? `Effort: ${effort}` : ''}\nEnvironment: ${Object.keys(JSON.parse(environment)).join(', ') || 'None'}\nSaved for new runs.`)) await request('add-runtime', { name, model, environment, provider: provider.id, harness: selected.id, ...(effort !== 'Inherit' ? { effort } : {}) }); } diff --git a/beehive/test/manager-controller.test.ts b/beehive/test/manager-controller.test.ts index 97dce16628..033c16d48d 100644 --- a/beehive/test/manager-controller.test.ts +++ b/beehive/test/manager-controller.test.ts @@ -65,7 +65,12 @@ test('manager gates owner, exact selection/revision/freshness, unresolved operat assert.match(f.snapshot.agents[0]!.detail, /Configuration for next start/); assert.ok(!f.snapshot.agents[0]!.detail.includes('{')); assert.equal(f.snapshot.agents[0]!.disabled!.start, ''); + assert.equal(f.snapshot.agents[0]!.names?.agent, 'Fixture agent'); + assert.equal(f.snapshot.agents[0]!.names?.host, 'host-a'); + f.receive(message('availability','host-a','',0,{ observedAt: Date.now(), configuration: { label: 'Readable host', relay: 'wss://example.invalid' } })); + assert.equal(f.snapshot.agents[0]!.names?.host, 'Readable host'); const target = JSON.stringify(['host-a','agent-a']); + assert.equal(f.snapshot.agents[0]!.target, target, 'presentation cannot replace routing authority'); 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'); diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts index ad9a3ab3df..524283f060 100644 --- a/beehive/test/opentui-screen.bun.ts +++ b/beehive/test/opentui-screen.bun.ts @@ -166,3 +166,36 @@ test('four sections navigate in both directions and remain readable at narrow wi assert.equal(scopes.at(-1),3); } finally { view.close(); } }); + +test('empty lists have no selection, keep controls, and recover real rows', async () => { + const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); + const view = new OpenTuiScreen(ui.renderer); + const selections: string[] = []; + view.onSelect = id => selections.push(id); + try { + view.show([], [{ label: 'Register agent', run() {} }]); + ui.mockInput.pressArrow('down'); ui.mockInput.pressKey('return'); await ui.renderOnce(); + const frame = ui.captureCharFrame(); + assert.match(frame, /0\/0/); assert.match(frame, /No items/); assert.match(frame, /Register agent/); + assert.ok(!frame.includes('1/1')); assert.deepEqual(selections, []); + view.show([{ id: 'real', label: 'Real agent', detail: 'Real detail' }], []); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Real detail/); + } finally { view.close(); } +}); + +test('temporary notices retire nested work but retain newer results and errors', async () => { + const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false }); + const view = new OpenTuiScreen(ui.renderer); + try { + view.notice('Idle status'); + const outer = view.temporaryNotice('Working outer'); + const inner = view.temporaryNotice('Working inner'); + inner(); outer(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /Idle status/); assert.ok(!ui.captureCharFrame().includes('Working')); + const retire = view.temporaryNotice('Working'); view.notice('Submitted result'); retire(); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Submitted result/); + view.show([], [{ label: 'Cancel form', run: async () => {} }]); + ui.mockInput.pressKey('a'); ui.mockInput.pressKey('return'); await new Promise(resolve => setTimeout(resolve, 20)); + await ui.renderOnce(); assert.ok(!ui.captureCharFrame().includes('Working')); + } finally { view.close(); } +}); diff --git a/beehive/test/runtime-integration.test.ts b/beehive/test/runtime-integration.test.ts index a1ad52b526..0e2b7e26ac 100644 --- a/beehive/test/runtime-integration.test.ts +++ b/beehive/test/runtime-integration.test.ts @@ -132,7 +132,7 @@ test('effort is manifest-backed, validates persisted settings and reaches actual test('short runtime form selects supported model-specific effort; unknown custom inherits provider capabilities and unsupported harness cannot save', async () => { const snapshot: ManagerSnapshot = { local:[], agents:[], status:'synthetic', models:['gpt-5'], harnesses:[{id:'buzz-agent',label:'Buzz Agent',executable:'/fixture',state:'available',providers:['openai'],reason:'fixture'},{id:'pi',label:'Pi',state:'available',providers:[],reason:'Provider integration unavailable'}], settings:{version:1,revision:1,agents:[],runtimes:[],providers:[{id:'p',name:'OpenAI',type:'openai',endpoint:'https://api.openai.com/v1',key}]} }; const submitted: {action:string;values?:Record}[] = []; - const run = async (choices: string[], inputs: string[]) => runtimeForm({choose:async (_label,options) => {const value=choices.shift(); assert.ok(value === undefined || options.includes(value)); return value;},input:async label => label.startsWith('Environment') ? '{}' : inputs.shift(),confirm:async label => {assert.match(label,/Running agents do not change/);return true;},notice:() => {}}, () => snapshot, async (action,values) => {submitted.push({action,values});return {state:"completed"};}); + const run = async (choices: string[], inputs: string[]) => runtimeForm({choose:async (_label,options) => {const value=choices.shift(); assert.ok(value === undefined || options.includes(value)); return value;},input:async label => { assert.ok(label.split('\n')[0]!.length <= 30); if (label.startsWith('Custom model')) assert.equal(label, 'Custom model\nExact model ID'); if (label.startsWith('Environment')) { assert.equal(label, 'Environment\nJSON name/value map · no secrets'); return '{}'; } return inputs.shift(); },confirm:async label => {assert.match(label,/^Save runtime\n/);return true;},notice:() => {}}, () => snapshot, async (action,values) => {submitted.push({action,values});return {state:"completed"};}); await run(['Buzz Agent · Available','OpenAI · p','gpt-5','high'],['Reasoning']); assert.equal(submitted.at(-1)?.values?.effort,'high'); assert.equal(submitted.at(-1)?.values?.model,'gpt-5');