From 766ca0803be527b8b091c7d7ece3e6c0864f39a1 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 21 Sep 2026 12:53:20 -0400 Subject: [PATCH] fix(beehive): expose provider results at minimum terminal size Signed-off-by: Logan Johnson --- beehive/src/provider-panel.ts | 8 +++---- beehive/test/provider-ipc.test.ts | 28 ++++++++++++++++++++++ beehive/test/provider-tui-fixture-child.ts | 2 +- beehive/test/shell-providers.test.ts | 20 +++++++++++++++- 4 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 beehive/test/provider-ipc.test.ts diff --git a/beehive/src/provider-panel.ts b/beehive/src/provider-panel.ts index cffc97adb9..681bf25fa9 100644 --- a/beehive/src/provider-panel.ts +++ b/beehive/src/provider-panel.ts @@ -43,7 +43,7 @@ export class ProviderPanel { for (let i = 0; i < 7; i++) { const action = new TextRenderable(renderer, { left: 2, position: 'absolute', height: 1, fg: palette.text, onMouseDown: () => { if (!this.active || this.dialog) return; this.actionIndex = this.actionOffset + i; this.state.focus = 'detail'; void this.run(); } }); this.detail.add(action); this.actions.push(action); } - this.unsubscribe = client.subscribe(snapshot => { this.snapshot = snapshot; this.dialog?.updateSecretLength(snapshot.secretLength); this.paint(); }); + this.unsubscribe = client.subscribe(snapshot => { if (this.snapshot.message !== snapshot.message) this.detailOffset = 0; this.snapshot = snapshot; this.dialog?.updateSecretLength(snapshot.secretLength); this.paint(); }); } private items() { return [...this.snapshot.rows.map(row => row.id), 'add', 'reload']; } private row() { return this.snapshot.rows.find(row => row.id === this.selected); } @@ -128,7 +128,7 @@ export class ProviderPanel { if (!this.active || this.dialog) return; if (this.state.focus === 'list') this.select(y - 6 + this.offset); else { - const count = Math.min(this.commands().length, this.renderer.height < 28 ? 3 : 7); + const count = Math.min(this.commands().length, this.renderer.height < 28 ? 2 : 7); const first = this.renderer.height - 5 - count - 4; const index = y - 3 - first + this.actionOffset; if (index >= 0 && index < this.commands().length) { this.actionIndex = index; void this.run(); } @@ -166,12 +166,12 @@ export class ProviderPanel { const row = this.row(); this.detailTitle.content = row ? row.name.toUpperCase() : this.selected === 'add' ? 'ADD PROVIDER' : 'RELOAD PROVIDERS'; this.detailTitle.width = width; const commands = this.commands(); - const actionCapacity = Math.min(commands.length, this.renderer.height < 28 ? 3 : 7); + const actionCapacity = Math.min(commands.length, this.renderer.height < 28 ? 2 : 7); if (this.actionIndex < this.actionOffset) this.actionOffset = this.actionIndex; if (this.actionIndex >= this.actionOffset + actionCapacity) this.actionOffset = this.actionIndex - actionCapacity + 1; const actionStart = height - actionCapacity - 4; const info = row ? [`State ${row.state}`, `Type ${row.type}`, `Connects to ${row.endpoint}`, '', row.detail, ...(this.snapshot.modelProvider === row.id ? ['', 'MODELS', ...this.snapshot.models] : [])] : this.selected === 'add' ? ['Choose OpenAI, Anthropic, OpenAI-compatible, OpenRouter, or Databricks v2.', '', 'Credentials stay in the OS credential store.'] : ['Reload saved providers and the DATABRICKS_HOST workspace. No owner sign-in is required.']; - info.push('', 'RESULT', this.snapshot.message); + if (this.snapshot.message) info.unshift('RESULT', this.snapshot.message, ''); const wrapped = info.flatMap(line => { const result: string[] = []; for (let start = 0; start < Math.max(1, line.length); start += width) result.push(line.slice(start, start + width)); return result; }); this.detailOffset = Math.min(this.detailOffset, Math.max(0, wrapped.length - (actionStart - 4))); this.details.width = width; this.details.height = Math.max(1, actionStart - 4); this.details.content = wrapped.slice(this.detailOffset, this.detailOffset + Math.max(1, actionStart - 4)).join('\n'); diff --git a/beehive/test/provider-ipc.test.ts b/beehive/test/provider-ipc.test.ts new file mode 100644 index 0000000000..e5004d7948 --- /dev/null +++ b/beehive/test/provider-ipc.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fork } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { once } from 'node:events'; +import { readSettings, saveSettings } from '../src/settings.ts'; + +test('real fixture IPC rejects stale Add revision without a public settings mutation', async () => { + const home = mkdtempSync(join(tmpdir(), 'provider-tui-fixture-cas-')); + const child = fork(new URL('./provider-tui-fixture-child.ts', import.meta.url), [], { execArgv: ['--experimental-transform-types'], silent: true, env: { HOME: home, BEEHIVE_HOME: home, PATH: '/usr/bin:/bin' } }); + child.stdout?.resume(); child.stderr?.resume(); + try { + await once(child, 'message'); + const directory = join(home, '.beehive', 'host'); + saveSettings(directory, readSettings(directory), 0); + const completion = new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(Error('IPC timeout')), 5000); + child.on('message', (message: any) => { if (message.type === 'result') { clearTimeout(timer); resolve(message); } }); + }); + child.send({ type: 'secret', action: 'append', value: 'fixture-secret' }); + child.send({ type: 'request', id: 1, request: { action: 'save', revision: 0, values: { type: 'openai', name: 'Stale' } } }); + assert.equal((await completion).ok, false); + assert.equal(readSettings(directory).revision, 1); + assert.deepEqual(readSettings(directory).providers, []); + } finally { const exited = once(child, 'close'); child.kill(); await exited; rmSync(home, { recursive: true, force: true }); } +}); diff --git a/beehive/test/provider-tui-fixture-child.ts b/beehive/test/provider-tui-fixture-child.ts index e67ab52dc9..62fe7342c5 100644 --- a/beehive/test/provider-tui-fixture-child.ts +++ b/beehive/test/provider-tui-fixture-child.ts @@ -9,7 +9,7 @@ const keys = new Map(); const backend: ProviderCredentials = { read: ref => keys.get(ref.account) ?? null, create: (ref, value) => { keys.set(ref.account, value); } }; const service = new ProviderService(home, process.env, { credential: async (input: any, signal) => { - if (input.action === 'add-provider') addProvider(directory, input.name, input.secret, backend, input.type, input.endpoint, input.wire); + if (input.action === 'add-provider') addProvider(directory, input.name, input.secret, backend, input.type, input.endpoint, input.wire, input.revision); else if (input.action === 'edit-provider') editProvider(directory, input.provider, input.revision, input.name, input.endpoint, input.wire, input.secret ?? '', backend); else if (input.action === 'models') { await new Promise(resolve => setTimeout(resolve, 400)); signal.throwIfAborted(); diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index bd2d0e3530..f6d371f8c9 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -19,7 +19,7 @@ function fixture() { cancel() {}, dispose() {}, }; const inventory = new HarnessInventoryController({ read: () => ({ version: 1, revision: 0, agents: [], providers: [], runtimes: [] }), save: candidate => candidate }, async () => []); - return { client, inventory, requests, secrets }; + return { client, inventory, requests, secrets, snapshot, emit() { for (const fn of listeners) fn(structuredClone(snapshot)); } }; } for (const [width, height] of [[120, 40], [60, 20]]) test(`Providers signed-out keyboard actions and modal at ${width}x${height}`, async () => { const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture(); @@ -55,3 +55,21 @@ test('form input preserves q as text and cancellation clears secret without save dialog.cancel(); assert.equal(await dialog.done, undefined); assert.deepEqual(calls, ['clear']); } finally { ui.renderer.destroy(); } }); + +test('minimum-size results, recovery text and models remain reachable through real page keys', async () => { + const ui = await createTestRenderer({ width: 60, height: 20, exitOnCtrlC: false }), f = fixture(); + const shell = new OpenTuiShell(ui.renderer, f.inventory, undefined, f.client); + try { + for (let i = 0; i < 3; i++) ui.mockInput.pressArrow('right'); + ui.mockInput.pressEnter(); ui.mockInput.pressArrow('up'); ui.mockInput.pressTab(); + f.snapshot.phase = 'error'; f.snapshot.message = 'Denied. Check provider access. Reload before retrying.'; f.emit(); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Denied/); assert.match(ui.captureCharFrame(), /retrying/); + ui.mockInput.pressKey('\x1b[6~'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /State/); + f.snapshot.phase = 'idle'; f.snapshot.message = 'Models loaded.'; f.snapshot.modelProvider = 'p'; f.snapshot.models = ['gpt-visible']; f.emit(); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Models loaded/); + for (let i = 0; i < 12; i++) ui.mockInput.pressKey('\x1b[6~'); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /gpt-visible/); + f.snapshot.message = 'Stopped waiting. Changes may already be saved. Reload before retrying.'; f.emit(); + await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Stopped waiting/); + } finally { shell.close(); } +});