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.
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseUpdatesLines, formatToolLine } from "../src/activity.js";
|
||||
|
||||
// dist/tests -> package root is ../..
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureUpdates = path.join(root, "fixtures", "session", "updates.jsonl");
|
||||
|
||||
describe("activity", () => {
|
||||
it("parses fixture updates into tool summary including read_file", () => {
|
||||
const lines = fs.readFileSync(fixtureUpdates, "utf8").split(/\r?\n/);
|
||||
const { tools, agents } = parseUpdatesLines(lines);
|
||||
assert.ok(tools.length > 0, "expected tools from fixture");
|
||||
const names = tools.map((t) => t.name);
|
||||
assert.ok(
|
||||
names.includes("read_file") || names.some((n) => n.includes("read")),
|
||||
`expected read_file in ${names.join(",")}`,
|
||||
);
|
||||
// call-4 is running (no completed status)
|
||||
const running = tools.filter((t) => t.status === "running");
|
||||
assert.ok(running.length >= 1, "expected at least one running tool");
|
||||
assert.ok(agents.length >= 1, "expected agent thought from fixture");
|
||||
|
||||
const line = formatToolLine(tools);
|
||||
assert.match(line, /read_file|✓|◐/);
|
||||
});
|
||||
|
||||
it("returns empty quietly for blank input", () => {
|
||||
const { tools, agents, todos } = parseUpdatesLines(["", "not-json", "{}"]);
|
||||
assert.deepEqual(tools, []);
|
||||
assert.deepEqual(agents, []);
|
||||
assert.deepEqual(todos, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clampPercent,
|
||||
contextPercentFromSignals,
|
||||
formatTokenCount,
|
||||
renderBar,
|
||||
formatDuration,
|
||||
projectLabel,
|
||||
} from "../src/bar.js";
|
||||
|
||||
describe("bar", () => {
|
||||
it("derives context percent from contextWindowUsage", () => {
|
||||
assert.equal(
|
||||
contextPercentFromSignals({
|
||||
contextWindowUsage: 37,
|
||||
contextTokensUsed: 190000,
|
||||
contextWindowTokens: 500000,
|
||||
}),
|
||||
37,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to used/window ratio", () => {
|
||||
assert.equal(
|
||||
contextPercentFromSignals({
|
||||
contextTokensUsed: 250000,
|
||||
contextWindowTokens: 500000,
|
||||
}),
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps and formats", () => {
|
||||
assert.equal(clampPercent(150), 100);
|
||||
assert.equal(renderBar(37, 10).length, 10);
|
||||
assert.match(formatTokenCount(190000), /190k|190\.0k/);
|
||||
assert.equal(formatDuration(4620), "1h 17m");
|
||||
assert.equal(projectLabel("/Users/dex/demo/CoachFlow", 2), "demo/CoachFlow");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
normalizeBillingPayload,
|
||||
getCreditUsage,
|
||||
clearUsageCache,
|
||||
} from "../src/billing.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
describe("billing", () => {
|
||||
it("normalizes present monthly used/limit fixture ({val})", () => {
|
||||
const body = JSON.parse(
|
||||
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
|
||||
);
|
||||
const u = normalizeBillingPayload(body);
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.used, 17510);
|
||||
assert.equal(u.limit, 150000);
|
||||
assert.ok(u.percent != null);
|
||||
assert.ok(Math.abs((u.percent ?? 0) - (17510 / 150000) * 100) < 0.05);
|
||||
assert.equal(u.period, "monthly");
|
||||
});
|
||||
|
||||
it("normalizes weekly creditUsagePercent fixture", () => {
|
||||
const body = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "fixtures", "billing-weekly-percent.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const u = normalizeBillingPayload(body);
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.percent, 22);
|
||||
assert.equal(u.period, "weekly");
|
||||
assert.match(u.message ?? "", /GrokBuild/);
|
||||
});
|
||||
|
||||
it("missing payload is unavailable without throw", () => {
|
||||
const u = normalizeBillingPayload(null);
|
||||
assert.equal(u.available, false);
|
||||
assert.match(u.message ?? "", /unavailable/i);
|
||||
});
|
||||
|
||||
it("getCreditUsage degrades when auth missing", async () => {
|
||||
clearUsageCache();
|
||||
const u = await getCreditUsage("/tmp/does-not-exist-grok-home-xyz", {
|
||||
enabled: true,
|
||||
cacheTtlMs: 1,
|
||||
}, {
|
||||
readAuth: () => null,
|
||||
});
|
||||
assert.equal(u.available, false);
|
||||
assert.match(u.message ?? "", /unavailable|auth/i);
|
||||
});
|
||||
|
||||
it("getCreditUsage uses injected fetch payload", async () => {
|
||||
clearUsageCache();
|
||||
const weekly = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "fixtures", "billing-weekly-percent.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const monthly = JSON.parse(
|
||||
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
|
||||
);
|
||||
let n = 0;
|
||||
const u = await getCreditUsage("/tmp/fake-home-billing-inject", { cacheTtlMs: 1 }, {
|
||||
readAuth: () => ({ token: "test-token" }),
|
||||
fetchJson: async (url) => {
|
||||
n += 1;
|
||||
const body = url.includes("format=credits") ? weekly : monthly;
|
||||
return { ok: true, status: 200, body };
|
||||
},
|
||||
});
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.percent, 22);
|
||||
assert.equal(u.used, 17510);
|
||||
assert.equal(u.limit, 150000);
|
||||
assert.ok(n >= 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs, runCli, helpText } from "../src/index.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("cli", () => {
|
||||
it("parseArgs handles watch and session-dir", () => {
|
||||
const o = parseArgs([
|
||||
"--watch",
|
||||
"--session-dir",
|
||||
"/tmp/x",
|
||||
"--no-usage",
|
||||
"--max-iterations",
|
||||
"2",
|
||||
]);
|
||||
assert.equal(o.watch, true);
|
||||
assert.equal(o.sessionDir, "/tmp/x");
|
||||
assert.equal(o.noUsage, true);
|
||||
assert.equal(o.maxIterations, 2);
|
||||
});
|
||||
|
||||
it("runCli one-shot against fixture exits 0 with context", async () => {
|
||||
const chunks: string[] = [];
|
||||
const code = await runCli(
|
||||
["--once", "--session-dir", fixtureSession, "--no-usage", "--no-color"],
|
||||
{
|
||||
stdout: (s) => chunks.push(s),
|
||||
stderr: () => {},
|
||||
},
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const text = chunks.join("\n");
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /190k|500k/);
|
||||
});
|
||||
|
||||
it("runCli watch with max-iterations produces frames", async () => {
|
||||
const chunks: string[] = [];
|
||||
const code = await runCli(
|
||||
[
|
||||
"--watch",
|
||||
"--session-dir",
|
||||
fixtureSession,
|
||||
"--no-usage",
|
||||
"--no-color",
|
||||
"--max-iterations",
|
||||
"2",
|
||||
"--interval",
|
||||
"10",
|
||||
],
|
||||
{
|
||||
stdout: (s) => chunks.push(s),
|
||||
stderr: () => {},
|
||||
sleep: async () => {},
|
||||
},
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const text = chunks.join("\n");
|
||||
assert.match(text, /37%/);
|
||||
// at least two frames
|
||||
const hits = text.match(/37%/g) ?? [];
|
||||
assert.ok(hits.length >= 2, `expected ≥2 frames, got ${hits.length}`);
|
||||
});
|
||||
|
||||
it("help is non-empty", () => {
|
||||
assert.match(helpText(), /grok-build-hud/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
titleLine,
|
||||
refreshDashboard,
|
||||
runDashboardLoop,
|
||||
ttyForPid,
|
||||
} from "../src/dashboard.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
|
||||
const pkgRoot = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("dashboard", () => {
|
||||
it("titleLine includes ctx percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const t = titleLine(snap, {
|
||||
available: true,
|
||||
percent: 22,
|
||||
period: "weekly",
|
||||
used: 17510,
|
||||
limit: 150000,
|
||||
});
|
||||
assert.match(t, /◆/);
|
||||
assert.match(t, /37%/);
|
||||
assert.match(t, /22%|quota/);
|
||||
});
|
||||
|
||||
it("ttyForPid returns null for bogus pid", () => {
|
||||
assert.equal(ttyForPid(99999999), null);
|
||||
});
|
||||
|
||||
it("refreshDashboard writes status files for fixture home", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-dash-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "active_sessions.json"),
|
||||
JSON.stringify([
|
||||
{ session_id: sid, pid: process.pid, cwd: "/Users/dex/demo/CoachFlow" },
|
||||
]),
|
||||
);
|
||||
|
||||
const r = await refreshDashboard({ grokHome: tmp, noUsage: true });
|
||||
assert.ok(r.session);
|
||||
assert.match(r.title, /37%/);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-status.txt")));
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runDashboardLoop respects maxIterations", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-loop-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
const code = await runDashboardLoop({
|
||||
grokHome: tmp,
|
||||
intervalMs: 1,
|
||||
maxIterations: 2,
|
||||
noUsage: true,
|
||||
writePid: true,
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
parseHookPayload,
|
||||
resolveSessionForHook,
|
||||
runHookTick,
|
||||
shouldEmitAnnotation,
|
||||
} from "../src/hook.js";
|
||||
import { findSessionDirById } from "../src/session.js";
|
||||
import { formatCompactLine, writeStatusFiles } from "../src/status.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import { installGlobalHooks, buildHookManifest } from "../src/install.js";
|
||||
|
||||
const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("hook / live status", () => {
|
||||
it("parses hook payload sessionId", () => {
|
||||
const p = parseHookPayload(
|
||||
JSON.stringify({ sessionId: "abc", cwd: "/tmp/x", hookEventName: "stop" }),
|
||||
);
|
||||
assert.equal(p.sessionId, "abc");
|
||||
assert.equal(p.cwd, "/tmp/x");
|
||||
});
|
||||
|
||||
it("formatCompactLine includes context percent from fixture", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
assert.ok(snap);
|
||||
const line = formatCompactLine(snap, null);
|
||||
assert.match(line, /\[hud\]/);
|
||||
assert.match(line, /37%/);
|
||||
assert.match(line, /Grok 4\.5|ctx|tools/);
|
||||
});
|
||||
|
||||
it("writeStatusFiles creates status-line.txt", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-"));
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const r = writeStatusFiles(snap, null, tmp);
|
||||
assert.ok(fs.existsSync(r.compactPath));
|
||||
const text = fs.readFileSync(r.compactPath, "utf8");
|
||||
assert.match(text, /37%/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runHookTick against fixture via session-dir home layout", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-home-"));
|
||||
// Build mini grok home with fixture session
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(
|
||||
tmp,
|
||||
"sessions",
|
||||
encodeURIComponent("/Users/dex/demo/CoachFlow"),
|
||||
sid,
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "active_sessions.json"),
|
||||
JSON.stringify([
|
||||
{
|
||||
session_id: sid,
|
||||
pid: process.pid,
|
||||
cwd: "/Users/dex/demo/CoachFlow",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const found = findSessionDirById(tmp, sid);
|
||||
assert.ok(found);
|
||||
|
||||
const result = await runHookTick({
|
||||
payloadRaw: JSON.stringify({ sessionId: sid, hookEventName: "stop" }),
|
||||
env: {
|
||||
GROK_SESSION_ID: sid,
|
||||
GROK_HOOK_EVENT: "stop",
|
||||
},
|
||||
grokHome: tmp,
|
||||
forceAnnotate: true,
|
||||
noUsage: true,
|
||||
});
|
||||
assert.equal(result.code, 0);
|
||||
assert.ok(result.compact);
|
||||
assert.match(result.compact!, /37%/);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("shouldEmitAnnotation always true for Stop", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-th-"));
|
||||
assert.equal(shouldEmitAnnotation("stop", tmp), true);
|
||||
assert.equal(shouldEmitAnnotation("Stop", tmp), true);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("buildHookManifest points at node + hook.js", () => {
|
||||
const m = buildHookManifest("/abs/hook.js") as {
|
||||
hooks: { Stop: { hooks: { command: string }[] }[] };
|
||||
};
|
||||
assert.match(m.hooks.Stop[0]!.hooks[0]!.command, /hook\.js/);
|
||||
});
|
||||
|
||||
it("installGlobalHooks writes ~/.grok style hooks file", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-inst-"));
|
||||
// ensure dist hook exists (built)
|
||||
const hookJs = path.join(pkgRoot, "dist", "src", "hook.js");
|
||||
assert.ok(fs.existsSync(hookJs), "dist must be built before this test");
|
||||
const { hooksPath } = installGlobalHooks({ grokHome: tmp, root: pkgRoot });
|
||||
assert.ok(fs.existsSync(hooksPath));
|
||||
const body = fs.readFileSync(hooksPath, "utf8");
|
||||
assert.match(body, /Stop/);
|
||||
assert.match(body, /hook\.js/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolveSessionForHook uses env session id", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-res-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
const snap = resolveSessionForHook(
|
||||
{},
|
||||
{ GROK_SESSION_ID: sid },
|
||||
tmp,
|
||||
);
|
||||
assert.ok(snap);
|
||||
assert.equal(snap!.sessionId, sid);
|
||||
assert.equal(snap!.contextPercent, 37);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import { renderHud, renderTmux } from "../src/render.js";
|
||||
import { contextPercentFromSignals } from "../src/bar.js";
|
||||
import type { UsageSnapshot } from "../src/types.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("session + render", () => {
|
||||
it("loads fixture session with known context values", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession);
|
||||
assert.ok(snap);
|
||||
assert.equal(snap!.sessionId, "fixture-session-001");
|
||||
assert.equal(snap!.model, "grok-4.5");
|
||||
assert.equal(snap!.cwd, "/Users/dex/demo/CoachFlow");
|
||||
assert.equal(snap!.contextPercent, 37);
|
||||
assert.equal(snap!.contextTokensUsed, 190000);
|
||||
assert.equal(snap!.contextWindowTokens, 500000);
|
||||
// Must use same formula as production bar module
|
||||
assert.equal(
|
||||
snap!.contextPercent,
|
||||
contextPercentFromSignals(snap!.signals),
|
||||
);
|
||||
assert.ok(snap!.tools.length > 0);
|
||||
});
|
||||
|
||||
it("renders HUD with context bar matching fixture percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const usage: UsageSnapshot = {
|
||||
available: true,
|
||||
percent: 22,
|
||||
used: 17510,
|
||||
limit: 150000,
|
||||
period: "weekly",
|
||||
message: "GrokBuild 9%",
|
||||
};
|
||||
const text = renderHud(snap, usage, { color: false });
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /190k\/500k|190\.0k\/500k/);
|
||||
assert.match(text, /Usage|Quota|22%/);
|
||||
assert.match(text, /22%/);
|
||||
assert.match(text, /Grok|grok/i);
|
||||
assert.match(text, /CoachFlow/);
|
||||
});
|
||||
|
||||
it("renders when usage unavailable without crash", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const text = renderHud(
|
||||
snap,
|
||||
{ available: false, message: "usage unavailable" },
|
||||
{ color: false },
|
||||
);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /unavailable/i);
|
||||
});
|
||||
|
||||
it("tmux line includes context percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const line = renderTmux(snap, null, {
|
||||
color: false,
|
||||
tmux: true,
|
||||
compact: false,
|
||||
pathLevels: 2,
|
||||
warningThreshold: 70,
|
||||
criticalThreshold: 90,
|
||||
});
|
||||
assert.match(line, /ctx 37%/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import {
|
||||
formatStatusBlock,
|
||||
formatTmuxStatusLines,
|
||||
writeStatusFiles,
|
||||
} from "../src/status.js";
|
||||
import { PRESET_FULL } from "../src/hud-config.js";
|
||||
import { THEME_TOKYONIGHT } from "../src/theme.js";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixture = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("Claude-HUD parity status", () => {
|
||||
it("formatStatusBlock has model line + context + usage lines", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
const text = formatStatusBlock(
|
||||
snap,
|
||||
{
|
||||
available: true,
|
||||
percent: 23,
|
||||
period: "weekly",
|
||||
used: 18000,
|
||||
limit: 150000,
|
||||
resetsIn: "4d",
|
||||
message: "GrokBuild 10%",
|
||||
},
|
||||
PRESET_FULL,
|
||||
);
|
||||
assert.match(text, /\[Grok 4\.5\]/);
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /Usage/);
|
||||
assert.match(text, /23%/);
|
||||
// multi-line
|
||||
assert.ok(text.split("\n").length >= 2);
|
||||
});
|
||||
|
||||
it("formatTmuxStatusLines returns 3 rows in full preset", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
// inject todos/agents for line 3
|
||||
snap.todos = [
|
||||
{ content: "Ship HUD parity", status: "in_progress" },
|
||||
{ content: "Write tests", status: "completed" },
|
||||
];
|
||||
const lines = formatTmuxStatusLines(
|
||||
snap,
|
||||
{ available: true, percent: 22, period: "weekly" },
|
||||
THEME_TOKYONIGHT,
|
||||
PRESET_FULL,
|
||||
);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.match(lines[0]!, /Grok 4\.5|CoachFlow/);
|
||||
assert.match(lines[1]!, /Context|Usage|22%|37%/);
|
||||
});
|
||||
|
||||
it("writeStatusFiles emits tmux-lines.txt", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-full-"));
|
||||
// point grok home at tmp with config
|
||||
fs.mkdirSync(path.join(tmp, "hud"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "hud", "config.json"),
|
||||
JSON.stringify(PRESET_FULL, null, 2),
|
||||
);
|
||||
// session needs to be written via writeStatusFiles with grokHome=tmp
|
||||
// but session is loaded from fixture; OK
|
||||
const r = writeStatusFiles(
|
||||
snap,
|
||||
{ available: true, percent: 20, period: "weekly" },
|
||||
tmp,
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-lines.txt")));
|
||||
assert.ok(r.tmuxLines.length >= 2);
|
||||
assert.match(r.full, /Context/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatTmuxStatusLine } from "../src/status.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import {
|
||||
miniBar,
|
||||
THEME_TOKYONIGHT,
|
||||
THEME_GROKDAY,
|
||||
paletteForGrokTheme,
|
||||
readGrokUiConfig,
|
||||
resolveTheme,
|
||||
normalizeGrokThemeName,
|
||||
} from "../src/theme.js";
|
||||
|
||||
const pkgRoot = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("theme", () => {
|
||||
it("maps Grok theme names to palettes", () => {
|
||||
assert.equal(normalizeGrokThemeName("TokyoNight"), "tokyonight");
|
||||
assert.equal(paletteForGrokTheme("tokyonight").name, "tokyonight");
|
||||
assert.equal(paletteForGrokTheme("grokday").name, "grokday");
|
||||
assert.equal(paletteForGrokTheme("light").name, "grokday");
|
||||
assert.equal(paletteForGrokTheme("dark").name, "groknight");
|
||||
});
|
||||
|
||||
it("tokyonight palette uses Tokyo Night ink", () => {
|
||||
assert.equal(THEME_TOKYONIGHT.value, "#c0caf5");
|
||||
assert.equal(THEME_TOKYONIGHT.ok, "#9ece6a");
|
||||
assert.equal(THEME_TOKYONIGHT.mark, "#7aa2f7");
|
||||
});
|
||||
|
||||
it("readGrokUiConfig parses [ui] theme", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "config.toml"),
|
||||
`[ui]\ntheme = "tokyonight"\nauto_dark_theme = "tokyonight"\nauto_light_theme = "grokday"\n`,
|
||||
);
|
||||
const cfg = readGrokUiConfig(tmp);
|
||||
assert.equal(cfg.theme, "tokyonight");
|
||||
assert.equal(cfg.autoLightTheme, "grokday");
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolveTheme follows Grok config when mode is auto", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg2-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "config.toml"),
|
||||
`[ui]\ntheme = "tokyonight"\n`,
|
||||
);
|
||||
const t = resolveTheme("auto", { GROK_HUD_THEME: "auto" }, { grokHome: tmp });
|
||||
assert.equal(t.name, "tokyonight");
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("miniBar works with tokyonight", () => {
|
||||
const bar = miniBar(50, 8, THEME_TOKYONIGHT);
|
||||
assert.match(bar, /#9ece6a|#e0af68|#f7768e/);
|
||||
});
|
||||
|
||||
it("formatTmuxStatusLine uses provided palette colours", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const line = formatTmuxStatusLine(
|
||||
snap,
|
||||
{ available: true, percent: 22, message: "GrokBuild 9%" },
|
||||
THEME_TOKYONIGHT,
|
||||
);
|
||||
assert.match(line, /#c0caf5|#565f89|#7aa2f7|#9ece6a/);
|
||||
assert.match(line, /37%/);
|
||||
});
|
||||
|
||||
it("grokday is light-paper friendly", () => {
|
||||
assert.equal(THEME_GROKDAY.value, "#111111");
|
||||
assert.match(THEME_GROKDAY.barEmpty, /#dcdcdc/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user