mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(beehive): name action targets and retire transient UX state
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
70
beehive/UX_CORRECTIONS.md
Normal file
70
beehive/UX_CORRECTIONS.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 } : {}) });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user