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.
135 lines
3.9 KiB
TypeScript
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`;
|
|
}
|