fix(beehive): name action targets and retire transient UX state

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-16 11:22:38 -04:00
parent 7d455ded43
commit 1157e98f7f
8 changed files with 153 additions and 30 deletions

70
beehive/UX_CORRECTIONS.md Normal file
View File

@@ -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.

View File

@@ -31,7 +31,7 @@ export type ManagerResult =
| { state: 'failed' | 'cancelled' | 'ignored'; reason: string };
export type ManagerRequest = { id: number; action: string; values?: Record<string, string>; target?: string; revision?: number };
export type ManagerItem = { startTarget?: string; startRevision?: number; target?: string; id: string; label: string; detail: string; evidence?: string; disabled?: Record<string, string> };
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<string, string> };
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<string,string>; 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)

View File

@@ -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<string,string>, target?: string
const id = ++sequence;
const completion = new Promise<ManagerResult>(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<string,string> = { 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 {

View File

@@ -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<string | undefined> {

View File

@@ -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 } : {}) });
}

View File

@@ -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');

View File

@@ -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(); }
});

View File

@@ -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<string,string>}[] = [];
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');