diff --git a/beehive/src/provider-dialog.ts b/beehive/src/provider-dialog.ts index f5882ca6d2..f147ca829b 100644 --- a/beehive/src/provider-dialog.ts +++ b/beehive/src/provider-dialog.ts @@ -11,19 +11,24 @@ export class ProviderDialog { private title: TextRenderable; private rows: TextRenderable[] = []; private actions: TextRenderable; + private notice?: TextRenderable; private index = 0; private offset = 0; private secretLength = 0; private closed = false; readonly done: Promise; private finish!: (value: DialogResult | undefined) => void; - constructor(private renderer: CliRenderer, private heading: string, readonly fields: DialogField[], private secret: (action: 'append' | 'backspace' | 'clear', value?: string) => void, private submitLabel = 'Save') { + constructor(private renderer: CliRenderer, private heading: string, readonly fields: DialogField[], private secret: (action: 'append' | 'backspace' | 'clear', value?: string) => void, private submitLabel = 'Save', private message?: string) { this.done = new Promise(resolve => this.finish = resolve); this.scrim = new BoxRenderable(renderer, { position: 'absolute', width: '100%', height: '100%', backgroundColor: palette.scrim }); this.surface = new BoxRenderable(renderer, { position: 'absolute', border: true, borderColor: palette.dialog, backgroundColor: palette.surface }); renderer.root.add(this.scrim); renderer.root.add(this.surface); this.title = new TextRenderable(renderer, { position: 'absolute', top: 1, left: 2, height: 1, fg: palette.focus }); this.surface.add(this.title); + if (message) { + this.notice = new TextRenderable(renderer, { position: 'absolute', top: 3, left: 2, fg: palette.text, wrapMode: 'word', content: message }); + this.surface.add(this.notice); + } for (let i = 0; i < fields.length; i++) { const row = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 2, fg: palette.text, onMouseDown: () => { this.index = i; this.paint(); } }); this.surface.add(row); this.rows.push(row); @@ -67,10 +72,11 @@ export class ProviderDialog { } paint() { if (this.closed) return; - const width = Math.min(68, this.renderer.width - 4), height = Math.min(this.renderer.height - 4, 7 + this.fields.length * 3); + const width = Math.min(68, this.renderer.width - 4), height = Math.min(this.renderer.height - 4, this.message ? 15 : 7 + this.fields.length * 3); this.surface.width = width; this.surface.height = height; this.surface.left = Math.floor((this.renderer.width - width) / 2); this.surface.top = Math.floor((this.renderer.height - height) / 2); this.title.content = this.heading; this.title.width = width - 6; + if (this.notice) { this.notice.width = width - 6; this.notice.height = height - 7; } const capacity = Math.max(1, Math.floor((height - 7) / 3)); const selected = Math.min(this.index, this.fields.length - 1); if (selected < this.offset) this.offset = selected; @@ -84,7 +90,7 @@ export class ProviderDialog { row.fg = this.index === i ? palette.selectedText : palette.text; }); this.actions.top = height - 4; this.actions.width = width - 6; - this.actions.content = ` ${this.submitLabel} \nTab next · Esc cancel · Ctrl-U clear`; + this.actions.content = this.message ? ` ${this.submitLabel} \nEnter or Esc close` : ` ${this.submitLabel} \nTab next · Esc cancel · Ctrl-U clear`; this.actions.bg = this.index === this.fields.length ? palette.selected : palette.surface; this.actions.fg = this.index === this.fields.length ? palette.selectedText : palette.text; } diff --git a/beehive/src/provider-panel.ts b/beehive/src/provider-panel.ts index c59283035e..45761bf4af 100644 --- a/beehive/src/provider-panel.ts +++ b/beehive/src/provider-panel.ts @@ -12,7 +12,7 @@ const labels: Record = { openai: 'OpenAI', anthropic: 'Anthropic 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.' }]; + 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 restart Beehive.' : 'Not configured. Add an account to use this provider.' }]; }); } /** Providers presentation. All durable changes and secret state live in Node. */ @@ -94,9 +94,9 @@ export class ProviderPanel { } return false; } - private async form(title: string, fields: DialogField[], submit = 'Save') { + private async form(title: string, fields: DialogField[], submit = 'Save', message?: string) { this.client.secret('clear'); - const dialog = new ProviderDialog(this.renderer, title, fields, (action, value) => this.client.secret(action, value), submit); + const dialog = new ProviderDialog(this.renderer, title, fields, (action, value) => this.client.secret(action, value), submit, message); this.dialog = dialog; const result = await dialog.done; if (this.dialog === dialog) this.dialog = undefined; @@ -110,8 +110,12 @@ export class ProviderPanel { 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; + } 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'] }); + if (type === 'databricks_v2') fields.push({ label: 'Workspace (DATABRICKS_HOST)', value: this.snapshot.databricksHost!, choices: [this.snapshot.databricksHost!] }); else { if (type === 'openai-compat') fields.push({ label: 'Endpoint', value: row?.endpoint ?? 'https://' }, { label: 'Wire', value: row?.wire ?? 'auto', choices: ['auto', 'chat', 'responses'] }); fields.push({ label: row ? 'API key (blank keeps saved)' : 'API key', secret: true }); diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index 181ae47a5c..dc76247f89 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -104,3 +104,34 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`fresh Providers alway assert.match(ui.captureCharFrame(), /API key/); assert.doesNotMatch(ui.captureCharFrame(), /Type ←/); } finally { shell.close(); } }); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`Databricks prerequisite is non-committing and configured setup remains available 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(); + for (let i = 0; i < 4; i++) ui.mockInput.pressArrow('down'); + const before = structuredClone(f.snapshot), requests = structuredClone(f.requests); + ui.mockInput.pressTab(); ui.mockInput.pressEnter(); await ui.renderOnce(); + const frame = ui.captureCharFrame(); + assert.match(frame, /DATABRICKS_HOST REQUIRED/); + assert.match(frame.replace(/[│\s]+/g, ' '), /workspace HTTPS origin/); + assert.match(frame, /https:\/\/your-workspace.cloud.databricks.com/); + assert.match(frame, /Then restart Beehive from that environment/); + assert.match(frame, /No changes have been saved/); + assert.match(frame, /Close/); assert.doesNotMatch(frame, /Save|FAILED|Changes may already/); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.doesNotMatch(ui.captureCharFrame(), /DATABRICKS_HOST REQUIRED/); + assert.match(ui.captureCharFrame(), /ENV MISSING/); + assert.deepEqual(f.requests, requests); assert.deepEqual(f.snapshot, before); + f.snapshot.databricksHost = 'https://fixture.cloud.databricks.com'; f.emit(); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /ADD PROVIDER/); + assert.match(ui.captureCharFrame(), /https:\/\/fixture.cloud.databricks.com/); + assert.match(ui.captureCharFrame(), /Save/); + ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(f.requests.at(-1)?.action, 'save'); + } finally { shell.close(); } +});