diff --git a/beehive/src/provider-panel.ts b/beehive/src/provider-panel.ts index 8d0d44dad2..6f47b68e2f 100644 --- a/beehive/src/provider-panel.ts +++ b/beehive/src/provider-panel.ts @@ -57,10 +57,9 @@ 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 [...providerInventory(this.snapshot).map(row => row.id), 'add', 'reload']; } + private items() { return [...providerInventory(this.snapshot).map(row => row.id), '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']; @@ -105,14 +104,8 @@ export class ProviderPanel { if (this.dialog === dialog) this.dialog = undefined; this.repaint(); return result; } - private async edit(row?: ProviderRow, preset?: ProviderRow['type']) { + private async edit(row: ProviderRow | undefined, type: ProviderRow['type']) { const generation = this.generation, revision = this.snapshot.revision; - 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']; - } if (type === 'databricks_v2' && !this.snapshot.databricksHost) { await this.form('DATABRICKS_HOST REQUIRED', [], 'Close', 'Set DATABRICKS_HOST to your workspace HTTPS origin, for example:\nhttps://your-workspace.cloud.databricks.com\nThen restart Beehive from that environment.\nNo changes have been saved.'); return; @@ -125,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 ?? (preset ? `type:${type}` : undefined), revision, values: { type, name: result.Name!, endpoint: result.Endpoint ?? '', wire: result.Wire ?? 'auto' } }); + await this.client.request({ action: 'save', provider: row?.id ?? `type:${type}`, revision, values: { type, name: result.Name!, endpoint: result.Endpoint ?? '', wire: result.Wire ?? 'auto' } }); } private async setup(row: ProviderRow, harness: string) { const generation = this.generation; @@ -157,11 +150,10 @@ export class ProviderPanel { private async run() { if (this.snapshot.phase === 'busy' || this.dialog || !this.active) return; const command = this.commands()[this.actionIndex], row = this.row(); - if (command === 'Add provider') await this.edit(); - else if (command === 'Reload providers') await this.client.request({ action: 'reload' }); + 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); + if (command === 'Edit provider') await this.edit(row, row.type); 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 }); } @@ -184,15 +176,15 @@ export class ProviderPanel { 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); + } else renderable.content = '↻ 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(); - this.detailTitle.content = row ? row.name.toUpperCase() : this.selected === 'add' ? 'ADD PROVIDER' : 'RELOAD PROVIDERS'; this.detailTitle.width = width; + this.detailTitle.content = row ? row.name.toUpperCase() : 'RELOAD PROVIDERS'; this.detailTitle.width = width; const commands = this.commands(); const compact = this.renderer.height < 28; const ownsResult = this.snapshot.resultTarget === this.selected; - const info = row ? [`State ${row.state}`, `Type ${row.type}`, ...(row.endpoint ? [`Connects to ${row.endpoint}`] : []), '', row.detail] : 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.']; + const info = row ? [`State ${row.state}`, `Type ${row.type}`, ...(row.endpoint ? [`Connects to ${row.endpoint}`] : []), '', row.detail] : ['Reload saved providers and the DATABRICKS_HOST workspace. No owner sign-in is required.']; const wrap = (lines: string[]) => lines.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; }); const fullDetails = wrap(info); // Reserve every action and a result viewport before allocating detail rows. diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index c46e0da0a6..d7866836f7 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -27,7 +27,7 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`Providers signed-out try { for (let i = 0; i < 3; i++) ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); - assert.match(ui.captureCharFrame(), /Add provider/); + assert.doesNotMatch(ui.captureCharFrame(), /Add provider/); ui.mockInput.pressTab(); await ui.renderOnce(); assert.equal(shell.state.focus, 'detail'); assert.match(ui.captureCharFrame(), /Test provider/); @@ -220,3 +220,35 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`all provider actions assert.ok(recoveryVisible); } finally { shell.close(); } }); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`provider inventory has no global Add command and retains saved accounts at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture(); + f.snapshot.rows.push({ ...f.snapshot.rows[0]!, id: 'second', name: 'Second' }); + const before = structuredClone(f.snapshot); + 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(); + assert.doesNotMatch(ui.captureCharFrame(), /Add provider/); + assert.match(ui.captureCharFrame(), /Fixture/); + assert.match(ui.captureCharFrame(), /Second/); + ui.mockInput.pressArrow('down'); ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /EDIT PROVIDER/); + assert.match(ui.captureCharFrame(), /Second/); + ui.mockInput.pressEscape(); await new Promise(resolve => setTimeout(resolve, 50)); await ui.renderOnce(); + assert.doesNotMatch(ui.captureCharFrame(), /EDIT PROVIDER/); + ui.mockInput.pressTab(); + for (let i = 0; i < 5; i++) ui.mockInput.pressArrow('down'); + ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.deepEqual(f.requests.at(-1), { action: 'reload' }); + assert.doesNotMatch(ui.captureCharFrame(), /Add provider|ADD PROVIDER/); + const lines = ui.captureCharFrame().split('\n'); + const y = lines.findIndex(line => line.includes('› Reload providers')); + assert.ok(y >= 0, ui.captureCharFrame()); + const count = f.requests.length; + await ui.mockMouse.click(lines[y]!.indexOf('Reload providers'), y); await ui.renderOnce(); + assert.equal(f.requests.length, count + 1); + assert.deepEqual(f.requests.at(-1), { action: 'reload' }); + assert.deepEqual(f.snapshot, before); + } finally { shell.close(); } +});