From db8d5f49c2274e2336ab8b20331d380d004bb7db Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Mon, 21 Sep 2026 15:06:40 -0400 Subject: [PATCH] fix(beehive): follow header selection and populate saved key masks Signed-off-by: Logan Johnson --- beehive/README.md | 7 +++- beehive/src/opentui-shell.ts | 4 +-- beehive/src/provider-dialog.ts | 18 ++++++---- beehive/src/provider-panel.ts | 2 +- beehive/src/shell-state.ts | 14 ++++---- beehive/test/provider-service.test.ts | 9 +++++ beehive/test/shell-opentui.test.ts | 2 +- beehive/test/shell-providers.test.ts | 49 +++++++++++++++++++++++++++ beehive/test/shell-state.test.ts | 24 ++++++++++++- 9 files changed, 111 insertions(+), 18 deletions(-) diff --git a/beehive/README.md b/beehive/README.md index 85e14af888..db7cdcf4bb 100644 --- a/beehive/README.md +++ b/beehive/README.md @@ -895,7 +895,9 @@ OpenAI-compatible (endpoint and wire mode), OpenRouter, and Databricks v2. Select a saved row, then use Tab or `a` to focus its actions. Arrows select an action; Enter runs it. PgUp/PgDn scroll details and the full result. Forms use Tab/Shift-Tab, Left/Right choices, Enter to advance/submit, Ctrl-U to -clear a field, and Esc to cancel. API-key input is masked; its value stays in the +clear a field, and Esc to cancel. Saved API-key fields open with a fixed mask; +typing replaces the key, while an untouched field retains it. The mask does not +read or expose the saved key. API-key input is masked; its value stays in the Node controller and credential helper, not renderer state or settings. Edit preserves provider identity and affects future launches. A replacement API @@ -930,3 +932,6 @@ any account is saved. Missing accounts show NOT SET. Saving configuration alone does not establish connection. Select an unconfigured type and use Configure, or select a saved account and use Edit configuration. The global Add provider command is removed; all existing saved accounts remain accessible. + +In the prototype shell, Left/Right in the header changes the visible destination +immediately and keeps focus in the header. Enter/Down moves into that pane. diff --git a/beehive/src/opentui-shell.ts b/beehive/src/opentui-shell.ts index bdb762268e..7ae63461dc 100644 --- a/beehive/src/opentui-shell.ts +++ b/beehive/src/opentui-shell.ts @@ -9,9 +9,9 @@ export const palette = { focus: '#ffd34e', healthy: '#75d795', failure: '#ff8179', selected: '#dce1d8', selectedText: '#10130f', dialog: '#9ca49b', scrim: '#070907', } as const; -const wideFooter = '←→ move Enter open Tab panes Esc return ? help q quit'; +const wideFooter = '←→ navigate Enter pane Tab panes Esc return ? help q quit'; const minimumFooter = 'Ctrl-Q quit'; -const helpBody = 'Header: ←→ destination; Enter / ↓ opens\nList: ↑ header; → / Enter Details\nDetails: ↑ header; ← List\nTab / Shift-Tab visible regions\nEsc active header\n? / Enter / Esc close help\nq quit Beehive\nProviders: a / Tab actions; ↑↓ selects\nPgUp / PgDn scroll details and results\nForms: Tab next; ←→ choice; Ctrl-U clear'; +const helpBody = 'Header: ←→ navigates; Enter / ↓ enters pane\nList: ↑ header; → / Enter Details\nDetails: ↑ header; ← List\nTab / Shift-Tab visible regions\nEsc active header\n? / Enter / Esc close help\nq quit Beehive\nProviders: a / Tab actions; ↑↓ selects\nPgUp / PgDn scroll details and results\nForms: Tab next; ←→ choice; Ctrl-U clear'; const helpTitleRows = 2; const helpActionRows = 2; const helpVerticalChrome = 4; // one-cell border and one-row inset at both edges diff --git a/beehive/src/provider-dialog.ts b/beehive/src/provider-dialog.ts index 0c33681da1..91fd87318d 100644 --- a/beehive/src/provider-dialog.ts +++ b/beehive/src/provider-dialog.ts @@ -1,7 +1,7 @@ import { BoxRenderable, TextRenderable, type CliRenderer, type KeyEvent } from '@opentui/core'; import { palette } from './opentui-shell.ts'; -export type DialogField = { label: string; value?: string; secret?: boolean; choices?: string[] }; +export type DialogField = { label: string; value?: string; secret?: boolean; savedSecret?: boolean; choices?: string[] }; export type DialogResult = Record; /** Public form state only. Secret keystrokes go straight to the controller. */ @@ -54,7 +54,11 @@ export class ProviderDialog { const field = this.fields[this.index]; if (!field || field.choices) return; const text = value.replace(/[\x00-\x1f\x7f]/g, ''); - if (field.secret) this.secret('append', text); + if (field.secret) { + if (!text) return; + field.savedSecret = false; + this.secret('append', text); + } else field.value = ((field.value ?? '') + text).slice(0, 4096); this.paint(); } @@ -77,9 +81,9 @@ export class ProviderDialog { field.value = field.choices[(current + (key.name === 'left' ? -1 : 1) + field.choices.length) % field.choices.length]; } } else if (key.ctrl && key.name === 'u') { - if (field.secret) this.secret('clear'); else field.value = ''; + if (field.secret) { field.savedSecret = false; this.secret('clear'); } else field.value = ''; } else if (key.name === 'backspace') { - if (field.secret) this.secret('backspace'); else field.value = [...(field.value ?? '')].slice(0, -1).join(''); + if (field.secret) { field.savedSecret = false; this.secret('backspace'); } else field.value = [...(field.value ?? '')].slice(0, -1).join(''); } else if (!key.ctrl && !key.meta && key.sequence && !/[\x00-\x1f\x7f]/.test(key.sequence)) this.paste(key.sequence); this.paint(); } @@ -99,9 +103,11 @@ export class ProviderDialog { 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 ?? ''; + // A fixed mask represents the retained credential, never its value or length. + // First input replaces it; the existing empty-secret save contract retains it. + const value = field.secret ? '•'.repeat(Math.min(field.savedSecret ? 12 : this.secretLength, width - 10)) : field.value ?? ''; 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 prompt = field.secret ? '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; diff --git a/beehive/src/provider-panel.ts b/beehive/src/provider-panel.ts index 16b9e99dc0..96ed6a0c8e 100644 --- a/beehive/src/provider-panel.ts +++ b/beehive/src/provider-panel.ts @@ -110,7 +110,7 @@ export class ProviderPanel { if (type === 'databricks_v2') fields.push({ label: 'Workspace (DATABRICKS_HOST)', value: row?.endpoint ?? 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 }); + fields.push({ label: 'API key', secret: true, savedSecret: !!row }); } const result = await this.form(row ? 'EDIT PROVIDER' : 'CONFIGURE PROVIDER', fields); if (!result || generation !== this.generation) return; diff --git a/beehive/src/shell-state.ts b/beehive/src/shell-state.ts index d0929e2be4..31035c1186 100644 --- a/beehive/src/shell-state.ts +++ b/beehive/src/shell-state.ts @@ -92,14 +92,13 @@ export class ShellState { if (name === 'left' || name === 'right') { const delta = name === 'left' ? -1 : 1; const next = Math.max(0, Math.min(destinations.length, this.headerIndex + delta)); - if (next !== this.headerIndex) { this.headerIndex = next; return 'render'; } + if (next !== this.headerIndex) { this.selectHeader(next); return 'render'; } return 'none'; } if (name === 'return' || name === 'down') { this.activateHeader(); return 'render'; } } - // Collection panes follow their visible geometry. Header focus and the - // active destination remain separate: only header activation changes the - // destination, while these transitions only move the focus perimeter. + // Header selection changes the destination immediately. Collection-pane + // transitions only move focus, never the destination. if (this.collection && this.focus === 'list') { if (this.activeSection === 2 && name === 'down') return this.moveHarness(1) ? 'render' : 'none'; if (name === 'up') { @@ -133,15 +132,18 @@ export class ShellState { return 'none'; } - activateHeader(index = this.headerIndex) { + private selectHeader(index: number) { this.headerIndex = Math.max(0, Math.min(destinations.length, index)); if (this.headerIndex === destinations.length) { this.mode = 'owner'; - this.focus = 'detail'; return; } this.mode = 'section'; this.activeSection = this.headerIndex; + } + + activateHeader(index = this.headerIndex) { + this.selectHeader(index); this.focus = this.collection ? 'list' : 'detail'; } } diff --git a/beehive/test/provider-service.test.ts b/beehive/test/provider-service.test.ts index 3aa7ff2204..c6e40f5965 100644 --- a/beehive/test/provider-service.test.ts +++ b/beehive/test/provider-service.test.ts @@ -39,6 +39,15 @@ test('save/edit automatically check catalogs, retain active credential reference assert.equal(f.service.snapshot().rows[0]?.modelCount, 1); assert.doesNotMatch(JSON.stringify(f.service.snapshot()), /gpt-fixture/); assert.equal(f.service.snapshot().resultTarget, provider.id); + assert.equal(await f.service.request({ action: 'save', provider: provider.id, revision: settings.revision, values: { name: 'Unchanged key' } }), true); + settings = readSettings(f.directory); + assert.deepEqual(settings.providers[0]!.key, oldKey); + assert.equal(f.keys.size, 1, 'unchanged masked field does not rotate credentials'); + assert.equal(f.calls.find(c => c.action === 'edit-provider').secret, ''); + f.service.secret('append', 'cancelled-private-key'); f.service.secret('clear'); + assert.equal(await f.service.request({ action: 'save', provider: provider.id, revision: settings.revision, values: { name: 'After cancel' } }), true); + settings = readSettings(f.directory); + assert.deepEqual(settings.providers[0]!.key, oldKey); assert.equal(f.keys.size, 1); f.service.secret('append', 'second-private-key'); assert.equal(await f.service.request({ action: 'save', provider: provider.id, revision: settings.revision, values: { name: 'Renamed' } }), true); settings = readSettings(f.directory); provider = settings.providers[0]!; diff --git a/beehive/test/shell-opentui.test.ts b/beehive/test/shell-opentui.test.ts index ab2295cf5c..3b419d2cb3 100644 --- a/beehive/test/shell-opentui.test.ts +++ b/beehive/test/shell-opentui.test.ts @@ -33,7 +33,7 @@ test('memory renderer draws the exact wide empty shell and focus perimeter', asy // Host is protected and therefore owns one literally empty full-width body. assert.equal(text(frame).slice(3, 38).join('').replace(/[│─┌┐└┘ ]/g, ''), ''); ui.mockInput.pressArrow('right'); await ui.renderOnce(); - assert.equal(shell.state.headerIndex, 1); assert.equal(shell.state.activeSection, 0); + assert.equal(shell.state.headerIndex, 1); assert.equal(shell.state.activeSection, 1); ui.mockInput.pressArrow('right'); ui.mockInput.pressEnter(); await ui.renderOnce(); frame = ui.captureCharFrame(); assert.equal(shell.state.activeSection, 2); assert.equal(shell.state.focus, 'list'); assert.ok(frame.includes('│')); assert.ok(frame.includes('Fixture Harness')); assert.ok(frame.includes('● READY')); diff --git a/beehive/test/shell-providers.test.ts b/beehive/test/shell-providers.test.ts index cfedafcced..7af0d95bb1 100644 --- a/beehive/test/shell-providers.test.ts +++ b/beehive/test/shell-providers.test.ts @@ -35,6 +35,8 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`Providers signed-out assert.deepEqual(f.requests.at(-1), { action: 'models', provider: 'p', revision: 1 }); ui.mockInput.pressArrow('up'); ui.mockInput.pressEnter(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /EDIT PROVIDER/); + assert.match(ui.captureCharFrame(), /••••••••••••/); + assert.doesNotMatch(ui.captureCharFrame(), /blank keeps saved|Leave blank/); ui.mockInput.pressTab(); await ui.renderOnce(); ui.mockInput.typeText('never-render-this-key'); await ui.renderOnce(); assert.doesNotMatch(ui.captureCharFrame(), /never-render-this-key/); @@ -270,3 +272,50 @@ for (const [width, height] of [[120, 40], [60, 20]]) test(`provider status is co assert.match(ui.captureCharFrame(), /CONFIGURE PROVIDER/); assert.doesNotMatch(ui.captureCharFrame(), /ADD PROVIDER/); } finally { shell.close(); } }); + + +for (const [width, height] of [[120, 40], [60, 20]]) test(`header navigation paints Providers and cancels on exit without Enter at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }), f = fixture(); + let cancelled = 0; f.client.cancel = () => { cancelled++; }; + const shell = new OpenTuiShell(ui.renderer, f.inventory, undefined, f.client); + try { + for (let i = 0; i < 3; i++) ui.mockInput.pressArrow('right'); + await ui.renderOnce(); + assert.equal(shell.state.focus, 'header'); assert.match(ui.captureCharFrame(), /MODEL ACCOUNTS/); + assert.deepEqual(f.requests, [{ action: 'reload' }]); + ui.mockInput.pressArrow('right'); await ui.renderOnce(); + assert.equal(shell.state.mode, 'owner'); assert.equal(shell.state.focus, 'header'); + assert.doesNotMatch(ui.captureCharFrame(), /MODEL ACCOUNTS/); assert.equal(cancelled, 1); + ui.mockInput.pressArrow('left'); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /MODEL ACCOUNTS/); assert.equal(f.requests.length, 2); + ui.mockInput.pressEnter(); await ui.renderOnce(); + assert.equal(shell.state.focus, 'list'); assert.equal(f.requests.length, 2); + assert.ok(f.requests.every(request => request.action === 'reload')); + } finally { shell.close(); } +}); + +for (const [width, height] of [[120, 40], [60, 20]]) test(`saved mask is retained until replacement, clear or cancellation at ${width}x${height}`, async () => { + const ui = await createTestRenderer({ width, height, exitOnCtrlC: false }); + const calls: { action: string; value?: string }[] = []; + let dialog: ProviderDialog; + let secret = ''; + const open = () => new ProviderDialog(ui.renderer, 'EDIT PROVIDER', [{ label: 'API key', secret: true, savedSecret: true }], (action, value) => { + calls.push({ action, value }); + secret = action === 'clear' ? '' : action === 'backspace' ? secret.slice(0, -1) : secret + value; + dialog.updateSecretLength(secret.length); + }); + const key = (name: string, ctrl = false) => dialog.key({ name, ctrl, sequence: '', shift: false, meta: false }); + try { + dialog = open(); dialog.updateSecretLength(0); await ui.renderOnce(); + assert.match(ui.captureCharFrame(), /••••••••••••/); + key('return'); key('return'); assert.deepEqual(await dialog.done, {}); assert.equal(calls.length, 0); + dialog = open(); dialog.paste('replacement'); await ui.renderOnce(); + assert.equal(secret, 'replacement'); assert.match(ui.captureCharFrame(), /••••••••••• /); + assert.doesNotMatch(ui.captureCharFrame(), /replacement/); + key('u', true); await ui.renderOnce(); assert.equal(secret, ''); assert.match(ui.captureCharFrame(), /Enter API key/); + dialog.cancel(); assert.equal(await dialog.done, undefined); + dialog = open(); key('backspace'); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /Enter API key/); + dialog.paste('cancelled'); dialog.cancel(); assert.equal(secret, ''); assert.equal(await dialog.done, undefined); + dialog = open(); await ui.renderOnce(); assert.match(ui.captureCharFrame(), /••••••••••••/); dialog.cancel(); + } finally { ui.renderer.destroy(); } +}); diff --git a/beehive/test/shell-state.test.ts b/beehive/test/shell-state.test.ts index 069cb4c4f1..8b8fa295f2 100644 --- a/beehive/test/shell-state.test.ts +++ b/beehive/test/shell-state.test.ts @@ -13,7 +13,7 @@ test('never-signed-in shell labels and initial state are exact', () => { test('wide collection arrows traverse header, List and Details without activating a new destination', () => { const state = new ShellState(); state.setHarnessRows([{ id: 'harness:fixture', kind: 'harness' }, { id: 'command:refresh', kind: 'command' }]); - assert.equal(state.key('right'), 'render'); assert.equal(state.headerIndex, 1); assert.equal(state.activeSection, 0); + assert.equal(state.key('right'), 'render'); assert.equal(state.headerIndex, 1); assert.equal(state.activeSection, 1); state.key('right'); state.key('return'); assert.deepEqual({ active: state.activeSection, header: state.headerIndex, focus: state.focus, split: state.splitPane }, { active: 2, header: 2, focus: 'list', split: true }); state.key('right'); assert.deepEqual({ active: state.activeSection, focus: state.focus }, { active: 2, focus: 'detail' }); @@ -59,3 +59,25 @@ test('harness rows preserve stable identity, fall back deterministically, and vi test('prototype breakpoint formulas are exact', () => { assert.equal(listWidth(120), 35); assert.equal(listWidth(90), 28); assert.equal(listWidth(72), 25); assert.equal(listWidth(60), 24); }); + + +test('header cursor follows every destination and owner without entering panes', () => { + const state = new ShellState(); + assert.equal(state.key('left'), 'none'); + for (let index = 1; index <= 4; index++) { + state.key('right'); + assert.equal(state.headerIndex, index); assert.equal(state.focus, 'header'); + assert.equal(state.mode, index === 4 ? 'owner' : 'section'); + if (index < 4) assert.equal(state.activeSection, index); + } + assert.equal(state.key('right'), 'none'); + state.key('down'); assert.equal(state.focus, 'detail'); + state.key('escape'); + for (let index = 3; index >= 0; index--) { + state.key('left'); + assert.equal(state.activeSection, index); assert.equal(state.mode, 'section'); + assert.equal(state.focus, 'header'); + } + state.key('?'); state.key('right'); assert.equal(state.activeSection, 0); + state.key('escape'); state.resize(59, 19); state.key('right'); assert.equal(state.activeSection, 0); +});