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
+91 -3
View File
@@ -1,20 +1,108 @@
# Changelog
## 0.3.9
### Docs / positioning
- **Product copy**: README / skill / package / plugin descriptions now present
this as a **standalone Grok Build** live status strip (context, quota, tokens,
tools, multi-terminal). Removed third-party comparison and migration docs.
## 0.3.8
### Features
- **Settings UI** (`grok-hud settings`): interactive menu for language, preset,
status rows, token breakdown. Default language **简体中文**; switch to
English / 繁體. Shortcuts: `grok-hud lang zh` / `grok-hud lang en`.
- Status strip labels localized (窗/额/入/出/缓 vs ctx/use/i/o/c).
## 0.3.7
### Fixes
- **Theme follows Grok, never locked by default**: HUD palette always tracks
`~/.grok/config.toml [ui].theme` (and auto→OS light/dark maps). Changing
`/theme` in Grok re-paints the strip (~1s). Optional lock only with
`GROK_HUD_LOCK=1`. Install resets any legacy locked palette to follow mode.
- Theme fingerprint includes auto maps + system appearance so light/dark
toggles rewrite tmux chrome, not only palette name.
## 0.3.6
### Fixes
- **GrokDay readability**: paper strip background (`#f4f1ea`), near-black ink,
darker labels/seps. Light themes **never use tmux `dim`** (it washed text out
on white). Labels use italic + bold slate instead.
- Theme auto→grokday now paints a solid status bar so colours actually switch
away from dark Tokyo-style greys.
## 0.3.5
### Features
- **Width-adaptive HUD**: layout follows each Terminal/tmux `client_width`
(xs/sm/md/lg). Bars, rows, and fields drop by priority so the strip never
overflows the window.
- **Typography hierarchy** (terminal can't change font size — use styles):
- labels: dim + *italic*
- primary numbers / model: **bold**
- secondary (path, tools): italic
- separators: dim middots (less noisy than repeated `│`)
- tokens: compact `i` / `o` / `c` with mixed colours (not one bold wall)
## 0.3.4
### Fixes
- **Parallel Terminals = independent sessions**: `grok` no longer attaches to a
shared tmux session named `grok-hud` (`-A`). Each Terminal gets a unique
tmux session + its own Grok conversation.
- **Per-session HUD bars**: status files under `~/.grok/hud/tmux/<session>/`;
dashboard updates every live session, not only the newest.
- **No title broadcast**: Apple Terminal custom title only updates the matching
tty — never every tab.
## 0.3.3
### Fixes
- **Lifecycle = open Terminal, not machine boot**: HUD daemon is ensured when
an interactive shell starts (`~/.zshrc` hook). Login LaunchAgent is no longer
KeepAlive/RunAtLoad — `grok` + Terminal open is the primary path.
### Features
- **IN / OUT / CACHE exact token numbers** from `turn_completed` usage in
`updates.jsonl`. HUD shows full digits (e.g. `IN 974,820 · OUT 15,706 ·
CACHE 944,000 (97%) · REASON 9,717`) plus optional session ΣTOK sum.
Also written to `status.json` as `lastTurnTokens` / `sessionTokens`.
## 0.3.2
### Features
- **`grok` = start with HUD**: install wraps `~/.grok/bin/grok` so plain `grok`
opens the same-window tmux strip. Real binary kept at `~/.grok/bin/grok-real`.
Escape: `GROK_NO_HUD=1 grok …` (also auto-bare for `-p`, pipes, `login`, etc.).
- **Login LaunchAgent**: `com.dex.grok-hud-dashboard` uses modern
`launchctl bootstrap/kickstart` so the status updater really starts at login.
## 0.3.1
### Fixes
- **HUD stuck at old ctx% after `/new`**: when multiple Grok tabs are live,
prefer the *most recently active* session instead of the first entry in
`active_sessions.json` (which keeps the older tab first). Dashboard, hooks,
and `--follow-active` all share this ranking.
## 0.3.0
### Highlights
- **Claude-HUD parity multi-line strip** (same Terminal window via tmux)
- **Multi-line live status strip** (same Terminal window via tmux)
- Line 1: model · project · git · live · title · effort
- Line 2: Context bar + tokens · Usage/quota · time · turns · tools · errors · diff
- Line 3: tool activity · agents · todos · product breakdown
- **Presets**: `full` / `essential` / `minimal` (Claude HUD naming)
- **Presets**: `full` / `essential` / `minimal`
- **Theme sync with Grok UI**: reads `~/.grok/config.toml` `[ui].theme`
- Palettes: tokyonight, groknight, grokday, rosepinemoon, oscuramindnight
- **Bold + wide bars** for readability (`bold`, `barWidth` in config)
- **Quota** from real `cli-chat-proxy.grok.com` billing (weekly % + monthly + GrokBuild split)
- CLI: `grok-build-hud`, `grok-hud`, `grok-hud-run`
- One-shot installer: `bash scripts/install.sh` / `npm run setup`
- Docs: README + **README.zh-CN.md**, MIGRATION, CONTRIBUTING, troubleshooting
- Docs: README + **README.zh-CN.md**, CONTRIBUTING, troubleshooting
### Fixes
- Single dashboard daemon (no multi-writer fights)
-69
View File
@@ -1,69 +0,0 @@
# Migrating from Claude HUD → Grok Build HUD
This plugin is designed so Claude Code users can switch to Grok Build **without relearning status UX**.
## Side-by-side map
| Claude HUD | Grok Build HUD | Notes |
|------------|----------------|-------|
| Native statusline under prompt | **tmux multi-line status** in the **same Terminal window** | Grok has no statusline API; this is the supported equivalent |
| `[Opus] │ project git:(main*)` | `[Grok 4.5] │ project git:(main*)` | Same line-1 shape |
| `Context ████ 45%` | `Context ████ 45% (224k/500k)` | Native `signals.json` tokens |
| `Usage ██ 25% (1h / 5h)` | `Usage ██ 23% weekly · 4d left` | Grok weekly quota + monthly credits |
| Tools activity line | `◐ read_file … \| ✓ grep ×3` | From `updates.jsonl` |
| Agents line | `◐ explore …` / subagent titles | Best-effort from session updates |
| Todos `▸ task (2/5)` | `▸ task (2/5)` | From `todo_write` events |
| Presets Full / Essential / Minimal | `preset: full\|essential\|minimal` | Same intent |
| `/claude-hud:setup` | `grok-build-hud --install-dashboard` + `grok-hud-run` | One-time |
| `/claude-hud:configure` | edit `~/.grok/hud/config.json` or `--preset` | |
| Theme follows Claude UI | Theme follows **Grok `[ui].theme`** (tokyonight, grokday, …) | |
## 5-minute migrate checklist
1. Install Node 18+ and tmux (`brew install tmux`).
2. Install HUD:
```bash
git clone https://github.com/sooneocean/grok-build-hud.git
cd grok-build-hud
bash scripts/install.sh
```
Or from an existing clone: `npm run install-local` after `npm link`.
3. Start Grok **in the same Terminal tab** with bottom HUD:
```bash
grok-hud-run
```
4. You should see **23 status rows** (Claude-HUD style), not a second window.
5. Verify once: `grok-hud status`
## Mental model (why not identical)
Claude Code ships a **statusline hook** that redraws under the prompt.
Grok Build does not. We use a **same-window tmux status strip** that:
- stays always visible
- does not open a second window
- uses real Grok session signals + billing (same sources as `/context` and `/usage`)
## Config file
`~/.grok/hud/config.json` — presets and display toggles (Claude-HUD-like options).
```bash
grok-build-hud --preset full # everything
grok-build-hud --preset essential # model/ctx/usage/tools/todos
grok-build-hud --preset minimal # model + context + usage only
```
## Command cheatsheet
| Claude habit | Grok equivalent |
|--------------|-----------------|
| Glance HUD | bottom strip (always on with `grok-hud-run`) |
| `/context` | bottom Context bar **or** `/context` |
| rate-limit bar | Usage / quota bar (weekly + product split) |
| open configure | `~/.grok/hud/config.json` or `--preset` |
## Why this helps Grok grow
Claude users already trained on **context bar + usage bar + tools/todos**.
Matching that literacy lowers switch cost: same glances, same panic thresholds (70% / 90% context), same session hygiene — on Groks speed and pricing.
+16 -21
View File
@@ -1,20 +1,18 @@
# grok-build-hud
**Claude-HUD-style live status for [Grok Build](https://x.ai/cli).**
**Live multi-line status strip for [Grok Build](https://x.ai/cli).**
> 中文安装与使用说明:[README.zh-CN.md](./README.zh-CN.md)
Always-on strip in the **same Terminal window** (tmux status — not a second window):
```text
[Grok 4.5] my-project git:(main*) LIVE
CTX ██████████░░░░ 70% (351k/500k) │ USE ███░░░░░░░░░░░ 23% weekly · 4d │ TIME 1h │ T 16 │ TOOLS 299
◐ read_file… | ✓ grep ×3 |Ship HUD (2/5)
[Grok 4.5] · my-project git:(main*) ·
窗/ctx ██████████░░░░ 70% (351k/500k) · 入/i · 出/o · 缓/c · 额/use 23% · 轮/t 16
◐ read_file… · ✓ grep ×3 ·todos
```
Built for Claude Code users who already know **context bar + usage bar + tools/todos** — same glances on Grok.
> Grok has no native statusline API. This is the supported equivalent: a multi-line status strip + optional scrollback annotations.
Built for Grok Build sessions: context window, quota, **input/output/cache tokens**, tools, todos, git — with Chinese/English UI and theme follow.
---
@@ -83,11 +81,12 @@ Then `source ~/.zshrc` and verify: `which grok-hud-run`.
```bash
# Start Grok with the multi-line HUD at the bottom of THIS tab
grok-hud-run
grok
# (or: grok-hud-run)
```
| Row | Content (Claude HUD analogue) |
|-----|--------------------------------|
| Row | Content |
|-----|---------|
| 1 | Model · project · git · live · title · effort |
| 2 | **Context** bar + tokens · **Usage/quota** · time · turns · tools · errors · diff |
| 3 | Tool activity · agents · todos · GrokBuild product share |
@@ -121,7 +120,7 @@ tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
| Preset | Rows | Contents |
|--------|------|----------|
| **full** (default) | 3 | Everything (Claude “Full”) |
| **full** (default) | 3 | Everything |
| **essential** | 2 | Model/git + context/usage + activity |
| **minimal** | 1 | Dense single row |
@@ -191,17 +190,13 @@ No second window. No cloud upload of your code by this tool (read-only local ses
---
## Migrating from Claude HUD
## Settings (language)
See **[MIGRATION-FROM-CLAUDE.md](./MIGRATION-FROM-CLAUDE.md)** for option-by-option mapping.
| Claude HUD | Grok Build HUD |
|------------|----------------|
| Statusline under prompt | Same-window tmux multi-line strip |
| Full / Essential / Minimal | `--preset full\|essential\|minimal` |
| Context + Usage bars | Same layout on line 2 |
| Tools / agents / todos | Line 3 |
| `/claude-hud:setup` | `--install-dashboard` + `grok-hud-run` |
```bash
grok-hud settings # interactive UI
grok-hud lang zh # 简体中文 (default)
grok-hud lang en # English
```
---
+42 -23
View File
@@ -1,18 +1,18 @@
# grok-build-hud
**给 [Grok Build](https://x.ai/cli) 用的 Claude-HUD 风格状态条。**
**[Grok Build](https://x.ai/cli) 的实时多行状态条。**
在**同一个 Terminal 窗口底部**常驻显示(tmux 状态栏,不会多开窗口):
在**同一个 Terminal 窗口底部**常驻显示(tmux 状态栏,不开窗口):
```text
[Grok 4.5] my-project git:(main*) │ ● LIVE
CTX ██████████░░░░ 70% (351k/500k) │ USE ███░░░░░░░░░░░ 23% weekly · 4d │ TIME 1h │ T 16 │ TOOLS 299
◐ read_file… | ✓ grep ×3 |Ship HUD (2/5)
[Grok 4.5] · my-project git · ●
██████░░░░ 70% (351k/500k) · 入 … 出 … 缓 … · 额 23% · 轮 16
◐ read_file… · ✓ grep ×3 ·todos
```
> Grok 没有 Claude 那种 statusline API。本工具用「同窗口多行状态条」实现同等体验
专为 Grok Build 会话设计:上下文窗口、配额、**入/出/缓存 token**、工具、待办、git;支持中英文 UI,主题跟随 Grok
中文说明见本页;英文完整文档见 [README.md](./README.md)。
中文说明见本页;英文见 [README.md](./README.md)。
---
@@ -75,13 +75,44 @@ export PATH="$(npm prefix -g)/bin:$HOME/.local/bin:$PATH"
## 日常使用
```bash
# 在本标签页启动 Grok,底部自带多行 HUD(不要另开窗
# 1) 打开 Terminal(会自动确保 HUD 后台更新器
# 2) 直接输入:
grok
# 底部状态条会显示:
# CTX … │ TOK IN 974,820 · OUT 15,706 · CACHE 944,000 (97%) │ USE …
# input / output / cache 精确数字,来自本会话 turn_completed
# 不要 HUD 的裸命令(脚本 / -p 打印模式)
GROK_NO_HUD=1 grok -p "hello"
# 旧名字仍然可用
grok-hud-run
```
| 行 | 内容(类比 Claude HUD |
|----|-------------------------|
| 第 1 行 | 模型 · 项目 · git · 是否 live · 标题 · effort |
**生命周期**:开 **Terminal** 就绪,不是电脑开机自启。
`~/.zshrc` 里会装一段 interactive hook;输入 `grok` 即带 HUD。
**并行开发**:每个 Terminal 标签页是**独立** Grok session(独立 tmux 名,互不 attach)。
多开几个终端,各自 `grok` 即可同时干活,不会串会话。
## 设定(语言 / 预设)
```bash
# 交互设定界面(默认中文)
grok-hud settings
# 快捷切换语言
grok-hud lang zh # 简体中文(默认)
grok-hud lang en # English
grok-hud lang tw # 繁體中文
```
状态条标签会跟着语言变:中文 `窗/额/入/出/缓`,英文 `ctx/use/i/o/c`
| 行 | 内容 |
|----|------|
| 第 1 行 | 模型 · 项目 · git · 是否在线 · 标题 · effort |
| 第 2 行 | **上下文**进度条 + token · **配额** · 时长 · 轮次 · 工具数 · 错误 · diff |
| 第 3 行 | 最近工具 · agents · todos · GrokBuild 用量占比 |
@@ -134,18 +165,6 @@ tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
---
## 从 Claude HUD 迁过来
**[MIGRATION-FROM-CLAUDE.md](./MIGRATION-FROM-CLAUDE.md)**。
| Claude HUD | 本工具 |
|------------|--------|
| 提示符下 statusline | 同窗口 tmux 多行条 |
| Full / Essential / Minimal | `--preset full\|essential\|minimal` |
| `/claude-hud:setup` | `--install-dashboard` + `grok-hud-run` |
---
## 卸载
```bash
+4
View File
@@ -31,6 +31,10 @@ else if (head === "status" || head === undefined) {
mapped = ["--dashboard-start"];
} else if (head === "install") {
mapped = ["--install-dashboard"];
} else if (head === "settings" || head === "config" || head === "设定" || head === "設置") {
mapped = ["--settings", ...argv.slice(1)];
} else if (head === "lang" || head === "language" || head === "语言" || head === "語言") {
mapped = ["--lang", argv[1] || "zh", ...argv.slice(2)];
}
const code = await mod.runCli(mapped);
+5 -19
View File
@@ -1,29 +1,15 @@
---
description: Show live context window + quota bars (Claude-HUD style)
description: Show live Grok Build context + quota status strip
---
# /hud — live context + quota
Print the multi-line HUD for the active Grok session:
# HUD status
```bash
grok-hud status
# or:
# or
grok-build-hud --once --follow-active --no-color
```
If CLIs are not on PATH (plugin-only install):
Prints the multi-line status (context, usage, tokens, tools) once.
```bash
node "${GROK_PLUGIN_ROOT}/bin/grok-build-hud.js" --once --follow-active --no-color
```
Example:
```text
[Grok 4.5] │ my-project git:(main*) │ ● LIVE
Context ████████░░ 78% (392k/500k) │ Quota ██░░░░░░░░ 24% weekly · 4d
◐ read_file… | ✓ grep ×3
```
For always-on same-window strip: start with `grok-hud-run` (not a second window).
For always-on same-window strip: start with `grok` (or `grok-hud-run`).
+9 -13
View File
@@ -1,23 +1,19 @@
---
description: Set Claude-HUD-style HUD preset (full / essential / minimal)
description: Set Grok HUD display preset (full / essential / minimal)
---
# HUD presets (Claude HUD parity)
# HUD presets
```bash
# Full — model, git, context+usage dual bars, tools, agents, todos, diff stats (3 rows)
grok-build-hud --preset full
# or: grok-hud preset full
# Essential — dual bars + activity (2 rows)
grok-build-hud --preset essential
# Minimal — single dense row
grok-build-hud --preset minimal
grok-build-hud --preset full # 3 rows (default)
grok-build-hud --preset essential # 2 rows
grok-build-hud --preset minimal # 1 compact row
```
Then refresh tmux (if already in a HUD session):
Or open the settings UI:
```bash
tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
grok-hud settings
```
Presets keep your language preference (中文 / English).
+21
View File
@@ -0,0 +1,21 @@
---
description: Open Grok HUD settings (language 中/英, preset, rows)
---
# Settings
```bash
grok-hud settings
# or
grok-build-hud --settings
```
## Language shortcuts
```bash
grok-hud lang zh # 简体中文 (default)
grok-hud lang en # English
grok-hud lang tw # 繁體中文
```
Config file: `~/.grok/hud/config.json``language`
+4 -5
View File
@@ -1,7 +1,7 @@
{
"name": "grok-build-hud",
"version": "0.3.0",
"description": "Claude-HUD-style live status for Grok Build: multi-line context, quota, tools, todos, git — same-window tmux strip + theme sync",
"version": "0.3.9",
"description": "Live multi-line status strip for Grok Build: context, quota, tokens, tools, todos, git — same-window tmux bar with theme sync and Chinese/English UI",
"type": "module",
"main": "dist/src/index.js",
"bin": {
@@ -19,7 +19,6 @@
".grok-plugin/",
"README.md",
"README.zh-CN.md",
"MIGRATION-FROM-CLAUDE.md",
"CHANGELOG.md",
"LICENSE",
"scripts/",
@@ -44,11 +43,11 @@
"grok-build",
"hud",
"statusline",
"claude-hud",
"tmux",
"cli",
"context",
"quota"
"quota",
"tokens"
],
"author": "dex",
"license": "MIT",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "grok-build-hud",
"version": "0.3.0",
"description": "Claude-HUD-style multi-line live status for Grok Build (context, quota, tools, todos, git)",
"version": "0.3.9",
"description": "Live multi-line status strip for Grok Build: context, quota, tokens, tools, todos, git",
"author": {
"name": "sooneocean",
"email": "redredchen01@gmail.com"
+12 -10
View File
@@ -1,11 +1,11 @@
---
name: grok-build-hud
description: Explain and operate the Claude-HUD-style external status HUD for Grok Build (same-window multi-line context, quota, tools, todos, theme sync).
description: Explain and operate the Grok Build live status HUD (same-window multi-line context, quota, tokens, tools, todos, theme sync, Chinese/English settings).
---
# grok-build-hud
Grok Build has **no** Claude Code statusline API. This package provides a Claude-HUD-like always-on strip via **same-window tmux status** plus optional scrollback annotations.
Always-on status strip for **Grok Build**: multi-line bar in the **same Terminal tab** (tmux status) plus optional scrollback annotations. Reads local session files and your Grok auth for quota — no second window required.
## What it shows
@@ -16,7 +16,7 @@ Grok Build has **no** Claude Code statusline API. This package provides a Claude
Data sources (local):
- `~/.grok/sessions/**/signals.json` — context window
- `updates.jsonl` — tools / agents / todos
- `updates.jsonl` — tools / agents / todos / token usage
- `summary.json` — title, effort, model
- Grok auth → `cli-chat-proxy` billing for quota
@@ -31,19 +31,21 @@ bash scripts/install.sh
## Daily
```bash
grok-hud-run # Grok + bottom HUD (same tab)
grok-hud status # one-shot print
grok # wrapped: Grok + bottom HUD (same tab)
grok-hud status # one-shot print
grok-hud settings # language (中/英), preset, rows
grok-hud lang zh|en|tw
grok-build-hud --preset full|essential|minimal
grok-build-hud --theme auto # follow Grok [ui].theme
grok-hud stop # stop dashboard daemon
grok-build-hud --theme auto # follow Grok [ui].theme
grok-hud stop # stop dashboard daemon
```
## In-session slash commands (if plugin enabled)
- `/hud` `/status` `/quota` `/preset` `/setup` `/watch` — see `commands/`
- `/hud` `/status` `/quota` `/preset` `/setup` `/watch` `/settings` — see `commands/`
## Config
`~/.grok/hud/config.json` — presets, `bold`, `barWidth`, display toggles.
`~/.grok/hud/config.json` language, presets, `bold`, `barWidth`, display toggles.
Full docs: [README.md](../../README.md) · 中文: [README.zh-CN.md](../../README.zh-CN.md) · Claude migrate: [MIGRATION-FROM-CLAUDE.md](../../MIGRATION-FROM-CLAUDE.md)
Full docs: [README.md](../../README.md) · 中文: [README.zh-CN.md](../../README.zh-CN.md)
+1 -1
View File
@@ -261,7 +261,7 @@ function summarizeRawInput(raw: unknown): string | undefined {
return undefined;
}
/** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */
/** Format tool activity line: ◐ tool · ✓ tool ×3 */
export function formatToolLine(tools: ToolActivityItem[]): string {
if (!tools.length) return "";
const parts: string[] = [];
+76 -81
View File
@@ -10,13 +10,15 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import {
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
findSessionDirById,
pickBestSession,
pickFromActiveSessions,
} from "./session.js";
import { getCreditUsage } from "./billing.js";
import { writeStatusFiles, formatCompactLine } from "./status.js";
import { findTmuxSessionForPid } from "./multi-session.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
export function dashboardPidPath(grokHome = defaultGrokHome()): string {
@@ -64,59 +66,39 @@ export function writeOscTitle(ttyPath: string, title: string): boolean {
}
/**
* 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.
* Apple Terminal: set tab custom title for ONE tty only.
* Never broadcast to all tabs — that broke parallel Terminal development.
*/
export function setAppleTerminalTitle(
title: string,
options: { ttyHint?: string | null } = {},
): boolean {
if (process.platform !== "darwin") return false;
const tty = (options.ttyHint || "")
.replace(/^\/dev\//, "")
.replace(/"/g, "");
// Without a tty match target, skip (do not overwrite every tab)
if (!tty) 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
repeat with w in windows
repeat with tb in tabs of w
try
set custom title of w to hud
end try
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
end try
end repeat
end if
end try
end repeat
end if
end repeat
end tell
`;
execFileSync("osascript", ["-e", script], {
@@ -129,27 +111,34 @@ end tell
}
}
/** All live Grok sessions (parallel Terminals). */
export function listLiveSessions(grokHome = defaultGrokHome()): SessionSnapshot[] {
const active = loadActiveSessions(grokHome);
const out: SessionSnapshot[] = [];
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
const snap = loadSnapshotFromDir(dir, { active });
if (snap?.live) out.push(snap);
}
return out;
}
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;
// Parallel Terminals: update EVERY live session independently.
// Global "primary" = most recently active (for status.json / hooks fallback).
let primary: SessionSnapshot | null = pickFromActiveSessions(grokHome);
if (!primary) primary = pickBestSession({ grokHome });
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 });
const live = listLiveSessions(grokHome);
const targets =
live.length > 0 ? live : primary ? [primary] : [];
if (!session) {
if (!targets.length) {
return { title: "◆ grok-hud: no session", session: null };
}
@@ -162,17 +151,23 @@ export async function refreshDashboard(options: {
}
}
// Theme follows Grok [ui].theme; re-apply tmux chrome when palette flips
const { resolveTheme } = await import("./theme.js");
// Theme always follows Grok [ui].theme (auto → OS light/dark maps).
// Fingerprint includes mapping + system appearance so /theme and OS toggle re-paint.
const {
resolveTheme,
readGrokUiConfig,
themeFingerprint,
} = await import("./theme.js");
const ui = readGrokUiConfig(grokHome);
const theme = resolveTheme(undefined, process.env, { grokHome });
writeStatusFiles(session, usage, grokHome);
try {
const stamp = path.join(grokHome, "hud", ".last-theme");
const fp = themeFingerprint(theme, ui, process.env);
const prev = fs.existsSync(stamp)
? fs.readFileSync(stamp, "utf8").trim()
: "";
if (prev !== theme.name) {
fs.writeFileSync(stamp, theme.name + "\n", "utf8");
if (prev !== fp) {
fs.writeFileSync(stamp, fp + "\n", "utf8");
const { writeTmuxConfFile, applyTmuxStatusBar } = await import(
"./tmux-hud.js"
);
@@ -182,38 +177,38 @@ export async function refreshDashboard(options: {
} 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);
// Write per-tmux-session status so each Terminal bar shows ITS own Grok
// Layout adapts to each window's client width.
for (const snap of targets) {
const tmuxSession = findTmuxSessionForPid(snap.pid);
const tty = ttyForPid(snap.pid);
const isPrimary =
primary != null && snap.sessionId === primary.sessionId;
writeStatusFiles(snap, usage, grokHome, {
tmuxSession,
ttyPath: tty,
// Only primary overwrites global status.* (hooks / grok-hud status)
writeGlobal: isPrimary || targets.length === 1,
});
// 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 */
const title = titleLine(snap, usage);
if (tty) {
writeOscTitle(tty, title);
setAppleTerminalTitle(title, { ttyHint: path.basename(tty) });
}
}
return { title, session };
// If somehow no primary write happened, write one
if (primary && !targets.some((t) => t.sessionId === primary!.sessionId)) {
writeStatusFiles(primary, usage, grokHome, {
tmuxSession: findTmuxSessionForPid(primary.pid),
writeGlobal: true,
});
}
const session = primary ?? targets[0]!;
return { title: titleLine(session, usage), session };
}
export function isDashboardRunning(grokHome = defaultGrokHome()): boolean {
+327
View File
@@ -0,0 +1,327 @@
/**
* Make plain `grok` start with HUD (same-window tmux strip).
* Preserves bare CLI for scripts, pipes, and non-interactive flags.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { packageRoot } from "./install.js";
import { defaultGrokHome } from "./session.js";
const WRAP_MARKER = "# >>> grok-build-hud wrap >>>";
/** Subcommands / flags that must hit the real binary without tmux HUD. */
const BARE_FIRST_ARGS = new Set([
"-p",
"--print",
"-h",
"--help",
"-V",
"--version",
"version",
"login",
"logout",
"auth",
"plugin",
"mcp",
"doctor",
"update",
"upgrade",
"completion",
"config",
"whoami",
"uninstall",
]);
export function shouldUseHudForGrokInvoke(options: {
args?: string[];
env?: NodeJS.ProcessEnv;
isTtyIn?: boolean;
isTtyOut?: boolean;
}): boolean {
const env = options.env ?? process.env;
if (env.GROK_NO_HUD === "1" || env.GROK_BARE === "1") return false;
if (env.GROK_HUD_ACTIVE === "1") return false;
const ttyIn = options.isTtyIn ?? Boolean(process.stdin.isTTY);
const ttyOut = options.isTtyOut ?? Boolean(process.stdout.isTTY);
if (!ttyIn || !ttyOut) return false;
const args = options.args ?? [];
const first = args[0];
if (first && BARE_FIRST_ARGS.has(first)) return false;
// long flags like --print=...
if (first?.startsWith("--print") || first?.startsWith("-p=")) return false;
return true;
}
/** Resolve the real Grok binary (never the HUD wrapper). */
export function resolveRealGrokBin(
grokHome = defaultGrokHome(),
): string | null {
const realLink = path.join(grokHome, "bin", "grok-real");
if (isExecutable(realLink)) return fs.realpathSync(realLink);
const downloads = path.join(grokHome, "downloads");
if (fs.existsSync(downloads)) {
try {
const names = fs
.readdirSync(downloads)
.filter((n) => n.startsWith("grok-") && !n.includes("hud"))
.map((n) => path.join(downloads, n))
.filter(isExecutable)
.sort(
(a, b) =>
fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs,
);
if (names[0]) return names[0];
} catch {
/* ignore */
}
}
// Existing ~/.grok/bin/grok if it is NOT our wrapper
const grokBin = path.join(grokHome, "bin", "grok");
if (isExecutable(grokBin) && !isOurWrapper(grokBin)) {
try {
return fs.realpathSync(grokBin);
} catch {
return grokBin;
}
}
for (const c of [
"/usr/local/bin/grok",
"/opt/homebrew/bin/grok",
path.join(os.homedir(), ".local", "bin", "grok"),
]) {
if (isExecutable(c) && !isOurWrapper(c)) {
try {
return fs.realpathSync(c);
} catch {
return c;
}
}
}
try {
const w = execFileSync("which", ["-a", "grok"], {
encoding: "utf8",
})
.trim()
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
for (const c of w) {
if (isExecutable(c) && !isOurWrapper(c)) return c;
}
} catch {
/* ignore */
}
return null;
}
export function isOurWrapper(filePath: string): boolean {
try {
// symlink to binary → not wrapper
const st = fs.lstatSync(filePath);
if (st.isSymbolicLink()) {
const target = fs.readlinkSync(filePath);
if (!target.endsWith(".sh") && !target.includes("wrap")) {
// could still be our script if relative — read content
}
}
if (st.isFile() || st.isSymbolicLink()) {
// only read if small text script
if (st.isSymbolicLink()) {
const real = fs.realpathSync(filePath);
const rst = fs.statSync(real);
if (rst.size > 64_000) return false;
const head = fs.readFileSync(real, "utf8").slice(0, 400);
return head.includes(WRAP_MARKER) || head.includes("grok-build-hud wrap");
}
if (st.size > 64_000) return false;
const head = fs.readFileSync(filePath, "utf8").slice(0, 400);
return head.includes(WRAP_MARKER) || head.includes("grok-build-hud wrap");
}
} catch {
return false;
}
return false;
}
function isExecutable(p: string): boolean {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export interface GrokWrapInstallResult {
wrapperPath: string;
realPath: string;
realLink: string;
localBinPath?: string;
}
/**
* Install: `grok` → HUD when interactive; bare otherwise.
* Real binary kept at ~/.grok/bin/grok-real.
*/
export function installGrokCommandWrap(options: {
grokHome?: string;
nodeBin?: string;
} = {}): GrokWrapInstallResult {
const grokHome = options.grokHome ?? defaultGrokHome();
const binDir = path.join(grokHome, "bin");
fs.mkdirSync(binDir, { recursive: true });
const real = resolveRealGrokBin(grokHome);
if (!real) {
throw new Error(
"Cannot find real Grok binary. Install Grok Build first (https://x.ai/cli).",
);
}
const realLink = path.join(binDir, "grok-real");
// Point grok-real at the resolved binary (absolute path symlink)
try {
fs.lstatSync(realLink);
fs.unlinkSync(realLink);
} catch {
/* missing */
}
fs.symlinkSync(real, realLink);
const root = packageRoot();
const node = options.nodeBin ?? process.execPath;
const entry = path.join(root, "bin", "grok-build-hud.js");
const wrapperPath = path.join(binDir, "grok");
const body = `#!/bin/zsh
${WRAP_MARKER}
# Plain \`grok\` → same-window HUD. Bare CLI: GROK_NO_HUD=1 grok …
# Auto-installed by grok-build-hud. Real binary: ~/.grok/bin/grok-real
set -euo pipefail
REAL="\${GROK_REAL_BIN:-$HOME/.grok/bin/grok-real}"
if [[ ! -x "$REAL" ]]; then
# fallback: newest download
REAL="$(ls -t "$HOME"/.grok/downloads/grok-* 2>/dev/null | head -1 || true)"
fi
if [[ ! -x "$REAL" ]]; then
echo "grok-hud wrap: real binary not found (expected ~/.grok/bin/grok-real)" >&2
exit 127
fi
# Escape hatches
if [[ "\${GROK_NO_HUD:-}" == "1" || "\${GROK_BARE:-}" == "1" || "\${GROK_HUD_ACTIVE:-}" == "1" ]]; then
exec "$REAL" "$@"
fi
# Non-interactive / pipes → bare
if [[ ! -t 0 || ! -t 1 ]]; then
exec "$REAL" "$@"
fi
# Subcommands that are not the TUI
case "\${1:-}" in
-p|--print|-h|--help|-V|--version|version|login|logout|auth|plugin|mcp|doctor|update|upgrade|completion|config|whoami|uninstall)
exec "$REAL" "$@"
;;
--print=*|-p=*)
exec "$REAL" "$@"
;;
esac
NODE=${JSON.stringify(node)}
ENTRY=${JSON.stringify(entry)}
export GROK_HUD_ACTIVE=1
export GROK_REAL_BIN="$REAL"
export GROK_HUD_THEME="\${GROK_HUD_THEME:-auto}"
# Ensure background status updater is up, then same-window HUD + Grok
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
"$NODE" "$ENTRY" --once --follow-active --no-color >/dev/null 2>&1 || true
exec "$NODE" "$ENTRY" --run-in-terminal "$@"
# <<< grok-build-hud wrap <<<
`;
// Replace existing grok (symlink or file)
try {
fs.unlinkSync(wrapperPath);
} catch {
/* ignore */
}
fs.writeFileSync(wrapperPath, body, { mode: 0o755 });
// Also put a thin pointer in ~/.local/bin if that path is commonly used
let localBinPath: string | undefined;
const localBin = path.join(os.homedir(), ".local", "bin", "grok");
try {
fs.mkdirSync(path.dirname(localBin), { recursive: true });
// Only replace if missing, symlink, or already our wrap
let replace = !fs.existsSync(localBin);
if (!replace) {
try {
const st = fs.lstatSync(localBin);
if (st.isSymbolicLink() || isOurWrapper(localBin)) replace = true;
} catch {
replace = true;
}
}
if (replace) {
try {
fs.unlinkSync(localBin);
} catch {
/* ignore */
}
// Prefer symlink to the canonical wrapper so one source of truth
fs.symlinkSync(wrapperPath, localBin);
localBinPath = localBin;
}
} catch {
/* optional */
}
return { wrapperPath, realPath: real, realLink, localBinPath };
}
/** Restore plain grok symlink to real binary; remove wrap. */
export function uninstallGrokCommandWrap(options: {
grokHome?: string;
} = {}): { restored: boolean; path?: string } {
const grokHome = options.grokHome ?? defaultGrokHome();
const wrapperPath = path.join(grokHome, "bin", "grok");
const realLink = path.join(grokHome, "bin", "grok-real");
let real: string | null = null;
if (isExecutable(realLink)) {
try {
real = fs.realpathSync(realLink);
} catch {
real = realLink;
}
} else {
real = resolveRealGrokBin(grokHome);
}
if (!real) return { restored: false };
try {
if (fs.existsSync(wrapperPath) || fs.lstatSync(wrapperPath)) {
fs.unlinkSync(wrapperPath);
}
} catch {
try {
fs.unlinkSync(wrapperPath);
} catch {
/* ignore */
}
}
fs.symlinkSync(real, wrapperPath);
return { restored: true, path: wrapperPath };
}
+8 -10
View File
@@ -11,6 +11,7 @@ import {
loadActiveSessions,
loadSnapshotFromDir,
pickBestSession,
pickFromActiveSessions,
} from "./session.js";
import { writeStatusFiles } from "./status.js";
import { getCreditUsage } from "./billing.js";
@@ -67,20 +68,17 @@ export function resolveSessionForHook(
}
}
// Prefer live active session matching cwd
// Prefer newest live active session matching cwd (not first-in-list)
if (cwd) {
const hit = pickBestSession({ grokHome, cwd });
const hit =
pickFromActiveSessions(grokHome, { cwd }) ??
pickBestSession({ grokHome, cwd });
if (hit) return hit;
}
// Any live session
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (dir) {
const snap = loadSnapshotFromDir(dir, { active });
if (snap) return snap;
}
}
// Newest live session among active tabs
const fromActive = pickFromActiveSessions(grokHome);
if (fromActive) return fromActive;
return pickBestSession({ grokHome });
}
+60 -19
View File
@@ -1,5 +1,5 @@
/**
* Claude-HUD-compatible display config + presets.
* Grok Build HUD display config + presets.
* Stored at ~/.grok/hud/config.json
*/
import fs from "node:fs";
@@ -40,6 +40,15 @@ export interface HudDisplayConfig {
showDiffStats: boolean;
showLive: boolean;
showTitle: boolean;
/** Show input/output/cache token breakdown (exact integers). */
showTokenBreakdown: boolean;
/**
* Which token totals to show: last completed turn, session sum, or both.
* Default "last" (most relevant); full status.txt also prints session sum.
*/
tokenScope: "last" | "session" | "both";
/** exact = 974,820 full digits; short = 974.8k */
tokenDigits: "exact" | "short";
};
warningThreshold: number;
criticalThreshold: number;
@@ -49,7 +58,8 @@ export const PRESET_FULL: HudDisplayConfig = {
preset: "full",
lineLayout: "expanded",
pathLevels: 2,
language: "en",
/** Default UI language: 简体中文 (switch to en in settings) */
language: "zh-Hans",
statusLines: 3,
bold: true,
barWidth: 14,
@@ -72,6 +82,9 @@ export const PRESET_FULL: HudDisplayConfig = {
showDiffStats: true,
showLive: true,
showTitle: true,
showTokenBreakdown: true,
tokenScope: "both",
tokenDigits: "exact",
},
warningThreshold: 70,
criticalThreshold: 90,
@@ -91,6 +104,9 @@ export const PRESET_ESSENTIAL: HudDisplayConfig = {
showDiffStats: false,
showTitle: false,
contextValue: "percent",
showTokenBreakdown: true,
tokenScope: "last",
tokenDigits: "exact",
},
};
@@ -116,6 +132,9 @@ export const PRESET_MINIMAL: HudDisplayConfig = {
showDiffStats: false,
showTitle: false,
contextValue: "percent",
showTokenBreakdown: true,
tokenScope: "last",
tokenDigits: "short",
},
};
@@ -131,6 +150,7 @@ export function loadHudConfig(
if (!fs.existsSync(p)) {
return {
...PRESET_FULL,
language: "zh-Hans",
display: { ...PRESET_FULL.display },
};
}
@@ -144,6 +164,8 @@ export function loadHudConfig(
return {
...base,
...raw,
// Prefer saved language; default 中文 when missing
language: raw.language ?? base.language ?? "zh-Hans",
bold: raw.bold ?? base.bold ?? true,
barWidth: raw.barWidth ?? base.barWidth ?? 14,
statusLines: raw.statusLines ?? base.statusLines ?? 3,
@@ -152,11 +174,29 @@ export function loadHudConfig(
} catch {
return {
...PRESET_FULL,
language: "zh-Hans",
display: { ...PRESET_FULL.display },
};
}
}
/** Ensure config exists with Chinese default (idempotent). */
export function ensureDefaultConfig(
grokHome = path.join(os.homedir(), ".grok"),
): HudDisplayConfig {
const p = configPath(grokHome);
if (!fs.existsSync(p)) {
const cfg = {
...PRESET_FULL,
language: "zh-Hans" as const,
display: { ...PRESET_FULL.display },
};
saveHudConfig(cfg, grokHome);
return cfg;
}
return loadHudConfig(grokHome);
}
export function saveHudConfig(
cfg: HudDisplayConfig,
grokHome = path.join(os.homedir(), ".grok"),
@@ -174,20 +214,21 @@ export function applyPreset(preset: HudPreset): HudDisplayConfig {
return { ...PRESET_FULL, display: { ...PRESET_FULL.display } };
}
/** Claude-HUD option name → Grok HUD config path (migration map). */
export const CLAUDE_HUD_MIGRATION: Record<string, string> = {
"display.showModel": "display.showModel",
"display.showContextBar": "display.showContextBar",
"display.contextValue": "display.contextValue",
"display.showUsage": "display.showUsage",
"display.showSessionTime / sessionTime": "display.showSessionTime",
"gitStatus.enabled": "display.showGit",
"gitStatus.showDirty": "display.showGitDirty",
"display tools activity": "display.showToolActivity",
"display agents": "display.showAgents",
"display todos": "display.showTodos",
lineLayout: "lineLayout",
pathLevels: "pathLevels",
language: "language",
"presets Full/Essential/Minimal": "preset",
};
/** Documented display option keys (for settings / docs). */
export const DISPLAY_OPTION_KEYS = [
"display.showModel",
"display.showContextBar",
"display.contextValue",
"display.showUsage",
"display.showSessionTime",
"display.showGit",
"display.showGitDirty",
"display.showToolActivity",
"display.showAgents",
"display.showTodos",
"display.showTokenBreakdown",
"lineLayout",
"pathLevels",
"language",
"preset",
] as const;
+238
View File
@@ -0,0 +1,238 @@
/**
* HUD + settings UI strings.
* Default language: zh-Hans (简体中文). Switch via settings → English.
*/
import type { HudDisplayConfig } from "./hud-config.js";
export type HudLang = "zh-Hans" | "zh-Hant" | "en";
export interface HudStrings {
/** Short labels for status strip (keep short — window width) */
ctx: string;
use: string;
turn: string;
tools: string;
in: string;
out: string;
cache: string;
reason: string;
sum: string;
live: string;
stale: string;
err: string;
weekly: string;
monthly: string;
left: string;
/** Settings UI */
settingsTitle: string;
settingsHint: string;
lang: string;
langZh: string;
langZhHant: string;
langEn: string;
preset: string;
statusLines: string;
tokenBreakdown: string;
on: string;
off: string;
themeFollow: string;
themeFollowHint: string;
saveExit: string;
quitNoSave: string;
saved: string;
cancelled: string;
current: string;
choose: string;
invalid: string;
rows: string;
presetFull: string;
presetEssential: string;
presetMinimal: string;
back: string;
languageSet: string;
helpSettings: string;
}
const ZH_HANS: HudStrings = {
ctx: "窗",
use: "额",
turn: "轮",
tools: "具",
in: "入",
out: "出",
cache: "缓",
reason: "思",
sum: "Σ",
live: "在线",
stale: "闲置",
err: "错",
weekly: "周",
monthly: "月",
left: "剩余",
settingsTitle: "Grok HUD 设定",
settingsHint: "输入数字选择 · Enter 确认 · 0 保存退出",
lang: "语言",
langZh: "简体中文",
langZhHant: "繁體中文",
langEn: "English",
preset: "显示预设",
statusLines: "状态行数",
tokenBreakdown: "Token 明细 (入/出/缓)",
on: "开",
off: "关",
themeFollow: "主题",
themeFollowHint: "跟随 Grok /theme(不锁死)",
saveExit: "保存并退出",
quitNoSave: "不保存退出",
saved: "已保存",
cancelled: "已取消,未保存",
current: "当前",
choose: "请选择",
invalid: "无效选项,请重试",
rows: "行",
presetFull: "完整 (3 行)",
presetEssential: "精简 (2 行)",
presetMinimal: "最小 (1 行)",
back: "返回",
languageSet: "语言已设为",
helpSettings: "打开设定界面(语言 / 预设 / 行数)",
};
const ZH_HANT: HudStrings = {
...ZH_HANS,
ctx: "窗",
use: "額",
turn: "輪",
tools: "具",
in: "入",
out: "出",
cache: "緩",
reason: "思",
live: "在線",
stale: "閒置",
err: "錯",
weekly: "週",
monthly: "月",
left: "剩餘",
settingsTitle: "Grok HUD 設定",
settingsHint: "輸入數字選擇 · Enter 確認 · 0 儲存離開",
lang: "語言",
langZh: "簡體中文",
langZhHant: "繁體中文",
langEn: "English",
preset: "顯示預設",
statusLines: "狀態行數",
tokenBreakdown: "Token 明細 (入/出/緩)",
on: "開",
off: "關",
themeFollow: "主題",
themeFollowHint: "跟隨 Grok /theme(不鎖死)",
saveExit: "儲存並離開",
quitNoSave: "不儲存離開",
saved: "已儲存",
cancelled: "已取消,未儲存",
current: "目前",
choose: "請選擇",
invalid: "無效選項,請重試",
rows: "行",
presetFull: "完整 (3 行)",
presetEssential: "精簡 (2 行)",
presetMinimal: "最小 (1 行)",
back: "返回",
languageSet: "語言已設為",
helpSettings: "開啟設定介面(語言 / 預設 / 行數)",
};
const EN: HudStrings = {
ctx: "ctx",
use: "use",
turn: "t",
tools: "⚙",
in: "i",
out: "o",
cache: "c",
reason: "r",
sum: "Σ",
live: "live",
stale: "stale",
err: "err",
weekly: "weekly",
monthly: "monthly",
left: "left",
settingsTitle: "Grok HUD Settings",
settingsHint: "Enter a number · 0 save & exit",
lang: "Language",
langZh: "简体中文",
langZhHant: "繁體中文",
langEn: "English",
preset: "Display preset",
statusLines: "Status rows",
tokenBreakdown: "Token breakdown (in/out/cache)",
on: "on",
off: "off",
themeFollow: "Theme",
themeFollowHint: "Follows Grok /theme (not locked)",
saveExit: "Save & exit",
quitNoSave: "Quit without saving",
saved: "Saved",
cancelled: "Cancelled — not saved",
current: "current",
choose: "Choose",
invalid: "Invalid option, try again",
rows: "rows",
presetFull: "Full (3 rows)",
presetEssential: "Essential (2 rows)",
presetMinimal: "Minimal (1 row)",
back: "Back",
languageSet: "Language set to",
helpSettings: "Open settings UI (language / preset / rows)",
};
export function normalizeLang(raw?: string | null): HudLang {
const s = (raw || "").trim().toLowerCase();
if (!s) return "zh-Hans";
if (s === "en" || s === "english" || s === "eng") return "en";
if (
s === "zh-hant" ||
s === "zh_tw" ||
s === "zh-tw" ||
s === "tw" ||
s === "hant" ||
s === "繁體" ||
s === "繁体"
) {
return "zh-Hant";
}
if (
s === "zh" ||
s === "zh-hans" ||
s === "zh_cn" ||
s === "zh-cn" ||
s === "cn" ||
s === "hans" ||
s === "中文" ||
s === "简体" ||
s === "簡體"
) {
return "zh-Hans";
}
return "zh-Hans";
}
export function t(lang?: string | null): HudStrings {
const l = normalizeLang(lang);
if (l === "en") return EN;
if (l === "zh-Hant") return ZH_HANT;
return ZH_HANS;
}
export function stringsFromConfig(cfg: Pick<HudDisplayConfig, "language">): HudStrings {
return t(cfg.language);
}
export function langLabel(lang: HudLang, uiLang?: HudLang): string {
const s = t(uiLang ?? lang);
if (lang === "en") return s.langEn;
if (lang === "zh-Hant") return s.langZhHant;
return s.langZh;
}
+151 -37
View File
@@ -6,6 +6,8 @@ import {
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
pickFromActiveSessions,
sortSessionsByRecency,
} from "./session.js";
import { getCreditUsage, unavailableUsage } from "./billing.js";
import { renderHud, renderJson } from "./render.js";
@@ -37,15 +39,19 @@ import {
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";
@@ -54,8 +60,11 @@ 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";
@@ -92,6 +101,8 @@ export interface CliOptions {
runInTerminal: boolean;
theme?: string;
preset?: string;
settings: boolean;
language?: string;
}
export function parseArgs(argv: string[]): CliOptions {
@@ -116,6 +127,7 @@ export function parseArgs(argv: string[]): CliOptions {
dashboardStop: false,
dashboardWindow: false,
runInTerminal: false,
settings: false,
};
for (let i = 0; i < argv.length; i++) {
@@ -211,6 +223,16 @@ export function parseArgs(argv: string[]): CliOptions {
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;
@@ -232,24 +254,31 @@ export function parseArgs(argv: string[]): CliOptions {
export function helpText(): string {
return `grok-build-hud — live context + quota in the SAME Terminal window
Same-window mode (recommended, no second window):
grok-hud-run
# or: grok-build-hud --run
# Starts Grok inside this tab with a permanent BOTTOM status bar:
# ctx 44% │ quota 23% │ GrokBuild 10% │ t8 │ tools 144
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
Then:
grok-hud-run # start Grok + bottom bar (same window)
grok-hud status # print full dual bars once
Also:
grok-hud status # full dual bars + token breakdown
grok-hud stop # stop background updater
grok-build-hud --theme auto # follow Grok [ui].theme
grok-build-hud --preset full|essential|minimal # Claude-HUD presets
grok-hud-run # same-window Grok + multi-line HUD
grok-build-hud --theme auto # follow Grok /theme (default, not locked)
grok-build-hud --preset full|essential|minimal
grok-hud settings # 设定界面(语言 中/英、预设、行数)
grok-hud lang zh # 快捷:简体中文
grok-hud lang en # 快捷:English
Claude HUD users: see MIGRATION-FROM-CLAUDE.md
Theme: always tracks Grok [ui].theme (auto → OS light/dark maps).
Change inside Grok with /theme — HUD re-paints in ~1s.
Language: default 简体中文; switch in settings or: grok-hud lang en
Lifecycle: Terminal open → ready (not machine boot).
Docs: README.md · README.zh-CN.md
`;
}
@@ -328,37 +357,87 @@ export async function runCli(
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 when applying presets
// keep readability defaults + user language
cfg.bold = true;
cfg.barWidth = Math.max(cfg.barWidth ?? 12, 12);
const saved = saveHudConfig(cfg, opts.grokHome ?? defaultGrokHome());
cfg.language = prev.language ?? "zh-Hans";
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` +
`Claude HUD map: Full≈full, Essential≈essential, Minimal≈minimal`,
`language: ${cfg.language}\n` +
`presets: full | essential | minimal`,
);
return 0;
}
if (opts.theme) {
const mode = resolveThemeMode(opts.theme);
persistTheme(mode);
process.env.GROK_HUD_THEME = mode;
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 = resolveTheme(mode, process.env, { 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({
@@ -366,15 +445,24 @@ export async function runCli(
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
if (mode === "auto") {
if (mode === "auto" || mode === "system") {
out(
`HUD follows Grok theme: [ui] theme="${ui.theme}" → palette "${t.name}"\n` +
`Change with /theme inside Grok; HUD updates within ~1s.\n` +
`Force lock: grok-build-hud --theme tokyonight|grokday|light|dark`,
`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(
`Theme locked to "${t.name}". Use --theme auto to follow Grok [ui].theme again.`,
`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;
@@ -386,16 +474,38 @@ export async function runCli(
grokHome: opts.grokHome,
startDaemon: true,
});
const launchers = installSameWindowLauncher();
out("Installed SAME-WINDOW dashboard (no second window):");
out(` start: ${launchers.runPath}`);
out(` → run: grok-hud-run`);
out(` also: ${launchers.grokWrapPath}`);
out(` hooks: ${r.hooksPath}`);
out(` tmux cfg: ${r.tmuxConf}`);
if (r.launchAgent) out(` launchd: ${r.launchAgent} (file updater)`);
// 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(
` daemon: ${r.daemon.alreadyRunning ? "already running" : r.daemon.started ? `started pid ${r.daemon.pid}` : "not started"}`,
` language: ${hudCfg.language} (设定: grok-hud settings · 切英文: grok-hud lang en)`,
);
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()) {
@@ -403,7 +513,7 @@ export async function runCli(
out("\nAlready inside tmux — status bar applied to THIS window.");
} else {
out(
"\n★ To show the bar in THIS Terminal window:\n 1. End current Grok session (/quit)\n 2. Run: grok-hud-run\n Bottom line will always show ctx + quota — no second window.",
"\n★ 打开 Terminal 后输入: grok\n 同标签页启动 Grok + 底部 HUD(含 IN/OUT/CACHE 精确数字)。\n 开终端会自动拉起 status 更新器;不是开机自启。",
);
}
return 0;
@@ -506,7 +616,7 @@ export async function runCli(
return one ? [one] : [];
}
if (opts.followActive || (!opts.cwd && !opts.session)) {
// Prefer any live active session first
// Prefer newest live active session (not active_sessions insertion order)
const active = loadActiveSessions(grokHome);
const liveSnaps: SessionSnapshot[] = [];
for (const a of active) {
@@ -516,8 +626,9 @@ export async function runCli(
if (snap) liveSnaps.push(snap);
}
if (liveSnaps.length) {
const ranked = sortSessionsByRecency(liveSnaps, active);
if (opts.cwd) {
const matched = liveSnaps.filter((s) =>
const matched = ranked.filter((s) =>
s.cwd ? s.cwd === opts.cwd || s.cwd.includes(opts.cwd!) : false,
);
// use path equality via discover as fallback
@@ -526,11 +637,14 @@ export async function runCli(
if (matched.length) return matched;
}
if (opts.session) {
const m = liveSnaps.filter((s) => s.sessionId === opts.session);
const m = ranked.filter((s) => s.sessionId === opts.session);
if (m.length) return m;
}
if (!opts.cwd && !opts.session) return liveSnaps;
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);
};
+235 -23
View File
@@ -9,13 +9,95 @@ import {
stopDashboard,
} from "./dashboard.js";
import { writeTmuxConfFile } from "./tmux-hud.js";
import {
installGrokCommandWrap,
uninstallGrokCommandWrap,
} from "./grok-wrap.js";
import { ensureFollowMode } from "./theme.js";
import { ensureDefaultConfig } from "./hud-config.js";
export interface DashboardInstallResult {
binPath: string;
tmuxConf: string;
launchAgent?: string;
launchAgentLoaded?: boolean;
terminalHook?: string;
hooksPath: string;
daemon: { started: boolean; alreadyRunning: boolean; pid?: number };
grokWrap?: { wrapperPath: string; realPath: string };
}
const TERMINAL_HOOK_MARKER = "# >>> grok-build-hud terminal >>>";
/**
* When Terminal (interactive shell) starts, ensure HUD daemon is alive.
* This is the intended lifecycle — NOT machine boot. User opens Terminal → ready.
*/
export function installTerminalStartupHook(options: {
binDir?: string;
nodeBin?: string;
entryJs?: string;
} = {}): string {
const zshrc = path.join(os.homedir(), ".zshrc");
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
const node = options.nodeBin ?? process.execPath;
const entry =
options.entryJs ?? path.join(packageRoot(), "bin", "grok-build-hud.js");
const block = `${TERMINAL_HOOK_MARKER}
# Start HUD status updater when an interactive Terminal opens (not machine boot).
if [[ -o interactive ]] && [[ -z "\${GROK_HUD_SKIP_SHELL_START:-}" ]]; then
(
NODE=${JSON.stringify(node)}
ENTRY=${JSON.stringify(entry)}
# only if not already running — silent, non-blocking
if [[ -x "$NODE" && -f "$ENTRY" ]]; then
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
elif [[ -x "${binDir}/grok-hud" ]]; then
"${binDir}/grok-hud" start >/dev/null 2>&1 || true
fi
) &!
fi
# <<< grok-build-hud terminal <<<
`;
let cur = "";
try {
if (fs.existsSync(zshrc)) cur = fs.readFileSync(zshrc, "utf8");
} catch {
cur = "";
}
if (cur.includes(TERMINAL_HOOK_MARKER)) {
// Replace existing block
const re =
/# >>> grok-build-hud terminal >>>[\s\S]*?# <<< grok-build-hud terminal <<<\n?/;
cur = cur.replace(re, block.endsWith("\n") ? block : block + "\n");
fs.writeFileSync(zshrc, cur, "utf8");
} else {
fs.writeFileSync(
zshrc,
(cur.replace(/\s*$/, "") + "\n\n" + block + "\n").replace(/^\n+/, ""),
"utf8",
);
}
return zshrc;
}
export function uninstallTerminalStartupHook(): boolean {
const zshrc = path.join(os.homedir(), ".zshrc");
try {
if (!fs.existsSync(zshrc)) return false;
const cur = fs.readFileSync(zshrc, "utf8");
if (!cur.includes(TERMINAL_HOOK_MARKER)) return false;
const next = cur.replace(
/# >>> grok-build-hud terminal >>>[\s\S]*?# <<< grok-build-hud terminal <<<\n?/,
"",
);
fs.writeFileSync(zshrc, next, "utf8");
return true;
} catch {
return false;
}
}
export function installDashboard(options: {
@@ -89,23 +171,52 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
fs.writeFileSync(userTmux, block + "\n", "utf8");
}
// LaunchAgent keeps dashboard alive across reboots (macOS)
// Plain `grok` → HUD (real binary at ~/.grok/bin/grok-real)
let grokWrap: DashboardInstallResult["grokWrap"];
try {
const w = installGrokCommandWrap({ nodeBin: node });
grokWrap = { wrapperPath: w.wrapperPath, realPath: w.realPath };
} catch {
/* Grok not installed yet */
}
// Theme: always follow Grok [ui].theme (clear legacy locks)
ensureFollowMode(grokHome);
// Default language 简体中文 (user can switch in settings)
ensureDefaultConfig(grokHome);
// Terminal-open hook (primary lifecycle): interactive zsh → ensure daemon
const terminalHook = installTerminalStartupHook({
binDir,
nodeBin: node,
entryJs: entry,
});
// Optional LaunchAgent: keep status files fresh while sessions exist.
// NOT the primary "boot" story — Terminal open + `grok` is the UX.
let launchAgent: string | undefined;
let launchAgentLoaded = false;
if (process.platform === "darwin") {
const agentsDir = path.join(
os.homedir(),
"Library",
"LaunchAgents",
);
const agentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
fs.mkdirSync(agentsDir, { recursive: true });
launchAgent = path.join(agentsDir, "com.dex.grok-hud-dashboard.plist");
const log = path.join(grokHome, "hud", "dashboard.log");
const label = "com.dex.grok-hud-dashboard";
const pathEnv = [
path.dirname(node),
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
path.join(os.homedir(), ".local", "bin"),
path.join(os.homedir(), ".grok", "bin"),
].join(":");
const plist = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.dex.grok-hud-dashboard</string>
<string>${label}</string>
<key>ProgramArguments</key>
<array>
<string>${node}</string>
@@ -116,9 +227,11 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
<string>500</string>
</array>
<key>RunAtLoad</key>
<true/>
<false/>
<key>KeepAlive</key>
<true/>
<false/>
<key>ThrottleInterval</key>
<integer>5</integer>
<key>StandardOutPath</key>
<string>${log}</string>
<key>StandardErrorPath</key>
@@ -128,22 +241,32 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
<key>HOME</key>
<string>${os.homedir()}</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
<string>${pathEnv}</string>
<key>GROK_HUD_THEME</key>
<string>auto</string>
<key>GROK_HOME</key>
<string>${grokHome}</string>
</dict>
<key>WorkingDirectory</key>
<string>${os.homedir()}</string>
<key>ProcessType</key>
<string>Background</string>
</dict>
</plist>
`;
fs.writeFileSync(launchAgent, plist, "utf8");
// Unload login-boot agent if previously installed with KeepAlive
try {
execSilent(["launchctl", "unload", launchAgent]);
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
execSilent(["launchctl", "bootout", `gui/${uid}/${label}`]);
} catch {
/* not loaded */
}
try {
execSilent(["launchctl", "load", launchAgent]);
} catch {
/* may need user session */
try {
execSilent(["launchctl", "unload", launchAgent]);
} catch {
/* ignore */
}
}
launchAgentLoaded = false; // intentional: Terminal-start driven, not login boot
}
let daemon: DashboardInstallResult["daemon"] = {
@@ -154,37 +277,126 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
daemon = ensureDashboardDaemon({
grokHome,
entryJs: path.join(root, "dist", "src", "index.js"),
intervalMs: 2000,
intervalMs: 500,
});
}
return { binPath, tmuxConf, launchAgent, hooksPath, daemon };
return {
binPath,
tmuxConf,
launchAgent,
launchAgentLoaded,
terminalHook,
hooksPath,
daemon,
grokWrap,
};
}
function execSilent(args: string[]): void {
execFileSync(args[0]!, args.slice(1), {
stdio: "ignore",
timeout: 5000,
timeout: 8000,
});
}
/** macOS modern launchctl (bootstrap/kickstart) with legacy unload/load fallback. */
export function loadLaunchAgent(label: string, plistPath: string): boolean {
if (process.platform !== "darwin") return false;
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
const domain = `gui/${uid}`;
const service = `${domain}/${label}`;
// Tear down any previous registration
try {
execSilent(["launchctl", "bootout", service]);
} catch {
try {
execSilent(["launchctl", "unload", plistPath]);
} catch {
/* not loaded */
}
}
try {
execSilent(["launchctl", "bootstrap", domain, plistPath]);
} catch {
try {
execSilent(["launchctl", "load", "-w", plistPath]);
} catch {
return false;
}
}
try {
execSilent(["launchctl", "enable", service]);
} catch {
/* optional on older macOS */
}
try {
execSilent(["launchctl", "kickstart", "-k", service]);
} catch {
/* may already be running */
}
// Verify
try {
const out = execFileSync("launchctl", ["print", service], {
encoding: "utf8",
timeout: 3000,
stdio: ["ignore", "pipe", "ignore"],
});
return out.includes("state = running") || out.includes("pid =");
} catch {
try {
const list = execFileSync("launchctl", ["list"], {
encoding: "utf8",
timeout: 3000,
});
return list.includes(label);
} catch {
return false;
}
}
}
export function uninstallDashboard(options: {
grokHome?: string;
restoreGrokCommand?: boolean;
} = {}): void {
const grokHome = options.grokHome ?? defaultGrokHome();
stopDashboard(grokHome);
if (options.restoreGrokCommand !== false) {
try {
uninstallGrokCommandWrap({ grokHome });
} catch {
/* ignore */
}
}
try {
uninstallTerminalStartupHook();
} catch {
/* ignore */
}
if (process.platform === "darwin") {
const label = "com.dex.grok-hud-dashboard";
const launchAgent = path.join(
os.homedir(),
"Library",
"LaunchAgents",
"com.dex.grok-hud-dashboard.plist",
`${label}.plist`,
);
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
try {
execSilent(["launchctl", "unload", launchAgent]);
execSilent(["launchctl", "bootout", `gui/${uid}/${label}`]);
} catch {
/* ignore */
try {
execSilent(["launchctl", "unload", launchAgent]);
} catch {
/* ignore */
}
}
try {
if (fs.existsSync(launchAgent)) fs.unlinkSync(launchAgent);
+173
View File
@@ -0,0 +1,173 @@
/**
* Width-aware HUD layout + visible-length helpers (tmux style codes stripped).
*/
import fs from "node:fs";
import { execFileSync } from "node:child_process";
export type WidthTier = "xs" | "sm" | "md" | "lg";
export function stripTmuxStyles(s: string): string {
return s
.replace(/#\[[^\]]*\]/g, "")
.replace(/\x1b\[[0-9;]*m/g, "");
}
export function visibleLen(s: string): number {
return stripTmuxStyles(s).length;
}
/** Truncate styled string to max visible columns (keeps leading styles). */
export function trimVisible(s: string, max: number): string {
if (max <= 0) return "";
if (visibleLen(s) <= max) return s;
// Walk, counting only visible chars; drop trailing incomplete style safely
let vis = 0;
let out = "";
let i = 0;
while (i < s.length && vis < max - 1) {
if (s[i] === "#" && s[i + 1] === "[") {
const end = s.indexOf("]", i + 2);
if (end === -1) break;
out += s.slice(i, end + 1);
i = end + 1;
continue;
}
if (s[i] === "\x1b" && s[i + 1] === "[") {
const end = s.indexOf("m", i + 2);
if (end === -1) break;
out += s.slice(i, end + 1);
i = end + 1;
continue;
}
out += s[i];
vis += 1;
i += 1;
}
return out + "…#[default]";
}
export function widthTier(cols: number): WidthTier {
if (cols < 56) return "xs";
if (cols < 80) return "sm";
if (cols < 120) return "md";
return "lg";
}
/** Progress bar cell count from window width. */
export function adaptiveBarWidth(cols: number, preferred = 12): number {
if (cols < 56) return 6;
if (cols < 80) return 8;
if (cols < 100) return 10;
if (cols < 140) return preferred;
return Math.min(16, preferred + 2);
}
/** How many status rows to use for this width. */
export function adaptiveStatusLines(
cols: number,
configured: 1 | 2 | 3,
): 1 | 2 | 3 {
if (cols < 56) return 1;
if (cols < 80) return Math.min(2, configured) as 1 | 2;
return configured;
}
export function terminalColsForTty(ttyPath: string | null | undefined): number {
if (!ttyPath) return 100;
const dev = ttyPath.startsWith("/dev/") ? ttyPath : `/dev/${ttyPath}`;
try {
if (!fs.existsSync(dev)) return 100;
// stty size prints "rows cols"
const out = execFileSync(
"/bin/bash",
["-c", `stty size < ${JSON.stringify(dev)} 2>/dev/null`],
{ encoding: "utf8", timeout: 800, stdio: ["ignore", "pipe", "ignore"] },
).trim();
const parts = out.split(/\s+/);
const cols = Number(parts[1] ?? parts[0]);
if (Number.isFinite(cols) && cols >= 30) return Math.floor(cols);
} catch {
/* ignore */
}
return 100;
}
export function tmuxClientWidth(sessionName?: string | null): number | null {
try {
const args = sessionName
? (["display-message", "-t", sessionName, "-p", "#{client_width}"] as string[])
: (["display-message", "-p", "#{client_width}"] as string[]);
const n = Number(
execFileSync("tmux", args, {
encoding: "utf8",
timeout: 800,
stdio: ["ignore", "pipe", "ignore"],
}).trim(),
);
return Number.isFinite(n) && n >= 30 ? Math.floor(n) : null;
} catch {
return null;
}
}
/** Best-effort width for a live Grok session. */
export function resolveDisplayWidth(options: {
ttyPath?: string | null;
tmuxSession?: string | null;
fallback?: number;
}): number {
const fromTmux = tmuxClientWidth(options.tmuxSession);
if (fromTmux) return fromTmux;
const fromTty = terminalColsForTty(options.ttyPath);
if (fromTty && fromTty !== 100) return fromTty;
// stty often returns 100 as our default — still try tty
if (options.ttyPath) {
const c = terminalColsForTty(options.ttyPath);
if (c >= 30) return c;
}
return options.fallback ?? 100;
}
export interface FitSegment {
/** Visible plain text */
text: string;
/** Rendered with styles */
render: string;
/** Lower = keep first when space is tight */
priority: number;
}
/**
* Join segments with sep until max visible width; drop low-priority first.
*/
export function fitSegments(
segs: FitSegment[],
maxWidth: number,
sepPlain = " · ",
sepRender = " · ",
): string {
if (maxWidth < 8) return "";
// Sort copy by priority for dropping, but preserve original order in output
const active = [...segs];
const tryJoin = (list: FitSegment[]): { plain: number; render: string } => {
const plain = list.map((s) => s.text).join(sepPlain);
const render = list.map((s) => s.render).join(sepRender);
return { plain: plain.length, render };
};
let result = tryJoin(active);
if (result.plain <= maxWidth) return result.render;
// Drop highest priority numbers first (least important)
const byDrop = [...active].sort((a, b) => b.priority - a.priority);
const dropSet = new Set<FitSegment>();
for (const cand of byDrop) {
if (result.plain <= maxWidth) break;
dropSet.add(cand);
const kept = active.filter((s) => !dropSet.has(s));
if (!kept.length) break;
result = tryJoin(kept);
}
if (result.plain <= maxWidth) return result.render;
return trimVisible(result.render, maxWidth);
}
+178
View File
@@ -0,0 +1,178 @@
/**
* Parallel Terminal isolation helpers.
* Each Terminal gets its own tmux session + per-session HUD files.
*/
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { defaultGrokHome } from "./session.js";
/** Sanitize for tmux session names / directory names. */
export function sanitizeId(s: string): string {
return s.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 48) || "x";
}
/** Unique tmux session name — never reuse "grok-hud" (that forced attach). */
export function uniqueTmuxSessionName(env: NodeJS.ProcessEnv = process.env): string {
if (env.GROK_TMUX_SESSION?.trim()) {
return sanitizeId(env.GROK_TMUX_SESSION.trim());
}
const tty = detectControllingTtyBase(env);
const stamp = Date.now().toString(36);
const pid = process.pid;
const head = tty ? `g${sanitizeId(tty)}` : "g";
// pid+stamp guarantees two Terminals never collide
return `${head}-${pid}-${stamp}`.slice(0, 60);
}
export function detectControllingTtyBase(
env: NodeJS.ProcessEnv = process.env,
): string | null {
try {
// Prefer explicit; else query this process
const out = execFileSync("tty", {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 500,
})
.trim()
.replace(/^\/dev\//, "");
if (out && out !== "not a tty") return out;
} catch {
/* ignore */
}
const t = env.TTY?.replace(/^\/dev\//, "");
return t || null;
}
export function hudTmuxDir(
tmuxSession: string,
grokHome = defaultGrokHome(),
): string {
return path.join(grokHome, "hud", "tmux", sanitizeId(tmuxSession));
}
export interface TmuxInstanceMeta {
tmuxSession: string;
launcherPid: number;
startedAt: string;
tty?: string | null;
grokSessionId?: string;
}
export function writeTmuxInstanceMeta(
meta: TmuxInstanceMeta,
grokHome = defaultGrokHome(),
): string {
const dir = hudTmuxDir(meta.tmuxSession, grokHome);
fs.mkdirSync(dir, { recursive: true });
const p = path.join(dir, "meta.json");
fs.writeFileSync(p, JSON.stringify(meta, null, 2), "utf8");
return p;
}
export function readTmuxInstanceMeta(
tmuxSession: string,
grokHome = defaultGrokHome(),
): TmuxInstanceMeta | null {
try {
const p = path.join(hudTmuxDir(tmuxSession, grokHome), "meta.json");
if (!fs.existsSync(p)) return null;
return JSON.parse(fs.readFileSync(p, "utf8")) as TmuxInstanceMeta;
} catch {
return null;
}
}
/** Parent pid of a process (macOS/Linux). */
export function parentPid(pid: number): number | null {
if (!pid || pid <= 0) return null;
try {
const out = execFileSync("ps", ["-o", "ppid=", "-p", String(pid)], {
encoding: "utf8",
timeout: 1000,
stdio: ["ignore", "pipe", "ignore"],
}).trim();
const n = Number(out);
return Number.isFinite(n) && n > 0 ? n : null;
} catch {
return null;
}
}
/** True if target is the ancestor or a descendant of ancestor (walk up from target). */
export function isInProcessTree(
targetPid: number,
ancestorPid: number,
): boolean {
if (!targetPid || !ancestorPid) return false;
if (targetPid === ancestorPid) return true;
let pid = targetPid;
for (let i = 0; i < 40; i++) {
const pp = parentPid(pid);
if (!pp || pp <= 1) return false;
if (pp === ancestorPid) return true;
pid = pp;
}
return false;
}
export interface TmuxPaneRow {
sessionName: string;
panePid: number;
}
/** List tmux panes (session + pane shell pid). */
export function listTmuxPanes(): TmuxPaneRow[] {
try {
const out = execFileSync(
"tmux",
["list-panes", "-a", "-F", "#{session_name}\t#{pane_pid}"],
{
encoding: "utf8",
timeout: 2000,
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
if (!out) return [];
const rows: TmuxPaneRow[] = [];
for (const line of out.split("\n")) {
const [name, pidS] = line.split("\t");
const panePid = Number(pidS);
if (!name || !Number.isFinite(panePid)) continue;
rows.push({ sessionName: name, panePid });
}
return rows;
} catch {
return [];
}
}
/**
* Find which tmux session is running this Grok pid (pane shell is ancestor).
*/
export function findTmuxSessionForPid(grokPid?: number): string | null {
if (!grokPid || grokPid <= 0) return null;
const panes = listTmuxPanes();
for (const p of panes) {
// grok is child of pane's login shell (or grandchild)
if (isInProcessTree(grokPid, p.panePid) || grokPid === p.panePid) {
return p.sessionName;
}
}
return null;
}
/** List instance dirs under hud/tmux/ that look like our sessions. */
export function listHudTmuxSessions(grokHome = defaultGrokHome()): string[] {
const root = path.join(grokHome, "hud", "tmux");
try {
if (!fs.existsSync(root)) return [];
return fs
.readdirSync(root, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => d.name);
} catch {
return [];
}
}
+19 -1
View File
@@ -5,6 +5,7 @@ import {
renderBar,
} from "./bar.js";
import { formatToolLine } from "./activity.js";
import { formatTokenBreakdownLine } from "./token-usage.js";
import type {
RenderOptions,
SessionSnapshot,
@@ -85,9 +86,26 @@ export function renderHud(
}
if (session.turnCount > 0) meta.push(`Turns ${session.turnCount}`);
if (session.toolCallCount > 0) meta.push(`Tools ${session.toolCallCount}`);
const line2 = `${ctxLabel} ${bar} ${pct}%${tokenPart}${meta.length ? `${meta.join(" │ ")}` : ""}`;
const tokLine = session.lastTurnTokens
? formatTokenBreakdownLine(session.lastTurnTokens, { mode: "exact" })
: "";
const line2 = `${ctxLabel} ${bar} ${pct}%${tokenPart}${tokLine ? `${tokLine}` : ""}${meta.length ? `${meta.join(" │ ")}` : ""}`;
const lines = [line1, line2];
if (
session.sessionTokens &&
session.lastTurnTokens &&
(session.sessionTokens.inputTokens !== session.lastTurnTokens.inputTokens ||
session.sessionTokens.outputTokens !==
session.lastTurnTokens.outputTokens)
) {
lines.push(
formatTokenBreakdownLine(session.sessionTokens, {
mode: "exact",
prefix: "ΣTOK",
}),
);
}
const usageLine = formatUsageLine(usage, options);
if (usageLine) lines.push(usageLine);
+94 -16
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import os from "node:os";
import { contextPercentFromSignals } from "./bar.js";
import { parseUpdatesFile } from "./activity.js";
import { parseTokenUsageFile } from "./token-usage.js";
import { readGitInfo } from "./git.js";
import type {
ActiveSessionEntry,
@@ -83,6 +84,90 @@ export function mtimeMs(filePath: string): number {
}
}
/** Parse ISO timestamps; invalid → 0. */
export function parseTimeMs(value?: string | null): number {
if (!value) return 0;
const t = Date.parse(value);
return Number.isFinite(t) ? t : 0;
}
/**
* Recency score for picking "the session you are actually in".
* After /new, both old and new Grok processes can stay live; array order in
* active_sessions.json is insertion order (oldest first), so we must rank by
* activity — not by list position.
*/
export function sessionRecencyMs(
session: SessionSnapshot,
activeEntry?: ActiveSessionEntry,
): number {
const opened = parseTimeMs(activeEntry?.opened_at);
const lastActive = parseTimeMs(session.summary?.last_active_at);
const updated = parseTimeMs(session.summary?.updated_at);
const created = parseTimeMs(session.summary?.created_at);
const fileM = Math.max(
mtimeMs(path.join(session.sessionDir, "signals.json")),
mtimeMs(path.join(session.sessionDir, "summary.json")),
mtimeMs(path.join(session.sessionDir, "updates.jsonl")),
mtimeMs(path.join(session.sessionDir, "chat_history.jsonl")),
);
return Math.max(opened, lastActive, updated, created, fileM);
}
/** live first, then most recently active. */
export function compareSessionsByRecency(
a: SessionSnapshot,
b: SessionSnapshot,
active: ActiveSessionEntry[] = [],
): number {
if (a.live !== b.live) return a.live ? -1 : 1;
const ae = active.find((x) => x.session_id === a.sessionId);
const be = active.find((x) => x.session_id === b.sessionId);
return sessionRecencyMs(b, be) - sessionRecencyMs(a, ae);
}
export function sortSessionsByRecency(
sessions: SessionSnapshot[],
active: ActiveSessionEntry[] = [],
): SessionSnapshot[] {
return [...sessions].sort((a, b) => compareSessionsByRecency(a, b, active));
}
/**
* Prefer the newest live session among active_sessions.
* Fixes HUD stuck on an older tab's ctx% after the user runs /new.
*/
export function pickFromActiveSessions(
grokHome: string,
options: { cwd?: string; preferLive?: boolean } = {},
): SessionSnapshot | null {
const active = loadActiveSessions(grokHome);
if (!active.length) return null;
const snaps: SessionSnapshot[] = [];
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
if (options.cwd && a.cwd && !pathsEqual(a.cwd, options.cwd)) {
// still allow load if summary cwd matches later
}
const snap = loadSnapshotFromDir(dir, { active });
if (!snap) continue;
if (options.cwd && snap.cwd && !pathsEqual(snap.cwd, options.cwd)) {
if (!(a.cwd && pathsEqual(a.cwd, options.cwd))) continue;
}
snaps.push(snap);
}
if (!snaps.length) return null;
const ranked = sortSessionsByRecency(snaps, active);
if (options.preferLive !== false) {
const live = ranked.find((s) => s.live);
if (live) return live;
}
return ranked[0] ?? null;
}
export function loadSnapshotFromDir(
sessionDir: string,
options: {
@@ -131,7 +216,9 @@ export function loadSnapshotFromDir(
? signals.contextWindowTokens
: 0;
const activity = parseUpdatesFile(path.join(sessionDir, "updates.jsonl"));
const updatesPath = path.join(sessionDir, "updates.jsonl");
const activity = parseUpdatesFile(updatesPath);
const tokenUsage = parseTokenUsageFile(updatesPath);
// Fallback: toolsUsed from signals when updates empty
let tools = activity.tools;
if (!tools.length && Array.isArray(signals.toolsUsed) && signals.toolsUsed.length) {
@@ -191,6 +278,9 @@ export function loadSnapshotFromDir(
typeof summary?.reasoning_effort === "string"
? summary.reasoning_effort
: undefined,
lastTurnTokens: tokenUsage.lastTurn,
sessionTokens:
tokenUsage.turnCount > 0 ? tokenUsage.session : null,
tools,
agents: activity.agents,
todos: activity.todos ?? [],
@@ -259,21 +349,9 @@ export function discoverSessions(options: DiscoverOptions = {}): SessionSnapshot
}
}
// Sort: live first, then by signals/summary mtime
filtered.sort((a, b) => {
if (a.live !== b.live) return a.live ? -1 : 1;
const am = Math.max(
mtimeMs(path.join(a.sessionDir, "signals.json")),
mtimeMs(path.join(a.sessionDir, "summary.json")),
);
const bm = Math.max(
mtimeMs(path.join(b.sessionDir, "signals.json")),
mtimeMs(path.join(b.sessionDir, "summary.json")),
);
return bm - am;
});
return filtered;
// Sort: live first, then by recency (opened_at / last_active / file mtimes)
// Do NOT use active_sessions array order — after /new the old tab stays first.
return sortSessionsByRecency(filtered, active);
}
export function pickBestSession(options: DiscoverOptions = {}): SessionSnapshot | null {
+243
View File
@@ -0,0 +1,243 @@
/**
* Interactive settings UI for language / preset / rows.
* Default language: 简体中文; switchable to English / 繁體.
*/
import * as readline from "node:readline";
import {
applyPreset,
loadHudConfig,
saveHudConfig,
type HudDisplayConfig,
type HudPreset,
} from "./hud-config.js";
import {
langLabel,
normalizeLang,
t,
type HudLang,
} from "./i18n.js";
import { defaultGrokHome } from "./session.js";
import { ensureFollowMode } from "./theme.js";
export interface SettingsUiOptions {
grokHome?: string;
/** Non-interactive: set language and exit */
language?: string;
stdin?: NodeJS.ReadableStream;
stdout?: NodeJS.WritableStream;
}
function ask(
rl: readline.Interface,
prompt: string,
): Promise<string> {
return new Promise((resolve) => {
rl.question(prompt, (ans) => resolve((ans || "").trim()));
});
}
function printMenu(cfg: HudDisplayConfig, out: (s: string) => void): void {
const s = t(cfg.language);
const lang = normalizeLang(cfg.language);
out("");
out(`┌─ ${s.settingsTitle} ─────────────────────`);
out(`${s.settingsHint}`);
out(``);
out(
`│ 1) ${s.lang.padEnd(14)} [${langLabel(lang, lang)}]`,
);
out(
`│ 2) ${s.preset.padEnd(14)} [${cfg.preset}] ${
cfg.preset === "full"
? s.presetFull
: cfg.preset === "essential"
? s.presetEssential
: s.presetMinimal
}`,
);
out(
`│ 3) ${s.statusLines.padEnd(14)} [${cfg.statusLines} ${s.rows}]`,
);
out(
`│ 4) ${s.tokenBreakdown.padEnd(14)} [${
cfg.display.showTokenBreakdown ? s.on : s.off
}]`,
);
out(`│ 5) ${s.themeFollow.padEnd(14)} ${s.themeFollowHint}`);
out(``);
out(`│ 0) ${s.saveExit}`);
out(`│ q) ${s.quitNoSave}`);
out(`└──────────────────────────────────────`);
out("");
}
async function pickLanguage(
rl: readline.Interface,
cfg: HudDisplayConfig,
out: (s: string) => void,
): Promise<HudLang> {
const s = t(cfg.language);
out(` 1) ${s.langZh}`);
out(` 2) ${s.langZhHant}`);
out(` 3) ${s.langEn}`);
out(` b) ${s.back}`);
const ans = await ask(rl, ` ${s.choose}: `);
if (ans === "1" || ans === "zh" || ans === "cn") return "zh-Hans";
if (ans === "2" || ans === "tw" || ans === "hant") return "zh-Hant";
if (ans === "3" || ans === "en") return "en";
return normalizeLang(cfg.language);
}
async function pickPreset(
rl: readline.Interface,
cfg: HudDisplayConfig,
out: (s: string) => void,
): Promise<HudPreset> {
const s = t(cfg.language);
out(` 1) full — ${s.presetFull}`);
out(` 2) essential — ${s.presetEssential}`);
out(` 3) minimal — ${s.presetMinimal}`);
out(` b) ${s.back}`);
const ans = await ask(rl, ` ${s.choose}: `);
if (ans === "1" || ans === "full") return "full";
if (ans === "2" || ans === "essential") return "essential";
if (ans === "3" || ans === "minimal") return "minimal";
return cfg.preset;
}
async function pickRows(
rl: readline.Interface,
cfg: HudDisplayConfig,
out: (s: string) => void,
): Promise<1 | 2 | 3> {
const s = t(cfg.language);
out(` 1) 1 ${s.rows}`);
out(` 2) 2 ${s.rows}`);
out(` 3) 3 ${s.rows}`);
out(` b) ${s.back}`);
const ans = await ask(rl, ` ${s.choose}: `);
if (ans === "1") return 1;
if (ans === "2") return 2;
if (ans === "3") return 3;
return cfg.statusLines;
}
/**
* Interactive settings loop. Returns saved config or null if cancelled.
*/
export async function runSettingsUi(
options: SettingsUiOptions = {},
): Promise<HudDisplayConfig | null> {
const grokHome = options.grokHome ?? defaultGrokHome();
const stdout = options.stdout ?? process.stdout;
const out = (s: string) => {
stdout.write(s + (s.endsWith("\n") ? "" : "\n"));
};
let cfg = loadHudConfig(grokHome);
// Non-interactive language set
if (options.language) {
cfg = {
...cfg,
language: normalizeLang(options.language),
};
const path = saveHudConfig(cfg, grokHome);
const s = t(cfg.language);
out(
`${s.languageSet} ${langLabel(normalizeLang(cfg.language), normalizeLang(cfg.language))}`,
);
out(`${path}`);
return cfg;
}
// Default to Chinese if somehow empty
if (!cfg.language) {
cfg = { ...cfg, language: "zh-Hans" };
}
const rl = readline.createInterface({
input: options.stdin ?? process.stdin,
output: stdout,
terminal: true,
});
let dirty = false;
try {
// eslint-disable-next-line no-constant-condition
while (true) {
printMenu(cfg, out);
const s = t(cfg.language);
const ans = await ask(rl, ` > `);
if (ans === "0" || ans === "s" || ans === "save") {
ensureFollowMode(grokHome);
const path = saveHudConfig(cfg, grokHome);
out(`${s.saved}: ${path}`);
out(
` ${s.lang}: ${langLabel(normalizeLang(cfg.language), normalizeLang(cfg.language))} · ${s.preset}: ${cfg.preset} · ${s.statusLines}: ${cfg.statusLines}`,
);
return cfg;
}
if (ans === "q" || ans === "quit" || ans === "exit") {
out(dirty ? s.cancelled : s.quitNoSave);
return null;
}
if (ans === "1" || ans === "lang" || ans === "language") {
const next = await pickLanguage(rl, cfg, out);
if (next !== cfg.language) {
cfg = { ...cfg, language: next };
dirty = true;
}
continue;
}
if (ans === "2" || ans === "preset") {
const p = await pickPreset(rl, cfg, out);
if (p !== cfg.preset) {
const lang = cfg.language;
const next = applyPreset(p);
// Keep user's language when switching preset
cfg = { ...next, language: lang };
dirty = true;
}
continue;
}
if (ans === "3" || ans === "rows" || ans === "lines") {
const rows = await pickRows(rl, cfg, out);
if (rows !== cfg.statusLines) {
cfg = { ...cfg, statusLines: rows };
dirty = true;
}
continue;
}
if (ans === "4" || ans === "token" || ans === "tokens") {
cfg = {
...cfg,
display: {
...cfg.display,
showTokenBreakdown: !cfg.display.showTokenBreakdown,
},
};
dirty = true;
continue;
}
if (ans === "5" || ans === "theme") {
ensureFollowMode(grokHome);
out(`${t(cfg.language).themeFollowHint}`);
continue;
}
out(` ${t(cfg.language).invalid}`);
}
} finally {
rl.close();
}
}
/** One-shot: set language without menu. */
export function setLanguage(
lang: string,
grokHome = defaultGrokHome(),
): HudDisplayConfig {
const cfg = loadHudConfig(grokHome);
const next = { ...cfg, language: normalizeLang(lang) };
saveHudConfig(next, grokHome);
return next;
}
+445 -148
View File
@@ -1,29 +1,34 @@
/**
* Claude-HUD-parity multi-line status rendering for Grok Build.
* Multi-line status rendering for Grok Build HUD.
*
* Default expanded layout (mirrors Claude HUD):
* [Grok 4.5] project git:(main*) │ ● live
* Context ████░░░░ 45% (224k/500k) │ Usage ██░░░░ 23% (weekly · resets 4d)
* ◐ read_file: x │ ✓ grep ×3 │ ▸ todos 2/5
* Default expanded layout:
* [Grok 4.5] · project git · ●
* 窗/ctx ████ 45% · 入/i · 出/o · 缓/c · 额/use …
* tool activity · agents · todos
*/
import fs from "node:fs";
import path from "node:path";
import {
formatDuration,
formatTokenCount,
formatTokenCount as fmtTokShort,
projectLabel,
renderBar,
} from "./bar.js";
import { formatToolLine } from "./activity.js";
import {
formatTokenBreakdownLine,
} from "./token-usage.js";
import type {
SessionSnapshot,
TodoItem,
TokenBreakdown,
UsageSnapshot,
} from "./types.js";
import { defaultGrokHome } from "./session.js";
import {
miniBar,
tmuxFg,
tmuxRole,
resolveTheme,
type HudTheme,
} from "./theme.js";
@@ -31,6 +36,17 @@ import {
loadHudConfig,
type HudDisplayConfig,
} from "./hud-config.js";
import {
adaptiveBarWidth,
adaptiveStatusLines,
fitSegments,
resolveDisplayWidth,
trimVisible,
visibleLen,
widthTier,
type FitSegment,
} from "./layout.js";
import { stringsFromConfig } from "./i18n.js";
export function hudDataDir(grokHome = defaultGrokHome()): string {
return path.join(grokHome, "hud");
@@ -80,7 +96,59 @@ function truncate(s: string, n: number): string {
return s.slice(0, n - 1) + "…";
}
/** Plain multi-line Claude-HUD parity block (for files / /hud / hooks). */
function pickTokenForDisplay(
session: SessionSnapshot,
scope: HudDisplayConfig["display"]["tokenScope"],
): { last: TokenBreakdown | null; sessionSum: TokenBreakdown | null } {
const last = session.lastTurnTokens ?? null;
const sessionSum = session.sessionTokens ?? null;
if (scope === "session") return { last: null, sessionSum };
if (scope === "last") return { last, sessionSum: null };
return { last, sessionSum };
}
function tokenLinesForHud(
session: SessionSnapshot,
cfg: HudDisplayConfig,
): string[] {
if (!cfg.display.showTokenBreakdown) return [];
const mode = cfg.display.tokenDigits ?? "exact";
const { last, sessionSum } = pickTokenForDisplay(
session,
cfg.display.tokenScope ?? "both",
);
const short = mode === "short" ? formatTokenCount : undefined;
const out: string[] = [];
if (last) {
out.push(
formatTokenBreakdownLine(last, {
mode,
prefix: "TOK",
formatShort: short,
}),
);
}
if (sessionSum && (cfg.display.tokenScope === "both" || cfg.display.tokenScope === "session")) {
// Avoid duplicating identical last==session when only one turn
const same =
last &&
last.inputTokens === sessionSum.inputTokens &&
last.outputTokens === sessionSum.outputTokens &&
last.cachedReadTokens === sessionSum.cachedReadTokens;
if (!same) {
out.push(
formatTokenBreakdownLine(sessionSum, {
mode,
prefix: "ΣTOK",
formatShort: short,
}),
);
}
}
return out;
}
/** Plain multi-line status block (for files / /hud / hooks). */
export function formatStatusBlock(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
@@ -108,7 +176,7 @@ export function formatStatusBlock(
if (session.reasoningEffort) l1.push(`effort:${session.reasoningEffort}`);
lines.push(l1.join(" │ "));
// Line 2 — Context + Usage (Claude default merge)
// Line 2 — Context + Usage
const pct = Math.round(session.contextPercent);
const cBar = d.showContextBar ? renderBar(session.contextPercent) + " " : "";
const ctxPart = `Context ${cBar}${contextValueText(session, d.contextValue)}`;
@@ -139,7 +207,8 @@ export function formatStatusBlock(
meta.push(`Tools ${session.toolCallCount}`);
}
if (d.showErrors && (session.errorCount > 0 || session.toolFailureCount > 0)) {
meta.push(`Err ${session.errorCount || session.toolFailureCount}`);
const Ls = stringsFromConfig(cfg);
meta.push(`${Ls.err} ${session.errorCount || session.toolFailureCount}`);
}
if (
d.showDiffStats &&
@@ -155,10 +224,16 @@ export function formatStatusBlock(
if (gb) meta.push(gb);
}
const line2 = [ctxPart, usagePart, ...meta].filter(Boolean).join(" │ ");
const tokParts = tokenLinesForHud(session, cfg);
// Prefer first token line on the context/usage row for glanceability
const line2 = [ctxPart, tokParts[0], usagePart, ...meta]
.filter(Boolean)
.join(" │ ");
lines.push(line2);
// Session sum (ΣTOK) gets its own row when present and different
if (tokParts[1]) lines.push(tokParts[1]);
// Line 3 — tools / agents / todos
// Activity — tools / agents / todos
const activity: string[] = [];
if (d.showToolActivity) {
const tl = formatToolLine(session.tools);
@@ -195,120 +270,323 @@ export function formatCompactLine(
cfg.display.showGit && session.branch
? ` git:(${session.branch}${session.gitDirty && cfg.display.showGitDirty ? "*" : ""})`
: "";
return `[hud] [${displayModel(session.model)}] ${projectLabel(session.cwd, cfg.pathLevels)}${git} │ ctx ${pct}% │ ${q} │ t${session.turnCount} │ tools ${session.toolCallCount}`;
const tok = session.lastTurnTokens;
const tokBit =
cfg.display.showTokenBreakdown && tok
? ` │ in ${formatTokenCount(tok.inputTokens)} out ${formatTokenCount(tok.outputTokens)} cache ${formatTokenCount(tok.cachedReadTokens)}`
: "";
return `[hud] [${displayModel(session.model)}] ${projectLabel(session.cwd, cfg.pathLevels)}${git} │ ctx ${pct}% │ ${q}${tokBit} │ t${session.turnCount} │ tools ${session.toolCallCount}`;
}
/**
* Multi-line tmux status content (colour-coded).
* Written as separate lines for status-format[0..n].
* Compact token cluster with mixed styles (not one big bold blob).
* label italic/dim · number bold (colour by kind)
*/
function formatTokenCluster(
session: SessionSnapshot,
theme: HudTheme,
cfg: HudDisplayConfig,
tier: ReturnType<typeof widthTierLike>,
): { plain: string; render: string } | null {
if (!cfg.display.showTokenBreakdown) return null;
const tok = session.lastTurnTokens;
if (!tok) return null;
const L = stringsFromConfig(cfg);
const exact = cfg.display.tokenDigits === "exact" && tier !== "xs" && tier !== "sm";
const n = (x: number) => (exact ? x.toLocaleString("en-US") : fmtTokShort(x));
const hit = Math.round(tok.cacheHitPct);
// short labels from i18n: 入/出/缓 or i/o/c
const partsPlain = [
`${L.in} ${n(tok.inputTokens)}`,
`${L.out} ${n(tok.outputTokens)}`,
`${L.cache} ${n(tok.cachedReadTokens)}${hit ? ` ${hit}%` : ""}`,
];
if (tok.reasoningTokens > 0 && (tier === "md" || tier === "lg")) {
partsPlain.push(`${L.reason} ${n(tok.reasoningTokens)}`);
}
const plain = partsPlain.join(" ");
const render =
tmuxRole(theme, "label", `${L.in} `) +
tmuxRole(theme, "primary", n(tok.inputTokens)) +
tmuxRole(theme, "sep", " ") +
tmuxRole(theme, "label", `${L.out} `) +
tmuxRole(theme, "secondary", n(tok.outputTokens)) +
tmuxRole(theme, "sep", " ") +
tmuxRole(theme, "label", `${L.cache} `) +
tmuxRole(theme, hit >= 90 ? "ok" : "warn", n(tok.cachedReadTokens)) +
(hit
? tmuxRole(theme, "muted", ` ${hit}%`)
: "") +
(tok.reasoningTokens > 0 && (tier === "md" || tier === "lg")
? tmuxRole(theme, "sep", " ") +
tmuxRole(theme, "label", `${L.reason} `) +
tmuxRole(theme, "secondary", n(tok.reasoningTokens))
: "");
return { plain, render };
}
function widthTierLike(cols: number): "xs" | "sm" | "md" | "lg" {
return widthTier(cols);
}
/**
* Multi-line tmux status — width-adaptive + typographic hierarchy.
* Labels: dim italic · primary numbers: bold · secondary: italic · seps: dim
*/
export function formatTmuxStatusLines(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
theme: HudTheme = resolveTheme(),
cfg: HudDisplayConfig = loadHudConfig(),
options: { maxWidth?: number } = {},
): string[] {
const d = cfg.display;
const bold = cfg.bold !== false;
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
// wider separators + bold values = easier to scan at a glance
const sep = tmuxFg(theme.sep, " │ ", { bold: false });
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
const value = (s: string) => tmuxFg(theme.value, s, { bold });
const accent = (s: string) => tmuxFg(theme.mark, s, { bold });
const lines: string[] = [];
const L = stringsFromConfig(cfg);
const cols = options.maxWidth && options.maxWidth > 20 ? options.maxWidth : 100;
const tier = widthTier(cols);
const barW = adaptiveBarWidth(
cols,
cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12,
);
const rows = adaptiveStatusLines(cols, cfg.statusLines ?? 3);
// leave 2 cols margin for tmux chrome
const maxW = Math.max(24, cols - 2);
const sepR = tmuxRole(theme, "sep", " · ");
const sepP = " · ";
// L0 model + project + git + live
const l0: string[] = [];
// ── Line 0: model (bold) · project (italic) · live (bold accent)
const l0: FitSegment[] = [];
if (d.showModel) {
l0.push(accent(`[${displayModel(session.model)}]`));
const t = displayModel(session.model);
l0.push({
text: t,
render: tmuxRole(theme, "accent", t),
priority: 0,
});
}
if (d.showProject) {
let p = value(projectLabel(session.cwd, cfg.pathLevels));
let plain = projectLabel(session.cwd, cfg.pathLevels);
if (d.showGit && session.branch) {
const dirty = d.showGitDirty && session.gitDirty ? "*" : "";
p +=
tmuxFg(theme.label, " git:(", { bold: false }) +
value(`${session.branch}${dirty}`) +
tmuxFg(theme.label, ")", { bold: false });
plain += ` ${session.branch}${dirty}`;
}
l0.push(p);
// short path on narrow
const shown =
tier === "xs" ? plain.split("/").pop() || plain : plain;
l0.push({
text: shown,
render: tmuxRole(theme, "secondary", shown),
priority: 2,
});
}
if (d.showLive) {
l0.push(
session.live
? tmuxFg(theme.live, " ● LIVE ", { bold: true })
: tmuxFg(theme.stale, " ○ stale ", { bold: false }),
);
const t = session.live ? "●" : "○";
l0.push({
text: t,
render: session.live
? tmuxRole(theme, "live", t)
: tmuxRole(theme, "muted", t),
priority: 1,
});
}
if (d.showTitle && session.title && tier === "lg") {
const t = truncate(session.title, 28);
l0.push({
text: t,
render: tmuxRole(theme, "muted", t),
priority: 5,
});
}
if (session.reasoningEffort && (tier === "md" || tier === "lg")) {
const t = session.reasoningEffort;
l0.push({
text: t,
render: tmuxRole(theme, "label", t),
priority: 6,
});
}
// Extra leading/trailing spaces = more breathing room (reads larger)
lines.push(" " + l0.join(sep) + " ");
// L1 context + usage — thicker bars, bold percentages
const line0 = fitSegments(l0, maxW, sepP, sepR);
// ── Line 1: ctx bar + % · tokens · use · meta (priority drop)
const pct = Math.round(session.contextPercent);
const ctx =
label("CTX ") +
(d.showContextBar ? miniBar(pct, barW, theme, { bold }) + " " : "") +
value(contextValueText(session, d.contextValue));
const l1: FitSegment[] = [];
{
const bar = d.showContextBar
? miniBar(pct, barW, theme, { bold: true }) + " "
: "";
const val =
tier === "xs" || tier === "sm"
? `${pct}%`
: contextValueText(session, d.contextValue);
const plain = `${L.ctx} ${val}`;
const render =
tmuxRole(theme, "label", `${L.ctx} `) +
bar +
tmuxRole(
theme,
pct >= 90 ? "crit" : pct >= 70 ? "warn" : "ok",
val,
);
l1.push({ text: plain, render, priority: 0 });
}
const cluster = formatTokenCluster(session, theme, cfg, tier);
if (cluster && tier !== "xs") {
l1.push({
text: cluster.plain,
render: cluster.render,
priority: tier === "sm" ? 3 : 1,
});
}
let usagePart = label("USE ") + value("—");
if (d.showUsage && usage?.available && usage.percent != null) {
const q = Math.round(usage.percent);
usagePart =
label("USE ") +
(d.showContextBar ? miniBar(q, barW, theme, { bold }) + " " : "") +
value(
`${q}%${usage.period ? ` ${usage.period}` : ""}${usage.resetsIn ? ` · ${usage.resetsIn}` : ""}`,
);
const bar = d.showContextBar
? miniBar(q, Math.max(6, barW - 2), theme, { bold: true }) + " "
: "";
const periodWord =
usage.period === "weekly"
? L.weekly
: usage.period === "monthly"
? L.monthly
: usage.period ?? "";
const tail =
tier === "lg" && usage.resetsIn
? ` ${periodWord} ${usage.resetsIn}`.trim()
: "";
const plain = `${L.use} ${q}%${tail ? " " + tail : ""}`;
const render =
tmuxRole(theme, "label", `${L.use} `) +
bar +
tmuxRole(theme, "primary", `${q}%`) +
(tail ? tmuxRole(theme, "muted", ` ${tail}`) : "");
l1.push({ text: plain, render, priority: 2 });
}
const meta: string[] = [];
if (d.showSessionTime && session.durationSeconds > 0) {
meta.push(label("TIME ") + value(formatDuration(session.durationSeconds)));
if (d.showTurns && session.turnCount > 0 && tier !== "xs") {
l1.push({
text: `${L.turn}${session.turnCount}`,
render:
tmuxRole(theme, "label", L.turn) +
tmuxRole(theme, "primary", String(session.turnCount)),
priority: 4,
});
}
if (d.showTurns) meta.push(label("T ") + value(String(session.turnCount)));
if (d.showTools)
meta.push(label("TOOLS ") + value(String(session.toolCallCount)));
if (d.showErrors && session.toolFailureCount > 0) {
meta.push(tmuxFg(theme.crit, ` ERR ${session.toolFailureCount} `, { bold: true }));
if (d.showTools && session.toolCallCount > 0 && (tier === "md" || tier === "lg")) {
l1.push({
text: `${L.tools}${session.toolCallCount}`,
render:
tmuxRole(theme, "label", L.tools) +
tmuxRole(theme, "secondary", String(session.toolCallCount)),
priority: 5,
});
}
if (d.showSessionTime && session.durationSeconds > 0 && tier === "lg") {
const t = formatDuration(session.durationSeconds);
l1.push({
text: t,
render: tmuxRole(theme, "muted", t),
priority: 7,
});
}
if (
d.showDiffStats &&
(session.agentLinesAdded || session.agentLinesRemoved)
d.showErrors &&
(session.errorCount > 0 || session.toolFailureCount > 0)
) {
meta.push(
value(`+${session.agentLinesAdded}/-${session.agentLinesRemoved}`),
);
const n = session.errorCount || session.toolFailureCount;
l1.push({
text: `!${n}`,
render: tmuxRole(theme, "crit", `!${n}`),
priority: 1,
});
}
lines.push(" " + [ctx, usagePart, ...meta].join(sep) + " ");
// L2 activity
if (cfg.statusLines >= 3) {
const bits: string[] = [];
const line1 = fitSegments(l1, maxW, sepP, sepR);
// Single-row windows: merge identity + metrics into one fitted line
if (rows === 1) {
const merged = fitSegments(
[
...l0,
...l1.filter((s) => s.priority <= 2),
],
maxW,
sepP,
sepR,
);
return [visibleLen(merged) > maxW ? trimVisible(merged, maxW) : merged];
}
const lines: string[] = [line0, line1];
// ── Line 2: activity (italic) — secondary tools muted
if (rows >= 3) {
const bits: FitSegment[] = [];
// Σ only on large screens (avoid repeating full token wall)
if (
d.showTokenBreakdown &&
tier === "lg" &&
cfg.display.tokenScope === "both"
) {
const sum = session.sessionTokens;
const last = session.lastTurnTokens;
if (
sum &&
last &&
(sum.inputTokens !== last.inputTokens ||
sum.outputTokens !== last.outputTokens)
) {
const t = `${L.sum} ${L.in} ${fmtTokShort(sum.inputTokens)} ${L.out} ${fmtTokShort(sum.outputTokens)} ${L.cache} ${fmtTokShort(sum.cachedReadTokens)}`;
bits.push({
text: t,
render: tmuxRole(theme, "muted", t),
priority: 4,
});
}
}
if (d.showToolActivity) {
const tl = formatToolLine(session.tools);
if (tl) bits.push(value(tl));
if (tl) {
// only first tool chunk bold; rest already in formatToolLine — soften whole as secondary
const short = truncate(tl, tier === "sm" ? 36 : tier === "md" ? 56 : 80);
bits.push({
text: short,
render: tmuxRole(theme, "secondary", short),
priority: 2,
});
}
}
if (d.showAgents) {
const al = formatAgentsLine(session);
if (al) bits.push(label(al));
if (al) {
bits.push({
text: al,
render: tmuxRole(theme, "label", truncate(al, 40)),
priority: 5,
});
}
}
if (d.showTodos) {
const td = formatTodosLine(session.todos);
if (td) bits.push(accent(td));
if (td) {
bits.push({
text: td,
render: tmuxRole(theme, "accent", truncate(td, 36)),
priority: 3,
});
}
}
if (d.showProductBreakdown && usage?.message) {
const gb = usage.message
.split(",")
.map((s) => s.trim())
.find((s) => /GrokBuild/i.test(s));
if (gb) bits.push(label(gb));
if (bits.length) {
lines.push(fitSegments(bits, maxW, sepP, sepR));
}
if (bits.length)
lines.push(" " + bits.join(tmuxFg(theme.sep, " · ", { bold: false })) + " ");
}
const n = Math.max(1, Math.min(3, cfg.statusLines));
return lines.slice(0, n);
// Final safety trim each line to window
return lines.map((ln) =>
visibleLen(ln) > maxW ? trimVisible(ln, maxW) : ln,
);
}
/** Single-line coloured status (fallback / compact). */
@@ -317,39 +595,22 @@ export function formatTmuxStatusLine(
usage?: UsageSnapshot | null,
theme: HudTheme = resolveTheme(),
cfg: HudDisplayConfig = loadHudConfig(),
options: { maxWidth?: number } = {},
): string {
if (cfg.lineLayout === "compact" || cfg.statusLines === 1) {
const lines = formatTmuxStatusLines(session, usage, theme, {
const lines = formatTmuxStatusLines(
session,
usage,
theme,
{
...cfg,
statusLines: 1,
});
// collapse expanded L0+L1 into one dense line for single-row mode
const pct = Math.round(session.contextPercent);
const q =
usage?.available && usage.percent != null
? Math.round(usage.percent)
: null;
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
const bold = cfg.bold !== false;
const sep = tmuxFg(theme.sep, " · ", { bold: false });
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
const value = (s: string) => tmuxFg(theme.value, s, { bold });
const parts = [
tmuxFg(theme.mark, `[${displayModel(session.model)}]`, { bold: true }),
value(projectLabel(session.cwd, cfg.pathLevels)),
label("CTX ") + miniBar(pct, barW, theme, { bold }) + " " + value(`${pct}%`),
q != null
? label("USE ") + miniBar(q, barW, theme, { bold }) + " " + value(`${q}%`)
: label("USE ") + value("—"),
label("T ") + value(String(session.turnCount)),
label("TOOLS ") + value(String(session.toolCallCount)),
session.live
? tmuxFg(theme.live, "● LIVE", { bold: true })
: tmuxFg(theme.stale, "○", { bold: false }),
];
return " " + parts.join(sep) + " ";
}
return formatTmuxStatusLines(session, usage, theme, cfg).join("\n");
statusLines:
cfg.lineLayout === "compact" || cfg.statusLines === 1
? 1
: cfg.statusLines,
},
options,
);
return lines.join("\n");
}
export interface WriteStatusResult {
@@ -365,16 +626,35 @@ export function writeStatusFiles(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
grokHome = defaultGrokHome(),
options: {
/** Also write under hud/tmux/<name>/ for parallel Terminal isolation. */
tmuxSession?: string | null;
/** When false, skip global status.* (only instance dir). Default true. */
writeGlobal?: boolean;
/** Terminal/tmux client width for adaptive layout. */
maxWidth?: number;
ttyPath?: string | null;
} = {},
): WriteStatusResult {
const cfg = loadHudConfig(grokHome);
const theme = resolveTheme(undefined, process.env, { grokHome });
const dir = hudDataDir(grokHome);
fs.mkdirSync(dir, { recursive: true });
const maxWidth =
options.maxWidth ??
resolveDisplayWidth({
ttyPath: options.ttyPath,
tmuxSession: options.tmuxSession,
fallback: 100,
});
const compact = formatCompactLine(session, usage, cfg);
const full = formatStatusBlock(session, usage, cfg);
const tmuxLines = formatTmuxStatusLines(session, usage, theme, cfg);
const single = formatTmuxStatusLine(session, usage, theme, cfg);
const tmuxLines = formatTmuxStatusLines(session, usage, theme, cfg, {
maxWidth,
});
const single = formatTmuxStatusLine(session, usage, theme, cfg, { maxWidth });
const compactPath = path.join(dir, "status-line.txt");
const fullPath = path.join(dir, "status.txt");
@@ -382,45 +662,62 @@ export function writeStatusFiles(
const tmuxPath = path.join(dir, "tmux-status.txt");
const tmuxLinesPath = path.join(dir, "tmux-lines.txt");
fs.writeFileSync(compactPath, compact + "\n", "utf8");
fs.writeFileSync(fullPath, full + "\n", "utf8");
fs.writeFileSync(tmuxPath, single + "\n", "utf8");
// pad to 3 lines for stable sed -n
const padded = [...tmuxLines];
while (padded.length < 3) padded.push("");
fs.writeFileSync(tmuxLinesPath, padded.join("\n") + "\n", "utf8");
fs.writeFileSync(
jsonPath,
JSON.stringify(
{
updatedAt: new Date().toISOString(),
sessionId: session.sessionId,
model: session.model,
cwd: session.cwd,
live: session.live,
contextPercent: session.contextPercent,
contextTokensUsed: session.contextTokensUsed,
contextWindowTokens: session.contextWindowTokens,
turnCount: session.turnCount,
toolCallCount: session.toolCallCount,
toolFailureCount: session.toolFailureCount,
agentLinesAdded: session.agentLinesAdded,
agentLinesRemoved: session.agentLinesRemoved,
todos: session.todos,
tools: session.tools,
agents: session.agents,
usage: usage ?? null,
preset: cfg.preset,
compact,
full,
tmuxLines,
},
null,
2,
),
"utf8",
);
const payload = {
updatedAt: new Date().toISOString(),
sessionId: session.sessionId,
model: session.model,
cwd: session.cwd,
live: session.live,
contextPercent: session.contextPercent,
contextTokensUsed: session.contextTokensUsed,
contextWindowTokens: session.contextWindowTokens,
turnCount: session.turnCount,
toolCallCount: session.toolCallCount,
toolFailureCount: session.toolFailureCount,
agentLinesAdded: session.agentLinesAdded,
agentLinesRemoved: session.agentLinesRemoved,
lastTurnTokens: session.lastTurnTokens ?? null,
sessionTokens: session.sessionTokens ?? null,
tmuxSession: options.tmuxSession ?? null,
displayWidth: maxWidth,
todos: session.todos,
tools: session.tools,
agents: session.agents,
usage: usage ?? null,
preset: cfg.preset,
compact,
full,
tmuxLines,
};
const writeBundle = (targetDir: string) => {
fs.mkdirSync(targetDir, { recursive: true });
fs.writeFileSync(path.join(targetDir, "status-line.txt"), compact + "\n", "utf8");
fs.writeFileSync(path.join(targetDir, "status.txt"), full + "\n", "utf8");
fs.writeFileSync(path.join(targetDir, "tmux-status.txt"), single + "\n", "utf8");
fs.writeFileSync(path.join(targetDir, "tmux-lines.txt"), padded.join("\n") + "\n", "utf8");
fs.writeFileSync(
path.join(targetDir, "status.json"),
JSON.stringify(payload, null, 2),
"utf8",
);
};
if (options.writeGlobal !== false) {
writeBundle(dir);
}
// Per-tmux-session copy (parallel Terminals — independent bars)
if (options.tmuxSession) {
const safe = options.tmuxSession.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 60);
if (safe) {
writeBundle(path.join(dir, "tmux", safe));
}
}
return {
dir,
+244 -60
View File
@@ -61,21 +61,30 @@ export const THEME_GROKNIGHT: HudTheme = {
stale: "#7a7a7a",
};
/** GrokDay — light / 白底黑字 */
/**
* GrokDay — light / paper strip.
* High contrast on white: never rely on terminal "dim" (washes out on light bg).
* Solid soft statusBg so text isn't lost against mixed terminal backgrounds.
*/
export const THEME_GROKDAY: HudTheme = {
name: "grokday",
statusBg: "default",
statusFg: "#2a2a2a",
label: "#6b6b6b",
value: "#111111",
sep: "#c8c8c8",
mark: "#6C3EB2",
ok: "#2f7a3e",
warn: "#8a6a12",
crit: "#a03030",
barEmpty: "#dcdcdc",
live: "#6C3EB2",
stale: "#8a8a8a",
// Warm paper bar — separates HUD from Grok content, readable black on cream
statusBg: "#f4f1ea",
statusFg: "#1c1917",
// Labels: slate, dark enough without dim
label: "#57534e",
// Primary numbers: near-black ink
value: "#0c0a09",
// Separators: visible mid grey (not #c8c8c8 on white)
sep: "#a8a29e",
// Accent purple — deeper for WCAG-ish contrast on paper
mark: "#5b21b6",
ok: "#166534",
warn: "#a16207",
crit: "#b91c1c",
barEmpty: "#d6d3d1",
live: "#5b21b6",
stale: "#78716c",
};
/** Rose Pine Moon */
@@ -112,21 +121,21 @@ export const THEME_OSCURA: HudTheme = {
stale: "#6a6a6a",
};
/** Generic light (白底) */
/** Generic light (白底) — high contrast */
export const THEME_CLEAR_LIGHT: HudTheme = {
name: "light",
statusBg: "default",
statusFg: "#3c424a",
label: "#7a828c",
value: "#1a1d21",
sep: "#c5cad1",
mark: "#5a6570",
ok: "#3d6b55",
warn: "#8a6a2e",
crit: "#8f4040",
barEmpty: "#d8dce2",
live: "#3d6b55",
stale: "#9aa3ad",
statusBg: "#f1f5f9",
statusFg: "#0f172a",
label: "#475569",
value: "#020617",
sep: "#94a3b8",
mark: "#1e3a5f",
ok: "#15803d",
warn: "#a16207",
crit: "#b91c1c",
barEmpty: "#cbd5e1",
live: "#1d4ed8",
stale: "#64748b",
};
/** Generic dark */
@@ -310,12 +319,15 @@ export function detectSystemAppearance(
}
/**
* Resolve HUD palette.
* Priority:
* 1. explicit CLI / GROK_HUD_THEME (if not auto)
* 2. ~/.grok/hud/theme file (if not auto)
* 3. Grok [ui].theme from config.toml ← main source of truth
* 4. if Grok theme is auto → system light/dark → auto_light/dark_theme
* Resolve HUD palette from Grok's theme — never lock to one palette by default.
*
* Default (follow):
* ~/.grok/config.toml [ui].theme
* → if "auto"/"system": OS light/dark → auto_light_theme / auto_dark_theme
*
* Optional lock (explicit only):
* GROK_HUD_LOCK=1 plus CLI `--theme X` / env GROK_HUD_THEME=X
* (without LOCK, file/env "auto" is ignored and we still follow Grok)
*/
export function resolveTheme(
name?: string | null,
@@ -324,53 +336,129 @@ export function resolveTheme(
): HudTheme {
const grokHome = options.grokHome ?? path.join(os.homedir(), ".grok");
const ui = readGrokUiConfig(grokHome);
const lock =
env.GROK_HUD_LOCK === "1" ||
env.GROK_HUD_LOCK === "true" ||
Boolean(name && name !== "auto" && name !== "system");
const override = (
name ||
env.GROK_HUD_THEME ||
env.GROK_BUILD_HUD_THEME ||
readThemeFile() ||
""
)
.toString()
.trim()
.toLowerCase();
// Explicit lock to a known palette name
if (override && override !== "auto" && override !== "system") {
return paletteForGrokTheme(override);
// Explicit CLI name (e.g. --theme tokyonight) = temporary override for that command
const cliName = (name || "").toString().trim().toLowerCase();
if (cliName && cliName !== "auto" && cliName !== "system") {
const theme = paletteForGrokTheme(cliName);
writeAppearanceSnapshot({
appearance: isLightTheme(theme) ? "light" : "dark",
source: "cli-override",
grokTheme: ui.theme,
mappedTheme: theme.name,
hudPalette: theme.name,
follow: false,
});
return theme;
}
// Follow Grok's active theme (what the TUI paints)
// Locked env/file palette only when GROK_HUD_LOCK=1
if (lock && !cliName) {
const locked = (
env.GROK_HUD_THEME ||
env.GROK_BUILD_HUD_THEME ||
readThemeFile() ||
""
)
.toString()
.trim()
.toLowerCase();
if (locked && locked !== "auto" && locked !== "system") {
const theme = paletteForGrokTheme(locked);
writeAppearanceSnapshot({
appearance: isLightTheme(theme) ? "light" : "dark",
source: "locked",
grokTheme: ui.theme,
mappedTheme: theme.name,
hudPalette: theme.name,
follow: false,
});
return theme;
}
}
// ── Follow Grok [ui].theme (default path) ──
return resolveFromGrokUi(ui, grokHome);
}
/** Map Grok config.toml [ui] → HUD palette (live follow). */
export function resolveFromGrokUi(
ui: GrokUiConfig,
grokHome?: string,
): HudTheme {
const grokTheme = normalizeGrokThemeName(ui.theme || "groknight");
if (grokTheme === "auto") {
const sys = detectSystemAppearance();
const mapped =
// Poll system appearance with short TTL so OS toggle tracks quickly
const sys = detectSystemAppearance({ ttlMs: 1500 });
const mappedRaw =
sys === "dark"
? ui.autoDarkTheme || "groknight"
: ui.autoLightTheme || "grokday";
const mapped = normalizeGrokThemeName(mappedRaw);
const theme = paletteForGrokTheme(mapped);
writeAppearanceSnapshot({
appearance: sys,
source: "grok-auto+" + sys,
source: "follow-grok-auto+" + sys,
grokTheme: ui.theme,
autoDarkTheme: ui.autoDarkTheme,
autoLightTheme: ui.autoLightTheme,
mappedTheme: mapped,
hudPalette: theme.name,
follow: true,
grokHome: grokHome ?? null,
});
return theme;
}
const theme = paletteForGrokTheme(grokTheme);
writeAppearanceSnapshot({
appearance: theme.name === "grokday" || theme.name === "light" ? "light" : "dark",
source: "grok-config",
appearance: isLightTheme(theme) ? "light" : "dark",
source: "follow-grok-config",
grokTheme: ui.theme,
mappedTheme: grokTheme,
hudPalette: theme.name,
follow: true,
});
return theme;
}
/**
* Fingerprint for dashboard: any Grok theme / OS / mapping change rewrites tmux.
* Not just palette name — so auto light↔dark always refreshes.
*/
export function themeFingerprint(
theme: HudTheme,
ui?: GrokUiConfig | null,
env: NodeJS.ProcessEnv = process.env,
): string {
const u = ui ?? { theme: "?", autoDarkTheme: "?", autoLightTheme: "?" };
const sys =
normalizeGrokThemeName(u.theme || "") === "auto"
? detectSystemAppearance({ ttlMs: 1500 })
: "-";
return [
theme.name,
u.theme,
u.autoDarkTheme,
u.autoLightTheme,
sys,
env.GROK_HUD_LOCK || "0",
].join("|");
}
/** Ensure ~/.grok/hud/theme is "auto" (follow mode). */
export function ensureFollowMode(grokHome = path.join(os.homedir(), ".grok")): string {
const dir = path.join(grokHome, "hud");
fs.mkdirSync(dir, { recursive: true });
const p = path.join(dir, "theme");
fs.writeFileSync(p, "auto\n", "utf8");
return p;
}
function writeAppearanceSnapshot(data: Record<string, unknown>): void {
try {
const dir = path.join(os.homedir(), ".grok", "hud");
@@ -433,14 +521,105 @@ export function miniBar(
return `#[${bold}fg=${fg}]${"█".repeat(filled)}#[fg=${theme.barEmpty}]${"░".repeat(empty)}#[default]`;
}
/** Tmux styled span. Values default to bold for readability. */
export interface TmuxStyleOpts {
bold?: boolean;
/** Italics — hierarchy for labels / secondary facts */
italics?: boolean;
/** Dim — de-emphasize separators and labels */
dim?: boolean;
underscore?: boolean;
}
/** Tmux styled span. Mix bold / italics / dim for scannable hierarchy. */
export function tmuxFg(
color: string,
text: string,
options: { bold?: boolean } = {},
options: TmuxStyleOpts = {},
): string {
const bold = options.bold ? "bold," : "";
return `#[${bold}fg=${color}]${text}#[default]`;
const attrs: string[] = [];
if (options.bold) attrs.push("bold");
if (options.italics) attrs.push("italics");
if (options.dim) attrs.push("dim");
if (options.underscore) attrs.push("underscore");
// no attributes → still set fg
const head = attrs.length ? attrs.join(",") + "," : "";
return `#[${head}fg=${color}]${text}#[default]`;
}
/** Light / paper palettes — dim attribute destroys contrast on light bg. */
export function isLightTheme(theme: HudTheme): boolean {
const n = (theme.name || "").toLowerCase();
if (n === "grokday" || n === "light" || n === "day") return true;
// Heuristic: explicit light status background hex
const bg = (theme.statusBg || "").toLowerCase();
if (bg.startsWith("#")) {
const hex = bg.slice(1);
if (hex.length >= 6) {
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
// relative luminance approx
const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
return L > 0.72;
}
}
return false;
}
/** Semantic text roles for the HUD strip (light-safe: no dim on paper). */
export function tmuxRole(
theme: HudTheme,
role:
| "label"
| "primary"
| "secondary"
| "accent"
| "muted"
| "ok"
| "warn"
| "crit"
| "live"
| "sep",
text: string,
): string {
const light = isLightTheme(theme);
switch (role) {
case "label":
// Light: italic only (no dim). Dark: dim+italic OK.
return tmuxFg(theme.label, text, {
italics: true,
dim: !light,
bold: light, // slightly stronger labels on paper
});
case "primary":
return tmuxFg(theme.value, text, { bold: true });
case "secondary":
// Path / tools — italic, full ink on light (statusFg is dark)
return tmuxFg(theme.statusFg, text, {
italics: true,
bold: light,
});
case "accent":
return tmuxFg(theme.mark, text, { bold: true });
case "muted":
return tmuxFg(theme.label, text, {
italics: true,
dim: !light,
});
case "ok":
return tmuxFg(theme.ok, text, { bold: true });
case "warn":
return tmuxFg(theme.warn, text, { bold: true });
case "crit":
return tmuxFg(theme.crit, text, { bold: true });
case "live":
return tmuxFg(theme.live, text, { bold: true });
case "sep":
// Never dim seps on light — they vanish on white
return tmuxFg(theme.sep, text, { dim: !light });
default:
return tmuxFg(theme.value, text, { bold: true });
}
}
export function tmuxStatusChrome(theme: HudTheme = THEME_DEFAULT): {
@@ -452,15 +631,20 @@ export function tmuxStatusChrome(theme: HudTheme = THEME_DEFAULT): {
statusLeftLength: string;
statusPosition: string;
} {
const light = isLightTheme(theme);
// Light: solid paper bg + dark ink. Dark: default/transparent + nobold base.
const statusStyle = light
? `bg=${theme.statusBg},fg=${theme.statusFg},nobold`
: `bg=${theme.statusBg === "default" ? "default" : theme.statusBg},fg=${theme.statusFg},nobold`;
return {
// bold + slightly brighter default fg for the whole strip
statusStyle: `bg=${theme.statusBg},fg=${theme.statusFg},bold`,
statusStyle,
statusLeft: "",
statusRightTemplate: (filePath: string) =>
`#(cat ${filePath} 2>/dev/null)`,
statusInterval: "1",
statusRightLength: "200",
statusLeftLength: "0",
// allow strip to use full client width (was 0 / cramped)
statusRightLength: "500",
statusLeftLength: "500",
statusPosition: "bottom",
};
}
+171 -24
View File
@@ -11,11 +11,35 @@ import { defaultGrokHome } from "./session.js";
import { packageRoot } from "./install.js";
import { resolveTheme, tmuxStatusChrome } from "./theme.js";
import { loadHudConfig } from "./hud-config.js";
import { installGrokCommandWrap } from "./grok-wrap.js";
import {
hudTmuxDir,
uniqueTmuxSessionName,
writeTmuxInstanceMeta,
} from "./multi-session.js";
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.TMUX && env.TMUX.length > 0);
}
/** Current tmux session name, or null. */
export function currentTmuxSessionName(): string | null {
try {
const name = execFileSync(
"tmux",
["display-message", "-p", "#{session_name}"],
{
encoding: "utf8",
timeout: 1000,
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
return name || null;
} catch {
return null;
}
}
export function tmuxAvailable(): boolean {
try {
execFileSync("which", ["tmux"], { stdio: "ignore" });
@@ -25,13 +49,21 @@ export function tmuxAvailable(): boolean {
}
}
/** Build the list of tmux set commands for a cohesive status bar. */
/**
* Build tmux status commands.
* Status files are per-tmux-session under ~/.grok/hud/tmux/#{session_name}/
* so parallel Terminals never share one bar / one Grok session.
*/
export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
const theme = resolveTheme(undefined, process.env, { grokHome });
const chrome = tmuxStatusChrome(theme);
const linesFile = path.join(grokHome, "hud", "tmux-lines.txt");
const singleFile = path.join(grokHome, "hud", "tmux-status.txt");
// Claude-HUD parity: up to 3 status rows in the SAME terminal window
const instRoot = path.join(grokHome, "hud", "tmux");
// #{session_name} expanded by tmux before running #() — one bar per session
const scopedLines = `${instRoot}/#{session_name}/tmux-lines.txt`;
const scopedSingle = `${instRoot}/#{session_name}/tmux-status.txt`;
const fallbackLines = path.join(grokHome, "hud", "tmux-lines.txt");
const fallbackSingle = path.join(grokHome, "hud", "tmux-status.txt");
// Up to 3 status rows in the SAME terminal window
const statusLines = Math.max(
1,
Math.min(3, loadHudConfig(grokHome).statusLines ?? 3),
@@ -46,18 +78,20 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
["set", "-g", "window-status-format", ""],
["set", "-g", "window-status-current-format", ""],
["set", "-g", "window-status-separator", ""],
["set", "-g", "status-left-length", "0"],
["set", "-g", "status-right-length", "0"],
// Allow full client width (dashboard rewrites lines to fit each window)
["set", "-g", "status-left-length", chrome.statusLeftLength],
["set", "-g", "status-right-length", chrome.statusRightLength],
["set", "-g", "status-left", ""],
["set", "-g", "status-right", ""],
];
// align=left; content already truncated to #{client_width} by dashboard
if (statusLines === 1) {
cmds.push([
"set",
"-g",
"status-format[0]",
`#[align=left]#(cat ${singleFile} 2>/dev/null)`,
`#[align=left]#(cat ${scopedSingle} 2>/dev/null || cat ${fallbackSingle} 2>/dev/null)`,
]);
} else {
for (let i = 0; i < statusLines; i++) {
@@ -65,7 +99,7 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
"set",
"-g",
`status-format[${i}]`,
`#[align=left]#(sed -n '${i + 1}p' ${linesFile} 2>/dev/null)`,
`#[align=left]#(sed -n '${i + 1}p' ${scopedLines} 2>/dev/null || sed -n '${i + 1}p' ${fallbackLines} 2>/dev/null)`,
]);
}
}
@@ -102,8 +136,8 @@ export function writeTmuxConfFile(grokHome = defaultGrokHome()): string {
const confPath = path.join(grokHome, "hud", "tmux.conf");
const cmds = tmuxStatusCommands(grokHome);
const body =
`# grok-build-hud — Claude-HUD-parity multi-line status (same window)\n` +
`# Follows Grok [ui].theme. Preset: ~/.grok/hud/config.json\n` +
`# grok-build-hud — multi-line live status (same window)\n` +
`# Follows Grok [ui].theme. Config: ~/.grok/hud/config.json\n` +
`# Auto-sourced by ~/.tmux.conf when present.\n\n` +
cmds
.map((args) => {
@@ -127,6 +161,9 @@ export function writeTmuxConfFile(grokHome = defaultGrokHome()): string {
/**
* Re-exec current process inside tmux with status bar, then run `grok`.
* Uses the SAME Terminal.app window (tmux replaces the foreground process).
*
* IMPORTANT: each launch gets a UNIQUE tmux session name (no `-A` attach).
* Parallel Terminals must never share one tmux session / one Grok conversation.
*/
export function execGrokInSameWindowTmux(options: {
grokHome?: string;
@@ -136,24 +173,60 @@ export function execGrokInSameWindowTmux(options: {
}): never | void {
const grokHome = options.grokHome ?? defaultGrokHome();
const grokBin = options.grokBin ?? detectGrokBin();
const session = options.sessionName ?? "grok-hud";
const statusFile = path.join(grokHome, "hud", "tmux-status.txt");
fs.mkdirSync(path.dirname(statusFile), { recursive: true });
// Unique per Terminal launch — never fixed "grok-hud" + -A (that synced all tabs)
const session =
options.sessionName?.trim() ||
uniqueTmuxSessionName(process.env);
const instDir = hudTmuxDir(session, grokHome);
fs.mkdirSync(instDir, { recursive: true });
const statusFile = path.join(instDir, "tmux-status.txt");
if (!fs.existsSync(statusFile)) {
fs.writeFileSync(statusFile, "ctx … · quota …\n", "utf8");
}
// Also ensure global fallback placeholders
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
const globalStatus = path.join(grokHome, "hud", "tmux-status.txt");
if (!fs.existsSync(globalStatus)) {
fs.writeFileSync(globalStatus, "ctx … · quota …\n", "utf8");
}
writeTmuxInstanceMeta({
tmuxSession: session,
launcherPid: process.pid,
startedAt: new Date().toISOString(),
tty: process.env.TTY ?? null,
}, grokHome);
writeTmuxConfFile(grokHome);
if (isInsideTmux()) {
// Already in tmux: stay in THIS session (never attach to another)
const current =
process.env.GROK_HUD_TMUX_SESSION?.trim() ||
currentTmuxSessionName() ||
session;
applyTmuxStatusBar({ grokHome });
writeTmuxInstanceMeta({
tmuxSession: current,
launcherPid: process.pid,
startedAt: new Date().toISOString(),
tty: process.env.TTY ?? null,
}, grokHome);
const args = options.extraGrokArgs ?? [];
const r = spawnSync(grokBin, args, { stdio: "inherit" });
const r = spawnSync(grokBin, args, {
stdio: "inherit",
env: {
...process.env,
GROK_HUD_TMUX_SESSION: current,
GROK_HUD_ACTIVE: "1",
},
});
process.exit(r.status ?? 0);
}
if (!tmuxAvailable()) {
console.error(
"tmux not found. Install: brew install tmux\nThen re-run: grok-hud-run",
"tmux not found. Install: brew install tmux\nThen re-run: grok",
);
process.exit(1);
}
@@ -163,31 +236,88 @@ export function execGrokInSameWindowTmux(options: {
const conf = path.join(grokHome, "hud", "tmux.conf");
// Apply conf then exec grok — same tty, no second window
// Export session name so dashboard can bind status files to this Terminal
const wrapper = [
`export GROK_HUD_TMUX_SESSION=${shellQuote(session)}`,
`export GROK_HUD_ACTIVE=1`,
`tmux source-file ${shellQuote(conf)} 2>/dev/null || true`,
// re-apply in case source failed mid-session
// session-local options preferred over -g when possible
...tmuxStatusCommands(grokHome).map(
(args) => `tmux ${args.map(shellQuote).join(" ")} 2>/dev/null || true`,
),
`exec ${grokCmd}`,
].join(" && ");
// NO -A: never attach to an existing session (that was the multi-tab sync bug)
const r = spawnSync(
"tmux",
["new-session", "-A", "-s", session, "zsh", "-lc", wrapper],
{ stdio: "inherit" },
["new-session", "-s", session, "zsh", "-lc", wrapper],
{
stdio: "inherit",
env: {
...process.env,
GROK_HUD_TMUX_SESSION: session,
GROK_HUD_ACTIVE: "1",
},
},
);
process.exit(r.status ?? 1);
}
function detectGrokBin(): string {
/** Real Grok binary only — never the HUD wrapper (avoids exec loops). */
export function detectGrokBin(): string {
// Lazy import-free: prefer grok-real then downloads, skip wrap scripts
const grokHome = process.env.GROK_HOME?.trim() || path.join(os.homedir(), ".grok");
const realLink = path.join(grokHome, "bin", "grok-real");
if (fs.existsSync(realLink)) {
try {
return fs.realpathSync(realLink);
} catch {
return realLink;
}
}
if (process.env.GROK_REAL_BIN && fs.existsSync(process.env.GROK_REAL_BIN)) {
return process.env.GROK_REAL_BIN;
}
const downloads = path.join(grokHome, "downloads");
if (fs.existsSync(downloads)) {
try {
const hit = fs
.readdirSync(downloads)
.filter((n) => n.startsWith("grok-"))
.map((n) => path.join(downloads, n))
.filter((p) => {
try {
fs.accessSync(p, fs.constants.X_OK);
return true;
} catch {
return false;
}
})
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];
if (hit) return hit;
} catch {
/* ignore */
}
}
const candidates = [
path.join(os.homedir(), ".grok", "bin", "grok"),
path.join(grokHome, "bin", "grok"),
"/usr/local/bin/grok",
"/opt/homebrew/bin/grok",
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
if (!fs.existsSync(c)) continue;
// Skip our zsh wrapper if somehow still on PATH
try {
const st = fs.statSync(c);
if (st.size < 64_000) {
const head = fs.readFileSync(c, "utf8").slice(0, 200);
if (head.includes("grok-build-hud wrap")) continue;
}
} catch {
/* binary */
}
return c;
}
try {
return execFileSync("which", ["grok"], { encoding: "utf8" }).trim();
@@ -201,10 +331,16 @@ function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/** Install same-window launchers. */
/** Install same-window launchers (+ optional plain `grok` wrap). */
export function installSameWindowLauncher(options: {
binDir?: string;
} = {}): { runPath: string; grokWrapPath: string } {
wrapGrokCommand?: boolean;
} = {}): {
runPath: string;
grokWrapPath: string;
grokCommand?: string;
realGrok?: string;
} {
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
fs.mkdirSync(binDir, { recursive: true });
const root = packageRoot();
@@ -215,7 +351,10 @@ export function installSameWindowLauncher(options: {
const runShim = `#!/bin/zsh
# Start Grok in THIS Terminal window with a permanent bottom status bar
# (tmux). Does not open a second window.
# Prefer plain: grok
set -e
export GROK_HUD_THEME="\${GROK_HUD_THEME:-auto}"
export GROK_HUD_ACTIVE=1
NODE=${JSON.stringify(node)}
ENTRY=${JSON.stringify(entry)}
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
@@ -225,5 +364,13 @@ exec "$NODE" "$ENTRY" --run-in-terminal "$@"
fs.writeFileSync(runPath, runShim, { mode: 0o755 });
const grokWrapPath = path.join(binDir, "grok-with-hud");
fs.writeFileSync(grokWrapPath, runShim, { mode: 0o755 });
return { runPath, grokWrapPath };
let grokCommand: string | undefined;
let realGrok: string | undefined;
if (options.wrapGrokCommand !== false) {
const r = installGrokCommandWrap({ nodeBin: node });
grokCommand = r.wrapperPath;
realGrok = r.realPath;
}
return { runPath, grokWrapPath, grokCommand, realGrok };
}
+184
View File
@@ -0,0 +1,184 @@
/**
* Parse per-turn token usage (input / output / cache) from updates.jsonl.
* Source: sessionUpdate "turn_completed" → usage{ inputTokens, outputTokens, cachedReadTokens, … }
*/
import fs from "node:fs";
import type { TokenBreakdown } from "./types.js";
function num(v: unknown): number {
return typeof v === "number" && Number.isFinite(v) ? v : 0;
}
export function parseUsageObject(raw: unknown): TokenBreakdown | null {
if (!raw || typeof raw !== "object") return null;
const u = raw as Record<string, unknown>;
const inputTokens = num(u.inputTokens ?? u.input_tokens);
const outputTokens = num(u.outputTokens ?? u.output_tokens);
const cachedReadTokens = num(
u.cachedReadTokens ?? u.cache_read_input_tokens ?? u.cached_read_tokens,
);
const reasoningTokens = num(u.reasoningTokens ?? u.reasoning_tokens);
const totalTokens =
num(u.totalTokens ?? u.total_tokens) ||
inputTokens + outputTokens;
const modelCalls = num(u.modelCalls ?? u.model_calls ?? u.numTurns);
if (
inputTokens <= 0 &&
outputTokens <= 0 &&
cachedReadTokens <= 0 &&
totalTokens <= 0
) {
return null;
}
const cacheHitPct =
inputTokens > 0
? Math.min(100, (cachedReadTokens / inputTokens) * 100)
: 0;
return {
inputTokens,
outputTokens,
cachedReadTokens,
reasoningTokens,
totalTokens,
modelCalls,
cacheHitPct,
};
}
export function emptyTokenBreakdown(): TokenBreakdown {
return {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
reasoningTokens: 0,
totalTokens: 0,
modelCalls: 0,
cacheHitPct: 0,
};
}
export function addTokenBreakdown(
a: TokenBreakdown,
b: TokenBreakdown,
): TokenBreakdown {
const inputTokens = a.inputTokens + b.inputTokens;
const cachedReadTokens = a.cachedReadTokens + b.cachedReadTokens;
return {
inputTokens,
outputTokens: a.outputTokens + b.outputTokens,
cachedReadTokens,
reasoningTokens: a.reasoningTokens + b.reasoningTokens,
totalTokens: a.totalTokens + b.totalTokens,
modelCalls: a.modelCalls + b.modelCalls,
cacheHitPct:
inputTokens > 0
? Math.min(100, (cachedReadTokens / inputTokens) * 100)
: 0,
};
}
/**
* Scan updates.jsonl lines for turn_completed usage.
* Returns last turn + session sum (sum of each turn's reported usage).
*/
export function parseTokenUsageFromLines(lines: string[]): {
lastTurn: TokenBreakdown | null;
session: TokenBreakdown;
turnCount: number;
} {
let lastTurn: TokenBreakdown | null = null;
let session = emptyTokenBreakdown();
let turnCount = 0;
for (const raw of lines) {
const line = raw.trim();
if (!line) continue;
let obj: Record<string, unknown>;
try {
obj = JSON.parse(line) as Record<string, unknown>;
} catch {
continue;
}
const params = (obj.params as Record<string, unknown> | undefined) ?? {};
const update =
(params.update as Record<string, unknown> | undefined) ??
(obj.update as Record<string, unknown> | undefined) ??
{};
const kind =
(update.sessionUpdate as string | undefined) ??
(obj.sessionUpdate as string | undefined) ??
"";
if (kind !== "turn_completed") continue;
const usage = parseUsageObject(update.usage ?? obj.usage);
if (!usage) continue;
lastTurn = usage;
session = addTokenBreakdown(session, usage);
turnCount += 1;
}
return { lastTurn, session, turnCount };
}
export function parseTokenUsageFile(filePath: string): {
lastTurn: TokenBreakdown | null;
session: TokenBreakdown;
turnCount: number;
} {
try {
if (!fs.existsSync(filePath)) {
return { lastTurn: null, session: emptyTokenBreakdown(), turnCount: 0 };
}
// Large logs: read tail first for last-turn speed, full scan for session sum
// For correctness scan full file (typical session < 50MB). Stream line by line.
const text = fs.readFileSync(filePath, "utf8");
return parseTokenUsageFromLines(text.split("\n"));
} catch {
return { lastTurn: null, session: emptyTokenBreakdown(), turnCount: 0 };
}
}
/** Full integers with thousands separators: 974820 → "974,820" */
export function formatExactCount(n: number): string {
if (!Number.isFinite(n) || n < 0) return "0";
return Math.round(n).toLocaleString("en-US");
}
/**
* Token line for HUD.
* mode "exact" → full digits; "short" → 974.8k style via formatTokenCount.
*/
export function formatTokenBreakdownLine(
tokens: TokenBreakdown | null | undefined,
options: {
mode?: "exact" | "short";
prefix?: string;
includeReason?: boolean;
includeCachePct?: boolean;
formatShort?: (n: number) => string;
} = {},
): string {
if (!tokens) return "";
const {
mode = "exact",
prefix = "TOK",
includeReason = true,
includeCachePct = true,
} = options;
const fmt =
mode === "short" && options.formatShort
? options.formatShort
: formatExactCount;
const parts = [
`IN ${fmt(tokens.inputTokens)}`,
`OUT ${fmt(tokens.outputTokens)}`,
`CACHE ${fmt(tokens.cachedReadTokens)}`,
];
if (includeCachePct && tokens.inputTokens > 0) {
parts[2] += ` (${Math.round(tokens.cacheHitPct)}%)`;
}
if (includeReason && tokens.reasoningTokens > 0) {
parts.push(`REASON ${fmt(tokens.reasoningTokens)}`);
}
return `${prefix} ${parts.join(" · ")}`;
}
+16
View File
@@ -63,6 +63,18 @@ export interface TodoItem {
id?: string;
}
/** Per-turn or session-sum token usage from turn_completed events. */
export interface TokenBreakdown {
inputTokens: number;
outputTokens: number;
cachedReadTokens: number;
reasoningTokens: number;
totalTokens: number;
modelCalls: number;
/** cachedRead / input * 100 (0100). */
cacheHitPct: number;
}
export interface SessionSnapshot {
sessionId: string;
sessionDir: string;
@@ -90,6 +102,10 @@ export interface SessionSnapshot {
avgTtftMs: number;
agentName?: string;
reasoningEffort?: string;
/** Last completed turn's input/output/cache (exact counts). */
lastTurnTokens?: TokenBreakdown | null;
/** Sum of turn_completed usage over the session. */
sessionTokens?: TokenBreakdown | null;
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
+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/);
});
});