fix(beehive): open Host directly after owner sign-in

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-22 09:48:53 -04:00
parent db7da12f0e
commit 1d3782a9c7
3 changed files with 82 additions and 62 deletions

View File

@@ -20,7 +20,7 @@ export class OwnerPanel {
private index = 0;
private actionTop = 0;
private relay = '';
private continuation?: number;
private requestedSection?: number;
private prior?: { mode: ShellState['mode']; activeSection: number; headerIndex: number; focus: ShellState['focus'] };
private shown = false;
private generation = 0;
@@ -39,7 +39,7 @@ export class OwnerPanel {
get modal() { return !!this.dialog; }
private commands() {
if (!this.state.ownerActive) return this.snapshot.signedIn ? [] : ['Sign in'];
if (this.snapshot.signedIn) return [...(this.continuation === undefined ? [] : [`Continue to ${this.continuation === 0 ? 'Host' : 'Agents'}`, 'Stay here']), 'Sign out'];
if (this.snapshot.signedIn) return ['Sign out'];
if (!this.snapshot.relay && !this.relay) return ['Choose a relay', 'Use Buzz Desktop identity', 'Provide owner nsec'];
return ['Use Buzz Desktop identity', 'Provide owner nsec', ...(this.snapshot.relay ? [] : ['Change relay']), 'Check Desktop identity'];
}
@@ -47,24 +47,22 @@ export class OwnerPanel {
private selectionNote(command: string) {
if (command === 'Use Buzz Desktop identity') return !(this.snapshot.relay || this.relay) ? 'Choose a relay first. No sign-in or connection happens yet.' : this.snapshot.desktopReason;
if (command === 'Provide owner nsec') return !(this.snapshot.relay || this.relay) ? 'Choose a relay first.' : 'Your nsec stays in memory until sign-out or quit.';
if (command === 'Stay here') return 'Keeps you signed in without opening the requested section.';
if (command === 'Sign out') return 'Signs out of Beehive. Host and agents keep running.';
if (command.startsWith('Continue')) return 'Opens the requested section. No Host or agent operation runs.';
if (command.includes('relay')) return 'First sign-in binds this Host to the owner and relay.';
return 'Host and Agents require owner sign-in. Harnesses and Providers do not.';
}
private openOwner() {
this.prior = { mode: this.state.mode, activeSection: this.state.activeSection, headerIndex: this.state.headerIndex, focus: this.state.focus };
this.continuation = this.state.activeSection;
this.requestedSection = this.state.activeSection;
this.state.mode = 'owner'; this.state.headerIndex = destinations.length; this.state.focus = 'detail'; this.index = 0; this.repaint();
}
private stay() {
this.generation++; this.continuation = undefined; this.prior = undefined; this.index = 0;
private focusOwner() {
this.generation++; this.requestedSection = undefined; this.prior = undefined; this.index = 0;
this.state.mode = 'owner'; this.state.headerIndex = destinations.length; this.state.focus = 'header'; this.repaint();
}
private back() {
if (this.snapshot.signedIn) { this.stay(); return; }
this.generation++; this.client.cancel(); this.continuation = undefined;
if (this.snapshot.signedIn) { this.focusOwner(); return; }
this.generation++; this.client.cancel(); this.requestedSection = undefined;
if (this.prior) { Object.assign(this.state, this.prior); this.prior = undefined; }
else { this.state.mode = 'section'; this.state.headerIndex = this.state.activeSection; this.state.focus = 'header'; }
this.repaint();
@@ -72,7 +70,7 @@ export class OwnerPanel {
key(key: KeyEvent) {
if (!this.shown) return false;
if (this.dialog) { this.dialog.key(key); return true; }
if (key.name === 'escape' && this.snapshot.phase === 'busy') { this.client.cancel(); return true; }
if (key.name === 'escape' && this.snapshot.phase === 'busy') { this.generation++; this.client.cancel(); return true; }
if (key.name === 'escape' && this.state.ownerActive) { this.back(); return true; }
if (key.name === 'a' && this.commands().length) { this.state.focus = 'detail'; this.repaint(); return true; }
if (this.state.focus !== 'detail') return false;
@@ -94,13 +92,7 @@ export class OwnerPanel {
const command = this.commands()[this.index]; if (!command || this.disabled(command)) return;
const generation = this.generation;
if (command === 'Sign in') { this.openOwner(); return; }
if (command.startsWith('Continue')) {
const target = this.continuation; this.continuation = undefined; this.prior = undefined;
if (target !== undefined && this.snapshot.signedIn) this.state.activateHeader(target);
this.repaint(); return;
}
if (command === 'Stay here') { this.stay(); return; }
if (command === 'Sign out') { this.continuation = undefined; this.prior = undefined; this.index = 0; await this.client.request({ action: 'signout' }); this.repaint(); return; }
if (command === 'Sign out') { this.requestedSection = undefined; this.prior = undefined; this.index = 0; await this.client.request({ action: 'signout' }); this.repaint(); return; }
if (command === 'Check Desktop identity') { await this.client.request({ action: 'probe' }); return; }
if (command.includes('relay')) {
const dialog = new ProviderDialog(this.renderer, 'CHOOSE A RELAY', [{ label: 'Relay URL', value: this.relay || 'wss://' }], () => {}, 'Use relay');
@@ -115,7 +107,11 @@ export class OwnerPanel {
if (!result || generation !== this.generation) { this.repaint(); return; }
}
const ok = await this.client.request({ action: command === 'Use Buzz Desktop identity' ? 'signin-desktop' : 'signin-nsec', relay: this.relay });
if (ok && generation === this.generation) { this.index = 0; this.state.focus = 'header'; this.state.headerIndex = destinations.length; }
if (ok && generation === this.generation && this.snapshot.signedIn) {
const target = this.requestedSection ?? 0;
this.requestedSection = undefined; this.prior = undefined; this.index = 0;
this.state.activateHeader(target);
}
this.repaint();
}
paint() {
@@ -127,21 +123,21 @@ export class OwnerPanel {
if (left) {
this.generation++; this.dialog?.cancel(); this.dialog = undefined;
if (this.snapshot.phase === 'busy') this.client.cancel();
this.continuation = undefined; this.prior = undefined; this.index = 0;
this.requestedSection = undefined; this.prior = undefined; this.index = 0;
}
if (!shown) return;
if (entered && !this.snapshot.signedIn && this.snapshot.desktop === 'unknown') void this.client.request({ action: 'probe' });
const width = this.renderer.width - 4, height = this.renderer.height - 5;
const wrap = (value: string) => { const rows: string[] = []; for (const line of value.split('\n')) { let rest = line; while (rest.length > width) { const at = rest.lastIndexOf(' ', width); const end = at > 0 ? at : width; rows.push(rest.slice(0, end)); rest = rest.slice(end).trimStart(); } rows.push(rest); } return rows; };
const truncate = (value: string) => { const limit = width - (owner && this.continuation !== undefined ? 19 : 8); return value.length > limit ? value.slice(0, Math.ceil((limit - 1) / 2)) + '…' + value.slice(-Math.floor((limit - 1) / 2)) : value; };
const truncate = (value: string) => { const limit = width - (owner && this.requestedSection !== undefined ? 19 : 8); return value.length > limit ? value.slice(0, Math.ceil((limit - 1) / 2)) + '…' + value.slice(-Math.floor((limit - 1) / 2)) : value; };
const commands = this.commands(); this.index = Math.max(0, Math.min(this.index, commands.length - 1));
this.title.top = 1; this.title.width = width; this.title.height = 1;
this.title.content = owner ? this.snapshot.signedIn ? 'SIGNED IN' : this.continuation === undefined ? 'OWNER SIGN-IN' : 'SIGN-IN REQUIRED' : destinations[this.state.activeSection]!;
const contextual = owner && this.continuation !== undefined;
this.title.content = owner ? this.snapshot.signedIn ? 'SIGNED IN' : this.requestedSection === undefined ? 'OWNER SIGN-IN' : 'SIGN-IN REQUIRED' : destinations[this.state.activeSection]!;
const contextual = owner && this.requestedSection !== undefined;
const labelWidth = contextual ? 19 : 8;
this.facts.top = 2; this.facts.left = owner ? 2 + labelWidth : 2; this.facts.width = owner ? width - labelWidth : width;
this.labels.top = 2; this.labels.width = labelWidth; this.labels.height = contextual ? 3 : 2; this.labels.visible = owner; this.labels.content = (contextual ? 'Requested section\n' : '') + 'Relay\nOwner';
this.facts.content = owner ? `${contextual ? (this.continuation === 0 ? 'Host' : 'Agents') + '\n' : ''}${truncate(this.snapshot.relay || this.relay || 'Not configured')}\n${truncate(this.snapshot.owner || this.snapshot.boundOwner || 'Not configured')}` : this.snapshot.signedIn ? 'Owner access granted.\nThis section is not implemented in this slice.' : `Sign in to view and manage ${this.state.activeSection === 0 ? 'Host' : 'Agents'}.`;
this.facts.content = owner ? `${contextual ? (this.requestedSection === 0 ? 'Host' : 'Agents') + '\n' : ''}${truncate(this.snapshot.relay || this.relay || 'Not configured')}\n${truncate(this.snapshot.owner || this.snapshot.boundOwner || 'Not configured')}` : this.snapshot.signedIn ? 'Owner access granted.\nThis section is not implemented in this slice.' : `Sign in to view and manage ${this.state.activeSection === 0 ? 'Host' : 'Agents'}.`;
this.facts.height = contextual ? 3 : owner || this.snapshot.signedIn ? 2 : 1;
this.heading.top = contextual ? 5 : 4; this.heading.width = width; this.heading.height = 1; this.heading.visible = commands.length > 0;
this.heading.content = 'AVAILABLE ACTIONS ' + '─'.repeat(Math.max(1, width - 18));

View File

@@ -26,14 +26,19 @@ for (const [width, height] of [[120,40],[60,20]]) test(`real owner IPC: form fai
input.typeText('relay.example'); submit(); await new Promise(r=>setTimeout(r,0)); await ui.renderOnce(); assert.doesNotMatch(ui.captureCharFrame(),/CHOOSE A RELAY/);
input.pressArrow('down'); enter(); input.typeText('invalid'); submit();
await wait(()=>client.snapshot().phase==='error');
assert.equal(shell.state.mode,'owner');
assert.match(ui.captureCharFrame(),/valid owner nsec/);
enter(); input.typeText(nip19.nsecEncode(Buffer.from('1'.repeat(64),'hex')));
await wait(()=>client.snapshot().secretLength>0);
assert.doesNotMatch(ui.captureCharFrame(),/nsec1/); assert.match(ui.captureCharFrame(),/••/);
submit(); await wait(()=>client.snapshot().signedIn && shell.state.focus==='header');
submit(); await wait(()=>client.snapshot().signedIn && shell.state.mode==='section' && shell.state.activeSection===0 && client.snapshot().host?.state==='stopped');
assert.doesNotMatch(ui.captureCharFrame(),/Continue to/);
assert.match(ui.captureCharFrame(),/Start Host/); assert.match(ui.captureCharFrame(),/Reset Host/);
input.pressEscape(); await new Promise(r=>setTimeout(r,50));
for(let i=0;i<4;i++) input.pressArrow('right');
enter(); enter(); await wait(()=>!client.snapshot().signedIn);
mode('denied'); enter(); await wait(()=>client.snapshot().phase==='error');
assert.equal(shell.state.mode,'owner');
assert.match(ui.captureCharFrame(),/key access failed/);
mode('mismatch'); enter(); await wait(()=>client.snapshot().phase==='error' && client.snapshot().message.includes('does not match'));
assert.match(ui.captureCharFrame(),/does not match/);
@@ -43,7 +48,7 @@ for (const [width, height] of [[120,40],[60,20]]) test(`real owner IPC: form fai
await new Promise(r=>setTimeout(r,1400));
assert.equal(client.snapshot().signedIn,false); assert.equal(shell.state.activeSection,3);
input.pressArrow('right'); mode('available'); enter(); enter();
await wait(()=>client.snapshot().signedIn && shell.state.focus==='header');
assert.match(ui.captureCharFrame(),/SIGNED IN/);
await wait(()=>client.snapshot().signedIn && shell.state.mode==='section' && shell.state.activeSection===0 && client.snapshot().host?.state==='stopped');
assert.match(ui.captureCharFrame(),/Start Host/); assert.doesNotMatch(ui.captureCharFrame(),/Continue to|Stay here/);
} finally {shell.close();rmSync(home,{recursive:true,force:true});}
});

View File

@@ -4,6 +4,11 @@ import { createTestRenderer } from '@opentui/core/testing';
import { OpenTuiShell } from '../src/opentui-shell.ts';
import { HarnessInventoryController } from '../src/harness-inventory.ts';
import type { OwnerClient, OwnerRequest, OwnerSnapshot } from '../src/owner-protocol.ts';
// Flush the request promise and Host status refresh before capturing the frame.
async function render(ui: Awaited<ReturnType<typeof createTestRenderer>>) {
await new Promise<void>(resolve => setImmediate(resolve));
await ui.renderOnce();
}
function fixture() {
const snapshot: OwnerSnapshot = { signedIn: false, desktop: 'available', desktopReason: 'Uses the Buzz Desktop identity in Keychain.', phase: 'idle', message: 'Sign in to manage Host and Agents.', secretLength: 0, relay: 'wss://relay.example' };
const requests: OwnerRequest[] = [], listeners = new Set<(s: OwnerSnapshot) => void>();
@@ -17,27 +22,27 @@ function fixture() {
const inventory = new HarnessInventoryController({ read: () => ({ version: 1, revision: 0, agents: [], providers: [], runtimes: [] }), save: candidate => candidate }, async () => []);
return { snapshot, requests, client, inventory, emit };
}
for (const [width, height] of [[120,40],[60,20]]) test(`owner standalone sign-in, explicit contextual continuation, and signout at ${width}x${height}`, async () => {
for (const [width, height] of [[120,40],[60,20]]) test(`owner standalone sign-in, direct Host navigation, and signout at ${width}x${height}`, async () => {
const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture();
const shell = new OpenTuiShell(ui.renderer, f.inventory, undefined, undefined, f.client);
try {
await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Sign in to view and manage Host/);
await render(ui); assert.match(ui.captureCharFrame(), /Sign in to view and manage Host/);
assert.equal(f.requests.length, 0);
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce();
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await render(ui);
assert.match(ui.captureCharFrame(), /SIGN-IN REQUIRED/); assert.match(ui.captureCharFrame(), /Use Buzz Desktop identity/);
ui.mockInput.pressEnter(); await ui.renderOnce();
assert.equal(shell.state.focus, 'header'); assert.match(ui.captureCharFrame(), /SIGNED IN/);
assert.match(ui.captureCharFrame(), /Continue to Host/); assert.equal(shell.state.mode, 'owner');
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce();
ui.mockInput.pressEnter(); await render(ui);
assert.equal(shell.state.focus, 'detail'); assert.equal(shell.state.mode, 'section');
assert.doesNotMatch(ui.captureCharFrame(), /Continue to|Stay here/);
assert.match(ui.captureCharFrame(), /Fixture Host/); assert.match(ui.captureCharFrame(), /Start Host/); assert.match(ui.captureCharFrame(), /Reset Host/); assert.equal(shell.state.activeSection, 0);
ui.mockInput.pressKey('\x1b'); await new Promise(r => setTimeout(r, 50));
for (let i=0;i<4;i++) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce();
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await render(ui);
assert.equal(shell.state.signedIn, false); assert.match(ui.captureCharFrame(), /Use Buzz Desktop identity/);
ui.mockInput.pressEnter(); await ui.renderOnce();
assert.equal(shell.state.signedIn, true); assert.equal(shell.state.mode, 'owner');
ui.mockInput.pressEnter(); await render(ui);
assert.equal(shell.state.signedIn, true); assert.equal(shell.state.mode, 'section');
assert.equal(shell.state.activeSection, 0); assert.match(ui.captureCharFrame(), /Start Host/); assert.match(ui.captureCharFrame(), /Reset Host/);
assert.doesNotMatch(ui.captureCharFrame(), /Continue to/);
assert.deepEqual(f.requests.map(r=>r.action), ['signin-desktop','host-status','signout','signin-desktop']);
assert.deepEqual(f.requests.map(r=>r.action), ['signin-desktop','host-status','signout','signin-desktop','host-status']);
} finally { shell.close(); }
});
for (const [width,height] of [[120,40],[60,20]]) test(`owner unavailable identity, masked input, cancellation and pointer at ${width}x${height}`, async () => {
@@ -46,36 +51,50 @@ for (const [width,height] of [[120,40],[60,20]]) test(`owner unavailable identit
const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client);
try {
for(let i=0;i<4;i++) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce();
ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await render(ui);
assert.equal(f.requests.length,0); assert.match(ui.captureCharFrame(),/No usable Buzz/);
const lines=ui.captureCharFrame().split('\n'),y=lines.findIndex(l=>l.includes('Provide owner nsec'));
await ui.mockMouse.click(lines[y]!.indexOf('Provide owner nsec'),y);await ui.renderOnce();
await ui.mockMouse.click(lines[y]!.indexOf('Provide owner nsec'),y);await render(ui);
assert.match(ui.captureCharFrame(),/PROVIDE OWNER NSEC/); assert.match(ui.captureCharFrame(),/Enter owner nsec/);
ui.mockInput.typeText('nsec1never-show');await ui.renderOnce();
ui.mockInput.typeText('nsec1never-show');await render(ui);
assert.doesNotMatch(ui.captureCharFrame(),/nsec1never-show/); assert.match(ui.captureCharFrame(),/••/);
ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50)); await ui.renderOnce();
ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50)); await render(ui);
assert.equal(f.snapshot.secretLength,0);assert.equal(f.requests.length,0);
ui.mockInput.pressEnter();await ui.renderOnce();ui.mockInput.typeText('fixture');ui.mockInput.pressTab();ui.mockInput.pressEnter();await ui.renderOnce();
assert.equal(f.requests.at(-1)?.action,'signin-nsec');assert.equal(shell.state.signedIn,true);
ui.mockInput.pressEnter();await render(ui);ui.mockInput.typeText('fixture');ui.mockInput.pressTab();ui.mockInput.pressEnter();await render(ui);
assert.deepEqual(f.requests.map(r=>r.action),['signin-nsec','host-status']);assert.equal(shell.state.signedIn,true);
assert.equal(shell.state.activeSection,0); assert.equal(shell.state.mode,'section'); assert.match(ui.captureCharFrame(),/Start Host/);
}finally{shell.close();}
});
for (const [width,height] of [[120,40],[60,20]]) for (const target of [0,1]) test(`contextual request fact and decline keep owner screen at ${width}x${height}, target ${target}`, async () => {
for (const decline of ['action','escape','left']) {
const ui=await createTestRenderer({width,height,exitOnCtrlC:false}),f=fixture();
const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client);
try {
if(target) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter();ui.mockInput.pressEnter();await ui.renderOnce();
assert.match(ui.captureCharFrame(),new RegExp(`Requested section\\s+${target?'Agents':'Host'}`));
ui.mockInput.pressEnter();await ui.renderOnce();
assert.equal(shell.state.signedIn,true);assert.match(ui.captureCharFrame(),/Stay here/);
if(decline==='action') { ui.mockInput.pressEnter();ui.mockInput.pressArrow('down');ui.mockInput.pressEnter(); }
else if(decline==='escape') {ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50));}
else {ui.mockInput.pressEnter();ui.mockInput.pressArrow('left');}
await ui.renderOnce();
assert.equal(shell.state.mode,'owner');assert.equal(shell.state.signedIn,true);assert.equal(shell.state.focus,'header');
assert.doesNotMatch(ui.captureCharFrame(),/Continue to|Requested section|not implemented/);
assert.deepEqual(f.requests.map(r=>r.action),['signin-desktop']);
}finally{shell.close();}
}
for (const [width,height] of [[120,40],[60,20]]) for (const target of [0,1]) test(`contextual sign-in opens requested section and cancellation stays signed out at ${width}x${height}, target ${target}`, async () => {
const ui=await createTestRenderer({width,height,exitOnCtrlC:false}),f=fixture();
const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client);
try {
if(target) ui.mockInput.pressArrow('right');
ui.mockInput.pressEnter();ui.mockInput.pressEnter();await render(ui);
assert.match(ui.captureCharFrame(),new RegExp(`Requested section\\s+${target?'Agents':'Host'}`));
ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50));await render(ui);
assert.equal(shell.state.mode,'section');assert.equal(shell.state.activeSection,target);
assert.equal(shell.state.signedIn,false);assert.equal(f.requests.length,0);
ui.mockInput.pressEnter();ui.mockInput.pressEnter();await render(ui);
assert.equal(shell.state.mode,'section');assert.equal(shell.state.activeSection,target);assert.equal(shell.state.signedIn,true);
assert.doesNotMatch(ui.captureCharFrame(),/Continue to|Stay here|Requested section/);
assert.match(ui.captureCharFrame(),target ? /Owner access granted/ : /Start Host/);
assert.deepEqual(f.requests.map(r=>r.action),target ? ['signin-desktop'] : ['signin-desktop','host-status']);
}finally{shell.close();}
});
for (const cancel of ['escape', 'navigate']) test(`late successful sign-in does not redirect after ${cancel}`, async () => {
const ui=await createTestRenderer({width:60,height:20,exitOnCtrlC:false}),f=fixture();
let finish!: (ok:boolean)=>void;
f.client.request=async r=>{ f.requests.push(r); f.snapshot.phase='busy'; f.emit(); return new Promise<boolean>(resolve=>{finish=resolve;}); };
const shell=new OpenTuiShell(ui.renderer,f.inventory,undefined,undefined,f.client);
try {
ui.mockInput.pressEnter();ui.mockInput.pressEnter();ui.mockInput.pressEnter();await render(ui);
if(cancel==='escape') {ui.mockInput.pressEscape();await new Promise(r=>setTimeout(r,50));}
else {ui.mockInput.pressArrow('up');ui.mockInput.pressArrow('left');}
f.snapshot.phase='idle';f.snapshot.signedIn=true;f.emit();finish(true);await render(ui);
assert.equal(shell.state.mode,cancel==='escape'?'owner':'section');
if(cancel==='navigate') assert.equal(shell.state.activeSection,3);
assert.deepEqual(f.requests.map(r=>r.action),['signin-desktop']);
assert.doesNotMatch(ui.captureCharFrame(),/Start Host|Continue to|Stay here/);
}finally{shell.close();}
});