mirror of
https://github.com/block/buzz.git
synced 2026-11-01 23:11:58 +00:00
feat(beehive): keep controls visible beside details and preserve scroll across pushes
Move the action list out of the Details scroll into a bordered, bounded Controls region in the right pane so agent actions stay reachable and visibly focused beside arbitrarily long detail text, for pointer and keyboard alike. Map clicks through the pinned SelectRenderable's deterministic window so every visible action runs exactly itself when the real 13-action Agents menu overflows the bounded region; wheel and keyboard movement keep the remaining actions reachable. Only a genuine item or section change re-routes selection and presents its details from the top. Unchanged snapshot pushes, background or explicit refresh, preserve the user's Details scroll position. Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -15,14 +15,17 @@ export class OpenTuiScreen {
|
||||
private readonly header: TextRenderable;
|
||||
private readonly list: SelectRenderable;
|
||||
private readonly listFrame: BoxRenderable;
|
||||
private readonly pane: BoxRenderable;
|
||||
private readonly detail: TextRenderable;
|
||||
private readonly scroll: ScrollBoxRenderable;
|
||||
private readonly controlsFrame: BoxRenderable;
|
||||
private readonly actions: SelectRenderable;
|
||||
private readonly status: TextRenderable;
|
||||
private readonly footer: TextRenderable;
|
||||
private readonly small: TextRenderable;
|
||||
private rows: ManagerRow[] = [];
|
||||
private commands: ManagerAction[] = [];
|
||||
private selectedRow = -1;
|
||||
private focusIndex = 0;
|
||||
private scope = 0;
|
||||
private owner = 'signed out';
|
||||
@@ -66,12 +69,33 @@ export class OpenTuiScreen {
|
||||
else this.notice('Use arrows to select an item in this list. Mouse selection is unavailable.');
|
||||
} });
|
||||
this.listFrame.add(this.list);
|
||||
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, height: '100%', border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
|
||||
body.add(this.scroll);
|
||||
// Details and Controls share the right column. Controls always occupy a
|
||||
// bounded bottom region, so agent actions stay reachable and visibly
|
||||
// focused beside arbitrarily long detail text, for pointer and keyboard.
|
||||
this.pane = new BoxRenderable(renderer, { flexGrow: 1, height: '100%', flexDirection: 'column' });
|
||||
body.add(this.pane);
|
||||
this.scroll = new ScrollBoxRenderable(renderer, { flexGrow: 1, border: true, title: 'Details', scrollY: true, onMouseDown: () => { this.focusIndex = 1; this.scroll.focus(); } });
|
||||
this.pane.add(this.scroll);
|
||||
this.detail = new TextRenderable(renderer, { fg, width: '100%', content: 'No items.', selectable: false });
|
||||
this.scroll.add(this.detail);
|
||||
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, height: 10, showDescription: false, showSelectionIndicator: true, options: [], onMouseDown: event => { this.focusIndex = 2; this.actions.focus(); this.actions.setSelectedIndex(Math.max(0, Math.min(this.commands.length - 1, event.y - this.actions.y))); this.actions.selectCurrent(); } });
|
||||
this.scroll.add(this.actions);
|
||||
this.controlsFrame = new BoxRenderable(renderer, { height: 7, border: true, title: 'Controls' });
|
||||
this.pane.add(this.controlsFrame);
|
||||
this.actions = new SelectRenderable(renderer, { backgroundColor: bg, textColor: fg, focusedBackgroundColor: bg, focusedTextColor: fg, selectedBackgroundColor: fg, selectedTextColor: bg, width: '100%', height: '100%', showDescription: false, showSelectionIndicator: true, showScrollIndicator: true, options: [], onMouseScroll: event => { if (event.scroll?.direction === 'up') this.actions.moveUp(); else if (event.scroll?.direction === 'down') this.actions.moveDown(); }, onMouseDown: event => {
|
||||
this.focusIndex = 2; this.actions.focus();
|
||||
const delta = event.y - this.actions.y, visible = Math.max(1, Math.floor(this.actions.height));
|
||||
if (delta < 0 || delta >= visible) return;
|
||||
// The pinned SelectRenderable never exposes its window offset, but its
|
||||
// scroll is deterministic: one row per action, the selection kept centered
|
||||
// (offset = max(0, min(selected - floor(visible/2), count - visible))).
|
||||
// Map the click through that same window so every visible action runs
|
||||
// exactly itself even when the real Agents menu overflows the bounded
|
||||
// region; keyboard and wheel keep the remaining actions reachable.
|
||||
const offset = Math.max(0, Math.min(this.actions.getSelectedIndex() - Math.floor(visible / 2), this.commands.length - visible));
|
||||
const index = offset + delta;
|
||||
if (index >= this.commands.length) return;
|
||||
this.actions.setSelectedIndex(index); this.actions.selectCurrent();
|
||||
} });
|
||||
this.controlsFrame.add(this.actions);
|
||||
this.status = new TextRenderable(renderer, { fg, height: 2, onMouseDown: () => this.outcome() });
|
||||
this.root.add(this.status);
|
||||
this.footer = new TextRenderable(renderer, { fg, height: 1, content: OpenTuiScreen.hintsWide });
|
||||
@@ -79,6 +103,12 @@ export class OpenTuiScreen {
|
||||
this.small = new TextRenderable(renderer, { fg, position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', bg, content: 'Resize to at least 40 × 16. Ctrl-Q quits. Existing operations can continue.', visible: false });
|
||||
renderer.root.add(this.small);
|
||||
this.list.on('selectionChanged', (index: number) => {
|
||||
// Pinned OpenTUI re-emits selectionChanged even for an unchanged index
|
||||
// (programmatic snapshot restores, clamped moves, re-clicks). Only a
|
||||
// genuine selection change resets Details scroll and re-routes
|
||||
// selection, so unchanged background snapshots preserve user scroll.
|
||||
if (index === this.selectedRow) { this.focusStyle(); return; }
|
||||
this.selectedRow = index;
|
||||
const row = this.rows[index]; this.detail.content = clean(row?.detail ?? 'No items.'); this.scroll.scrollTo(0);
|
||||
this.focusStyle(); if (row) this.onSelect(row.id);
|
||||
});
|
||||
@@ -99,6 +129,7 @@ export class OpenTuiScreen {
|
||||
private focusStyle() {
|
||||
this.listFrame.title = `${this.list.focused ? '[List]' : 'List'} · ${this.list.getSelectedIndex() + 1}/${this.rows.length}`;
|
||||
this.scroll.title = managerSections[this.scope] ?? 'Details';
|
||||
this.controlsFrame.title = this.actions.focused ? '[Controls]' : 'Controls';
|
||||
for (const widget of [this.list, this.actions]) { widget.selectedBackgroundColor = widget.focused ? fg : bg; widget.selectedTextColor = widget.focused ? bg : fg; }
|
||||
}
|
||||
private narrow = false;
|
||||
@@ -109,13 +140,17 @@ export class OpenTuiScreen {
|
||||
setPending(value: boolean) { this.pending = value; }
|
||||
private formWidth() { return this.scope === 0 ? Math.max(20, this.renderer.width - this.listFrame.width - 2) : Math.min(72, this.renderer.width - 4); }
|
||||
private formLeft() { return this.scope === 0 ? this.listFrame.width : Math.floor((this.renderer.width - this.formWidth()) / 2); }
|
||||
/** Bounded Controls height: every action stays reachable through the list's
|
||||
* own focus scroll, while Details keeps at least a bordered sliver. */
|
||||
private controlsHeight() { return Math.min(Math.max(7, Math.min(12, this.commands.length + 2)), Math.max(7, this.renderer.height - 10)); }
|
||||
private resize() {
|
||||
this.heading();
|
||||
if (this.overlay && this.modal) { this.overlay.width = this.formWidth(); this.overlay.left = this.formLeft(); this.overlay.height = this.compactOverlay ? Math.min(14, this.renderer.height - 4) : this.renderer.height - 4; }
|
||||
this.small.visible = this.renderer.width < 40 || this.renderer.height < 16;
|
||||
this.narrow = this.scope !== 0 && (this.renderer.width < 88 || this.renderer.height < 24);
|
||||
this.listFrame.visible = !this.narrow || !this.drilled;
|
||||
this.scroll.visible = !this.narrow || this.drilled;
|
||||
this.pane.visible = !this.narrow || this.drilled;
|
||||
this.controlsFrame.height = this.controlsHeight();
|
||||
this.listFrame.width = this.narrow ? '100%' : Math.min(32, Math.max(14, Math.floor(this.renderer.width * .28)));
|
||||
this.scroll.title = managerSections[this.scope] ?? 'Details';
|
||||
this.footer.content = this.relayFooter + '\n' + (this.renderer.width >= 70 ? OpenTuiScreen.hintsWide : OpenTuiScreen.hintsNarrow); this.footer.height = 2;
|
||||
@@ -126,7 +161,9 @@ export class OpenTuiScreen {
|
||||
setOwner(publicSuffix?: string) { this.owner = publicSuffix ? `${publicSuffix} · signed in · key saved here` : 'signed out'; this.heading(); }
|
||||
private switchScope(scope: number) {
|
||||
if (this.modal || this.busy || this.small.visible || this.closed) return;
|
||||
this.scope = scope; this.drilled = false; this.resize(); this.heading(); this.onScope(scope);
|
||||
// A section change must present its own details from the top even when the
|
||||
// retained selection lands on the same list index.
|
||||
this.scope = scope; this.drilled = false; this.selectedRow = -1; this.resize(); this.heading(); this.onScope(scope);
|
||||
}
|
||||
private key(key: KeyEvent) {
|
||||
if (key.ctrl && (key.name === 'q' || key.name === 'c')) { key.preventDefault(); this.close(); return; }
|
||||
@@ -168,7 +205,7 @@ export class OpenTuiScreen {
|
||||
this.detail.visible = true;
|
||||
const commandOptions = actions.map(a => ({ name: clean(a.label + (a.disabled ? ' — unavailable' : '')), description: '' }));
|
||||
if (JSON.stringify(this.actions.options) !== JSON.stringify(commandOptions)) this.actions.options = commandOptions;
|
||||
this.actions.height = Math.max(5,actions.length);
|
||||
this.controlsFrame.height = this.controlsHeight();
|
||||
const options = rows.map(row => ({ name: clean(row.label), description: '', value: row.id }));
|
||||
if (JSON.stringify(this.list.options) !== JSON.stringify(options)) this.list.options = options;
|
||||
const index = Math.max(0, rows.findIndex(row => row.id === prior));
|
||||
|
||||
@@ -57,6 +57,95 @@ test('footer shows the optional sanitized relay name before the exact URL and st
|
||||
});
|
||||
|
||||
|
||||
test('agent controls stay visible beside long details and user scroll survives unchanged snapshot pushes', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
try {
|
||||
const detail = Array.from({ length: 40 }, (_, index) => `Report line ${String(index).padStart(2, '0')}`).join('\n');
|
||||
const rows = [{ id: 'agent-one', label: 'Agent one', detail }, { id: 'agent-two', label: 'Agent two', detail: `Second agent\n${detail}` }];
|
||||
const runs: string[] = [];
|
||||
const routed: string[] = [];
|
||||
view.onSelect = id => { routed.push(id); };
|
||||
const actions = ['Start', 'Stop', 'Restart', 'Move', 'Inspect'].map(label => ({ label, run: () => { runs.push(label); } }));
|
||||
view.show(rows, actions); await ui.renderOnce();
|
||||
// Controls are a bounded always-visible region: the action list renders beside
|
||||
// long detail text, without scrolling the Details pane to reach it.
|
||||
const initial = ui.captureCharFrame();
|
||||
assert.ok(initial.includes('Controls')); assert.ok(initial.includes('Restart')); assert.ok(initial.includes('Report line 00'));
|
||||
// The advertised 'a' key focuses controls and the focused region stays on screen.
|
||||
ui.mockInput.pressKey('a'); await ui.renderOnce();
|
||||
const focused = ui.captureCharFrame();
|
||||
assert.ok(focused.includes('[Controls]')); assert.ok(focused.includes('Restart'));
|
||||
// User wheel scroll over Details moves the long report off its first lines.
|
||||
for (let wheel = 0; wheel < 10; wheel++) await ui.mockMouse.scroll(60, 8, 'down');
|
||||
await ui.renderOnce();
|
||||
const scrolled = ui.captureCharFrame();
|
||||
assert.ok(!scrolled.includes('Report line 00'), 'wheel never scrolled Details'); assert.ok(scrolled.includes('Report line 10'));
|
||||
// Repeated unchanged snapshot pushes must preserve that scroll and must not
|
||||
// re-route selection: background refreshes cannot wipe the reading position.
|
||||
view.show(rows, actions); await ui.renderOnce();
|
||||
view.show(rows, actions); await ui.renderOnce();
|
||||
const pushed = ui.captureCharFrame();
|
||||
assert.ok(!pushed.includes('Report line 00'), 'unchanged snapshot push reset Details scroll');
|
||||
assert.ok(pushed.includes('Report line 10')); assert.deepEqual(routed, ['agent-one']); assert.deepEqual(runs, []);
|
||||
// Pointer wheel over the bounded Controls moves the focused action selection.
|
||||
await ui.mockMouse.scroll(40, 22, 'down'); await ui.mockMouse.scroll(40, 22, 'down'); await ui.renderOnce();
|
||||
assert.ok(ui.captureCharFrame().includes('▶ Restart'));
|
||||
// A click on a visible control runs exactly that action, never an offset-mapped stranger.
|
||||
await ui.mockMouse.click(40, 24); await ui.renderOnce();
|
||||
assert.deepEqual(runs, ['Inspect']);
|
||||
// A genuine item selection change still routes, swaps details and resets scroll.
|
||||
ui.mockInput.pressTab(); await ui.renderOnce();
|
||||
ui.mockInput.pressArrow('down'); await ui.renderOnce();
|
||||
const changed = ui.captureCharFrame();
|
||||
assert.ok(changed.includes('Second agent')); assert.ok(changed.includes('Report line 00'), 'genuine selection did not reset Details scroll');
|
||||
assert.deepEqual(routed, ['agent-one', 'agent-two']);
|
||||
// A genuine section change also presents its details from the top.
|
||||
view.onScope = () => { view.show(rows, actions); };
|
||||
ui.mockInput.pressArrow('right'); await ui.renderOnce();
|
||||
const switched = ui.captureCharFrame();
|
||||
assert.ok(switched.includes('[Agents]')); assert.ok(switched.includes('Report line 00'), 'section switch did not reset Details scroll');
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
test('overflowing real Agents controls map every visible click to its own action and keep the tail reachable', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
try {
|
||||
const runs: string[] = [];
|
||||
// The actual signed-in Agents menu from manager-view.ts is the longest list in
|
||||
// the product: it overflows the bounded Controls region at 100x30.
|
||||
const labels = ['Register agent', 'Refresh agents', 'Choose runtime…', 'Choose configuration…', 'Start', 'Stop', 'Restart', 'Move…', 'Inspect operations', 'Check operation results', 'Sign out', 'Publish profile or instructions', 'Quit Beehive'];
|
||||
const actions = labels.map(label => ({ label, run: () => { runs.push(label); } }));
|
||||
view.show([{ id: 'review-agent', label: 'Review agent', detail: 'Agent detail' }], actions);
|
||||
await ui.renderOnce();
|
||||
const initial = ui.captureCharFrame();
|
||||
assert.ok(initial.includes('Register agent'), 'first Agents action must be visible');
|
||||
assert.ok(!initial.includes('Sign out'), 'the overflowed tail starts outside the bounded window');
|
||||
// With the window on the first ten actions, each visible row runs exactly itself.
|
||||
await ui.mockMouse.click(40, 15); await ui.renderOnce();
|
||||
await ui.mockMouse.click(40, 24); await ui.renderOnce();
|
||||
assert.deepEqual(runs, ['Register agent', 'Check operation results']);
|
||||
// The wheel moves the selection to the tail; the window follows and the tail
|
||||
// becomes visible and clickable through the same offset-aware mapping.
|
||||
ui.mockInput.pressKey('a'); await ui.renderOnce();
|
||||
for (let wheel = 0; wheel < 12; wheel++) await ui.mockMouse.scroll(40, 20, 'down');
|
||||
await ui.renderOnce();
|
||||
const tailed = ui.captureCharFrame();
|
||||
assert.ok(tailed.includes('Quit Beehive'), 'keyboard/wheel must keep the tail reachable');
|
||||
assert.ok(!tailed.includes('Register agent'), 'window should have followed the selection');
|
||||
await ui.mockMouse.click(40, 22); await ui.renderOnce();
|
||||
await ui.mockMouse.click(40, 24); await ui.renderOnce();
|
||||
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Quit Beehive']);
|
||||
// Keyboard selection movement recentres the window on the head again.
|
||||
for (let step = 0; step < 12; step++) ui.mockInput.pressArrow('up');
|
||||
await ui.renderOnce();
|
||||
assert.ok(ui.captureCharFrame().includes('Register agent'));
|
||||
await ui.mockMouse.click(40, 15); await ui.renderOnce();
|
||||
assert.deepEqual(runs, ['Register agent', 'Check operation results', 'Sign out', 'Quit Beehive', 'Register agent']);
|
||||
} finally { view.close(); }
|
||||
});
|
||||
|
||||
test('four sections navigate in both directions and remain readable at narrow width', async () => {
|
||||
const ui = await createTestRenderer({ width: 100, height: 30, exitOnCtrlC: false });
|
||||
const view = new OpenTuiScreen(ui.renderer);
|
||||
|
||||
Reference in New Issue
Block a user