Files
grok-build-hud/src/dashboard.ts
T
Redredchen01 bb6a1321fb Release grok-build-hud v0.3.0: Claude-HUD-style same-window status for Grok Build
Multi-line tmux strip (context, quota, tools, todos, git), theme sync with
Grok UI, full/essential/minimal presets, one-shot installer, EN+ZH docs,
and 40 unit tests.
2026-07-17 15:45:25 +08:00

459 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Always-on terminal dashboard: pushes ctx+quota into
* - Grok TTY window title (OSC)
* - Apple Terminal custom title (osascript fallback)
* - ~/.grok/hud/status-line.txt (tmux / external)
*/
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
pickBestSession,
} from "./session.js";
import { getCreditUsage } from "./billing.js";
import { writeStatusFiles, formatCompactLine } from "./status.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
export function dashboardPidPath(grokHome = defaultGrokHome()): string {
return path.join(grokHome, "hud", "dashboard.pid");
}
export function titleLine(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
): string {
const compact = formatCompactLine(session, usage);
// Window titles are short — strip [hud] prefix noise
return compact.replace(/^\[hud\]\s*/, "◆ ");
}
/** Resolve tty device for a process (e.g. ttys001 → /dev/ttys001). */
export function ttyForPid(pid?: number): string | null {
if (!pid || pid <= 0) return null;
try {
const out = execFileSync("ps", ["-p", String(pid), "-o", "tty="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
}).trim();
if (!out || out === "??" || out === "-") return null;
const dev = out.startsWith("/dev/") ? out : `/dev/${out}`;
if (fs.existsSync(dev)) return dev;
} catch {
/* ignore */
}
return null;
}
/** Write OSC title sequence to a tty (updates Terminal/iTerm tab title). */
export function writeOscTitle(ttyPath: string, title: string): boolean {
try {
const safe = title.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120);
// OSC 0 = icon+title, OSC 2 = title only
const seq = `\x1b]0;${safe}\x07`;
fs.writeFileSync(ttyPath, seq, { encoding: "utf8", flag: "a" });
return true;
} catch {
return false;
}
}
/**
* Apple Terminal: set tab/window custom title so it appears in the title bar.
* Terminal composes: "cwd — custom title — process — size"
* We cannot reliably toggle title-display flags on all locales, but custom
* title is always shown when set on the tab.
*/
export function setAppleTerminalTitle(
title: string,
options: { ttyHint?: string | null } = {},
): boolean {
if (process.platform !== "darwin") return false;
try {
const safe = title
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/[\x00-\x1f\x7f]/g, " ")
.slice(0, 100);
const tty = (options.ttyHint || "")
.replace(/^\/dev\//, "")
.replace(/"/g, "");
// Prefer matching tab by tty; else set every Terminal tab/window.
const script = `
tell application "Terminal"
set hud to "${safe}"
set targetTty to "${tty}"
set matched to false
if targetTty is not "" then
repeat with w in windows
repeat with tb in tabs of w
try
set t to tty of tb as string
if t contains targetTty then
set custom title of tb to hud
try
set custom title of w to hud
end try
set matched to true
end if
end try
end repeat
end repeat
end if
if matched is false then
repeat with w in windows
try
set custom title of w to hud
end try
repeat with tb in tabs of w
try
set custom title of tb to hud
end try
end repeat
end repeat
end if
end tell
`;
execFileSync("osascript", ["-e", script], {
stdio: ["ignore", "ignore", "ignore"],
timeout: 4000,
});
return true;
} catch {
return false;
}
}
export async function refreshDashboard(options: {
grokHome?: string;
noUsage?: boolean;
}): Promise<{ title: string; session: SessionSnapshot | null }> {
const grokHome = options.grokHome ?? defaultGrokHome();
const active = loadActiveSessions(grokHome);
let session: SessionSnapshot | null = null;
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
const snap = loadSnapshotFromDir(dir, { active });
if (snap?.live) {
session = snap;
break;
}
if (!session && snap) session = snap;
}
if (!session) session = pickBestSession({ grokHome });
if (!session) {
return { title: "◆ grok-hud: no session", session: null };
}
let usage: UsageSnapshot | null = null;
if (!options.noUsage) {
try {
usage = await getCreditUsage(grokHome, { cacheTtlMs: 60_000 });
} catch {
usage = null;
}
}
// Theme follows Grok [ui].theme; re-apply tmux chrome when palette flips
const { resolveTheme } = await import("./theme.js");
const theme = resolveTheme(undefined, process.env, { grokHome });
writeStatusFiles(session, usage, grokHome);
try {
const stamp = path.join(grokHome, "hud", ".last-theme");
const prev = fs.existsSync(stamp)
? fs.readFileSync(stamp, "utf8").trim()
: "";
if (prev !== theme.name) {
fs.writeFileSync(stamp, theme.name + "\n", "utf8");
const { writeTmuxConfFile, applyTmuxStatusBar } = await import(
"./tmux-hud.js"
);
writeTmuxConfFile(grokHome);
applyTmuxStatusBar({ grokHome });
}
} catch {
/* ignore */
}
const title = titleLine(session, usage);
// 1) OSC into Grok's TTY (updates process title component)
const tty = ttyForPid(session.pid);
if (tty) writeOscTitle(tty, title);
// 2) Apple Terminal custom title (always visible as middle segment of window title)
// e.g. "0717 — ◆ ctx 44% | quota 23% — zsh — 101×50"
const ttyHint = tty ? path.basename(tty) : null;
setAppleTerminalTitle(title, { ttyHint });
// tmux-status.txt is written by writeStatusFiles (formatTmuxStatusLine)
// If already inside tmux (same-window mode), keep status bar config applied
if (process.env.TMUX) {
try {
execFileSync(
"tmux",
[
"set",
"-g",
"status-right",
`#(cat ${path.join(grokHome, "hud", "tmux-status.txt")} 2>/dev/null)`,
],
{ stdio: "ignore", timeout: 1000 },
);
} catch {
/* ignore */
}
}
return { title, session };
}
export function isDashboardRunning(grokHome = defaultGrokHome()): boolean {
const p = dashboardPidPath(grokHome);
try {
if (!fs.existsSync(p)) return false;
const pid = Number(fs.readFileSync(p, "utf8").trim());
if (!Number.isFinite(pid) || pid <= 0) return false;
process.kill(pid, 0);
// Confirm it's actually our dashboard (not a recycled pid)
try {
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
timeout: 1000,
stdio: ["ignore", "pipe", "ignore"],
});
if (!cmd.includes("dashboard") && !cmd.includes("grok-build-hud")) {
return false;
}
} catch {
return false;
}
return true;
} catch {
return false;
}
}
/** Kill every dashboard daemon we can find (prevents multi-writer theme fights). */
export function stopAllDashboardDaemons(grokHome = defaultGrokHome()): number {
let killed = 0;
const p = dashboardPidPath(grokHome);
try {
if (fs.existsSync(p)) {
const pid = Number(fs.readFileSync(p, "utf8").trim());
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
try {
process.kill(pid, "SIGTERM");
killed += 1;
} catch {
/* ignore */
}
}
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
} catch {
/* ignore */
}
// Sweep orphans matching our entry pattern
try {
// Match "--dashboard" as its own argv token (not "--dashboard-start")
const out = execFileSync(
"pgrep",
["-f", "grok-build-hud.*(dist/src/index\\.js|bin/grok-build-hud\\.js) --dashboard "],
{
encoding: "utf8",
timeout: 2000,
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
for (const line of out.split(/\n/)) {
const pid = Number(line.trim());
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) continue;
try {
process.kill(pid, "SIGTERM");
killed += 1;
} catch {
/* ignore */
}
}
} catch {
/* no matches */
}
return killed;
}
export function stopDashboard(grokHome = defaultGrokHome()): boolean {
return stopAllDashboardDaemons(grokHome) > 0;
}
/** Fork a detached dashboard daemon if not already running. */
export function ensureDashboardDaemon(options: {
grokHome?: string;
entryJs?: string;
intervalMs?: number;
}): { started: boolean; alreadyRunning: boolean; pid?: number } {
const grokHome = options.grokHome ?? defaultGrokHome();
if (isDashboardRunning(grokHome)) {
return { started: false, alreadyRunning: true };
}
// Clean orphans before starting a single writer
stopAllDashboardDaemons(grokHome);
const entry =
options.entryJs ??
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"index.js",
);
const interval = options.intervalMs ?? 500;
const logPath = path.join(grokHome, "hud", "dashboard.log");
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const logFd = fs.openSync(logPath, "a");
const child = spawn(
process.execPath,
[
entry,
"--dashboard",
"--follow-active",
"--interval",
String(interval),
"--grok-home",
grokHome,
],
{
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
GROK_HUD_THEME: process.env.GROK_HUD_THEME || "auto",
},
},
);
child.unref();
if (child.pid) {
fs.writeFileSync(dashboardPidPath(grokHome), String(child.pid) + "\n");
return { started: true, alreadyRunning: false, pid: child.pid };
}
return { started: false, alreadyRunning: false };
}
/**
* Open a dedicated Apple Terminal window that only shows the live HUD.
* Grok itself overwrites its own window title with tool activity — a separate
* window is the reliable "always visible dashboard" on Terminal.app.
*/
export function openTerminalDashboardWindow(options: {
entryBin?: string;
intervalMs?: number;
} = {}): boolean {
if (process.platform !== "darwin") return false;
try {
const bin =
options.entryBin ??
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"bin",
"grok-build-hud.js",
);
const node = process.execPath;
const interval = options.intervalMs ?? 2000;
// clear+redraw dual bars forever; title fixed so user can find the window
const cmd = `${JSON.stringify(node)} ${JSON.stringify(bin)} --watch --follow-active --no-color --interval ${interval}; echo '[hud] stopped — press enter'; read`;
const safeCmd = cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const script = `
tell application "Terminal"
activate
set tabRef to do script "${safeCmd}"
delay 0.3
try
set custom title of tabRef to "◆ Grok HUD"
end try
try
set custom title of front window to "◆ Grok HUD"
end try
end tell
`;
execFileSync("osascript", ["-e", script], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 8000,
});
return true;
} catch {
return false;
}
}
export async function runDashboardLoop(options: {
grokHome?: string;
intervalMs?: number;
maxIterations?: number;
noUsage?: boolean;
sleep?: (ms: number) => Promise<void>;
writePid?: boolean;
}): Promise<number> {
const grokHome = options.grokHome ?? defaultGrokHome();
const interval = options.intervalMs ?? 500;
const sleep =
options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
if (options.writePid !== false) {
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
fs.writeFileSync(dashboardPidPath(grokHome), String(process.pid) + "\n");
}
const cleanup = () => {
try {
const p = dashboardPidPath(grokHome);
if (fs.existsSync(p)) {
const cur = Number(fs.readFileSync(p, "utf8").trim());
if (cur === process.pid) fs.unlinkSync(p);
}
} catch {
/* ignore */
}
};
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
i += 1;
try {
await refreshDashboard({
grokHome,
noUsage: options.noUsage,
});
} catch {
/* keep looping */
}
if (options.maxIterations && i >= options.maxIterations) {
cleanup();
return 0;
}
await sleep(interval);
}
}