// Game-style debug HUD (#20): a fixed-corner overlay on the active session // showing the daemon-inferred fields (activity, foreground, idle time, status, // …) live off the events WS — an FPS-counter for sessions, to validate the // inferred data before any "real" UI is designed. // // It renders **whatever non-client-owned fields are present** on the Session, so // a new daemon-derived field (#19 `detail`, later metrics) surfaces with no // change here. Off by default — see `debug.ts`. import { HUD_TOGGLE_EVENT, isHudEnabled } from "./debug.js"; import type { CatalogStore } from "./events.js"; import { sessionName, type Session, type Uuid } from "./types.js"; /** Fields the client itself owns/sets — not daemon-inferred, so the HUD skips * them. Everything else on the Session is shown, including future fields. */ const CLIENT_OWNED = new Set([ "id", "cwd", "command", "env", "metadata", "created_at", ]); /** Friendlier labels for known keys; unknown (future) keys show their raw name. */ const LABELS: Record = { last_activity_at: "idle", foreground: "fg", }; export interface Hud { /** Re-evaluate visibility + content (e.g. after navigation). */ refresh(): void; dispose(): void; } /** Mount the HUD into `root`. It reflects the session returned by * `getActiveId()`, updating on store changes, a 1s ticker (for idle time), and * the toggle event. */ export function mountHud( root: HTMLElement, store: CatalogStore, getActiveId: () => Uuid | null, ): Hud { const el = document.createElement("aside"); el.className = "debug-hud"; el.hidden = true; el.setAttribute("aria-hidden", "true"); root.appendChild(el); const render = () => { if (!isHudEnabled()) { el.hidden = true; return; } const id = getActiveId(); const session = id ? store.snapshot().sessions.get(id) : undefined; if (!session) { el.hidden = true; return; } el.hidden = false; el.replaceChildren(...buildRows(session)); }; const unsubscribe = store.subscribe(render); const ticker = window.setInterval(render, 1000); window.addEventListener(HUD_TOGGLE_EVENT, render); render(); return { refresh: render, dispose() { unsubscribe(); window.clearInterval(ticker); window.removeEventListener(HUD_TOGGLE_EVENT, render); el.remove(); }, }; } function buildRows(session: Session): HTMLElement[] { const rows: HTMLElement[] = []; const head = document.createElement("div"); head.className = "hud-head"; head.textContent = sessionName(session); rows.push(head); for (const [key, value] of Object.entries(session)) { if (CLIENT_OWNED.has(key) || value === undefined) continue; rows.push(buildRow(key, value)); } return rows; } function buildRow(key: string, value: unknown): HTMLElement { const row = document.createElement("div"); row.className = "hud-row"; const k = document.createElement("span"); k.className = "hud-k"; k.textContent = LABELS[key] ?? key; const v = document.createElement("span"); v.className = "hud-v"; if (key === "last_activity_at") { v.textContent = relativeAge(value); } else if (value === null) { v.textContent = "—"; } else { v.textContent = String(value); } // Lets the stylesheet color the activity value by state. if (key === "activity" && typeof value === "string") { v.dataset.activity = value; } row.append(k, v); return row; } /** A timestamp rendered as "Ns" / "Nm Ns" / "Nh Nm" since now. */ function relativeAge(value: unknown): string { if (typeof value !== "string") return "—"; const t = Date.parse(value); if (!Number.isFinite(t)) return "—"; const secs = Math.max(0, Math.round((Date.now() - t) / 1000)); if (secs < 60) return `${secs}s`; const mins = Math.floor(secs / 60); if (mins < 60) return `${mins}m ${secs % 60}s`; const hrs = Math.floor(mins / 60); return `${hrs}h ${mins % 60}m`; }