diff --git a/harness/ruview/mod/hooks/register.mjs b/harness/ruview/mod/hooks/register.mjs index 27b63be0c..789cbdc23 100644 --- a/harness/ruview/mod/hooks/register.mjs +++ b/harness/ruview/mod/hooks/register.mjs @@ -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(); }, diff --git a/harness/ruview/mod/hooks/views.mjs b/harness/ruview/mod/hooks/views.mjs index 425567b27..510ebf4a5 100644 --- a/harness/ruview/mod/hooks/views.mjs +++ b/harness/ruview/mod/hooks/views.mjs @@ -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}` })); diff --git a/harness/ruview/mod/tests/register.test.ts b/harness/ruview/mod/tests/register.test.ts index 3953eb7cd..901159b7a 100644 --- a/harness/ruview/mod/tests/register.test.ts +++ b/harness/ruview/mod/tests/register.test.ts @@ -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++) { diff --git a/harness/ruview/test/mod-showcase.test.mjs b/harness/ruview/test/mod-showcase.test.mjs index 26819468f..fb1346d6b 100644 --- a/harness/ruview/test/mod-showcase.test.mjs +++ b/harness/ruview/test/mod-showcase.test.mjs @@ -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/); +});