diff --git a/beehive/src/manager-menu.ts b/beehive/src/manager-menu.ts new file mode 100644 index 0000000000..95d4872840 --- /dev/null +++ b/beehive/src/manager-menu.ts @@ -0,0 +1,37 @@ +import type { ManagerAction } from './opentui-screen.ts'; +import type { ManagerItem, ManagerSnapshot } from './manager-controller.ts'; + +export type ActionObject = { id: string; kind: 'host' | 'agent' | 'command' | 'provider' | 'harness' }; +export type ObjectAction = ManagerAction & { object: ActionObject }; + +const commands: Record = { + 'signin-buzz': 'Sign in with Buzz key', 'signin-saved': 'Sign in with saved owner key', + 'signin-import': 'Import matching owner key and sign in', 'register-agent': 'Register agent', + 'refresh-agents': 'Refresh agents', 'inspect-operations': 'Inspect operations', + 'check-results': 'Check operation results', 'sign-out': 'Sign out', + 'publish-profile': 'Publish profile or instructions', 'refresh-harnesses': 'Refresh harnesses', + 'add-provider': 'Add provider', +}; +const dividers = new Set(['registered-agents', 'other-agents', 'management']); + +/** Enforces the Details-object contract at the production composition seam. */ +export function actionsForRow(snapshot: ManagerSnapshot, section: number, row: ManagerItem | undefined, candidates: ManagerAction[]): ObjectAction[] { + if (!row || dividers.has(row.id)) return []; + const command = commands[row.id]; + if (command) return candidates.filter(a => a.label === command).map(a => ({ ...a, object: { id: row.id, kind: 'command' } })); + let labels: string[] = []; + let kind: ActionObject['kind']; + if (section === 0) { + kind = 'host'; + if (row.id === 'host') labels = ['Start', 'Stop']; + else if (row.id === 'host-missing') labels = ['Configure this computer']; + } else if (section === 1) { + kind = 'agent'; + const actual = snapshot.agents.some(a => a.id === row.id); + const registered = snapshot.settings?.agents.some(a => a.publicKey === row.id); + if (actual) labels = registered ? ['Configure', 'Start', 'Stop', 'Restart', 'Move…'] : ['Register agent', 'Start', 'Stop', 'Restart', 'Move…']; + else if (registered) labels = ['Configure']; + } else if (section === 2) { kind = 'harness'; } + else { kind = 'provider'; labels = ['Models']; } + return candidates.filter(a => labels.includes(a.label)).map(a => ({ ...a, object: { id: row.id, kind } })); +} diff --git a/beehive/src/manager-navigation.ts b/beehive/src/manager-navigation.ts index eff9dfd92e..1631c3e9ea 100644 --- a/beehive/src/manager-navigation.ts +++ b/beehive/src/manager-navigation.ts @@ -21,13 +21,22 @@ export function sectionRows(snapshot: ManagerSnapshot, section: number): Manager ...(settings?.agents ?? []).filter(agent => !snapshot.agents.some(row => row.id === agent.publicKey)).map(agent => ({ id: agent.publicKey, label: agent.profile?.name ?? agent.publicKey, detail: 'Registered here · Host unavailable' })), { id: 'other-agents', label: 'Other agents', detail: 'Other agents' }, ...snapshot.agents.filter(agent => !registered.has(agent.id)), + { id: 'management', label: 'Management', detail: 'Management commands' }, + { id: 'refresh-agents', label: ' Refresh agents', detail: 'Refresh the agent directory' }, + { id: 'inspect-operations', label: ' Inspect operations', detail: 'Inspect operation records' }, + { id: 'check-results', label: ' Check operation results', detail: 'Check uncertain operation results' }, + { id: 'sign-out', label: ' Sign out', detail: 'Sign out this owner' }, + { id: 'publish-profile', label: ' Publish profile or instructions', detail: 'Publication instructions' }, ]; } if (section === 2) return [ + { id: 'refresh-harnesses', label: 'Refresh harnesses', detail: 'Refresh harness discovery' }, ...(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 ?? '—'}` })), - ]; - if (section === 3) return (settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` })); + if (section === 3) return [ + { id: 'add-provider', label: 'Add provider', detail: 'Add a provider' }, + ...(settings?.providers ?? []).map(p => ({ id: p.id, label: p.name, detail: `Provider: ${p.name}\nType: ${p.type}\nEndpoint: ${p.endpoint}` })), + ]; return []; } diff --git a/beehive/src/manager-view.ts b/beehive/src/manager-view.ts index 19a7c40dcb..d2a2a19678 100644 --- a/beehive/src/manager-view.ts +++ b/beehive/src/manager-view.ts @@ -5,6 +5,7 @@ import { createReadStream, createWriteStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { OpenTuiScreen, type ManagerAction } from './opentui-screen.ts'; import type { ManagerItem, ManagerRequest, ManagerSnapshot, ManagerResult } from './manager-controller.ts'; +import { actionsForRow } from './manager-menu.ts'; function shortIdentity(value: string) { return value.length > 20 ? `${value.slice(0,8)}…${value.slice(-8)}` : value; } function targetNames(row: ManagerItem, start = false) { @@ -173,7 +174,9 @@ The source stops before destination launch. Workspace, session and credentials s if (snapshot.models) await screen.choose('Models', snapshot.models.map(id=>snapshot.modelLabels?.[id] && snapshot.modelLabels[id] !== id ? `${snapshot.modelLabels[id]} · ${id}` : id)); } }, ]; - screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows, actions, selected); + if (scope === 0 && selected === 'host-missing') actions.unshift({ label: 'Configure this computer', run: () => routing('configure') }); + actions = actionsForRow(snapshot, scope, rows.find(row => row.id === selected), actions); + screen.show(vanished ? [{ id: selected, label: 'Selection unavailable', detail: 'Unavailable' }, ...rows] : rows, vanished ? [] : 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); } } diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index 3d901584a1..79d8b7a52d 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -24,6 +24,9 @@ export class OpenTuiScreen { private readonly small: TextRenderable; private rows: ManagerRow[] = []; private commands: ManagerAction[] = []; + private menuGeneration = 0; + private actionOwner = ''; + private actionSignature = ''; private selectedRow = -1; private focusIndex = 0; private scope = 0; @@ -93,8 +96,8 @@ export class OpenTuiScreen { this.actions.setSelectedIndex(index); // Finish pointer focus dispatch before an action opens an input modal. // Otherwise Select's mouse handler can steal focus back from that input. - const action = this.commands[index]; - queueMicrotask(() => { void this.run(index, action); }); + const action = this.commands[index], generation = this.menuGeneration; + queueMicrotask(() => { void this.run(index, action, generation); }); } }); this.pane.add(this.actions); this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() }); @@ -190,7 +193,8 @@ export class OpenTuiScreen { 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(); } } - private async run(index: number, captured?: ManagerAction) { + private async run(index: number, captured?: ManagerAction, generation = this.menuGeneration) { + if (generation !== this.menuGeneration) return; 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; } @@ -205,7 +209,11 @@ export class OpenTuiScreen { this.rows = rows; this.commands = actions; 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; + const owner = selected ?? '', signature = JSON.stringify(commandOptions); + if (owner !== this.actionOwner || signature !== this.actionSignature) { + this.menuGeneration++; this.actionOwner = owner; this.actionSignature = signature; + this.actions.options = commandOptions; this.actions.setSelectedIndex(0); + } this.actions.height = this.actionHeight(); 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; diff --git a/beehive/test/manager-menu.test.ts b/beehive/test/manager-menu.test.ts new file mode 100644 index 0000000000..dd0654bb34 --- /dev/null +++ b/beehive/test/manager-menu.test.ts @@ -0,0 +1,38 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { actionsForRow } from '../src/manager-menu.ts'; +import { sectionRows } from '../src/manager-navigation.ts'; +import type { ManagerSnapshot } from '../src/manager-controller.ts'; + +const action = (label: string) => ({ label, run() {} }); +const candidates = ['Start','Stop','Configure this computer','Register agent','Configure','Restart','Move…','Refresh agents','Inspect operations','Check operation results','Sign out','Publish profile or instructions','Refresh harnesses','Add provider','Models','Sign in with Buzz key','Sign in with saved owner key','Import matching owner key and sign in'].map(action); +const agent = { id:'agent', label:'Agent', detail:'', revision:1, configurations:[] }; +const snapshot = { owner:'owner', local:[{id:'host',label:'Host',detail:''}], agents:[agent], status:'', harnesses:[{id:'h',label:'H',state:'available',providers:[],reason:''}], settings:{version:1,revision:1,agents:[],runtimes:[],providers:[{id:'p',name:'P',type:'openai',endpoint:'https://fixture.invalid',key:{service:'beehive',account:'x'}}]} } as ManagerSnapshot; + +function labels(section: number, id: string) { + const row = sectionRows(snapshot, section).find(r => r.id === id); + const actions = actionsForRow(snapshot, section, row, candidates); + assert.ok(actions.every(a => a.object.id === id)); + return actions.map(a => a.label); +} + +test('every Details menu belongs only to its selected entity or command row', () => { + assert.deepEqual(labels(0,'host'), ['Start','Stop']); + assert.deepEqual(labels(1,'registered-agents'), []); + assert.deepEqual(labels(1,'agent'), ['Start','Stop','Register agent','Restart','Move…']); + assert.deepEqual(labels(1,'refresh-agents'), ['Refresh agents']); + assert.deepEqual(labels(1,'inspect-operations'), ['Inspect operations']); + assert.deepEqual(labels(2,'refresh-harnesses'), ['Refresh harnesses']); + assert.deepEqual(labels(2,'harness:h'), []); + assert.deepEqual(labels(3,'add-provider'), ['Add provider']); + assert.deepEqual(labels(3,'p'), ['Models']); + assert.deepEqual(actionsForRow(snapshot,3,undefined,candidates), []); +}); + +test('signed-out sign-in rows each expose only themselves', () => { + const signedOut = {...snapshot, owner:undefined} as ManagerSnapshot; + for (const row of sectionRows(signedOut,1)) { + const actions = actionsForRow(signedOut,1,row,candidates); + assert.equal(actions.length,1); assert.equal(actions[0]!.object.id,row.id); + } +}); diff --git a/beehive/test/manager-navigation.test.ts b/beehive/test/manager-navigation.test.ts index 02258becb1..7b61d63151 100644 --- a/beehive/test/manager-navigation.test.ts +++ b/beehive/test/manager-navigation.test.ts @@ -18,16 +18,16 @@ test('four sections retain exact selections without retargeting removed items', test('section rows expose selected public fields, not credential references or other controls', () => { const snapshot: ManagerSnapshot = { owner: 'owner', 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.deepEqual(sectionRows(snapshot,1).map(row => row.label), ['Registered agents', ' Register agent', 'Other agents']); - assert.deepEqual(sectionRows(snapshot,2), []); // Legacy records stay internal, not a second setup surface. - assert.equal(sectionRows(snapshot,3)[0]!.id,'p'); + assert.deepEqual(sectionRows(snapshot,1).map(row => row.label), ['Registered agents', ' Register agent', 'Other agents', 'Management', ' Refresh agents', ' Inspect operations', ' Check operation results', ' Sign out', ' Publish profile or instructions']); + assert.equal(sectionRows(snapshot,2)[0]!.id, 'refresh-harnesses'); + assert.equal(sectionRows(snapshot,3)[0]!.id,'add-provider'); assert.ok(!JSON.stringify(sectionRows(snapshot,3)).includes('secret-reference')); }); test('Agents are separated by registered custody, retaining directory order within each group', () => { const agent = (id: string) => ({ id, label: id, detail: id, revision: 0, configurations: [] }); const snapshot = { owner: 'owner', local: [], agents: [agent('other'), agent('registered')], status: '', settings: { version: 1, revision: 0, providers: [], runtimes: [], agents: [{ publicKey: 'registered', key: { service: 'beehive', role: 'agent', publicKey: 'registered' }, profileState: 'none' }] } } as ManagerSnapshot; - assert.deepEqual(sectionRows(snapshot, 1).map(row => row.id), ['registered-agents', 'register-agent', 'registered', 'other-agents', 'other']); + assert.deepEqual(sectionRows(snapshot, 1).map(row => row.id), ['registered-agents', 'register-agent', 'registered', 'other-agents', 'other', 'management', 'refresh-agents', 'inspect-operations', 'check-results', 'sign-out', 'publish-profile']); }); test('signed-out Agents directly offer sign-in and no standing menu survives sign-in', () => {