mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
feat(beehive): bind Details actions to selected rows
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
37
beehive/src/manager-menu.ts
Normal file
37
beehive/src/manager-menu.ts
Normal file
@@ -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<string, string> = {
|
||||
'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 } }));
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
38
beehive/test/manager-menu.test.ts
Normal file
38
beehive/test/manager-menu.test.ts
Normal file
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user