diff --git a/beehive/src/opentui-screen.ts b/beehive/src/opentui-screen.ts index a2fc6c7a48..95bee2491b 100644 --- a/beehive/src/opentui-screen.ts +++ b/beehive/src/opentui-screen.ts @@ -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)); diff --git a/beehive/test/opentui-screen.bun.ts b/beehive/test/opentui-screen.bun.ts index 567784cade..ad9a3ab3df 100644 --- a/beehive/test/opentui-screen.bun.ts +++ b/beehive/test/opentui-screen.bun.ts @@ -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);