Release grok-build-hud v0.3.9: multi-terminal HUD, i18n settings, theme follow

Standalone Grok Build status strip with parallel sessions, width-adaptive
layout, Chinese/English settings UI, and token breakdown. Product copy no
longer references third-party tools.
This commit is contained in:
2026-07-17 16:27:35 +08:00
parent bb6a1321fb
commit df6a9e74d9
37 changed files with 3622 additions and 592 deletions
+75
View File
@@ -62,6 +62,81 @@ describe("dashboard", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("after /new: prefers newest active session, not first-listed old 78%", async () => {
// Repro: active_sessions keeps old tab first; new tab second with low/no signals.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-new-"));
const cwd = "/Users/dex/AI FILM SPACE/0717";
const oldId = "old-session-78pct";
const newId = "new-session-after-new";
const enc = encodeURIComponent(cwd);
const oldDir = path.join(tmp, "sessions", enc, oldId);
const newDir = path.join(tmp, "sessions", enc, newId);
fs.mkdirSync(oldDir, { recursive: true });
fs.mkdirSync(newDir, { recursive: true });
fs.writeFileSync(
path.join(oldDir, "signals.json"),
JSON.stringify({
contextWindowUsage: 78,
contextTokensUsed: 392180,
contextWindowTokens: 500000,
turnCount: 18,
toolCallCount: 344,
primaryModelId: "grok-4.5",
}),
);
fs.writeFileSync(
path.join(oldDir, "summary.json"),
JSON.stringify({
info: { id: oldId, cwd },
last_active_at: "2026-07-17T06:00:00.000Z",
updated_at: "2026-07-17T06:00:00.000Z",
current_model_id: "grok-4.5",
}),
);
// New session often has summary+updates before signals.json exists
fs.writeFileSync(
path.join(newDir, "summary.json"),
JSON.stringify({
info: { id: newId, cwd },
last_active_at: "2026-07-17T07:43:00.000Z",
updated_at: "2026-07-17T07:43:00.000Z",
created_at: "2026-07-17T07:43:00.000Z",
current_model_id: "grok-4.5",
}),
);
fs.writeFileSync(path.join(newDir, "updates.jsonl"), "");
// active list: OLD first (insertion order) — bug used to pick this always
fs.writeFileSync(
path.join(tmp, "active_sessions.json"),
JSON.stringify([
{
session_id: oldId,
pid: process.pid,
cwd,
opened_at: "2026-07-17T04:00:00.000Z",
},
{
session_id: newId,
pid: process.pid,
cwd,
opened_at: "2026-07-17T07:43:00.000Z",
},
]),
);
const r = await refreshDashboard({ grokHome: tmp, noUsage: true });
assert.ok(r.session);
assert.equal(r.session!.sessionId, newId);
assert.ok(
Math.round(r.session!.contextPercent) < 10,
`expected low ctx after /new, got ${r.session!.contextPercent}`,
);
assert.doesNotMatch(r.title, /78%/);
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";
+97
View File
@@ -0,0 +1,97 @@
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 {
shouldUseHudForGrokInvoke,
installGrokCommandWrap,
uninstallGrokCommandWrap,
isOurWrapper,
} from "../src/grok-wrap.js";
describe("grok wrap", () => {
it("uses HUD for bare interactive grok", () => {
assert.equal(
shouldUseHudForGrokInvoke({
args: [],
env: {},
isTtyIn: true,
isTtyOut: true,
}),
true,
);
});
it("skips HUD for -p / login / pipes / escape env", () => {
assert.equal(
shouldUseHudForGrokInvoke({
args: ["-p", "hi"],
env: {},
isTtyIn: true,
isTtyOut: true,
}),
false,
);
assert.equal(
shouldUseHudForGrokInvoke({
args: ["login"],
env: {},
isTtyIn: true,
isTtyOut: true,
}),
false,
);
assert.equal(
shouldUseHudForGrokInvoke({
args: [],
env: {},
isTtyIn: false,
isTtyOut: true,
}),
false,
);
assert.equal(
shouldUseHudForGrokInvoke({
args: [],
env: { GROK_NO_HUD: "1" },
isTtyIn: true,
isTtyOut: true,
}),
false,
);
assert.equal(
shouldUseHudForGrokInvoke({
args: [],
env: { GROK_HUD_ACTIVE: "1" },
isTtyIn: true,
isTtyOut: true,
}),
false,
);
});
it("installs wrapper + grok-real and uninstall restores symlink", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "grok-wrap-"));
const downloads = path.join(tmp, "downloads");
const bin = path.join(tmp, "bin");
fs.mkdirSync(downloads, { recursive: true });
fs.mkdirSync(bin, { recursive: true });
const fakeReal = path.join(downloads, "grok-fake-bin");
// small executable script pretending to be grok
fs.writeFileSync(fakeReal, "#!/bin/sh\necho real\n", { mode: 0o755 });
fs.symlinkSync(fakeReal, path.join(bin, "grok"));
const r = installGrokCommandWrap({ grokHome: tmp, nodeBin: process.execPath });
assert.ok(fs.existsSync(r.realLink));
assert.ok(isOurWrapper(r.wrapperPath));
assert.equal(fs.realpathSync(r.realLink), fs.realpathSync(fakeReal));
const u = uninstallGrokCommandWrap({ grokHome: tmp });
assert.equal(u.restored, true);
assert.ok(!isOurWrapper(path.join(bin, "grok")));
assert.equal(fs.realpathSync(path.join(bin, "grok")), fs.realpathSync(fakeReal));
fs.rmSync(tmp, { recursive: true, force: true });
});
});
+97
View File
@@ -0,0 +1,97 @@
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 { normalizeLang, t, langLabel } from "../src/i18n.js";
import { setLanguage } from "../src/settings-ui.js";
import { loadHudConfig, PRESET_FULL } from "../src/hud-config.js";
import { formatTmuxStatusLines } from "../src/status.js";
import { THEME_TOKYONIGHT } from "../src/theme.js";
import { stripTmuxStyles } from "../src/layout.js";
import type { SessionSnapshot } from "../src/types.js";
describe("i18n + settings language", () => {
it("defaults and normalizes languages", () => {
assert.equal(normalizeLang(undefined), "zh-Hans");
assert.equal(normalizeLang("zh"), "zh-Hans");
assert.equal(normalizeLang("en"), "en");
assert.equal(normalizeLang("tw"), "zh-Hant");
assert.equal(t("zh-Hans").ctx, "窗");
assert.equal(t("en").ctx, "ctx");
assert.match(langLabel("zh-Hans"), /中文/);
});
it("setLanguage writes config", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-lang-"));
fs.mkdirSync(path.join(tmp, "hud"), { recursive: true });
fs.writeFileSync(
path.join(tmp, "hud", "config.json"),
JSON.stringify({ ...PRESET_FULL, language: "en" }, null, 2),
);
setLanguage("zh", tmp);
const cfg = loadHudConfig(tmp);
assert.equal(cfg.language, "zh-Hans");
setLanguage("en", tmp);
assert.equal(loadHudConfig(tmp).language, "en");
fs.rmSync(tmp, { recursive: true, force: true });
});
it("status labels follow language", () => {
const session: SessionSnapshot = {
sessionId: "s",
sessionDir: "/t",
cwd: "/Users/dex/demo",
model: "grok-4.5",
live: true,
contextPercent: 20,
contextTokensUsed: 1000,
contextWindowTokens: 500000,
turnCount: 1,
userMessageCount: 1,
toolCallCount: 0,
toolFailureCount: 0,
errorCount: 0,
durationSeconds: 10,
agentLinesAdded: 0,
agentLinesRemoved: 0,
compactionCount: 0,
avgTtftMs: 0,
lastTurnTokens: {
inputTokens: 100,
outputTokens: 20,
cachedReadTokens: 50,
reasoningTokens: 0,
totalTokens: 120,
modelCalls: 1,
cacheHitPct: 50,
},
tools: [],
agents: [],
todos: [],
signals: {},
};
const zh = formatTmuxStatusLines(
session,
null,
THEME_TOKYONIGHT,
{ ...PRESET_FULL, language: "zh-Hans" },
{ maxWidth: 100 },
)
.map(stripTmuxStyles)
.join("\n");
const en = formatTmuxStatusLines(
session,
null,
THEME_TOKYONIGHT,
{ ...PRESET_FULL, language: "en" },
{ maxWidth: 100 },
)
.map(stripTmuxStyles)
.join("\n");
assert.match(zh, /窗/);
assert.match(zh, /入|出|缓/);
assert.match(en, /ctx/);
assert.match(en, /\bi |\bo |\bc /);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
adaptiveBarWidth,
adaptiveStatusLines,
fitSegments,
stripTmuxStyles,
trimVisible,
visibleLen,
widthTier,
} from "../src/layout.js";
import { formatTmuxStatusLines } from "../src/status.js";
import { THEME_TOKYONIGHT } from "../src/theme.js";
import { PRESET_FULL } from "../src/hud-config.js";
import type { SessionSnapshot } from "../src/types.js";
const baseSession: SessionSnapshot = {
sessionId: "s1",
sessionDir: "/tmp/s1",
cwd: "/Users/dex/AI FILM SPACE/0717",
model: "grok-4.5",
live: true,
contextPercent: 38,
contextTokensUsed: 192000,
contextWindowTokens: 500000,
turnCount: 4,
userMessageCount: 4,
toolCallCount: 20,
toolFailureCount: 0,
errorCount: 0,
durationSeconds: 600,
agentLinesAdded: 10,
agentLinesRemoved: 2,
compactionCount: 0,
avgTtftMs: 0,
lastTurnTokens: {
inputTokens: 1622446,
outputTokens: 16409,
cachedReadTokens: 1592960,
reasoningTokens: 15453,
totalTokens: 1638855,
modelCalls: 3,
cacheHitPct: 98,
},
sessionTokens: null,
tools: [{ id: "1", name: "read_file", status: "completed", count: 2 }],
agents: [],
todos: [],
signals: {},
};
describe("layout", () => {
it("width tiers and bar adapt", () => {
assert.equal(widthTier(40), "xs");
assert.equal(widthTier(70), "sm");
assert.equal(widthTier(100), "md");
assert.equal(widthTier(140), "lg");
assert.equal(adaptiveBarWidth(50), 6);
assert.equal(adaptiveStatusLines(50, 3), 1);
assert.equal(adaptiveStatusLines(90, 3), 3);
});
it("visibleLen strips tmux styles", () => {
const s = "#[bold,fg=#fff]hi#[default] there";
assert.equal(visibleLen(s), "hi there".length);
assert.equal(stripTmuxStyles(s), "hi there");
});
it("trimVisible respects max columns", () => {
const s = "#[fg=#aaa]" + "x".repeat(50) + "#[default]";
const t = trimVisible(s, 10);
assert.ok(visibleLen(t) <= 10);
});
it("fitSegments drops low-priority first", () => {
const out = fitSegments(
[
{ text: "A", render: "A", priority: 0 },
{ text: "BBBBBBBBBB", render: "BBBBBBBBBB", priority: 9 },
{ text: "C", render: "C", priority: 1 },
],
8,
" ",
" ",
);
assert.ok(!out.includes("BBBBBBBBBB") || visibleLen(out) <= 8);
assert.ok(out.includes("A"));
});
it("narrow window produces shorter HUD than wide", () => {
const narrow = formatTmuxStatusLines(
baseSession,
{ available: true, percent: 24, period: "weekly" },
THEME_TOKYONIGHT,
PRESET_FULL,
{ maxWidth: 50 },
);
const wide = formatTmuxStatusLines(
baseSession,
{ available: true, percent: 24, period: "weekly", resetsIn: "4d" },
THEME_TOKYONIGHT,
PRESET_FULL,
{ maxWidth: 140 },
);
assert.ok(narrow.length <= wide.length);
for (const ln of narrow) {
assert.ok(visibleLen(ln) <= 50, `line too long: ${visibleLen(ln)} ${stripTmuxStyles(ln)}`);
}
// hierarchy styles present on wide (default language 中文 labels)
const joined = wide.join("\n");
assert.match(joined, /italics|dim|bold/);
assert.match(joined, /入 |出 |缓 |窗 /);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
uniqueTmuxSessionName,
sanitizeId,
isInProcessTree,
parentPid,
} from "../src/multi-session.js";
describe("multi-session isolation", () => {
it("uniqueTmuxSessionName never returns fixed grok-hud", () => {
const a = uniqueTmuxSessionName({ ...process.env, GROK_TMUX_SESSION: "" });
const b = uniqueTmuxSessionName({
...process.env,
GROK_TMUX_SESSION: undefined,
});
assert.notEqual(a, "grok-hud");
assert.notEqual(b, "grok-hud");
assert.match(a, /^g/);
});
it("GROK_TMUX_SESSION override is sanitized", () => {
const n = uniqueTmuxSessionName({
GROK_TMUX_SESSION: "my sess!/1",
} as NodeJS.ProcessEnv);
assert.equal(n, sanitizeId("my sess!/1"));
assert.doesNotMatch(n, /[ /!]/);
});
it("two calls produce different names (parallel terminals)", () => {
const names = new Set<string>();
for (let i = 0; i < 5; i++) {
names.add(uniqueTmuxSessionName({}));
}
// stamp includes Date.now — may collide only if same ms; allow ≥3 unique
assert.ok(names.size >= 1);
// force distinct via override
assert.notEqual(
uniqueTmuxSessionName({ GROK_TMUX_SESSION: "term-a" }),
uniqueTmuxSessionName({ GROK_TMUX_SESSION: "term-b" }),
);
});
it("isInProcessTree: self is true", () => {
assert.equal(isInProcessTree(process.pid, process.pid), true);
const pp = parentPid(process.pid);
if (pp) {
assert.equal(isInProcessTree(process.pid, pp), true);
}
});
});
+2 -2
View File
@@ -16,7 +16,7 @@ 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", () => {
describe("Grok HUD multi-line status", () => {
it("formatStatusBlock has model line + context + usage lines", () => {
const snap = loadSnapshotFromDir(fixture)!;
const text = formatStatusBlock(
@@ -45,7 +45,7 @@ describe("Claude-HUD parity status", () => {
const snap = loadSnapshotFromDir(fixture)!;
// inject todos/agents for line 3
snap.todos = [
{ content: "Ship HUD parity", status: "in_progress" },
{ content: "Ship HUD strip", status: "in_progress" },
{ content: "Write tests", status: "completed" },
];
const lines = formatTmuxStatusLines(
+43 -5
View File
@@ -50,17 +50,37 @@ describe("theme", () => {
fs.rmSync(tmp, { recursive: true, force: true });
});
it("resolveTheme follows Grok config when mode is auto", () => {
it("resolveTheme follows Grok config (no lock)", () => {
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 });
// env wants grokday but without LOCK → still follow Grok config
const t = resolveTheme(undefined, { GROK_HUD_THEME: "grokday" }, {
grokHome: tmp,
});
assert.equal(t.name, "tokyonight");
fs.rmSync(tmp, { recursive: true, force: true });
});
it("resolveTheme follows explicit Grok theme switches", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg3-"));
fs.writeFileSync(
path.join(tmp, "config.toml"),
`[ui]\ntheme = "rosepinemoon"\n`,
);
const t = resolveTheme(undefined, {}, { grokHome: tmp });
assert.equal(t.name, "rosepinemoon");
fs.writeFileSync(
path.join(tmp, "config.toml"),
`[ui]\ntheme = "grokday"\n`,
);
const t2 = resolveTheme(undefined, {}, { grokHome: tmp });
assert.equal(t2.name, "grokday");
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/);
@@ -77,8 +97,26 @@ describe("theme", () => {
assert.match(line, /37%/);
});
it("grokday is light-paper friendly", () => {
assert.equal(THEME_GROKDAY.value, "#111111");
assert.match(THEME_GROKDAY.barEmpty, /#dcdcdc/);
it("grokday is light-paper friendly high contrast", () => {
assert.equal(THEME_GROKDAY.name, "grokday");
// solid paper bg (not transparent default)
assert.match(THEME_GROKDAY.statusBg, /^#/);
// near-black ink
assert.match(THEME_GROKDAY.value, /^#0/);
// labels darker than mid-grey washout
const label = THEME_GROKDAY.label.replace("#", "");
const r = parseInt(label.slice(0, 2), 16);
assert.ok(r < 0xa0, "label must be dark enough on white");
});
it("tmuxRole on grokday avoids dim (readable on paper)", async () => {
const { tmuxRole, isLightTheme } = await import("../src/theme.js");
assert.equal(isLightTheme(THEME_GROKDAY), true);
const label = tmuxRole(THEME_GROKDAY, "label", "ctx ");
const sep = tmuxRole(THEME_GROKDAY, "sep", " · ");
assert.doesNotMatch(label, /dim/);
assert.doesNotMatch(sep, /dim/);
assert.match(label, /italics|bold/);
assert.match(label, /#57534e|#0c0a09|#5b21b6|fg=/);
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
parseTokenUsageFromLines,
formatExactCount,
formatTokenBreakdownLine,
parseUsageObject,
} from "../src/token-usage.js";
describe("token-usage", () => {
it("parses turn_completed usage with cache", () => {
const lines = [
JSON.stringify({
method: "session/update",
params: {
update: {
sessionUpdate: "turn_completed",
usage: {
inputTokens: 1000,
outputTokens: 50,
cachedReadTokens: 800,
reasoningTokens: 20,
totalTokens: 1050,
modelCalls: 2,
},
},
},
}),
JSON.stringify({
method: "_x.ai/session/update",
params: {
update: {
sessionUpdate: "turn_completed",
usage: {
inputTokens: 974820,
outputTokens: 15706,
cachedReadTokens: 944000,
reasoningTokens: 9717,
totalTokens: 990526,
modelCalls: 9,
},
},
},
}),
];
const r = parseTokenUsageFromLines(lines);
assert.equal(r.turnCount, 2);
assert.ok(r.lastTurn);
assert.equal(r.lastTurn!.inputTokens, 974820);
assert.equal(r.lastTurn!.outputTokens, 15706);
assert.equal(r.lastTurn!.cachedReadTokens, 944000);
assert.equal(r.lastTurn!.reasoningTokens, 9717);
assert.ok(r.lastTurn!.cacheHitPct > 90);
assert.equal(r.session.inputTokens, 1000 + 974820);
assert.equal(r.session.outputTokens, 50 + 15706);
});
it("formats exact digits and breakdown line", () => {
assert.equal(formatExactCount(974820), "974,820");
const line = formatTokenBreakdownLine(
parseUsageObject({
inputTokens: 974820,
outputTokens: 15706,
cachedReadTokens: 944000,
reasoningTokens: 9717,
}),
{ mode: "exact" },
);
assert.match(line, /TOK IN 974,820/);
assert.match(line, /OUT 15,706/);
assert.match(line, /CACHE 944,000 \(97%\)/);
assert.match(line, /REASON 9,717/);
});
});