From 1d3782a9c71aaab59cf4107224a9f00ea7490eb5 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Tue, 22 Sep 2026 09:48:53 -0400 Subject: [PATCH] fix(beehive): open Host directly after owner sign-in Signed-off-by: Logan Johnson --- beehive/src/owner-panel.ts | 42 ++++++------- beehive/test/shell-owner-ipc.test.ts | 11 +++- beehive/test/shell-owner.test.ts | 91 +++++++++++++++++----------- 3 files changed, 82 insertions(+), 62 deletions(-) diff --git a/beehive/src/owner-panel.ts b/beehive/src/owner-panel.ts index 1160280650..575df84cb3 100644 --- a/beehive/src/owner-panel.ts +++ b/beehive/src/owner-panel.ts @@ -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)); diff --git a/beehive/test/shell-owner-ipc.test.ts b/beehive/test/shell-owner-ipc.test.ts index a1e2bee348..06af239420 100644 --- a/beehive/test/shell-owner-ipc.test.ts +++ b/beehive/test/shell-owner-ipc.test.ts @@ -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});} }); diff --git a/beehive/test/shell-owner.test.ts b/beehive/test/shell-owner.test.ts index c0b81cfbd6..7e590a17e6 100644 --- a/beehive/test/shell-owner.test.ts +++ b/beehive/test/shell-owner.test.ts @@ -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>) { + await new Promise(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(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();} });