bb6a1321fb
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.
459 lines
13 KiB
TypeScript
459 lines
13 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|