mirror of
https://github.com/block/buzz.git
synced 2026-10-28 13:01:10 +00:00
fix(beehive): follow header selection and populate saved key masks
Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user