feat(ruview-live): redesigned pane — LIVE badge, cards, trends, plausibility flags

- Header: RuView + LIVE/STALE badge (fresh within 2 refresh periods),
  "updated <time> (Ns ago)", a 5 s redraw tick keeps the age current.
- Bordered cards for the radar and the CSI nodes, side by side from 100
  columns, stacked below.
- Radar: PRESENCE DETECTED (+ target count) in green; distance, heart and
  breathing with per-refresh sparklines; device-reported vitals outside
  resting ranges (heart 40-180, breathing 4-40 bpm) are flagged in yellow
  instead of shown as fact (the live kit reported breathing 1.0 bpm).
- Nodes: per-node rate sparkline; "no packets" is a short hint in the card
  rather than a long top alert (status no longer counts it as an alert);
  an unconfigured radar says how to configure it.
- Buttons show their hotkeys.

Tests: 11 Node unit tests, 3 engine tests (mounted pane validates on the
terminal surface), validate passes.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv committed 2026-10-01 17:50:09 -04:00
1 parent bd7c506076
commit c4d7ef8086
3 files changed
+224 -37

No files matched your search

+167 -33
View File
@@ -10,6 +10,8 @@
export const PANE_ID = 'ruview-live';
export const COMMAND = 'ruview';
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$/;
const HISTORY = 24;
const TICK_MS = 5000;
/**
* The harness CLI beside this mod: the plugin root is <pkg>/mod, so the CLI is
@@ -56,15 +58,48 @@ export function resultOf(run) {
const fixed = (v, d = 1) => (typeof v === 'number' && Number.isFinite(v) ? v.toFixed(d) : '—');
const pct = (v) => (typeof v === 'number' ? `${(v * 100).toFixed(1)}%` : '—');
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
/** Resting ranges outside which a device-reported vital is flagged, not trusted. */
export const RANGES = Object.freeze({ heart: [40, 180], breathing: [4, 40] });
/** Why a device-reported vital looks implausible, or null. */
export function plausibilityOf(kind, value) {
if (value == null || !RANGES[kind]) return null;
const [lo, hi] = RANGES[kind];
return value < lo || value > hi ? `outside ${lo}–${hi} bpm` : null;
}
const SPARK = '▁▂▃▄▅▆▇█';
/** Unicode sparkline of the last `width` finite values ('' below two points). */
export function sparkline(values, width = 16) {
const v = (values || []).filter((x) => typeof x === 'number' && Number.isFinite(x)).slice(-width);
if (v.length < 2) return '';
const lo = Math.min(...v);
const hi = Math.max(...v);
return v.map((x) => SPARK[hi === lo ? 3 : Math.round(((x - lo) / (hi - lo)) * 7)]).join('');
}
/** "12s ago" / "3m ago" from a millisecond age, or '' when unknown. */
export function ageOf(ms) {
if (!Number.isFinite(ms) || ms < 0) return '';
const s = Math.round(ms / 1000);
return s < 60 ? `${s}s ago` : `${Math.round(s / 60)}m ago`;
}
/** The pane's model: plain data, drawn by viewOf and summarised by statusOf. */
export function modelOf(capture, radar, at) {
const alerts = [];
if (capture && capture.ok === false) alerts.push({ level: capture.reason === 'no_packets' ? 'warn' : 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
// "No packets" is shown inside the nodes card; only real faults become alerts.
if (capture && capture.ok === false && capture.reason !== 'no_packets') {
alerts.push({ level: 'bad', text: `nodes: ${String(capture.reason).replace(/_/g, ' ')}${capture.remedy ? ` — ${capture.remedy}` : ''}` });
}
for (const hb of capture?.heartbeatOnlySenders || []) alerts.push({ level: 'warn', text: `${hb.address} sends heartbeats but no CSI (run \`ruview monitor --baud 1500000\` on it: "lack of csi buf" = buffer starvation)` });
if (radar && radar.ok === false) alerts.push({ level: 'bad', text: `radar: ${String(radar.reason).replace(/_/g, ' ')}${radar.detail ? ` — ${radar.detail}` : ''}` });
const nodes = (capture?.nodes || []).map((n) => ({
key: `${n.source}:${n.nodeId}`,
label: `${n.source === 'realtek' ? 'realtek' : 'esp32'} ${n.nodeId}`,
rateHz: num(n.csiRateHz),
rate: `${fixed(n.csiRateHz)} Hz`,
loss: pct(n.csiLossFraction),
rssi: `${fixed(n.rssiMean)} dBm`,
@@ -75,11 +110,32 @@ export function modelOf(capture, radar, at) {
const radarRow = radar && radar.ok !== false ? {
name: radar.device?.name || radar.host || 'radar',
present: radar.presentNow ?? (radar.presentFraction == null ? null : radar.presentFraction >= 0.5),
targets: num(radar.targetsMax),
distanceCm: num(radar.distanceCmMean),
heartBpm: num(radar.heartBpmMean),
breathingBpm: num(radar.breathingBpmMean),
distance: radar.distanceCmMean == null ? '—' : `${fixed(radar.distanceCmMean)} cm`,
heart: radar.heartBpmMean == null ? '—' : `${fixed(radar.heartBpmMean)} bpm`,
breathing: radar.breathingBpmMean == null ? '—' : `${fixed(radar.breathingBpmMean)} bpm`,
} : null;
return { at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0, nodes, radar: radarRow, alerts };
return {
at, packets: capture?.packets ?? 0, decoded: capture?.decodedPackets ?? 0,
noPackets: capture?.reason === 'no_packets', nodes, radar: radarRow, alerts,
};
}
/** Append one model to a bounded history of trend values. */
export function historyWith(history, model, max = HISTORY) {
const h = history || { heart: [], breathing: [], distance: [], rates: {} };
const cap = (arr, v) => [...arr, v].slice(-max);
const rates = { ...h.rates };
for (const n of model?.nodes || []) rates[n.key] = cap(rates[n.key] || [], n.rateHz);
return {
heart: cap(h.heart, model?.radar?.heartBpm ?? null),
breathing: cap(h.breathing, model?.radar?.breathingBpm ?? null),
distance: cap(h.distance, model?.radar?.distanceCm ?? null),
rates,
};
}
/** One line for the status bar. */
@@ -91,36 +147,101 @@ export function statusOf(model) {
return parts.join(' · ');
}
/** Draw the pane with the surface's own elements ({ Box, Text, Button }). */
export function viewOf(ui, model, { refreshMs, busy, onRefresh, onClose }) {
/**
* Draw the pane with the surface's own elements ({ Box, Text, Button }).
* opts: { refreshMs, busy, onRefresh, onClose, columns, now, history, udpPort, radarConfigured }.
*/
export function viewOf(ui, model, opts) {
const { Box, Text, Button } = ui;
const line = (children, props = {}) => Text({ ...props, wrap: 'truncate-end', children });
const rows = [];
const when = model && Number.isFinite(model.at) ? new Date(model.at).toLocaleTimeString() : '—';
rows.push(line(model ? `updated ${when} · every ${Math.round(refreshMs / 1000)}s · ${model.decoded}/${model.packets} packets decoded` : 'waiting for the first capture…', { dimColor: true }));
for (const a of model?.alerts || []) rows.push(Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
if (model) {
rows.push(line('NODES', { bold: true }));
if (!model.nodes.length) rows.push(line(' none streaming to this machine', { dimColor: true }));
for (const n of model.nodes) {
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
line(n.label.padEnd(10)), line(n.rate.padStart(9)),
line(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : {}), line(n.rssi), line(n.shape, { dimColor: true }),
n.synthetic ? line('SYNTHETIC', { color: 'yellow' }) : null,
].filter(Boolean) }));
}
if (model.radar) {
const r = model.radar;
rows.push(line(`RADAR ${r.name}`, { bold: true }));
rows.push(line(` presence ${r.present == null ? '—' : r.present ? '● detected' : '○ none'} · distance ${r.distance} · heart ${r.heart} · breathing ${r.breathing}`, r.present ? { color: 'green' } : {}));
rows.push(line(' device-reported values, not validated against a reference', { dimColor: true }));
}
const { refreshMs, busy, onRefresh, onClose } = opts;
const columns = opts.columns ?? 80;
const history = opts.history || historyWith(null, null, 0);
const t = (children, props = {}) => Text({ wrap: 'truncate-end', ...props, children });
const row = (children, props = {}) => Box({ flexDirection: 'row', gap: 1, ...props, children: children.filter(Boolean) });
const every = `every ${Math.round(refreshMs / 1000)}s`;
// Header: freshness badge, update time and age.
const finiteAt = model && Number.isFinite(model.at);
const age = finiteAt && Number.isFinite(opts.now) ? opts.now - model.at : NaN;
const live = Number.isFinite(age) && age <= refreshMs * 2 + 5000;
const when = finiteAt ? new Date(model.at).toLocaleTimeString() : '—';
const badge = !model ? t('○ STARTING', { dimColor: true, bold: true })
: live ? t('● LIVE', { color: 'green', bold: true }) : t('○ STALE', { color: 'yellow', bold: true });
const header = row([
t('RuView', { color: 'cyan', bold: true }),
badge,
t(model ? `updated ${when}${ageOf(age) ? ` (${ageOf(age)})` : ''} · ${every}` : `waiting for the first capture… · ${every}`, { dimColor: true }),
busy ? t('⟳ refreshing', { color: 'cyan' }) : null,
], { justifyContent: 'flex-start' });
const alerts = (model?.alerts || []).map((a) => Text({ color: a.level === 'bad' ? 'red' : 'yellow', wrap: 'wrap', children: `! ${a.text}` }));
// Radar card.
const vital = (label, value, raw, kind, series, unitWidth) => {
const warn = kind ? plausibilityOf(kind, raw) : null;
return row([
t(label.padEnd(10), { dimColor: true }),
t(value.padStart(unitWidth), { bold: raw != null && !warn, color: warn ? 'yellow' : undefined }),
t(sparkline(series), { color: 'cyan' }),
warn ? t(`⚠ ${warn}`, { color: 'yellow' }) : null,
]);
};
let radarCard = null;
if (model?.radar) {
const r = model.radar;
const presence = r.present == null ? t('? presence unknown', { dimColor: true })
: r.present ? t(`● PRESENCE DETECTED${r.targets ? ` · ${r.targets} target${r.targets === 1 ? '' : 's'}` : ''}`, { color: 'green', bold: true })
: t('○ no presence', { dimColor: true });
radarCard = card('60 GHz RADAR', r.name, r.present ? 'green' : 'gray', [
presence,
vital('distance', r.distance, r.distanceCm, null, history.distance, 9),
vital('heart', r.heart, r.heartBpm, 'heart', history.heart, 9),
vital('breathing', r.breathing, r.breathingBpm, 'breathing', history.breathing, 9),
t('device-reported values, not validated against a reference', { dimColor: true, italic: true }),
]);
} else if (model && !opts.radarConfigured) {
radarCard = card('60 GHz RADAR', 'not configured', 'gray', [
t('Set radarHost to an ESPHome radar kit:', { dimColor: true }),
t('claude plugin configure ruview-live', { color: 'cyan' }),
]);
}
rows.push(Box({ flexDirection: 'row', gap: 2, children: [
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh', hotkey: 'r', onPress: onRefresh }),
Button({ key: 'close', label: 'Close', hotkey: 'c', onPress: onClose }),
] }));
return Box({ flexDirection: 'column', gap: 0, padding: 1, children: rows });
// Nodes card.
let nodesCard = null;
if (model) {
const lines = model.nodes.length
? model.nodes.map((n) => row([
t(n.label.padEnd(10), { bold: true }),
t(n.rate.padStart(9)),
t(sparkline(history.rates[n.key]), { color: 'cyan' }),
t(`loss ${n.loss}`, n.lossy ? { color: 'yellow' } : { dimColor: true }),
t(n.rssi, { dimColor: true }),
t(n.shape, { dimColor: true }),
n.synthetic ? t('SYNTHETIC', { color: 'yellow', bold: true }) : null,
]))
: [
t('none streaming to this machine', { dimColor: true }),
model.noPackets ? t(`check node target IP/port · firewall UDP ${opts.udpPort ?? 5005}`, { dimColor: true }) : null,
].filter(Boolean);
nodesCard = card('CSI NODES', `UDP ${opts.udpPort ?? 5005} · ${model.decoded}/${model.packets} decoded`, model.nodes.length ? 'cyan' : 'gray', lines);
}
function card(title, subtitle, borderColor, children) {
return Box({
flexDirection: 'column', borderStyle: 'round', borderColor, paddingX: 1, flexGrow: 1,
children: [row([t(title, { bold: true }), t(subtitle, { dimColor: true })]), ...children],
});
}
const wide = columns >= 100;
const cards = [radarCard, nodesCard].filter(Boolean);
const body = cards.length ? Box({ flexDirection: wide ? 'row' : 'column', gap: 1, children: cards }) : null;
const footer = row([
Button({ key: 'refresh', label: busy ? 'Refreshing…' : 'Refresh (r)', hotkey: 'r', onPress: onRefresh }),
Button({ key: 'close', label: 'Close (c)', hotkey: 'c', onPress: onClose }),
], { gap: 2 });
return Box({ flexDirection: 'column', gap: 1, paddingX: 1, children: [header, ...alerts, body, footer].filter(Boolean) });
}
/**
@@ -135,10 +256,15 @@ export function register(on, options = {}) {
let isOpen = false;
let busy = false;
let model = null;
let history = null;
let stopTimer = null;
let stopTick = null;
let cliPath = null;
const stop = () => { if (stopTimer) { stopTimer(); stopTimer = null; } };
const stop = () => {
if (stopTimer) { stopTimer(); stopTimer = null; }
if (stopTick) { stopTick(); stopTick = null; }
};
const cancelOf = (t) => (typeof t === 'function' ? t : () => t?.cancel?.());
async function runCli(args) {
@@ -158,6 +284,7 @@ export function register(on, options = {}) {
const [capture, radar] = await Promise.all([runCli(commands.capture), runCli(commands.radar)]);
// $.clock.now() resolves a promise of epoch milliseconds.
model = modelOf(resultOf(capture), resultOf(radar), await host.now());
history = historyWith(history, model);
host.status(statusOf(model));
} finally {
busy = false;
@@ -165,11 +292,12 @@ export function register(on, options = {}) {
}
}
/** Start the refresh timer and fetch now, unless already polling. */
/** Start the refresh timer (and the age tick) and fetch now, unless already polling. */
function startPolling() {
isOpen = true;
if (stopTimer) return;
stopTimer = cancelOf(host.every(settings.refreshMs, () => { void refresh(); }));
stopTick = cancelOf(host.every(TICK_MS, () => host.invalidate()));
void refresh();
}
@@ -217,7 +345,13 @@ export function register(on, options = {}) {
// open, so resume polling instead of waiting for the first capture forever.
if (host && !stopTimer) startPolling();
const ui = await $.ui.resolve(e);
return viewOf(ui, model, { refreshMs: settings.refreshMs, busy, onRefresh: () => { void refresh(); }, onClose: () => { void close(); } });
const now = await $.clock.now();
return viewOf(ui, model, {
refreshMs: settings.refreshMs, busy, history, now,
columns: e.props?.bodyColumns ?? e.viewport?.columns ?? 80,
udpPort: settings.udpPort, radarConfigured: Boolean(settings.radarHost),
onRefresh: () => { void refresh(); }, onClose: () => { void close(); },
});
});
on('ui.close', { id: PANE_ID }, async ($, e, next) => {
+2 -2
View File
@@ -130,7 +130,7 @@ describe('register', () => {
await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' })
const { text } = await $.command.run({ command: 'ruview', args: 'refresh', origin: { kind: 'composer' } })
expect(text).toBe('RuView · 0 nodes · 1 alert')
expect(statuses.at(-1)).toBe('RuView · 0 nodes · 1 alert')
expect(text).toBe('RuView · 0 nodes')
expect(statuses.at(-1)).toBe('RuView · 0 nodes')
})
})
+55 -2
View File
@@ -3,7 +3,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { cliPathOf, commandsOf, modelOf, resultOf, settingsOf, statusOf, viewOf } from '../mod/hooks/register.mjs';
import { ageOf, cliPathOf, commandsOf, historyWith, modelOf, plausibilityOf, resultOf, settingsOf, sparkline, statusOf, viewOf } from '../mod/hooks/register.mjs';
const capture = {
ok: true, packets: 120, decodedPackets: 118,
@@ -62,7 +62,7 @@ test('the pane draws with the surface elements and wires both buttons', () => {
walk(tree);
const texts = flat.filter((n) => n.type === 'Text').map((n) => String(n.props.children));
assert.ok(texts.some((t) => t.includes('heartbeats but no CSI')));
assert.ok(texts.some((t) => t.startsWith('RADAR')));
assert.ok(texts.some((t) => t === '60 GHz RADAR'));
assert.ok(texts.some((t) => t.includes('not validated')));
assert.ok(texts.some((t) => t === 'SYNTHETIC'));
const buttons = flat.filter((n) => n.type === 'Button');
@@ -89,6 +89,59 @@ test('refresh awaits the clock before building the model', () => {
assert.match(src, /model = modelOf\(.*await host\.now\(\)\);/);
});
test('sparklines, plausibility, ages and history are bounded and honest', () => {
assert.equal(sparkline([1, 2, 3, 4, 5, 6, 7, 8]), '▁▂▃▄▅▆▇█');
assert.equal(sparkline([5]), '', 'one point is not a trend');
assert.equal(sparkline([null, 3, NaN, 3]), '▄▄');
assert.equal(plausibilityOf('breathing', 1.0), 'outside 4–40 bpm', 'the live 1.0 bpm reading is flagged');
assert.equal(plausibilityOf('heart', 80.7), null);
assert.equal(plausibilityOf('heart', 250), 'outside 40–180 bpm');
assert.equal(plausibilityOf('breathing', null), null);
assert.equal(ageOf(12_400), '12s ago');
assert.equal(ageOf(185_000), '3m ago');
assert.equal(ageOf(NaN), '');
let h = null;
for (let i = 0; i < 30; i++) h = historyWith(h, modelOf(capture, { ...radar, heartBpmMean: 70 + i }, i));
assert.equal(h.heart.length, 24, 'history is bounded');
assert.equal(h.heart.at(-1), 99);
assert.equal(h.rates['realtek:3'].length, 24);
});
test('the redesigned pane: badge, cards side by side when wide, flags implausible vitals', () => {
const el = (type) => (props) => ({ type, props });
const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') };
const live = { ...radar, breathingBpmMean: 1.0, heartBpmMean: 80.7, distanceCmMean: 40.7, targetsMax: 1 };
const model = modelOf(capture, live, 1_000_000);
const base = { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: true };
const flat = (node, out = []) => { if (node && typeof node === 'object') { out.push(node); [].concat(node.props?.children ?? []).forEach((c) => flat(c, out)); } return out; };
const wide = flat(viewOf(ui, model, { ...base, columns: 140, now: 1_012_000 }));
const texts = wide.filter((n) => n.type === 'Text').map((n) => String(n.props.children));
assert.ok(texts.includes('● LIVE'));
assert.ok(texts.some((t) => t.includes('(12s ago)')));
assert.ok(texts.some((t) => t.startsWith('● PRESENCE DETECTED · 1 target')));
assert.ok(texts.includes('⚠ outside 4–40 bpm'), 'breathing 1.0 bpm is flagged');
assert.ok(!texts.includes('⚠ outside 40–180 bpm'), 'heart 80.7 bpm is not flagged');
const cardsRow = wide.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round'));
assert.equal(cardsRow.props.flexDirection, 'row', 'cards sit side by side when wide');
const narrow = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_012_000 }));
assert.equal(narrow.find((n) => n.type === 'Box' && n.props.children?.some?.((c) => c?.props?.borderStyle === 'round')).props.flexDirection, 'column');
const stale = flat(viewOf(ui, model, { ...base, columns: 80, now: 1_000_000 + 120_000 }));
assert.ok(stale.some((n) => n.type === 'Text' && n.props.children === '○ STALE'));
for (const n of wide) assert.ok(!('flexBasis' in (n.props || {})), 'only props the terminal Box takes');
});
test('no packets is a hint inside the nodes card, not a top alert', () => {
const el = (type) => (props) => ({ type, props });
const ui = { Box: el('Box'), Text: el('Text'), Button: el('Button') };
const m = modelOf({ ok: false, reason: 'no_packets', packets: 0, remedy: 'long remedy text' }, null, 0);
assert.equal(m.alerts.length, 0);
assert.equal(statusOf(m), 'RuView · 0 nodes');
const s = JSON.stringify(viewOf(ui, m, { refreshMs: 15000, busy: false, onRefresh() {}, onClose() {}, udpPort: 5005, radarConfigured: false, columns: 80, now: 0 }));
assert.match(s, /check node target IP\/port · firewall UDP 5005/);
assert.match(s, /claude plugin configure ruview-live/, 'an unconfigured radar says how to set it');
assert.doesNotMatch(s, /long remedy text/);
});
test('the mod manifest and hooks module are a valid plugin shape', () => {
const plugin = JSON.parse(readFileSync(new URL('../mod/.claude-plugin/plugin.json', import.meta.url), 'utf8'));
const hooks = JSON.parse(readFileSync(new URL('../mod/hooks/hooks.json', import.meta.url), 'utf8'));