Files
grok-build-hud/src/index.ts
T

719 lines
21 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
import {
pickBestSession,
discoverSessions,
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
pickFromActiveSessions,
sortSessionsByRecency,
} from "./session.js";
import { getCreditUsage, unavailableUsage } from "./billing.js";
import { renderHud, renderJson } from "./render.js";
import { loadConfig } from "./config.js";
import { writeStatusFiles } from "./status.js";
import {
installGlobalHooks,
uninstallGlobalHooks,
packageRoot,
} from "./install.js";
import { installDashboard, uninstallDashboard } from "./install-dashboard.js";
import {
runDashboardLoop,
ensureDashboardDaemon,
stopDashboard,
isDashboardRunning,
refreshDashboard,
} from "./dashboard.js";
import {
execGrokInSameWindowTmux,
applyTmuxStatusBar,
installSameWindowLauncher,
isInsideTmux,
writeTmuxConfFile,
} from "./tmux-hud.js";
import {
persistTheme,
resolveTheme,
resolveThemeMode,
readGrokUiConfig,
clearAppearanceCache,
ensureFollowMode,
themeFingerprint,
} from "./theme.js";
import {
applyPreset,
saveHudConfig,
loadHudConfig,
ensureDefaultConfig,
type HudPreset,
} from "./hud-config.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
import path from "node:path";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
export { contextPercentFromSignals, renderBar, formatTokenCount } from "./bar.js";
export { parseUpdatesLines, formatToolLine } from "./activity.js";
export { normalizeBillingPayload } from "./billing.js";
export {
loadSnapshotFromDir,
pickBestSession,
pickFromActiveSessions,
discoverSessions,
findSessionDirById,
sortSessionsByRecency,
sessionRecencyMs,
} from "./session.js";
export { renderHud, renderJson } from "./render.js";
export { runHookTick } from "./hook.js";
export { writeStatusFiles, formatCompactLine } from "./status.js";
export { installGlobalHooks, uninstallGlobalHooks } from "./install.js";
export { installDashboard, uninstallDashboard } from "./install-dashboard.js";
export interface CliOptions {
watch: boolean;
once: boolean;
tmux: boolean;
json: boolean;
all: boolean;
noColor: boolean;
cwd?: string;
session?: string;
sessionDir?: string;
grokHome?: string;
refreshMs?: number;
maxIterations?: number;
help: boolean;
version: boolean;
noUsage: boolean;
installHooks: boolean;
uninstallHooks: boolean;
tick: boolean;
followActive: boolean;
installDashboard: boolean;
uninstallDashboard: boolean;
dashboard: boolean;
dashboardStart: boolean;
dashboardStop: boolean;
dashboardWindow: boolean;
runInTerminal: boolean;
theme?: string;
preset?: string;
settings: boolean;
language?: string;
}
export function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
watch: false,
once: true,
tmux: false,
json: false,
all: false,
noColor: false,
help: false,
version: false,
noUsage: false,
installHooks: false,
uninstallHooks: false,
tick: false,
followActive: false,
installDashboard: false,
uninstallDashboard: false,
dashboard: false,
dashboardStart: false,
dashboardStop: false,
dashboardWindow: false,
runInTerminal: false,
settings: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
switch (arg) {
case "-w":
case "--watch":
opts.watch = true;
opts.once = false;
break;
case "--once":
opts.once = true;
opts.watch = false;
break;
case "--tmux":
opts.tmux = true;
break;
case "--json":
opts.json = true;
break;
case "--all":
opts.all = true;
break;
case "--no-color":
case "--plain":
opts.noColor = true;
break;
case "--no-usage":
opts.noUsage = true;
break;
case "--cwd":
opts.cwd = argv[++i];
break;
case "--session":
opts.session = argv[++i];
break;
case "--session-dir":
opts.sessionDir = argv[++i];
break;
case "--grok-home":
opts.grokHome = argv[++i];
break;
case "--interval":
case "--refresh-ms":
opts.refreshMs = Number(argv[++i]);
break;
case "--max-iterations":
opts.maxIterations = Number(argv[++i]);
break;
case "--install-hooks":
case "--install":
opts.installHooks = true;
break;
case "--uninstall-hooks":
case "--uninstall":
opts.uninstallHooks = true;
break;
case "--tick":
opts.tick = true;
opts.noUsage = true;
break;
case "--follow-active":
opts.followActive = true;
break;
case "--install-dashboard":
opts.installDashboard = true;
break;
case "--uninstall-dashboard":
opts.uninstallDashboard = true;
break;
case "--dashboard":
opts.dashboard = true;
opts.once = false;
break;
case "--dashboard-start":
opts.dashboardStart = true;
break;
case "--dashboard-stop":
opts.dashboardStop = true;
break;
case "--dashboard-window":
case "--open-window":
// deprecated / rejected by user — ignore, prefer same-window tmux
opts.dashboardWindow = false;
break;
case "--run-in-terminal":
case "--run":
opts.runInTerminal = true;
break;
case "--theme":
opts.theme = argv[++i];
break;
case "--preset":
opts.preset = argv[++i];
break;
case "--settings":
case "--config-ui":
case "settings":
opts.settings = true;
break;
case "--lang":
case "--language":
opts.language = argv[++i];
opts.settings = true;
break;
case "-h":
case "--help":
opts.help = true;
break;
case "-V":
case "--version":
opts.version = true;
break;
default:
if (arg.startsWith("-")) {
// unknown flag: ignore softly
}
break;
}
}
return opts;
}
export function helpText(): string {
return `grok-build-hud — live context + quota in the SAME Terminal window
After install:
1. Open Terminal (shell hook ensures HUD daemon)
2. Type: grok
→ Grok + bottom HUD (IN/OUT/CACHE exact token counts)
bare CLI: GROK_NO_HUD=1 grok -p "hi"
Install once:
grok-build-hud --install-dashboard
Also:
grok-hud status # full dual bars + token breakdown
grok-hud stop # stop background updater
grok-build-hud --theme auto # follow Grok /theme (default, not locked)
grok-build-hud --preset full|essential|minimal
grok-hud settings # 设定界面(语言 中/英、预设、行数)
grok-hud lang en # English (default)
grok-hud lang zh # 简体中文
grok-hud lang tw # 繁體中文
Theme: always tracks Grok [ui].theme (auto → OS light/dark maps).
Change inside Grok with /theme — HUD re-paints in ~1s.
Language: default English; switch in settings or: grok-hud lang zh
Lifecycle: Terminal open → ready (not machine boot).
文档: README.md(中文)· README.en.mdEnglish
`;
}
async function resolveUsage(
grokHome: string,
opts: CliOptions,
cfg: ReturnType<typeof loadConfig>,
): Promise<UsageSnapshot> {
if (opts.noUsage || !cfg.showUsage) {
return unavailableUsage("usage disabled");
}
try {
return await getCreditUsage(grokHome, {
cacheTtlMs: cfg.usageCacheTtlMs,
enabled: true,
});
} catch {
return unavailableUsage("usage unavailable");
}
}
function printFrame(
sessions: SessionSnapshot[],
usage: UsageSnapshot,
opts: CliOptions,
cfg: ReturnType<typeof loadConfig>,
out: (s: string) => void,
): void {
if (!sessions.length) {
out("No Grok Build sessions found under grok home.");
return;
}
const list = opts.all ? sessions : [sessions[0]!];
const chunks: string[] = [];
for (const s of list) {
if (opts.json) {
chunks.push(renderJson(s, usage));
} else {
chunks.push(
renderHud(s, usage, {
color: !opts.noColor && !opts.tmux,
tmux: opts.tmux,
pathLevels: cfg.pathLevels,
warningThreshold: cfg.warningThreshold,
criticalThreshold: cfg.criticalThreshold,
}),
);
}
}
out(chunks.join(opts.json ? "\n" : "\n\n"));
}
export async function runCli(
argv: string[],
io: {
stdout?: (s: string) => void;
stderr?: (s: string) => void;
sleep?: (ms: number) => Promise<void>;
} = {},
): Promise<number> {
const out = io.stdout ?? ((s: string) => process.stdout.write(s + (s.endsWith("\n") ? "" : "\n")));
const err = io.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
const sleep =
io.sleep ??
((ms: number) => new Promise((r) => setTimeout(r, ms)));
const opts = parseArgs(argv);
const cfg = loadConfig();
if (opts.help) {
out(helpText());
return 0;
}
if (opts.version) {
out("0.2.0");
return 0;
}
if (opts.settings) {
const { runSettingsUi } = await import("./settings-ui.js");
const grokHome = opts.grokHome ?? defaultGrokHome();
const saved = await runSettingsUi({
grokHome,
language: opts.language,
});
if (saved) {
writeTmuxConfFile(grokHome);
applyTmuxStatusBar({ grokHome });
await refreshDashboard({ grokHome });
// restart daemon so labels pick up language
stopDashboard(grokHome);
ensureDashboardDaemon({
grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
}
return 0;
}
if (opts.preset) {
const p = opts.preset.toLowerCase();
if (p !== "full" && p !== "essential" && p !== "minimal") {
err("Preset must be: full | essential | minimal");
return 1;
}
const grokHome = opts.grokHome ?? defaultGrokHome();
const prev = loadHudConfig(grokHome);
const cfg = applyPreset(p as HudPreset);
// keep readability defaults + user language
cfg.bold = true;
cfg.barWidth = Math.max(cfg.barWidth ?? 12, 12);
cfg.language = prev.language ?? "en";
const saved = saveHudConfig(cfg, grokHome);
writeTmuxConfFile(opts.grokHome);
applyTmuxStatusBar({ grokHome: opts.grokHome });
await refreshDashboard({ grokHome: opts.grokHome });
out(
`HUD preset → ${p} (${cfg.statusLines} status row(s), bold, barWidth=${cfg.barWidth})\n config: ${saved}\n` +
`language: ${cfg.language}\n` +
`presets: full | essential | minimal`,
);
return 0;
}
if (opts.theme) {
const mode = resolveThemeMode(opts.theme);
clearAppearanceCache();
const grokHome = opts.grokHome ?? defaultGrokHome();
// Default path: always follow Grok. --theme auto restores follow mode.
// --theme tokyonight only previews once unless GROK_HUD_LOCK=1.
if (mode === "auto" || mode === "system") {
ensureFollowMode(grokHome);
persistTheme("auto");
delete process.env.GROK_HUD_THEME;
delete process.env.GROK_HUD_LOCK;
} else if (process.env.GROK_HUD_LOCK === "1") {
persistTheme(mode);
process.env.GROK_HUD_THEME = mode;
} else {
// One-shot preview without locking
persistTheme("auto");
}
const ui = readGrokUiConfig(grokHome);
const t =
mode === "auto" || mode === "system"
? resolveTheme("auto", process.env, { grokHome })
: resolveTheme(mode, process.env, { grokHome });
writeTmuxConfFile(grokHome);
applyTmuxStatusBar({ grokHome });
// force fingerprint rewrite next tick
try {
fs.writeFileSync(
path.join(grokHome, "hud", ".last-theme"),
themeFingerprint(t, ui, process.env) + "\n",
);
} catch {
/* ignore */
}
await refreshDashboard({ grokHome });
stopDashboard(grokHome);
ensureDashboardDaemon({
grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
if (mode === "auto" || mode === "system") {
out(
`HUD follows Grok [ui].theme (not locked).\n` +
` config: theme="${ui.theme}" → palette "${t.name}"\n` +
` auto maps: dark→${ui.autoDarkTheme} light→${ui.autoLightTheme}\n` +
`Change with /theme inside Grok; HUD re-paints within ~1s.\n` +
`Optional lock: GROK_HUD_LOCK=1 grok-build-hud --theme tokyonight`,
);
} else if (process.env.GROK_HUD_LOCK === "1") {
out(
`Theme locked to "${t.name}" (GROK_HUD_LOCK=1).\n` +
`Unlock: grok-build-hud --theme auto`,
);
} else {
out(
`Preview palette "${t.name}" once; still following Grok after this.\n` +
`To lock: GROK_HUD_LOCK=1 grok-build-hud --theme ${mode}\n` +
`Follow mode: grok-build-hud --theme auto`,
);
}
return 0;
}
if (opts.installDashboard) {
try {
const r = installDashboard({
grokHome: opts.grokHome,
startDaemon: true,
});
// Always follow Grok [ui].theme — clear any legacy locked palette
const home = opts.grokHome ?? defaultGrokHome();
ensureFollowMode(home);
const hudCfg = ensureDefaultConfig(home);
// wrapGrokCommand already done inside installDashboard; still refresh run shims
const launchers = installSameWindowLauncher({ wrapGrokCommand: true });
out("Installed Grok + HUD (same window, no second window):");
out(` ★ start: grok`);
out(` (plain command — auto HUD; no extra flags)`);
out(` theme: follows Grok /theme (not locked)`);
out(
` language: ${hudCfg.language} (settings: grok-hud settings · 中文: grok-hud lang zh)`,
);
if (r.grokWrap) {
out(` wrap: ${r.grokWrap.wrapperPath}`);
out(` real bin: ${r.grokWrap.realPath}`);
out(` bare escape: GROK_NO_HUD=1 grok …`);
}
out(` alias: ${launchers.runPath} (same as grok)`);
out(` hooks: ${r.hooksPath}`);
out(` tmux cfg: ${r.tmuxConf}`);
if (r.terminalHook) {
out(` terminal: ${r.terminalHook}`);
out(` (opens Terminal → auto ensure HUD daemon)`);
}
if (r.launchAgent) {
out(
` agent: ${r.launchAgent} (optional, not login-boot; Terminal-driven)`,
);
}
out(
` daemon: ${r.daemon.alreadyRunning ? "already running" : r.daemon.started ? `started pid ${r.daemon.pid}` : "not started"}`,
);
await refreshDashboard({ grokHome: opts.grokHome });
if (isInsideTmux()) {
applyTmuxStatusBar({ grokHome: opts.grokHome });
out("\nAlready inside tmux — status bar applied to THIS window.");
} else {
out(
"\n★ 打开 Terminal 后输入: grok\n 同标签页启动 Grok + 底部 HUD(含 IN/OUT/CACHE 精确数字)。\n 开终端会自动拉起 status 更新器;不是开机自启。",
);
}
return 0;
} catch (e) {
err(e instanceof Error ? e.message : String(e));
return 1;
}
}
if (opts.runInTerminal) {
ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
await refreshDashboard({ grokHome: opts.grokHome });
// never returns
execGrokInSameWindowTmux({
grokHome: opts.grokHome,
extraGrokArgs: [],
});
return 0;
}
if (opts.uninstallDashboard) {
uninstallDashboard({ grokHome: opts.grokHome });
uninstallGlobalHooks(opts.grokHome);
out("Dashboard uninstalled (daemon stopped, LaunchAgent removed).");
return 0;
}
if (opts.dashboardStop) {
const ok = stopDashboard(opts.grokHome);
out(ok ? "Dashboard daemon stopped." : "No dashboard daemon was running.");
return 0;
}
if (opts.dashboardStart) {
const r = ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: opts.refreshMs ?? 2000,
});
if (r.alreadyRunning) out("Dashboard already running.");
else if (r.started) out(`Dashboard started (pid ${r.pid}).`);
else out("Failed to start dashboard.");
await refreshDashboard({ grokHome: opts.grokHome });
return r.started || r.alreadyRunning ? 0 : 1;
}
if (opts.dashboard) {
return runDashboardLoop({
grokHome: opts.grokHome,
intervalMs: opts.refreshMs ?? 2000,
maxIterations: opts.maxIterations,
noUsage: opts.noUsage,
writePid: true,
});
}
if (opts.installHooks) {
try {
const { hooksPath, hookJs } = installGlobalHooks({
grokHome: opts.grokHome,
root: packageRoot(),
});
// also ensure dashboard daemon for title bar
ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
});
out(`Installed live HUD hooks:\n ${hooksPath}\n entry: ${hookJs}`);
out(
"\nNext: restart this Grok session (or open /hooks and press r to reload).\nThen keep working — after each turn you should see [hud] annotations in scrollback.",
);
return 0;
} catch (e) {
err(e instanceof Error ? e.message : String(e));
return 1;
}
}
if (opts.uninstallHooks) {
const { removed, hooksPath } = uninstallGlobalHooks(opts.grokHome);
out(removed ? `Removed ${hooksPath}` : `No hooks file at ${hooksPath}`);
return 0;
}
const grokHome = opts.grokHome ?? defaultGrokHome();
const discoverOpts = {
grokHome,
sessionId: opts.session,
cwd: opts.cwd,
sessionDir: opts.sessionDir,
};
const resolveSessions = (): SessionSnapshot[] => {
if (opts.sessionDir) {
const one = pickBestSession(discoverOpts);
return one ? [one] : [];
}
if (opts.followActive || (!opts.cwd && !opts.session)) {
// Prefer newest live active session (not active_sessions insertion order)
const active = loadActiveSessions(grokHome);
const liveSnaps: SessionSnapshot[] = [];
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
const snap = loadSnapshotFromDir(dir, { active });
if (snap) liveSnaps.push(snap);
}
if (liveSnaps.length) {
const ranked = sortSessionsByRecency(liveSnaps, active);
if (opts.cwd) {
const matched = ranked.filter((s) =>
s.cwd ? s.cwd === opts.cwd || s.cwd.includes(opts.cwd!) : false,
);
// use path equality via discover as fallback
const viaCwd = discoverSessions({ ...discoverOpts, cwd: opts.cwd });
if (viaCwd.length) return viaCwd;
if (matched.length) return matched;
}
if (opts.session) {
const m = ranked.filter((s) => s.sessionId === opts.session);
if (m.length) return m;
}
if (!opts.cwd && !opts.session) return ranked;
}
// Single-shot helper for tests / callers
const one = pickFromActiveSessions(grokHome);
if (one && !opts.cwd && !opts.session) return [one];
}
return discoverSessions(discoverOpts);
};
const renderOnce = async (): Promise<number> => {
const sessions = resolveSessions();
const usage = await resolveUsage(grokHome, opts, cfg);
if (!sessions.length) {
err("grok-build-hud: no sessions found");
printFrame([], usage, opts, cfg, out);
return 1;
}
// Always refresh status files so external readers stay current
try {
writeStatusFiles(sessions[0]!, opts.noUsage ? null : usage, grokHome);
} catch {
/* ignore */
}
if (opts.tick) {
out(writeStatusFiles(sessions[0]!, null, grokHome).compact);
return 0;
}
printFrame(sessions, usage, opts, cfg, out);
return 0;
};
if (opts.watch) {
const interval = opts.refreshMs && opts.refreshMs > 0 ? opts.refreshMs : cfg.refreshMs;
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
i += 1;
if (!opts.tmux && !opts.json && process.stdout.isTTY) {
// clear screen lightly for watch
process.stdout.write("\x1b[2J\x1b[H");
}
await renderOnce();
if (opts.maxIterations && i >= opts.maxIterations) {
return 0;
}
await sleep(interval);
}
}
return renderOnce();
}
// Direct `node dist/src/index.js ...` entry. When imported by tests, argv[1]
// is the test file path, so this block does not run.
function isDirectCliRun(): boolean {
const entry = process.argv[1];
if (!entry) return false;
try {
return path.resolve(entry) === fileURLToPath(import.meta.url);
} catch {
return false;
}
}
if (isDirectCliRun()) {
runCli(process.argv.slice(2))
.then((code) => {
process.exit(typeof code === "number" ? code : 0);
})
.catch((e) => {
console.error(e);
process.exit(1);
});
}