This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/hud.ts
T
Miguel Palhas 1bb464be4b refactor(maestro): flatten web UI to a single host
Session tree nests by working directory only; the daemon chips in the
header become a session tally, and the spawn form loses its host picker.
API calls move from /api/daemons/:id/* to /api/dirs, /api/recent-dirs,
/api/gitroot, and /api/info.
2026-08-22 10:14:10 +01:00

135 lines
3.9 KiB
TypeScript

// 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<string, string> = {
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`;
}