fix(beehive): expose provider results at minimum terminal size

Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
Logan Johnson
2026-09-21 12:53:20 -04:00
parent 7a9ac365ad
commit 766ca0803b
4 changed files with 52 additions and 6 deletions

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ const keys = new Map<string, string>();
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();

View File

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