From ee3765cad03f63b69ea35972ccbfc26f0780311d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 21 Sep 2026 13:30:23 -0400 Subject: [PATCH] fix(beehive): separate provider input and action focus surfaces Signed-off-by: Logan Johnson --- beehive/src/provider-dialog.ts | 44 +++++++++++++++++++++------- beehive/test/shell-providers.test.ts | 44 ++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 11 deletions(-) diff --git a/beehive/src/provider-dialog.ts b/beehive/src/provider-dialog.ts index f147ca829b..0c33681da1 100644 --- a/beehive/src/provider-dialog.ts +++ b/beehive/src/provider-dialog.ts @@ -10,6 +10,10 @@ export class ProviderDialog { private surface: BoxRenderable; private title: TextRenderable; private rows: TextRenderable[] = []; + private labels: TextRenderable[] = []; + private borders: TextRenderable[] = []; + private actionBorder: TextRenderable; + private help: TextRenderable; private actions: TextRenderable; private notice?: TextRenderable; private index = 0; @@ -30,11 +34,20 @@ export class ProviderDialog { 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(); } }); + const focus = () => { this.index = i; this.paint(); }; + const label = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.muted, onMouseDown: focus }); + this.surface.add(label); this.labels.push(label); + const border = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, onMouseDown: focus }); + this.surface.add(border); this.borders.push(border); + const row = new TextRenderable(renderer, { position: 'absolute', left: 4, height: 1, fg: palette.text, onMouseDown: focus }); this.surface.add(row); this.rows.push(row); } - this.actions = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 2, fg: palette.text, onMouseDown: () => this.complete() }); - this.surface.add(this.actions); this.paint(); + this.actions = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.text, onMouseDown: () => { this.index = this.fields.length; this.paint(); this.complete(); } }); + this.actionBorder = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, onMouseDown: () => this.complete() }); + this.surface.add(this.actionBorder); + this.actions.left = 4; + this.help = new TextRenderable(renderer, { position: 'absolute', left: 2, height: 1, fg: palette.muted }); + this.surface.add(this.help); this.surface.add(this.actions); this.paint(); } updateSecretLength(length: number) { this.secretLength = length; this.paint(); } paste(value: string) { @@ -83,16 +96,25 @@ export class ProviderDialog { if (selected >= this.offset + capacity) this.offset = selected - capacity + 1; this.rows.forEach((row, i) => { const field = this.fields[i]!; row.visible = i >= this.offset && i < this.offset + capacity; - row.top = 3 + (i - this.offset) * 3; row.width = width - 6; + const label = this.labels[i]!; label.visible = row.visible; + label.top = 3 + (i - this.offset) * 3; label.width = width - 6; label.content = field.label; + row.top = label.top + 1; row.width = width - 10; const value = field.secret ? '•'.repeat(Math.min(this.secretLength, width - 10)) : field.value ?? ''; - row.content = `${field.label}${field.choices ? ' ← →' : ''}\n${value.slice(-(width - 6)) || ' '}`; - row.bg = this.index === i ? palette.selected : palette.surface; - row.fg = this.index === i ? palette.selectedText : palette.text; + const focused = this.index === i; + const prompt = field.secret ? (field.label.includes('blank keeps saved') ? 'Leave blank to keep saved' : 'Enter API key') : `Enter ${field.label.toLowerCase()}`; + const suffix = field.choices ? ' ← →' : ''; + row.content = (value || prompt).slice(-(width - 10 - suffix.length)).padEnd(width - 10 - suffix.length) + suffix; + row.fg = value ? palette.text : palette.muted; + const border = this.borders[i]!; border.visible = row.visible; border.top = row.top; border.width = width - 6; + border.content = '[' + ' '.repeat(width - 8) + ']'; border.fg = focused ? palette.focus : palette.muted; }); - this.actions.top = height - 4; this.actions.width = width - 6; - 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; + this.actions.top = height - 4; this.actions.width = this.submitLabel.length; + this.actions.content = this.submitLabel; this.actions.fg = palette.text; + this.actionBorder.top = height - 4; this.actionBorder.width = this.submitLabel.length + 4; + this.actionBorder.content = '[' + ' '.repeat(this.submitLabel.length + 2) + ']'; + this.actionBorder.fg = this.index === this.fields.length ? palette.focus : palette.muted; + this.help.top = height - 3; this.help.width = width - 6; + this.help.content = this.message ? 'Enter or Esc close' : 'Tab next · Esc cancel · Ctrl-U clear'; } private complete() { const values = Object.fromEntries(this.fields.filter(field => !field.secret).map(field => [field.label, field.value ?? field.choices?.[0] ?? ''])); diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index dc76247f89..04210947ea 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -135,3 +135,47 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`Databricks prerequisi assert.equal(f.requests.at(-1)?.action, 'save'); } finally { shell.close(); } }); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`dialog visual hierarchy and pointer focus at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }); + const dialog = new ProviderDialog(ui.renderer, 'ADD PROVIDER', [{ label: 'Name', value: 'OpenAI' }, { label: 'API key', secret: true }], () => {}); + const rgb = (color: { buffer: Uint16Array }) => Array.from(color.buffer).slice(0, 3).join(','); + const spans = () => ui.captureSpans().lines.flatMap(line => line.spans); + const check = (text: string, color: string) => { + const span = spans().find(span => span.text.includes(text)); + assert.ok(span, text); assert.equal(rgb(span.fg), color); assert.equal(rgb(span.bg), '13,16,14'); + }; + const key = (name: string) => dialog.key({ name, sequence: '', ctrl: false, meta: false, shift: false }); + try { + await ui.renderOnce(); + check('Name', '125,134,124'); check('OpenAI', '220,225,216'); check('[', '255,211,78'); check('Enter API key', '125,134,124'); + key('tab'); await ui.renderOnce(); check('Enter API key', '125,134,124'); + const keyboard = ui.captureCharFrame(); + key('up'); await ui.renderOnce(); + const lines = ui.captureCharFrame().split('\n'), y = lines.findIndex(line => line.includes('Enter API key')); + await ui.mockMouse.click(lines[y]!.indexOf('Enter API key'), y); await ui.renderOnce(); + assert.equal(ui.captureCharFrame(), keyboard); + dialog.updateSecretLength(4); await ui.renderOnce(); check('••••', '220,225,216'); + key('tab'); await ui.renderOnce(); check('Save', '220,225,216'); check('Tab next', '125,134,124'); + for (const span of spans()) assert.notEqual(rgb(span.bg), '220,225,216'); + } finally { dialog.cancel(); ui.renderer.destroy(); } +}); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`choice arrows and notice actions have separate boundaries at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }); + let dialog = new ProviderDialog(ui.renderer, 'ADD PROVIDER', [{ label: 'Type', value: 'openai', choices: ['openai', 'anthropic'] }], () => {}, 'Continue'); + try { + await ui.renderOnce(); + const lines = ui.captureCharFrame().split('\n'); + assert.doesNotMatch(lines.find(line => line.includes('Type'))!, /←|→/); + assert.match(lines.find(line => line.includes('openai'))!, /\[ openai +← → \]/); + assert.match(lines.find(line => line.includes('Continue'))!, /\[ Continue +\]/); + dialog.cancel(); + dialog = new ProviderDialog(ui.renderer, 'NOTICE', [], () => {}, 'Close', 'Nothing changed.'); + await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /\[ Close +\]/); + const help = ui.captureSpans().lines.flatMap(line => line.spans).find(span => span.text.includes('Enter or Esc close'))!; + assert.deepEqual(Array.from(help.fg.buffer).slice(0, 3), [125, 134, 124]); + assert.deepEqual(Array.from(help.bg.buffer).slice(0, 3), [13, 16, 14]); + } finally { dialog.cancel(); ui.renderer.destroy(); } +});