fix(ruview-live): pane hotkeys need the keyboard — ask for focus, say how

Button hotkeys reach a Pane only while it holds the keyboard; the pane
opened without it, so 1/2/3/r/c went to the prompt. Open (and
/ruview waterfall|radar) now request `focus: true`; closeOnEscape is
dropped so Escape hands the keys back without closing; an unfocused pane
shows "click the pane or press ctrl+x tab to use the keys".

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv committed 2026-10-01 18:27:52 -04:00
1 parent 751b7ae17b
commit 78a1474057
4 files changed
+27 -5

No files matched your search

+9 -2
View File
@@ -143,7 +143,10 @@ export function register(on, options = {}) {
async function open(initialMode) {
if (MODES.includes(initialMode)) mode = initialMode;
await host.open({ id: PANE_ID, title: 'RuView', closeOnEscape: true });
// Ask for the keyboard so the view keys (1/2/3, r, c) work at once; the
// surface grants it only over an empty prompt. No closeOnEscape: Escape
// hands the keys back without closing the pane.
await host.open({ id: PANE_ID, title: 'RuView', focus: true });
stop();
startPolling();
}
@@ -176,7 +179,10 @@ export function register(on, options = {}) {
if (arg === 'off') { await close(); host.status(undefined); return { text: 'RuView pane closed.' }; }
if (arg === 'refresh') { await refresh(); return { text: statusOf(model) }; }
if (MODES.includes(arg)) {
if (isOpen) setMode(arg); else await open(arg);
if (isOpen) {
setMode(arg);
await host.open({ id: PANE_ID, title: 'RuView', focus: true }).catch(() => undefined);
} else await open(arg);
return { text: `RuView pane: ${arg} view.` };
}
if (isOpen) { await close(); return { text: 'RuView pane closed.' }; }
@@ -198,6 +204,7 @@ export function register(on, options = {}) {
const now = await $.clock.now();
return viewOf(ui, shownModel(), {
...drawOpts(now), refreshMs: settings.refreshMs, liveRefreshMs: settings.liveRefreshMs, busy, now,
focused: e.props?.isFocused !== false,
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
onRefresh: () => { void refresh(); },
onClose: () => { void close(); },
+7 -3
View File
@@ -84,9 +84,13 @@ export function viewOf(ui, model, opts) {
]);
const rule = pics.shimmer ? ui.Raster(pics.shimmer.grid.toRaster('shimmer')) : null;
const tabs = opts.onMode ? row(TABS.map(([m, key, label]) => Button({
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
})), { gap: 2 }) : null;
// Hotkeys reach a Pane only while it holds the keyboard; say how to give it.
const tabs = opts.onMode ? row([
...TABS.map(([m, key, label]) => Button({
key: `tab-${m}`, hotkey: key, label: `${m === mode ? '▸ ' : ''}${label} (${key})`, onPress: () => opts.onMode(m),
})),
opts.focused === false ? t('click the pane or press ctrl+x tab to use the keys', { dimColor: true, italic: true }) : null,
], { gap: 2 }) : null;
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
@@ -26,8 +26,10 @@ describe('register', () => {
runs.push(e.argv)
return { value: { exitCode: 0, stdout: JSON.stringify(CAPTURE), stderr: '' } }
})
const focusAsked: (true | undefined)[] = []
on('ui.open', ($, e) => {
opened.push(e.id)
focusAsked.push(e.focus)
return { value: undefined }
})
on('ui.close', ($, e) => {
@@ -50,6 +52,7 @@ describe('register', () => {
expect(text).toContain('RuView pane open')
expect(opened).toEqual(['ruview-live'])
expect(focusAsked).toEqual([true]) // hotkeys reach a Pane only while it holds the keyboard
expect(runs.length).toBeGreaterThanOrEqual(1)
const argv = runs[0] ?? []
expect(argv[0]).toBe('node')
@@ -109,6 +112,7 @@ describe('register', () => {
const again = await $.ui.mount({ plugin: 'ruview-live', surface: 'terminal', component: 'Pane', requestId: 'ruview-live', props: PANE })
expect(await again.find({ type: 'Text', text: /NODES/ })).toBeDefined()
expect(await again.find({ type: 'Text', text: /waiting for the first capture/ })).toBeUndefined()
expect(await again.find({ type: 'Text', text: /ctrl\+x tab/ })).toBeDefined() // unfocused: says how to focus
const polled = runs.length
// Step time as a live session does (the animation timer runs every 80 ms).
for (let s = 0; s < 16; s++) {
@@ -160,3 +160,10 @@ test('spectrum requests, merging across captures, and live commands', () => {
assert.deepEqual(merged[0].frames, [[2], [3]], 'frames carry across captures, bounded');
assert.deepEqual(spectrumWith(merged, [{ ...a, bins: 8, frames: [[9]] }])[0].frames, [[9]], 'a new bin count starts over');
});
test('an unfocused pane says how to give it the keys; a focused one does not', () => {
const base = { mode: 'overview', refreshMs: 15000, columns: 120, rows: 34, onMode() {}, onRefresh() {}, onClose() {} };
const model = modelOf(spectrumCapture(false), null, 0);
assert.match(JSON.stringify(viewOf(UI, model, { ...base, focused: false })), /ctrl\+x tab to use the keys/);
assert.doesNotMatch(JSON.stringify(viewOf(UI, model, { ...base, focused: true })), /ctrl\+x tab/);
});