diff --git a/beehive/README.md b/beehive/README.md index 8725400779..57c644ba91 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -918,3 +918,10 @@ and settings with synthetic external adapters. It requires a temporary `BEEHIVE_HOME` whose name contains `provider-tui-fixture-`. It is not reachable from the production launcher. API key `deny` exercises provider denial. The fixture is not proof of live vendor authentication. + +The Providers list always includes every supported provider type, even before +any account is saved. Missing accounts show NOT SET; Databricks shows ENV MISSING +without its workspace variable. Saved accounts show SAVED, not READY: saving +credentials does not prove live access. Each left-hand row displays its state. +Selecting an unconfigured type opens its own setup action; Add provider remains +available for additional accounts. diff --git a/beehive/src/provider-panel.ts b/beehive/src/provider-panel.ts index 2e64248310..c59283035e 100644 --- a/beehive/src/provider-panel.ts +++ b/beehive/src/provider-panel.ts @@ -6,11 +6,20 @@ import type { ShellState } from './shell-state.ts'; import { listWidth } from './shell-state.ts'; const types = ['openai', 'anthropic', 'openai-compat', 'openrouter', 'databricks_v2']; +const labels: Record = { openai: 'OpenAI', anthropic: 'Anthropic', 'openai-compat': 'OpenAI-compatible', openrouter: 'OpenRouter', databricks_v2: 'Databricks v2' }; + +/** Supported types remain visible before any accounts are configured. */ +export function providerInventory(snapshot: ProviderSnapshot): ProviderRow[] { + return types.flatMap(type => { + const accounts = snapshot.rows.filter(row => row.type === type); + return accounts.length ? accounts : [{ id: `type:${type}`, name: labels[type]!, type: type as ProviderRow['type'], endpoint: '', state: type === 'databricks_v2' && !snapshot.databricksHost ? 'ENV MISSING' : 'NOT SET', detail: type === 'databricks_v2' ? 'Set DATABRICKS_HOST to the workspace HTTPS origin, then reload providers.' : 'Not configured. Add an account to use this provider.' }]; + }); +} /** Providers presentation. All durable changes and secret state live in Node. */ export class ProviderPanel { private snapshot: ProviderSnapshot; private unsubscribe: () => void; - private selected = 'add'; + private selected = 'type:openai'; private actionIndex = 0; private actionOffset = 0; private offset = 0; @@ -32,7 +41,7 @@ export class ProviderPanel { this.list = new BoxRenderable(renderer, { width: '100%', height: '100%', backgroundColor: palette.surface }); this.detail = new BoxRenderable(renderer, { width: '100%', height: '100%', backgroundColor: palette.surface }); listPane.add(this.list); detailPane.add(this.detail); - this.listTitle = new TextRenderable(renderer, { left: 2, top: 1, position: 'absolute', height: 1, fg: palette.muted, content: 'PROVIDERS' }); this.list.add(this.listTitle); + this.listTitle = new TextRenderable(renderer, { left: 2, top: 1, position: 'absolute', height: 1, fg: palette.muted, content: 'MODEL ACCOUNTS' }); this.list.add(this.listTitle); this.detailTitle = new TextRenderable(renderer, { left: 2, top: 1, position: 'absolute', height: 1, fg: palette.muted }); this.detail.add(this.detailTitle); this.details = new TextRenderable(renderer, { left: 2, top: 3, position: 'absolute', fg: palette.text, wrapMode: 'word' }); this.detail.add(this.details); this.actionTitle = new TextRenderable(renderer, { left: 2, position: 'absolute', height: 1, fg: palette.muted, content: 'AVAILABLE ACTIONS' }); this.detail.add(this.actionTitle); @@ -45,11 +54,12 @@ export class ProviderPanel { } 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); } + private items() { return [...providerInventory(this.snapshot).map(row => row.id), 'add', 'reload']; } + private row() { return providerInventory(this.snapshot).find(row => row.id === this.selected); } private commands() { if (this.selected === 'add') return ['Add provider']; if (this.selected === 'reload') return ['Reload providers']; + if (this.selected.startsWith('type:')) return [this.row()?.type === 'databricks_v2' ? 'Set up Databricks' : `Add ${labels[this.row()!.type]} account`]; return ['Edit provider', 'Test provider', 'Load models', ...(this.row()?.type === 'databricks_v2' ? ['Sign in to Databricks'] : []), 'Set up Codex', 'Set up Pi']; } private select(index: number) { @@ -92,15 +102,15 @@ export class ProviderPanel { if (this.dialog === dialog) this.dialog = undefined; this.repaint(); return result; } - private async edit(row?: ProviderRow) { + private async edit(row?: ProviderRow, preset?: ProviderRow['type']) { const generation = this.generation, revision = this.snapshot.revision; - let type = row?.type; + let type = row?.type ?? preset; if (!type) { const result = await this.form('ADD PROVIDER', [{ label: 'Type', value: 'openai', choices: types }], 'Continue'); if (!result || generation !== this.generation) return; type = result.Type as ProviderRow['type']; } - const fields: DialogField[] = [{ label: 'Name', value: row?.name ?? (type === 'databricks_v2' ? 'Databricks' : type) }]; + const fields: DialogField[] = [{ label: 'Name', value: row?.name ?? labels[type]! }]; if (type === 'databricks_v2') fields.push({ label: 'Workspace (DATABRICKS_HOST)', value: this.snapshot.databricksHost ?? 'Set DATABRICKS_HOST and reopen Beehive', choices: [this.snapshot.databricksHost ?? 'Set DATABRICKS_HOST and reopen Beehive'] }); else { if (type === 'openai-compat') fields.push({ label: 'Endpoint', value: row?.endpoint ?? 'https://' }, { label: 'Wire', value: row?.wire ?? 'auto', choices: ['auto', 'chat', 'responses'] }); @@ -108,7 +118,7 @@ export class ProviderPanel { } const result = await this.form(row ? 'EDIT PROVIDER' : 'ADD PROVIDER', fields); if (!result || generation !== this.generation) return; - await this.client.request({ action: 'save', provider: row?.id, revision, values: { type, name: result.Name!, endpoint: result.Endpoint ?? '', wire: result.Wire ?? 'auto' } }); + await this.client.request({ action: 'save', provider: row?.id ?? (preset ? `type:${type}` : undefined), revision, values: { type, name: result.Name!, endpoint: result.Endpoint ?? '', wire: result.Wire ?? 'auto' } }); } private async setup(row: ProviderRow, harness: string) { const generation = this.generation; @@ -145,6 +155,7 @@ export class ProviderPanel { if (command === 'Add provider') await this.edit(); else if (command === 'Reload providers') await this.client.request({ action: 'reload' }); else if (row) { + if (row.id.startsWith('type:')) { await this.edit(undefined, row.type); return; } if (command === 'Edit provider') await this.edit(row); else if (command === 'Set up Codex' || command === 'Set up Pi') await this.setup(row, command === 'Set up Codex' ? 'codex' : 'pi'); else await this.client.request({ action: command === 'Test provider' ? 'test' : command === 'Load models' ? 'models' : 'login', provider: row.id, revision: this.snapshot.revision }); @@ -153,14 +164,22 @@ export class ProviderPanel { paint() { if (!this.active) return; const height = this.renderer.height - 5, leftWidth = listWidth(this.renderer.width) - 4, width = this.renderer.width - listWidth(this.renderer.width) - 5; - const items = this.items(); if (!items.includes(this.selected)) this.selected = 'add'; + const items = this.items(); if (!items.includes(this.selected)) { + const type = this.selected.startsWith('type:') ? this.selected.slice(5) : undefined; + this.selected = (type && this.snapshot.rows.find(row => row.type === type)?.id) || items[0]!; + } const selectedIndex = items.indexOf(this.selected), capacity = Math.min(this.rows.length, Math.max(1, height - 4)); if (selectedIndex < this.offset) this.offset = selectedIndex; if (selectedIndex >= this.offset + capacity) this.offset = selectedIndex - capacity + 1; this.rows.forEach((renderable, slot) => { - const id = items[this.offset + slot], row = this.snapshot.rows.find(row => row.id === id); + const id = items[this.offset + slot], row = providerInventory(this.snapshot).find(row => row.id === id); renderable.visible = slot < capacity && !!id; renderable.top = slot + 3; renderable.width = leftWidth; - renderable.content = (id === 'add' ? '+ Add provider' : id === 'reload' ? '↻ Reload providers' : `◇ ${row?.name ?? ''}`).slice(0, leftWidth); + if (row) { + const status = row.state; + const available = Math.max(1, leftWidth - status.length - 3); + const name = row.name.length > available ? row.name.slice(0, Math.max(1, available - 1)) + '…' : row.name; + renderable.content = `◇ ${name.padEnd(available)} ${status}`; + } else renderable.content = (id === 'add' ? '+ Add provider' : '↻ Reload providers').slice(0, leftWidth); renderable.bg = id === this.selected ? palette.selected : palette.surface; renderable.fg = id === this.selected ? palette.selectedText : palette.text; }); const row = this.row(); diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index 4ea79acfed..181ae47a5c 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -28,7 +28,7 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`Providers signed-out for (let i = 0; i < 3; i++) ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Add provider/); - ui.mockInput.pressArrow('up'); ui.mockInput.pressTab(); await ui.renderOnce(); + ui.mockInput.pressTab(); await ui.renderOnce(); assert.equal(shell.state.focus, 'detail'); assert.match(ui.captureCharFrame(), /Test provider/); ui.mockInput.pressArrow('down'); ui.mockInput.pressEnter(); await ui.renderOnce(); @@ -61,7 +61,7 @@ test('minimum-size results, recovery text and models remain reachable through re 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(); + ui.mockInput.pressEnter(); ui.mockInput.pressTab(); f.snapshot.resultTarget = 'p'; 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/); @@ -81,7 +81,7 @@ test('success and failure reports belong only to their selected provider', async 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'); await ui.renderOnce(); + ui.mockInput.pressEnter(); ui.mockInput.pressArrow('down'); await ui.renderOnce(); assert.doesNotMatch(ui.captureCharFrame(), /OpenAI result only/); ui.mockInput.pressArrow('up'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /OpenAI result only/); f.snapshot.phase = 'error'; f.snapshot.message = 'OpenAI denied only'; f.emit(); @@ -89,3 +89,18 @@ test('success and failure reports belong only to their selected provider', async ui.mockInput.pressArrow('up'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /OpenAI denied only/); } finally { shell.close(); } }); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`fresh Providers always exposes supported types and states at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture(); + f.snapshot.rows = []; + 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(); await ui.renderOnce(); + const frame = ui.captureCharFrame(); + assert.match(frame, /MODEL ACCOUNTS/); assert.match(frame, /OpenAI/); assert.match(frame, /Anthropic/); assert.match(frame, /OpenRouter/); + assert.match(frame, /NOT SET/); assert.match(frame, /ENV MISSING/); + ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /API key/); assert.doesNotMatch(ui.captureCharFrame(), /Type ←/); + } finally { shell.close(); } +});