Release grok-build-hud v0.3.0: Claude-HUD-style same-window status for Grok Build

Multi-line tmux strip (context, quota, tools, todos, git), theme sync with
Grok UI, full/essential/minimal presets, one-shot installer, EN+ZH docs,
and 40 unit tests.
This commit is contained in:
2026-07-17 15:45:25 +08:00
commit bb6a1321fb
56 changed files with 6334 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
node_modules/
dist/
*.log
.DS_Store
*.tsbuildinfo
.coverage
.env
.env.*
*.local
.idea/
.vscode/
*.swp
.hud-debug/
+13
View File
@@ -0,0 +1,13 @@
{
"name": "grok-build-hud-local",
"description": "Local marketplace entry for grok-build-hud",
"owner": { "name": "sooneocean" },
"plugins": [
{
"name": "grok-build-hud",
"source": { "type": "local", "path": ".." },
"description": "Claude-HUD-style live status HUD for Grok Build",
"category": "monitoring"
}
]
}
+32
View File
@@ -0,0 +1,32 @@
# Changelog
## 0.3.0
### Highlights
- **Claude-HUD parity multi-line 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)
- **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
### Fixes
- Single dashboard daemon (no multi-writer fights)
- Theme follows Grok TUI, not only Terminal.app profile
- PATH / install docs for “command not found”
## 0.2.0
- Same-window tmux HUD
- Hooks + status files
- Billing integration
## 0.1.0
- Initial one-shot / watch CLI
+37
View File
@@ -0,0 +1,37 @@
# Contributing
## Setup
```bash
git clone https://github.com/sooneocean/grok-build-hud.git
cd grok-build-hud
npm install
npm test
```
## Layout
| Path | Role |
|------|------|
| `src/` | TypeScript sources (session, billing, status, tmux, theme, CLI) |
| `bin/` | CLI entrypoints (`grok-build-hud`, `grok-hud`, `grok-hud-run`, hook) |
| `tests/` | Node built-in test runner (`node --test`) |
| `fixtures/` | Session + billing samples for tests |
| `commands/` / `skills/` | Grok plugin surface |
| `scripts/install.sh` | One-shot local install |
## Rules of thumb
- Prefer reading **local** `~/.grok/sessions/**` over inventing APIs.
- Billing must **degrade gracefully** when offline / unauthenticated (never throw in render path).
- Keep the HUD **same-window** (tmux status). Do not require a second Terminal window by default.
- Add or update a test when changing parse/normalize/render behaviour.
- Commit messages in English; user-facing docs may be EN + ZH.
## Before a PR
```bash
npm test
```
All 40 tests should pass.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+69
View File
@@ -0,0 +1,69 @@
# 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.
+254
View File
@@ -0,0 +1,254 @@
# grok-build-hud
**Claude-HUD-style live status 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)
```
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.
---
## Requirements
- **Node.js 18+**
- **tmux** (`brew install tmux` on macOS)
- **Grok Build** CLI signed in (`grok login`)
- Any terminal that can host tmux (tested on Apple Terminal / iTerm)
---
## Install
### A. One-shot script (recommended)
```bash
git clone https://github.com/sooneocean/grok-build-hud.git
cd grok-build-hud
bash scripts/install.sh
```
This runs `npm install``build``npm link` → dashboard + hooks → `--theme auto``--preset full`.
### B. Manual steps
```bash
git clone https://github.com/sooneocean/grok-build-hud.git
cd grok-build-hud
npm install
npm run build
npm link # puts grok-build-hud / grok-hud / grok-hud-run on PATH
npm run install-local
```
### C. From an already-cloned path
```bash
cd /path/to/grok-build-hud
bash scripts/install.sh
```
### D. As a Grok plugin (optional)
```bash
cd /path/to/grok-build-hud
npm install && npm run build
grok plugin install . --trust
grok plugin enable grok-build-hud
```
In Grok: `/hooks` → press `r` to reload.
### PATH fix (`command not found`)
```bash
# add to ~/.zshrc or ~/.bashrc
export PATH="$(npm prefix -g)/bin:$HOME/.local/bin:$PATH"
```
Then `source ~/.zshrc` and verify: `which grok-hud-run`.
---
## Daily use (same window — no second pane)
```bash
# Start Grok with the multi-line HUD at the bottom of THIS tab
grok-hud-run
```
| Row | Content (Claude HUD analogue) |
|-----|--------------------------------|
| 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 |
The strip refreshes about once per second while the session is open.
### Useful commands
```bash
grok-build-hud --once --follow-active # print full HUD once
grok-hud status # same
grok-hud stop # stop background updater
grok-build-hud --preset full|essential|minimal
grok-build-hud --theme auto # follow Grok [ui].theme
grok-build-hud --theme tokyonight # lock palette
```
### Refresh tmux after config change
```bash
tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
```
---
## Configuration
### Display preset & toggles
`~/.grok/hud/config.json`
| Preset | Rows | Contents |
|--------|------|----------|
| **full** (default) | 3 | Everything (Claude “Full”) |
| **essential** | 2 | Model/git + context/usage + activity |
| **minimal** | 1 | Dense single row |
Key options:
```json
{
"preset": "full",
"statusLines": 3,
"bold": true,
"barWidth": 14,
"pathLevels": 2,
"display": {
"showContextBar": true,
"contextValue": "both",
"showUsage": true,
"showToolActivity": true,
"showTodos": true,
"showAgents": true,
"showDiffStats": true
}
}
```
### Theme (matches Grok UI)
Reads `~/.grok/config.toml`:
```toml
[ui]
theme = "tokyonight" # also: groknight, grokday, rosepinemoon, oscuramindnight, auto
```
```bash
grok-build-hud --theme auto # follow Grok config (default)
```
### Readability
- `bold: true` — bold values (default)
- `barWidth: 14` — thicker progress bars
True **font size** is Terminals font (tmux cannot set a separate size for the status strip).
---
## How it works
```text
Grok sessions ──► ~/.grok/sessions/**/signals.json
──► updates.jsonl (tools / agents / todos)
──► summary.json (title, effort, model)
auth.json ──► cli-chat-proxy billing (weekly quota + monthly)
dashboard daemon (~500ms)
┌───────────┼───────────┐
▼ ▼ ▼
tmux-lines.txt status.txt hooks annotations
same-window tmux status (13 rows)
```
No second window. No cloud upload of your code by this tool (read-only local session files + your existing Grok auth for billing).
---
## Migrating from Claude HUD
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` |
---
## Uninstall
```bash
grok-build-hud --uninstall-dashboard
grok-build-hud --uninstall-hooks
npm unlink -g grok-build-hud # if you used npm link
```
Optional cleanup: `~/.grok/hud/`, `~/.grok/hooks/grok-build-hud.json`.
---
## Troubleshooting
| Symptom | Fix |
|---------|-----|
| `command not found` | Fix PATH (above) or run `node bin/grok-hud-run.js` |
| No bottom strip | Use `grok-hud-run`; install tmux; re-run `--install-dashboard` |
| Usage shows `—` | `grok login`, then `grok-hud status` |
| Colors clash with TUI | `grok-build-hud --theme auto` |
| Stale numbers | `grok-hud stop` then `grok-hud-run` |
| Text too small | Increase Terminal font; keep `bold` / `barWidth` |
---
## Development
```bash
npm install
npm test # build + 40 unit tests
npm run build
grok plugin validate . # if Grok CLI is available
```
See [CHANGELOG.md](./CHANGELOG.md) and [CONTRIBUTING.md](./CONTRIBUTING.md).
---
## License
MIT
---
## Disclaimer
Third-party tool. Billing/quota display depends on xAI session auth and API shapes that may change. Not affiliated with xAI.
+186
View File
@@ -0,0 +1,186 @@
# grok-build-hud
**给 [Grok Build](https://x.ai/cli) 用的 Claude-HUD 风格状态条。**
在**同一个 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 没有 Claude 那种 statusline API。本工具用「同窗口多行状态条」实现同等体验。
中文版说明见本页;英文完整文档见 [README.md](./README.md)。
---
## 需要什么
| 依赖 | 说明 |
|------|------|
| **Node.js 18+** | `node -v` 检查 |
| **tmux** | macOS`brew install tmux` |
| **Grok Build** | 已登录:`grok login` |
| 终端 | Apple Terminal / iTerm 等(在 tmux 里跑) |
---
## 安装(三选一)
### 方式 A:从 GitHub 克隆(推荐别人安装时用)
```bash
git clone https://github.com/sooneocean/grok-build-hud.git
cd grok-build-hud
bash scripts/install.sh
```
`install.sh` 会自动:`npm install` → 编译 → `npm link` → 装 dashboard/hooks → 主题 auto → preset full。
### 方式 B:本机已有源码目录
```bash
cd /path/to/grok-build-hud
bash scripts/install.sh
# 或分步:
npm install && npm run build && npm link
npm run install-local
```
### 方式 C:当 Grok 插件(可选)
```bash
cd /path/to/grok-build-hud
npm install && npm run build
grok plugin install . --trust
grok plugin enable grok-build-hud
```
在 Grok 里执行 `/hooks` 后按 `r` 重载 hooks。
### 命令找不到时
把 npm 全局 bin 和 `~/.local/bin` 放进 PATH(写进 `~/.zshrc`):
```bash
export PATH="$(npm prefix -g)/bin:$HOME/.local/bin:$PATH"
```
然后 `source ~/.zshrc`,再试 `which grok-hud-run`
---
## 日常使用
```bash
# 在本标签页启动 Grok,底部自带多行 HUD(不要另开窗)
grok-hud-run
```
| 行 | 内容(类比 Claude HUD |
|----|-------------------------|
| 第 1 行 | 模型 · 项目 · git · 是否 live · 标题 · effort |
| 第 2 行 | **上下文**进度条 + token · **配额** · 时长 · 轮次 · 工具数 · 错误 · diff |
| 第 3 行 | 最近工具 · agents · todos · GrokBuild 用量占比 |
会话开着时,状态条约每秒刷新一次。
### 常用命令
```bash
grok-hud status # 打印当前完整 HUD 一次
grok-build-hud --once --follow-active # 同上
grok-hud stop # 停后台刷新
grok-build-hud --preset full # 三行全开(默认)
grok-build-hud --preset essential # 两行
grok-build-hud --preset minimal # 一行精简
grok-build-hud --theme auto # 跟随 Grok 主题(推荐)
grok-build-hud --theme tokyonight # 锁定某套色
```
改完 preset/theme 后若条没变,在 tmux 里:
```bash
tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
```
---
## 配置
配置文件:`~/.grok/hud/config.json`
| Preset | 行数 | 内容 |
|--------|------|------|
| **full**(默认) | 3 | 全部信息 |
| **essential** | 2 | 模型/git + 上下文/配额 + 活动 |
| **minimal** | 1 | 单行压缩 |
可读性相关:
- `bold: true` — 粗体数值(默认开)
- `barWidth: 14` — 进度条宽度
字号由 Terminal 字体决定,tmux 状态栏不能单独放大。
主题跟 Grok`~/.grok/config.toml``[ui] theme = "tokyonight"` 等;HUD 用 `--theme auto` 会跟着走。
---
## 工作原理(一句话)
读本机 `~/.grok/sessions/**` 的 signals / updates / summary,用你已有的 Grok 登录去查周配额;后台 daemon 写状态文件,由 **同窗口 tmux 状态栏**显示。不上传你的代码。
---
## 从 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
grok-build-hud --uninstall-dashboard
grok-build-hud --uninstall-hooks
npm unlink -g grok-build-hud # 若用过 npm link
```
可选删除:`~/.grok/hud/``~/.grok/hooks/grok-build-hud.json`
---
## 常见问题
| 现象 | 处理 |
|------|------|
| `command not found: grok-hud-run` | 检查 PATH(见上文);或用 `node bin/grok-hud-run.js` |
| 底部没有状态条 | 确认用 `grok-hud-run` 启动;本机已装 tmux`grok-build-hud --install-dashboard` |
| 配额显示 `—` / n/a | 先 `grok login`;再 `grok-hud status` 看是否有 weekly |
| 颜色和 Grok 界面打架 | `grok-build-hud --theme auto`,并与 Grok `[ui].theme` 一致 |
| 状态条不更新 | `grok-hud stop``grok-hud-run`;或 `ensure``--dashboard-start` |
| 字太小 | 放大 Terminal 字体;配置里 `bold`/`barWidth` 已尽量加粗加宽 |
---
## 开发
```bash
npm install
npm test # 编译 + 40 个单元测试
npm run build
```
---
## License
MIT。第三方工具,与 xAI 无官方从属关系;配额接口以 xAI 实际 API 为准,可能变更。
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* CLI entry — works from repo, npm link, or global install.
*/
import { pathToFileURL } from "node:url";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const candidates = [
path.join(__dirname, "..", "dist", "src", "index.js"),
path.join(__dirname, "..", "dist", "index.js"),
];
const entry = candidates.find((p) => existsSync(p));
if (!entry) {
console.error(
"grok-build-hud: build missing. Run: npm install && npm run build",
);
process.exit(1);
}
const mod = await import(pathToFileURL(entry).href);
const code = await mod.runCli(process.argv.slice(2));
process.exit(typeof code === "number" ? code : 0);
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env node
/**
* Start Grok in THIS terminal with multi-line HUD (tmux, same window).
*/
import { pathToFileURL } from "node:url";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const entry = path.join(__dirname, "..", "dist", "src", "index.js");
if (!existsSync(entry)) {
console.error(
"grok-hud-run: build missing. Run: npm install && npm run build",
);
process.exit(1);
}
const mod = await import(pathToFileURL(entry).href);
// ensure updater + full HUD then enter same-window mode
await mod.runCli(["--dashboard-start"]);
await mod.runCli(["--once", "--follow-active", "--no-color"]);
const code = await mod.runCli(["--run-in-terminal", ...process.argv.slice(2)]);
process.exit(typeof code === "number" ? code : 0);
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env node
/**
* Short alias: grok-hud [status|run|stop|theme|…]
*/
import { pathToFileURL } from "node:url";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { existsSync } from "node:fs";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const entry = path.join(__dirname, "..", "dist", "src", "index.js");
if (!existsSync(entry)) {
console.error("grok-hud: build missing. Run: npm install && npm run build");
process.exit(1);
}
const mod = await import(pathToFileURL(entry).href);
const argv = process.argv.slice(2);
const head = argv[0];
let mapped = argv;
if (head === "stop") mapped = ["--dashboard-stop"];
else if (head === "run") mapped = ["--run-in-terminal", ...argv.slice(1)];
else if (head === "status" || head === undefined) {
mapped = ["--once", "--follow-active", "--no-color", ...argv.slice(head ? 1 : 0)];
} else if (head === "theme") {
mapped = ["--theme", argv[1] || "auto", ...argv.slice(2)];
} else if (head === "preset") {
mapped = ["--preset", argv[1] || "full", ...argv.slice(2)];
} else if (head === "start") {
mapped = ["--dashboard-start"];
} else if (head === "install") {
mapped = ["--install-dashboard"];
}
const code = await mod.runCli(mapped);
process.exit(typeof code === "number" ? code : 0);
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env node
import { pathToFileURL } from "node:url";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const entry = path.join(__dirname, "..", "dist", "src", "hook.js");
await import(pathToFileURL(entry).href);
+29
View File
@@ -0,0 +1,29 @@
---
description: Show live context window + quota bars (Claude-HUD style)
---
# /hud — live context + quota
Print the multi-line HUD for the active Grok session:
```bash
grok-hud status
# or:
grok-build-hud --once --follow-active --no-color
```
If CLIs are not on PATH (plugin-only install):
```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).
+23
View File
@@ -0,0 +1,23 @@
---
description: Set Claude-HUD-style HUD preset (full / essential / minimal)
---
# HUD presets (Claude HUD parity)
```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
```
Then refresh tmux (if already in a HUD session):
```bash
tmux source-file ~/.grok/hud/tmux.conf && tmux refresh-client -S
```
+13
View File
@@ -0,0 +1,13 @@
---
description: Show account quota / credit usage (weekly + monthly)
---
# /quota
```bash
grok-hud status
```
Look for the **Quota / Usage** line (weekly % · monthly used/limit · GrokBuild share).
Official panel inside Grok: `/usage`.
+45
View File
@@ -0,0 +1,45 @@
---
description: Install grok-build-hud dashboard + same-window multi-line status
---
# grok-build-hud setup
One-time setup (from the plugin / repo root):
```bash
npm install && npm run build
# Preferred: full same-window HUD (tmux multi-line strip)
node bin/grok-build-hud.js --install-dashboard
node bin/grok-build-hud.js --theme auto
node bin/grok-build-hud.js --preset full
```
Or: `bash scripts/install.sh` / `npm run install-local`.
## Start Grok with HUD (same Terminal tab)
```bash
grok-hud-run
```
You should see **23 rows** at the bottom (context + usage bars), not a second window.
## Hooks only (scrollback annotations)
```bash
node bin/grok-build-hud.js --install-hooks
```
Reload in Grok: `/hooks` then press `r`.
Example annotation:
```text
[hud] ctx 30% 154k/500k │ turns 3 │ tools 78 │ live │ project
```
## Verify
```bash
grok-hud status
```
+17
View File
@@ -0,0 +1,17 @@
---
description: Show one-shot grok-build-hud snapshot
---
# /status — one-shot HUD
```bash
grok-hud status
# or:
grok-build-hud --once --follow-active --no-color
```
For the always-on same-window bar (recommended):
```bash
grok-hud-run
```
+31
View File
@@ -0,0 +1,31 @@
---
description: How to keep the HUD live (same-window recommended)
---
# Live HUD
## Recommended: same Terminal window
```bash
grok-hud-run
```
Starts Grok with a permanent multi-line status strip at the bottom (tmux). **No second window.**
## Optional: external watch in another pane
```bash
grok-build-hud --watch --follow-active --cwd "$(pwd)"
```
Short capture / tests:
```bash
grok-build-hud --watch --max-iterations 3 --interval 500
```
Single tmux status line (legacy):
```bash
grok-build-hud --tmux --no-color
```
+8
View File
@@ -0,0 +1,8 @@
{
"config": {
"monthlyLimit": { "val": 150000 },
"used": { "val": 17510 },
"billingPeriodStart": "2026-07-01T00:00:00+00:00",
"billingPeriodEnd": "2026-08-01T00:00:00+00:00"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"config": {
"currentPeriod": {
"type": "USAGE_PERIOD_TYPE_WEEKLY",
"start": "2026-07-15T06:38:37.918313+00:00",
"end": "2026-07-22T06:38:37.918313+00:00"
},
"creditUsagePercent": 22.0,
"productUsage": [
{ "product": "Api", "usagePercent": 12.0 },
{ "product": "GrokBuild", "usagePercent": 9.0 },
{ "product": "GrokChat", "usagePercent": 1.0 }
],
"billingPeriodStart": "2026-07-15T06:38:37.918313+00:00",
"billingPeriodEnd": "2026-07-22T06:38:37.918313+00:00"
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"turnCount": 4,
"userMessageCount": 4,
"assistantMessageCount": 20,
"errorCount": 0,
"toolFailureCount": 0,
"contextWindowUsage": 37,
"contextTokensUsed": 190000,
"contextWindowTokens": 500000,
"toolCallCount": 12,
"toolsUsed": ["read_file", "grep", "run_terminal_command"],
"modelsUsed": ["grok-4.5"],
"primaryModelId": "grok-4.5",
"sessionDurationSeconds": 4620
}
+14
View File
@@ -0,0 +1,14 @@
{
"info": {
"id": "fixture-session-001",
"cwd": "/Users/dex/demo/CoachFlow"
},
"session_summary": "admin split optimization",
"generated_title": "admin split optimization",
"current_model_id": "grok-4.5",
"head_branch": "main",
"git_root_dir": "/Users/dex/demo/CoachFlow/",
"last_active_at": "2026-07-17T06:00:00.000Z",
"updated_at": "2026-07-17T06:00:00.000Z",
"created_at": "2026-07-17T04:00:00.000Z"
}
+9
View File
@@ -0,0 +1,9 @@
{"timestamp":1000,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call","toolCallId":"call-1","title":"read_file","rawInput":{"target_file":"/Users/dex/demo/CoachFlow/src/auth.ts"}}}}
{"timestamp":1001,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-1","status":"completed","title":"Read auth.ts"}}}
{"timestamp":1002,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call","toolCallId":"call-2","title":"grep","rawInput":{"pattern":"split","path":"src"}}}}
{"timestamp":1003,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-2","status":"completed"}}}
{"timestamp":1004,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call","toolCallId":"call-3","title":"run_terminal_command","rawInput":{"command":"ls -la src"}}}}
{"timestamp":1005,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-3","status":"completed","title":"Execute ls -la src"}}}
{"timestamp":1006,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call","toolCallId":"call-4","title":"read_file","rawInput":{"target_file":"/Users/dex/demo/CoachFlow/src/admin.ts"}}}}
{"timestamp":1007,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"tool_call_update","toolCallId":"call-4","kind":"read","title":"Read admin.ts"}}}
{"timestamp":1008,"method":"session/update","params":{"sessionId":"fixture-session-001","update":{"sessionUpdate":"agent_thought_chunk","title":"explore","agentId":"agent-1"}}}
+60
View File
@@ -0,0 +1,60 @@
{
"description": "Live context/tool HUD annotations while you use Grok Build",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${GROK_PLUGIN_ROOT}/dist/src/hook.js\"",
"timeout": 8
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${GROK_PLUGIN_ROOT}/dist/src/hook.js\"",
"timeout": 8
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "node \"${GROK_PLUGIN_ROOT}/dist/src/hook.js\"",
"timeout": 8
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "node \"${GROK_PLUGIN_ROOT}/dist/src/hook.js\"",
"timeout": 8
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "node \"${GROK_PLUGIN_ROOT}/dist/src/hook.js\"",
"timeout": 8
}
]
}
]
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "grok-build-hud",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "grok-build-hud",
"version": "0.1.0",
"license": "MIT",
"bin": {
"grok-build-hud": "bin/grok-build-hud.js"
},
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "22.20.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}
+67
View File
@@ -0,0 +1,67 @@
{
"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",
"type": "module",
"main": "dist/src/index.js",
"bin": {
"grok-build-hud": "bin/grok-build-hud.js",
"grok-hud": "bin/grok-hud.js",
"grok-hud-run": "bin/grok-hud-run.js"
},
"files": [
"dist/src/",
"bin/",
"commands/",
"skills/",
"hooks/",
"plugin.json",
".grok-plugin/",
"README.md",
"README.zh-CN.md",
"MIGRATION-FROM-CLAUDE.md",
"CHANGELOG.md",
"LICENSE",
"scripts/",
"fixtures/"
],
"scripts": {
"build": "tsc",
"test": "npm run build && node --test dist/tests/*.test.js",
"start": "node dist/src/index.js",
"once": "node dist/src/index.js --once --follow-active",
"watch": "node dist/src/index.js --watch --follow-active",
"install-local": "npm run build && node dist/src/index.js --install-dashboard && node dist/src/index.js --theme auto && node dist/src/index.js --preset full",
"setup": "bash scripts/install.sh",
"plugin:validate": "grok plugin validate ."
},
"engines": {
"node": ">=18"
},
"keywords": [
"grok",
"xai",
"grok-build",
"hud",
"statusline",
"claude-hud",
"tmux",
"cli",
"context",
"quota"
],
"author": "dex",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/sooneocean/grok-build-hud.git"
},
"bugs": {
"url": "https://github.com/sooneocean/grok-build-hud/issues"
},
"homepage": "https://github.com/sooneocean/grok-build-hud#readme",
"devDependencies": {
"@types/node": "^22.13.10",
"typescript": "^5.8.2"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"name": "grok-build-hud",
"version": "0.3.0",
"description": "Claude-HUD-style multi-line live status for Grok Build (context, quota, tools, todos, git)",
"author": {
"name": "sooneocean",
"email": "redredchen01@gmail.com"
},
"homepage": "https://github.com/sooneocean/grok-build-hud",
"repository": "https://github.com/sooneocean/grok-build-hud",
"commands": "./commands",
"skills": "./skills",
"hooks": "./hooks/hooks.json"
}
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# One-shot installer for grok-build-hud (run from repo root or via: npm run setup)
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
need() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "error: missing dependency: $1" >&2
if [[ "$1" == "tmux" ]]; then
echo " macOS: brew install tmux" >&2
elif [[ "$1" == "node" ]]; then
echo " install Node.js 18+ from https://nodejs.org" >&2
fi
exit 1
fi
}
need node
need npm
if ! command -v tmux >/dev/null 2>&1; then
echo "warn: tmux not found — same-window HUD needs tmux (brew install tmux)" >&2
fi
NODE_MAJOR="$(node -p "process.versions.node.split('.')[0]")"
if [[ "$NODE_MAJOR" -lt 18 ]]; then
echo "error: Node.js 18+ required (found $(node -v))" >&2
exit 1
fi
echo "==> Installing dependencies"
npm install
echo "==> Building"
npm run build
echo "==> Linking CLI onto PATH (npm link)"
npm link
echo "==> Installing dashboard (hooks + tmux conf + shims)"
node bin/grok-build-hud.js --install-dashboard
node bin/grok-build-hud.js --theme auto
node bin/grok-build-hud.js --preset full
echo ""
VER="$(node -e "import('node:fs').then(fs=>console.log(JSON.parse(fs.readFileSync('package.json','utf8')).version))" 2>/dev/null || echo 0.3.0)"
echo "Done. grok-build-hud v${VER}"
echo ""
echo " Start Grok with HUD (same window): grok-hud-run"
echo " One-shot snapshot: grok-hud status"
echo " Preset: grok-build-hud --preset full"
echo " Theme: grok-build-hud --theme auto"
echo " Stop updater: grok-hud stop"
echo ""
echo "中文说明: README.zh-CN.md"
echo "If command not found, add to PATH and re-open the shell:"
echo " export PATH=\"\$(npm prefix -g)/bin:\$HOME/.local/bin:\$PATH\""
+49
View File
@@ -0,0 +1,49 @@
---
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).
---
# 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.
## What it shows
- Line 1: model · project · git · live · title · effort
- Line 2: context bar + tokens · weekly/monthly usage · time · turns · tools · errors · diff
- Line 3: tool activity · agents · todos · GrokBuild product share
Data sources (local):
- `~/.grok/sessions/**/signals.json` — context window
- `updates.jsonl` — tools / agents / todos
- `summary.json` — title, effort, model
- Grok auth → `cli-chat-proxy` billing for quota
## Install once
```bash
cd /path/to/grok-build-hud
bash scripts/install.sh
# or: npm run install-local
```
## Daily
```bash
grok-hud-run # Grok + bottom HUD (same tab)
grok-hud status # one-shot print
grok-build-hud --preset full|essential|minimal
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/`
## Config
`~/.grok/hud/config.json` — 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)
+285
View File
@@ -0,0 +1,285 @@
import fs from "node:fs";
import type {
AgentActivityItem,
TodoItem,
ToolActivityItem,
} from "./types.js";
interface ToolState {
id: string;
name: string;
status: ToolActivityItem["status"];
detail?: string;
lastTs: number;
}
/**
* Parse Grok `updates.jsonl` for tool / agent activity.
* Pure-ish: accepts optional pre-split lines for tests.
*/
export function parseUpdatesLines(lines: string[]): {
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
} {
const tools = new Map<string, ToolState>();
const agents: AgentActivityItem[] = [];
let todos: TodoItem[] = [];
let lineNo = 0;
for (const raw of lines) {
lineNo += 1;
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) ??
"";
const ts =
typeof obj.timestamp === "number"
? obj.timestamp
: typeof obj.ts === "string"
? Date.parse(obj.ts) / 1000
: lineNo;
if (kind === "tool_call" || kind === "tool_call_update") {
const id =
(update.toolCallId as string | undefined) ??
(update.id as string | undefined) ??
`tool-${lineNo}`;
const title = (update.title as string | undefined) ?? "";
const statusRaw = (update.status as string | undefined) ?? "";
const prev = tools.get(id);
let name = prev?.name ?? "tool";
if (title && !title.includes(" ") && title.length < 64) {
name = title;
} else if (title && prev?.name === "tool") {
// Prefer short tool name from earlier tool_call event
name = prev.name;
}
// On initial tool_call, title is the tool name
if (kind === "tool_call" && title) {
name = title.split(/\s+/)[0] || title;
}
let status: ToolActivityItem["status"] = prev?.status ?? "running";
if (statusRaw === "completed" || statusRaw === "complete" || statusRaw === "success") {
status = "completed";
} else if (statusRaw === "failed" || statusRaw === "error") {
status = "failed";
} else if (kind === "tool_call") {
status = "running";
}
let detail = prev?.detail;
if (kind === "tool_call_update" && title && title.length < 120) {
detail = title;
}
const rawInput = update.rawInput;
if (!detail && rawInput != null) {
detail = summarizeRawInput(rawInput);
}
tools.set(id, {
id,
name,
status,
detail,
lastTs: ts,
});
// Capture latest todo list from todo_write
if (
name === "todo_write" ||
name === "TodoWrite" ||
(typeof title === "string" && /todo/i.test(title))
) {
const extracted = extractTodos(rawInput ?? update);
if (extracted.length) todos = extracted;
}
continue;
}
if (
kind === "agent_message_chunk" ||
kind === "agent_thought_chunk" ||
kind.includes("agent")
) {
const title =
(update.title as string | undefined) ??
(update.agentType as string | undefined) ??
(update.name as string | undefined);
if (title || kind === "agent_message_chunk") {
agents.push({
id: (update.agentId as string | undefined) ?? (update.toolCallId as string | undefined),
title: title ?? kind,
status: (update.status as string | undefined) ?? "active",
detail:
typeof update.content === "string"
? update.content.slice(0, 80)
: undefined,
});
}
}
}
// Aggregate completed tools by name for a compact summary
const sorted = [...tools.values()].sort((a, b) => b.lastTs - a.lastTs);
const recent = sorted.slice(0, 12);
const completedCounts = new Map<string, number>();
const items: ToolActivityItem[] = [];
for (const t of recent) {
if (t.status === "running") {
items.push({
id: t.id,
name: t.name,
status: "running",
detail: t.detail,
});
} else {
completedCounts.set(t.name, (completedCounts.get(t.name) ?? 0) + 1);
}
}
for (const [name, count] of completedCounts) {
items.push({
id: `agg-${name}`,
name,
status: "completed",
count,
});
}
// Keep running first, then completed aggregates
items.sort((a, b) => {
if (a.status === "running" && b.status !== "running") return -1;
if (b.status === "running" && a.status !== "running") return 1;
return 0;
});
return {
tools: items.slice(0, 8),
agents: agents.slice(-3),
todos,
};
}
export function parseUpdatesFile(
filePath: string,
options: { maxTailBytes?: number } = {},
): {
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
} {
if (!fs.existsSync(filePath)) {
return { tools: [], agents: [], todos: [] };
}
const maxTail = options.maxTailBytes ?? 256_000;
try {
const stat = fs.statSync(filePath);
let content: string;
if (stat.size <= maxTail) {
content = fs.readFileSync(filePath, "utf8");
} else {
const fd = fs.openSync(filePath, "r");
try {
const buf = Buffer.alloc(maxTail);
fs.readSync(fd, buf, 0, maxTail, stat.size - maxTail);
content = buf.toString("utf8");
// Drop partial first line
const nl = content.indexOf("\n");
if (nl >= 0) content = content.slice(nl + 1);
} finally {
fs.closeSync(fd);
}
}
return parseUpdatesLines(content.split(/\r?\n/));
} catch {
return { tools: [], agents: [], todos: [] };
}
}
function extractTodos(raw: unknown): TodoItem[] {
if (raw == null) return [];
let obj: unknown = raw;
if (typeof raw === "string") {
try {
obj = JSON.parse(raw.replace(/'/g, '"'));
} catch {
return [];
}
}
if (!obj || typeof obj !== "object") return [];
const o = obj as Record<string, unknown>;
const list = (o.todos ?? o.TodoWrite ?? o.items) as unknown;
if (!Array.isArray(list)) return [];
const out: TodoItem[] = [];
for (const item of list) {
if (!item || typeof item !== "object") continue;
const t = item as Record<string, unknown>;
const content = String(t.content ?? t.title ?? t.text ?? "").trim();
if (!content) continue;
out.push({
content,
status: String(t.status ?? "pending"),
id: t.id != null ? String(t.id) : undefined,
});
}
return out;
}
function summarizeRawInput(raw: unknown): string | undefined {
if (raw == null) return undefined;
if (typeof raw === "string") {
try {
return summarizeRawInput(JSON.parse(raw.replace(/'/g, '"')));
} catch {
return raw.slice(0, 60);
}
}
if (typeof raw === "object") {
const o = raw as Record<string, unknown>;
const command = o.command ?? o.target_file ?? o.path ?? o.query ?? o.pattern;
if (typeof command === "string") return command.slice(0, 60);
}
return undefined;
}
/** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */
export function formatToolLine(tools: ToolActivityItem[]): string {
if (!tools.length) return "";
const parts: string[] = [];
for (const t of tools) {
if (t.status === "running") {
const d = t.detail ? `: ${truncate(t.detail, 40)}` : "";
parts.push(`${t.name}${d}`);
} else if (t.status === "failed") {
parts.push(`${t.name}`);
} else {
const n = t.count && t.count > 1 ? ` ×${t.count}` : "";
parts.push(`${t.name}${n}`);
}
}
return parts.join(" | ");
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Progress bar helpers (pure).
*/
export function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
}
/** Derive percent from explicit usage or used/window tokens. */
export function contextPercentFromSignals(input: {
contextWindowUsage?: number;
contextTokensUsed?: number;
contextWindowTokens?: number;
}): number {
if (
typeof input.contextWindowUsage === "number" &&
Number.isFinite(input.contextWindowUsage)
) {
return clampPercent(input.contextWindowUsage);
}
const used = input.contextTokensUsed ?? 0;
const window = input.contextWindowTokens ?? 0;
if (window > 0) {
return clampPercent((used / window) * 100);
}
return 0;
}
export function formatTokenCount(n: number): string {
if (!Number.isFinite(n) || n < 0) return "0";
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) {
const k = n / 1000;
return `${k >= 100 ? Math.round(k) : k.toFixed(k >= 10 ? 0 : 1)}k`;
}
const m = n / 1_000_000;
return `${m.toFixed(m >= 10 ? 0 : 1)}M`;
}
export function renderBar(
percent: number,
width = 10,
filledChar = "█",
emptyChar = "░",
): string {
const p = clampPercent(percent);
const filled = Math.round((p / 100) * width);
const empty = Math.max(0, width - filled);
return filledChar.repeat(filled) + emptyChar.repeat(empty);
}
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "0s";
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const rem = s % 60;
if (h > 0) return `${h}h ${m}m`;
if (m > 0) return `${m}m ${rem}s`;
return `${rem}s`;
}
export function projectLabel(cwd: string, levels = 2): string {
if (!cwd) return "unknown";
const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
if (parts.length === 0) return cwd;
const take = Math.max(1, levels);
return parts.slice(-take).join("/");
}
+381
View File
@@ -0,0 +1,381 @@
import fs from "node:fs";
import path from "node:path";
import type { UsageSnapshot } from "./types.js";
export interface BillingAuth {
token: string;
teamId?: string;
email?: string;
}
export interface BillingFetchDeps {
readAuth?: (grokHome: string) => BillingAuth | null;
fetchJson?: (
url: string,
headers: Record<string, string>,
) => Promise<{ ok: boolean; status: number; body: unknown }>;
now?: () => number;
}
/** Real Grok Build billing endpoints (session OIDC token). */
export const BILLING_CREDITS_URL =
"https://cli-chat-proxy.grok.com/v1/billing?format=credits";
export const BILLING_MONTHLY_URL =
"https://cli-chat-proxy.grok.com/v1/billing";
const cache = new Map<string, { at: number; value: UsageSnapshot }>();
export function readGrokAuth(grokHome: string): BillingAuth | null {
const authPath = path.join(grokHome, "auth.json");
try {
if (!fs.existsSync(authPath)) return null;
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<
string,
Record<string, unknown>
>;
for (const entry of Object.values(raw)) {
if (!entry || typeof entry !== "object") continue;
const token =
(entry.key as string | undefined) ??
(entry.access_token as string | undefined) ??
(entry.token as string | undefined);
if (token && typeof token === "string") {
return {
token,
teamId: entry.team_id as string | undefined,
email: entry.email as string | undefined,
};
}
}
} catch {
return null;
}
return null;
}
/** Unwrap xAI `{ val: number }` or plain number/string. */
export function numVal(v: unknown): number | undefined {
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) {
return Number(v);
}
if (v && typeof v === "object" && "val" in (v as object)) {
return numVal((v as { val: unknown }).val);
}
return undefined;
}
function asRecord(v: unknown): Record<string, unknown> | null {
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
}
/**
* Normalize Grok cli-chat-proxy billing JSON (and generic shapes).
*/
export function normalizeBillingPayload(body: unknown): UsageSnapshot {
if (body == null || typeof body !== "object") {
return {
available: false,
message: "usage unavailable",
source: "empty",
};
}
const root = body as Record<string, unknown>;
const config = asRecord(root.config) ?? asRecord(root.credits) ?? root;
// Weekly / unified credits percent (preferred for "quota bar")
const creditPct = numVal(config.creditUsagePercent ?? config.percent_used ?? config.percent);
let productBreakdown: string | undefined;
const products = config.productUsage;
if (Array.isArray(products)) {
const bits: string[] = [];
for (const p of products) {
const pr = asRecord(p);
if (!pr) continue;
const name = String(pr.product ?? "");
const pct = numVal(pr.usagePercent);
if (name && pct != null) bits.push(`${name} ${Math.round(pct)}%`);
}
if (bits.length) productBreakdown = bits.join(", ");
}
// Monthly absolute credits
const used =
numVal(config.used) ??
numVal(config.used_credits) ??
numVal(config.monthly_used) ??
numVal(root.used);
const limit =
numVal(config.monthlyLimit) ??
numVal(config.monthly_limit) ??
numVal(config.limit) ??
numVal(root.monthlyLimit) ??
numVal(root.limit);
let percent = creditPct;
if (percent == null && used != null && limit != null && limit > 0) {
percent = (used / limit) * 100;
}
const periodTypeRaw =
(asRecord(config.currentPeriod)?.type as string | undefined) ??
(config.period as string | undefined);
let period = "monthly";
if (periodTypeRaw && /week/i.test(periodTypeRaw)) period = "weekly";
else if (creditPct != null && used == null) period = "weekly";
else if (limit != null) period = "monthly";
const periodEnd =
(asRecord(config.currentPeriod)?.end as string | undefined) ??
(config.billingPeriodEnd as string | undefined) ??
(config.periodEnd as string | undefined);
const resetsIn = formatResets(periodEnd ?? config.reset_at);
if (percent == null && used == null && limit == null) {
return {
available: false,
message: "usage unavailable",
source: "unrecognized",
};
}
return {
available: true,
percent: percent != null ? Math.max(0, Math.min(100, percent)) : undefined,
used: used ?? undefined,
limit: limit ?? undefined,
period,
resetsIn,
source: "billing",
message: productBreakdown,
};
}
/**
* Merge weekly credits view + monthly absolute into one snapshot.
* Prefer weekly % for the bar when present; keep monthly used/limit as detail.
*/
export function mergeBillingSnapshots(
credits: UsageSnapshot,
monthly: UsageSnapshot,
): UsageSnapshot {
if (!credits.available && !monthly.available) {
return credits.available === false ? credits : monthly;
}
const percent =
credits.available && credits.percent != null
? credits.percent
: monthly.percent;
const period =
credits.available && credits.percent != null
? credits.period ?? "weekly"
: monthly.period ?? "monthly";
return {
available: true,
percent,
used: monthly.used ?? credits.used,
limit: monthly.limit ?? credits.limit,
period,
resetsIn: credits.resetsIn ?? monthly.resetsIn,
source: "billing",
message: credits.message ?? monthly.message,
};
}
function formatResets(v: unknown): string | undefined {
if (v == null) return undefined;
if (typeof v === "string") {
const t = Date.parse(v);
if (!Number.isNaN(t)) {
const sec = Math.max(0, Math.floor((t - Date.now()) / 1000));
return humanDuration(sec);
}
return v;
}
if (typeof v === "number") {
const sec =
v > 1e12
? Math.floor((v - Date.now()) / 1000)
: v > 1e10
? Math.floor(v / 1000)
: v;
return humanDuration(Math.max(0, sec));
}
return undefined;
}
function humanDuration(sec: number): string {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
if (d > 0) return `${d}d`;
if (h > 0) return `${h}h`;
const m = Math.floor((sec % 3600) / 60);
return `${m}m`;
}
async function defaultFetchJson(
url: string,
headers: Record<string, string>,
): Promise<{ ok: boolean; status: number; body: unknown }> {
const res = await fetch(url, { headers });
let body: unknown = null;
const text = await res.text();
try {
body = text ? JSON.parse(text) : null;
} catch {
body = { raw: text.slice(0, 200) };
}
return { ok: res.ok, status: res.status, body };
}
/**
* Fetch credit/quota usage. Never throws.
* Uses cli-chat-proxy (same as Grok `/usage`).
*/
export async function getCreditUsage(
grokHome: string,
options: {
cacheTtlMs?: number;
enabled?: boolean;
} = {},
deps: BillingFetchDeps = {},
): Promise<UsageSnapshot> {
if (options.enabled === false) {
return {
available: false,
message: "usage disabled",
source: "disabled",
};
}
const cacheTtl = options.cacheTtlMs ?? 60_000;
const now = deps.now?.() ?? Date.now();
const cacheKey = grokHome;
const hit = cache.get(cacheKey);
if (hit && now - hit.at < cacheTtl) {
return hit.value;
}
// Disk cache for hook processes (each process has empty memory cache)
const disk = readDiskCache(grokHome, cacheTtl, now);
if (disk) {
cache.set(cacheKey, { at: now, value: disk });
return disk;
}
const readAuth = deps.readAuth ?? readGrokAuth;
const auth = readAuth(grokHome);
if (!auth?.token) {
const value: UsageSnapshot = {
available: false,
message: "usage unavailable (no auth)",
source: "no-auth",
};
cache.set(cacheKey, { at: now, value });
return value;
}
const fetchJson = deps.fetchJson ?? defaultFetchJson;
const headers: Record<string, string> = {
Authorization: `Bearer ${auth.token}`,
Accept: "application/json",
"User-Agent": "grok-build-hud/0.2",
};
if (auth.teamId) headers["x-team-id"] = auth.teamId;
let creditsSnap: UsageSnapshot = {
available: false,
message: "usage unavailable",
source: "fetch-failed",
};
let monthlySnap: UsageSnapshot = {
available: false,
message: "usage unavailable",
source: "fetch-failed",
};
try {
const r = await fetchJson(BILLING_CREDITS_URL, headers);
if (r.ok) creditsSnap = normalizeBillingPayload(r.body);
} catch {
/* ignore */
}
try {
const r = await fetchJson(BILLING_MONTHLY_URL, headers);
if (r.ok) monthlySnap = normalizeBillingPayload(r.body);
} catch {
/* ignore */
}
// Fallback URLs (older / alternate)
if (!creditsSnap.available && !monthlySnap.available) {
for (const url of [
"https://api.x.ai/v1/billing?format=credits",
"https://api.x.ai/v1/billing",
]) {
try {
const res = await fetchJson(url, headers);
if (!res.ok) continue;
const n = normalizeBillingPayload(res.body);
if (n.available) {
cache.set(cacheKey, { at: now, value: n });
writeDiskCache(grokHome, n);
return n;
}
} catch {
continue;
}
}
}
const value = mergeBillingSnapshots(creditsSnap, monthlySnap);
cache.set(cacheKey, { at: now, value });
if (value.available) writeDiskCache(grokHome, value);
return value;
}
function diskCachePath(grokHome: string): string {
return path.join(grokHome, "hud", "billing-cache.json");
}
function readDiskCache(
grokHome: string,
ttlMs: number,
now: number,
): UsageSnapshot | null {
try {
const p = diskCachePath(grokHome);
if (!fs.existsSync(p)) return null;
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as {
at: number;
value: UsageSnapshot;
};
if (!raw?.value?.available) return null;
if (now - raw.at > ttlMs) return null;
return { ...raw.value, source: "billing" };
} catch {
return null;
}
}
function writeDiskCache(grokHome: string, value: UsageSnapshot): void {
try {
const dir = path.join(grokHome, "hud");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
diskCachePath(grokHome),
JSON.stringify({ at: Date.now(), value }, null, 2) + "\n",
"utf8",
);
} catch {
/* ignore */
}
}
export function clearUsageCache(): void {
cache.clear();
}
export function unavailableUsage(message = "usage unavailable"): UsageSnapshot {
return { available: false, message, source: "offline" };
}
+14
View File
@@ -0,0 +1,14 @@
import type { HudConfig } from "./types.js";
export const DEFAULT_CONFIG: HudConfig = {
pathLevels: 2,
refreshMs: 1000,
warningThreshold: 70,
criticalThreshold: 90,
showUsage: true,
usageCacheTtlMs: 60_000,
};
export function loadConfig(): HudConfig {
return { ...DEFAULT_CONFIG };
}
+458
View File
@@ -0,0 +1,458 @@
/**
* Always-on terminal dashboard: pushes ctx+quota into
* - Grok TTY window title (OSC)
* - Apple Terminal custom title (osascript fallback)
* - ~/.grok/hud/status-line.txt (tmux / external)
*/
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
pickBestSession,
} from "./session.js";
import { getCreditUsage } from "./billing.js";
import { writeStatusFiles, formatCompactLine } from "./status.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
export function dashboardPidPath(grokHome = defaultGrokHome()): string {
return path.join(grokHome, "hud", "dashboard.pid");
}
export function titleLine(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
): string {
const compact = formatCompactLine(session, usage);
// Window titles are short — strip [hud] prefix noise
return compact.replace(/^\[hud\]\s*/, "◆ ");
}
/** Resolve tty device for a process (e.g. ttys001 → /dev/ttys001). */
export function ttyForPid(pid?: number): string | null {
if (!pid || pid <= 0) return null;
try {
const out = execFileSync("ps", ["-p", String(pid), "-o", "tty="], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1000,
}).trim();
if (!out || out === "??" || out === "-") return null;
const dev = out.startsWith("/dev/") ? out : `/dev/${out}`;
if (fs.existsSync(dev)) return dev;
} catch {
/* ignore */
}
return null;
}
/** Write OSC title sequence to a tty (updates Terminal/iTerm tab title). */
export function writeOscTitle(ttyPath: string, title: string): boolean {
try {
const safe = title.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120);
// OSC 0 = icon+title, OSC 2 = title only
const seq = `\x1b]0;${safe}\x07`;
fs.writeFileSync(ttyPath, seq, { encoding: "utf8", flag: "a" });
return true;
} catch {
return false;
}
}
/**
* Apple Terminal: set tab/window custom title so it appears in the title bar.
* Terminal composes: "cwd — custom title — process — size"
* We cannot reliably toggle title-display flags on all locales, but custom
* title is always shown when set on the tab.
*/
export function setAppleTerminalTitle(
title: string,
options: { ttyHint?: string | null } = {},
): boolean {
if (process.platform !== "darwin") return false;
try {
const safe = title
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/[\x00-\x1f\x7f]/g, " ")
.slice(0, 100);
const tty = (options.ttyHint || "")
.replace(/^\/dev\//, "")
.replace(/"/g, "");
// Prefer matching tab by tty; else set every Terminal tab/window.
const script = `
tell application "Terminal"
set hud to "${safe}"
set targetTty to "${tty}"
set matched to false
if targetTty is not "" then
repeat with w in windows
repeat with tb in tabs of w
try
set t to tty of tb as string
if t contains targetTty then
set custom title of tb to hud
try
set custom title of w to hud
end try
set matched to true
end if
end try
end repeat
end repeat
end if
if matched is false then
repeat with w in windows
try
set custom title of w to hud
end try
repeat with tb in tabs of w
try
set custom title of tb to hud
end try
end repeat
end repeat
end if
end tell
`;
execFileSync("osascript", ["-e", script], {
stdio: ["ignore", "ignore", "ignore"],
timeout: 4000,
});
return true;
} catch {
return false;
}
}
export async function refreshDashboard(options: {
grokHome?: string;
noUsage?: boolean;
}): Promise<{ title: string; session: SessionSnapshot | null }> {
const grokHome = options.grokHome ?? defaultGrokHome();
const active = loadActiveSessions(grokHome);
let session: SessionSnapshot | null = null;
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
const snap = loadSnapshotFromDir(dir, { active });
if (snap?.live) {
session = snap;
break;
}
if (!session && snap) session = snap;
}
if (!session) session = pickBestSession({ grokHome });
if (!session) {
return { title: "◆ grok-hud: no session", session: null };
}
let usage: UsageSnapshot | null = null;
if (!options.noUsage) {
try {
usage = await getCreditUsage(grokHome, { cacheTtlMs: 60_000 });
} catch {
usage = null;
}
}
// Theme follows Grok [ui].theme; re-apply tmux chrome when palette flips
const { resolveTheme } = await import("./theme.js");
const theme = resolveTheme(undefined, process.env, { grokHome });
writeStatusFiles(session, usage, grokHome);
try {
const stamp = path.join(grokHome, "hud", ".last-theme");
const prev = fs.existsSync(stamp)
? fs.readFileSync(stamp, "utf8").trim()
: "";
if (prev !== theme.name) {
fs.writeFileSync(stamp, theme.name + "\n", "utf8");
const { writeTmuxConfFile, applyTmuxStatusBar } = await import(
"./tmux-hud.js"
);
writeTmuxConfFile(grokHome);
applyTmuxStatusBar({ grokHome });
}
} catch {
/* ignore */
}
const title = titleLine(session, usage);
// 1) OSC into Grok's TTY (updates process title component)
const tty = ttyForPid(session.pid);
if (tty) writeOscTitle(tty, title);
// 2) Apple Terminal custom title (always visible as middle segment of window title)
// e.g. "0717 — ◆ ctx 44% | quota 23% — zsh — 101×50"
const ttyHint = tty ? path.basename(tty) : null;
setAppleTerminalTitle(title, { ttyHint });
// tmux-status.txt is written by writeStatusFiles (formatTmuxStatusLine)
// If already inside tmux (same-window mode), keep status bar config applied
if (process.env.TMUX) {
try {
execFileSync(
"tmux",
[
"set",
"-g",
"status-right",
`#(cat ${path.join(grokHome, "hud", "tmux-status.txt")} 2>/dev/null)`,
],
{ stdio: "ignore", timeout: 1000 },
);
} catch {
/* ignore */
}
}
return { title, session };
}
export function isDashboardRunning(grokHome = defaultGrokHome()): boolean {
const p = dashboardPidPath(grokHome);
try {
if (!fs.existsSync(p)) return false;
const pid = Number(fs.readFileSync(p, "utf8").trim());
if (!Number.isFinite(pid) || pid <= 0) return false;
process.kill(pid, 0);
// Confirm it's actually our dashboard (not a recycled pid)
try {
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
encoding: "utf8",
timeout: 1000,
stdio: ["ignore", "pipe", "ignore"],
});
if (!cmd.includes("dashboard") && !cmd.includes("grok-build-hud")) {
return false;
}
} catch {
return false;
}
return true;
} catch {
return false;
}
}
/** Kill every dashboard daemon we can find (prevents multi-writer theme fights). */
export function stopAllDashboardDaemons(grokHome = defaultGrokHome()): number {
let killed = 0;
const p = dashboardPidPath(grokHome);
try {
if (fs.existsSync(p)) {
const pid = Number(fs.readFileSync(p, "utf8").trim());
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
try {
process.kill(pid, "SIGTERM");
killed += 1;
} catch {
/* ignore */
}
}
try {
fs.unlinkSync(p);
} catch {
/* ignore */
}
}
} catch {
/* ignore */
}
// Sweep orphans matching our entry pattern
try {
// Match "--dashboard" as its own argv token (not "--dashboard-start")
const out = execFileSync(
"pgrep",
["-f", "grok-build-hud.*(dist/src/index\\.js|bin/grok-build-hud\\.js) --dashboard "],
{
encoding: "utf8",
timeout: 2000,
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
for (const line of out.split(/\n/)) {
const pid = Number(line.trim());
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) continue;
try {
process.kill(pid, "SIGTERM");
killed += 1;
} catch {
/* ignore */
}
}
} catch {
/* no matches */
}
return killed;
}
export function stopDashboard(grokHome = defaultGrokHome()): boolean {
return stopAllDashboardDaemons(grokHome) > 0;
}
/** Fork a detached dashboard daemon if not already running. */
export function ensureDashboardDaemon(options: {
grokHome?: string;
entryJs?: string;
intervalMs?: number;
}): { started: boolean; alreadyRunning: boolean; pid?: number } {
const grokHome = options.grokHome ?? defaultGrokHome();
if (isDashboardRunning(grokHome)) {
return { started: false, alreadyRunning: true };
}
// Clean orphans before starting a single writer
stopAllDashboardDaemons(grokHome);
const entry =
options.entryJs ??
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"index.js",
);
const interval = options.intervalMs ?? 500;
const logPath = path.join(grokHome, "hud", "dashboard.log");
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const logFd = fs.openSync(logPath, "a");
const child = spawn(
process.execPath,
[
entry,
"--dashboard",
"--follow-active",
"--interval",
String(interval),
"--grok-home",
grokHome,
],
{
detached: true,
stdio: ["ignore", logFd, logFd],
env: {
...process.env,
GROK_HUD_THEME: process.env.GROK_HUD_THEME || "auto",
},
},
);
child.unref();
if (child.pid) {
fs.writeFileSync(dashboardPidPath(grokHome), String(child.pid) + "\n");
return { started: true, alreadyRunning: false, pid: child.pid };
}
return { started: false, alreadyRunning: false };
}
/**
* Open a dedicated Apple Terminal window that only shows the live HUD.
* Grok itself overwrites its own window title with tool activity — a separate
* window is the reliable "always visible dashboard" on Terminal.app.
*/
export function openTerminalDashboardWindow(options: {
entryBin?: string;
intervalMs?: number;
} = {}): boolean {
if (process.platform !== "darwin") return false;
try {
const bin =
options.entryBin ??
path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
"bin",
"grok-build-hud.js",
);
const node = process.execPath;
const interval = options.intervalMs ?? 2000;
// clear+redraw dual bars forever; title fixed so user can find the window
const cmd = `${JSON.stringify(node)} ${JSON.stringify(bin)} --watch --follow-active --no-color --interval ${interval}; echo '[hud] stopped — press enter'; read`;
const safeCmd = cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const script = `
tell application "Terminal"
activate
set tabRef to do script "${safeCmd}"
delay 0.3
try
set custom title of tabRef to "◆ Grok HUD"
end try
try
set custom title of front window to "◆ Grok HUD"
end try
end tell
`;
execFileSync("osascript", ["-e", script], {
stdio: ["ignore", "pipe", "pipe"],
timeout: 8000,
});
return true;
} catch {
return false;
}
}
export async function runDashboardLoop(options: {
grokHome?: string;
intervalMs?: number;
maxIterations?: number;
noUsage?: boolean;
sleep?: (ms: number) => Promise<void>;
writePid?: boolean;
}): Promise<number> {
const grokHome = options.grokHome ?? defaultGrokHome();
const interval = options.intervalMs ?? 500;
const sleep =
options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
if (options.writePid !== false) {
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
fs.writeFileSync(dashboardPidPath(grokHome), String(process.pid) + "\n");
}
const cleanup = () => {
try {
const p = dashboardPidPath(grokHome);
if (fs.existsSync(p)) {
const cur = Number(fs.readFileSync(p, "utf8").trim());
if (cur === process.pid) fs.unlinkSync(p);
}
} catch {
/* ignore */
}
};
process.on("SIGTERM", () => {
cleanup();
process.exit(0);
});
process.on("SIGINT", () => {
cleanup();
process.exit(0);
});
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
i += 1;
try {
await refreshDashboard({
grokHome,
noUsage: options.noUsage,
});
} catch {
/* keep looping */
}
if (options.maxIterations && i >= options.maxIterations) {
cleanup();
return 0;
}
await sleep(interval);
}
}
+70
View File
@@ -0,0 +1,70 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
export interface GitInfo {
branch?: string;
dirty: boolean;
ahead?: number;
behind?: number;
}
export function readGitInfo(cwd: string): GitInfo {
if (!cwd || !fs.existsSync(cwd)) {
return { dirty: false };
}
try {
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
}).trim();
const status = execFileSync("git", ["status", "--porcelain"], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
});
let ahead: number | undefined;
let behind: number | undefined;
try {
const ab = execFileSync(
"git",
["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
{
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 1500,
},
).trim();
// behind ahead (left=upstream, right=HEAD)
const m = ab.match(/^(\d+)\s+(\d+)$/);
if (m) {
behind = Number(m[1]);
ahead = Number(m[2]);
}
} catch {
/* no upstream */
}
return {
branch: branch || undefined,
dirty: status.trim().length > 0,
ahead,
behind,
};
} catch {
try {
const headPath = path.join(cwd, ".git", "HEAD");
if (fs.existsSync(headPath)) {
const head = fs.readFileSync(headPath, "utf8").trim();
const m = head.match(/ref: refs\/heads\/(.+)/);
return { branch: m?.[1], dirty: false };
}
} catch {
/* ignore */
}
return { dirty: false };
}
}
+221
View File
@@ -0,0 +1,221 @@
/**
* Grok Build hook entry — runs on SessionStart / PostToolUse / Stop / UserPromptSubmit.
* Writes live status files and prints a compact line to stderr so TUI scrollback
* annotations show context + quota while you work (plugin-style live stats).
*/
import fs from "node:fs";
import path from "node:path";
import {
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
pickBestSession,
} from "./session.js";
import { writeStatusFiles } from "./status.js";
import { getCreditUsage } from "./billing.js";
import { ensureDashboardDaemon } from "./dashboard.js";
import { fileURLToPath } from "node:url";
export interface HookPayload {
sessionId?: string;
cwd?: string;
workspaceRoot?: string;
hookEventName?: string;
[key: string]: unknown;
}
export function readStdinSync(): string {
try {
return fs.readFileSync(0, "utf8");
} catch {
return "";
}
}
export function parseHookPayload(raw: string): HookPayload {
if (!raw.trim()) return {};
try {
return JSON.parse(raw) as HookPayload;
} catch {
return {};
}
}
export function resolveSessionForHook(
payload: HookPayload,
env: NodeJS.ProcessEnv = process.env,
grokHome = defaultGrokHome(),
): ReturnType<typeof loadSnapshotFromDir> {
const sessionId =
env.GROK_SESSION_ID?.trim() ||
payload.sessionId ||
undefined;
const cwd =
env.GROK_WORKSPACE_ROOT?.trim() ||
env.CLAUDE_PROJECT_DIR?.trim() ||
payload.workspaceRoot ||
payload.cwd ||
undefined;
const active = loadActiveSessions(grokHome);
if (sessionId) {
const dir = findSessionDirById(grokHome, sessionId);
if (dir) {
return loadSnapshotFromDir(dir, { active });
}
}
// Prefer live active session matching cwd
if (cwd) {
const hit = 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;
}
}
return pickBestSession({ grokHome });
}
/** Throttle PostToolUse floods so we don't spam scrollback. */
export function shouldEmitAnnotation(
event: string,
grokHome = defaultGrokHome(),
minIntervalMs = 4000,
): boolean {
// Always show on turn boundaries / prompt / session start
if (
event === "stop" ||
event === "Stop" ||
event === "session_start" ||
event === "SessionStart" ||
event === "user_prompt_submit" ||
event === "UserPromptSubmit" ||
event === "session_end" ||
event === "SessionEnd"
) {
return true;
}
const stampPath = path.join(hudThrottlePath(grokHome));
try {
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
if (fs.existsSync(stampPath)) {
const prev = Number(fs.readFileSync(stampPath, "utf8"));
if (Number.isFinite(prev) && Date.now() - prev < minIntervalMs) {
return false;
}
}
fs.writeFileSync(stampPath, String(Date.now()), "utf8");
} catch {
return true;
}
return true;
}
function hudThrottlePath(grokHome: string): string {
return path.join(grokHome, "hud", ".annotate-throttle");
}
export async function runHookTick(
options: {
payloadRaw?: string;
env?: NodeJS.ProcessEnv;
grokHome?: string;
/** force annotation even if throttled */
forceAnnotate?: boolean;
/** skip network quota fetch (tests) */
noUsage?: boolean;
} = {},
): Promise<{ code: number; compact?: string; annotated: boolean }> {
const env = options.env ?? process.env;
const grokHome = options.grokHome ?? defaultGrokHome();
const raw = options.payloadRaw ?? readStdinSync();
const payload = parseHookPayload(raw);
const event =
env.GROK_HOOK_EVENT ||
payload.hookEventName ||
"unknown";
try {
const snap = resolveSessionForHook(payload, env, grokHome);
if (!snap) {
// Still exit 0 — never block the agent
return { code: 0, annotated: false };
}
// Keep title-bar dashboard alive (no-op if already running)
try {
const entryJs = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"index.js",
);
ensureDashboardDaemon({ grokHome, entryJs, intervalMs: 2000 });
} catch {
/* ignore */
}
// Fetch quota (disk-cached ~60s so hooks stay fast)
let usage = null;
if (!options.noUsage && env.GROK_HUD_NO_USAGE !== "1") {
try {
usage = await getCreditUsage(grokHome, {
cacheTtlMs: 60_000,
enabled: true,
});
} catch {
usage = null;
}
}
const written = writeStatusFiles(snap, usage, grokHome);
const annotate =
options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome);
if (annotate) {
// stderr shows up in Grok hook scrollback annotations
process.stderr.write(written.compact + "\n");
// Also print dual-bar block on Stop / SessionStart for stronger visibility
if (
/stop|session_start|SessionStart|UserPromptSubmit|user_prompt/i.test(
String(event),
)
) {
process.stderr.write(written.full + "\n");
}
}
return { code: 0, compact: written.compact, annotated: annotate };
} catch (e) {
try {
process.stderr.write(
`[hud] error: ${e instanceof Error ? e.message : String(e)}\n`,
);
} catch {
/* ignore */
}
return { code: 0, annotated: false };
}
}
// Direct run as hook command
function isDirect(): boolean {
const entry = process.argv[1];
if (!entry) return false;
try {
return path.resolve(entry) === fileURLToPath(import.meta.url);
} catch {
return false;
}
}
if (isDirect()) {
runHookTick()
.then((result) => process.exit(result.code))
.catch(() => process.exit(0));
}
+193
View File
@@ -0,0 +1,193 @@
/**
* Claude-HUD-compatible display config + presets.
* Stored at ~/.grok/hud/config.json
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
export type HudPreset = "full" | "essential" | "minimal";
export type LineLayout = "expanded" | "compact";
export interface HudDisplayConfig {
preset: HudPreset;
lineLayout: LineLayout;
pathLevels: 1 | 2 | 3;
language: "en" | "zh-Hans" | "zh-Hant";
/** Multi-line tmux status rows (13). Expanded default 3. */
statusLines: 1 | 2 | 3;
/** Bold labels/values in tmux status (default true — easier to read). */
bold: boolean;
/** Context/usage bar width in cells (default 12). */
barWidth: number;
display: {
showModel: boolean;
showProject: boolean;
showGit: boolean;
showGitDirty: boolean;
showContextBar: boolean;
/** percent | tokens | both */
contextValue: "percent" | "tokens" | "both";
showUsage: boolean;
showProductBreakdown: boolean;
showSessionTime: boolean;
showTurns: boolean;
showTools: boolean;
showToolActivity: boolean;
showAgents: boolean;
showTodos: boolean;
showErrors: boolean;
showDiffStats: boolean;
showLive: boolean;
showTitle: boolean;
};
warningThreshold: number;
criticalThreshold: number;
}
export const PRESET_FULL: HudDisplayConfig = {
preset: "full",
lineLayout: "expanded",
pathLevels: 2,
language: "en",
statusLines: 3,
bold: true,
barWidth: 14,
display: {
showModel: true,
showProject: true,
showGit: true,
showGitDirty: true,
showContextBar: true,
contextValue: "both",
showUsage: true,
showProductBreakdown: true,
showSessionTime: true,
showTurns: true,
showTools: true,
showToolActivity: true,
showAgents: true,
showTodos: true,
showErrors: true,
showDiffStats: true,
showLive: true,
showTitle: true,
},
warningThreshold: 70,
criticalThreshold: 90,
};
export const PRESET_ESSENTIAL: HudDisplayConfig = {
...PRESET_FULL,
preset: "essential",
statusLines: 2,
bold: true,
barWidth: 14,
display: {
...PRESET_FULL.display,
showProductBreakdown: false,
showTodos: true,
showAgents: true,
showDiffStats: false,
showTitle: false,
contextValue: "percent",
},
};
export const PRESET_MINIMAL: HudDisplayConfig = {
...PRESET_FULL,
preset: "minimal",
lineLayout: "compact",
statusLines: 1,
bold: true,
barWidth: 14,
display: {
...PRESET_FULL.display,
showGit: true,
showGitDirty: true,
showProductBreakdown: false,
showSessionTime: false,
showTurns: false,
showTools: false,
showToolActivity: false,
showAgents: false,
showTodos: false,
showErrors: false,
showDiffStats: false,
showTitle: false,
contextValue: "percent",
},
};
export function configPath(grokHome = path.join(os.homedir(), ".grok")): string {
return path.join(grokHome, "hud", "config.json");
}
export function loadHudConfig(
grokHome = path.join(os.homedir(), ".grok"),
): HudDisplayConfig {
try {
const p = configPath(grokHome);
if (!fs.existsSync(p)) {
return {
...PRESET_FULL,
display: { ...PRESET_FULL.display },
};
}
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<HudDisplayConfig>;
const base =
raw.preset === "minimal"
? PRESET_MINIMAL
: raw.preset === "essential"
? PRESET_ESSENTIAL
: PRESET_FULL;
return {
...base,
...raw,
bold: raw.bold ?? base.bold ?? true,
barWidth: raw.barWidth ?? base.barWidth ?? 14,
statusLines: raw.statusLines ?? base.statusLines ?? 3,
display: { ...base.display, ...(raw.display ?? {}) },
};
} catch {
return {
...PRESET_FULL,
display: { ...PRESET_FULL.display },
};
}
}
export function saveHudConfig(
cfg: HudDisplayConfig,
grokHome = path.join(os.homedir(), ".grok"),
): string {
const p = configPath(grokHome);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
return p;
}
export function applyPreset(preset: HudPreset): HudDisplayConfig {
if (preset === "minimal") return { ...PRESET_MINIMAL, display: { ...PRESET_MINIMAL.display } };
if (preset === "essential")
return { ...PRESET_ESSENTIAL, display: { ...PRESET_ESSENTIAL.display } };
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",
};
+603
View File
@@ -0,0 +1,603 @@
#!/usr/bin/env node
import {
pickBestSession,
discoverSessions,
defaultGrokHome,
findSessionDirById,
loadActiveSessions,
loadSnapshotFromDir,
} from "./session.js";
import { getCreditUsage, unavailableUsage } from "./billing.js";
import { renderHud, renderJson } from "./render.js";
import { loadConfig } from "./config.js";
import { writeStatusFiles } from "./status.js";
import {
installGlobalHooks,
uninstallGlobalHooks,
packageRoot,
} from "./install.js";
import { installDashboard, uninstallDashboard } from "./install-dashboard.js";
import {
runDashboardLoop,
ensureDashboardDaemon,
stopDashboard,
isDashboardRunning,
refreshDashboard,
} from "./dashboard.js";
import {
execGrokInSameWindowTmux,
applyTmuxStatusBar,
installSameWindowLauncher,
isInsideTmux,
writeTmuxConfFile,
} from "./tmux-hud.js";
import {
persistTheme,
resolveTheme,
resolveThemeMode,
readGrokUiConfig,
clearAppearanceCache,
} from "./theme.js";
import {
applyPreset,
saveHudConfig,
loadHudConfig,
type HudPreset,
} from "./hud-config.js";
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
import path from "node:path";
import { fileURLToPath } from "node:url";
export { contextPercentFromSignals, renderBar, formatTokenCount } from "./bar.js";
export { parseUpdatesLines, formatToolLine } from "./activity.js";
export { normalizeBillingPayload } from "./billing.js";
export {
loadSnapshotFromDir,
pickBestSession,
discoverSessions,
findSessionDirById,
} from "./session.js";
export { renderHud, renderJson } from "./render.js";
export { runHookTick } from "./hook.js";
export { writeStatusFiles, formatCompactLine } from "./status.js";
export { installGlobalHooks, uninstallGlobalHooks } from "./install.js";
export { installDashboard, uninstallDashboard } from "./install-dashboard.js";
export interface CliOptions {
watch: boolean;
once: boolean;
tmux: boolean;
json: boolean;
all: boolean;
noColor: boolean;
cwd?: string;
session?: string;
sessionDir?: string;
grokHome?: string;
refreshMs?: number;
maxIterations?: number;
help: boolean;
version: boolean;
noUsage: boolean;
installHooks: boolean;
uninstallHooks: boolean;
tick: boolean;
followActive: boolean;
installDashboard: boolean;
uninstallDashboard: boolean;
dashboard: boolean;
dashboardStart: boolean;
dashboardStop: boolean;
dashboardWindow: boolean;
runInTerminal: boolean;
theme?: string;
preset?: string;
}
export function parseArgs(argv: string[]): CliOptions {
const opts: CliOptions = {
watch: false,
once: true,
tmux: false,
json: false,
all: false,
noColor: false,
help: false,
version: false,
noUsage: false,
installHooks: false,
uninstallHooks: false,
tick: false,
followActive: false,
installDashboard: false,
uninstallDashboard: false,
dashboard: false,
dashboardStart: false,
dashboardStop: false,
dashboardWindow: false,
runInTerminal: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
switch (arg) {
case "-w":
case "--watch":
opts.watch = true;
opts.once = false;
break;
case "--once":
opts.once = true;
opts.watch = false;
break;
case "--tmux":
opts.tmux = true;
break;
case "--json":
opts.json = true;
break;
case "--all":
opts.all = true;
break;
case "--no-color":
case "--plain":
opts.noColor = true;
break;
case "--no-usage":
opts.noUsage = true;
break;
case "--cwd":
opts.cwd = argv[++i];
break;
case "--session":
opts.session = argv[++i];
break;
case "--session-dir":
opts.sessionDir = argv[++i];
break;
case "--grok-home":
opts.grokHome = argv[++i];
break;
case "--interval":
case "--refresh-ms":
opts.refreshMs = Number(argv[++i]);
break;
case "--max-iterations":
opts.maxIterations = Number(argv[++i]);
break;
case "--install-hooks":
case "--install":
opts.installHooks = true;
break;
case "--uninstall-hooks":
case "--uninstall":
opts.uninstallHooks = true;
break;
case "--tick":
opts.tick = true;
opts.noUsage = true;
break;
case "--follow-active":
opts.followActive = true;
break;
case "--install-dashboard":
opts.installDashboard = true;
break;
case "--uninstall-dashboard":
opts.uninstallDashboard = true;
break;
case "--dashboard":
opts.dashboard = true;
opts.once = false;
break;
case "--dashboard-start":
opts.dashboardStart = true;
break;
case "--dashboard-stop":
opts.dashboardStop = true;
break;
case "--dashboard-window":
case "--open-window":
// deprecated / rejected by user — ignore, prefer same-window tmux
opts.dashboardWindow = false;
break;
case "--run-in-terminal":
case "--run":
opts.runInTerminal = true;
break;
case "--theme":
opts.theme = argv[++i];
break;
case "--preset":
opts.preset = argv[++i];
break;
case "-h":
case "--help":
opts.help = true;
break;
case "-V":
case "--version":
opts.version = true;
break;
default:
if (arg.startsWith("-")) {
// unknown flag: ignore softly
}
break;
}
}
return opts;
}
export function helpText(): string {
return `grok-build-hud — live context + quota in the SAME Terminal window
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
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
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
Claude HUD users: see MIGRATION-FROM-CLAUDE.md
`;
}
async function resolveUsage(
grokHome: string,
opts: CliOptions,
cfg: ReturnType<typeof loadConfig>,
): Promise<UsageSnapshot> {
if (opts.noUsage || !cfg.showUsage) {
return unavailableUsage("usage disabled");
}
try {
return await getCreditUsage(grokHome, {
cacheTtlMs: cfg.usageCacheTtlMs,
enabled: true,
});
} catch {
return unavailableUsage("usage unavailable");
}
}
function printFrame(
sessions: SessionSnapshot[],
usage: UsageSnapshot,
opts: CliOptions,
cfg: ReturnType<typeof loadConfig>,
out: (s: string) => void,
): void {
if (!sessions.length) {
out("No Grok Build sessions found under grok home.");
return;
}
const list = opts.all ? sessions : [sessions[0]!];
const chunks: string[] = [];
for (const s of list) {
if (opts.json) {
chunks.push(renderJson(s, usage));
} else {
chunks.push(
renderHud(s, usage, {
color: !opts.noColor && !opts.tmux,
tmux: opts.tmux,
pathLevels: cfg.pathLevels,
warningThreshold: cfg.warningThreshold,
criticalThreshold: cfg.criticalThreshold,
}),
);
}
}
out(chunks.join(opts.json ? "\n" : "\n\n"));
}
export async function runCli(
argv: string[],
io: {
stdout?: (s: string) => void;
stderr?: (s: string) => void;
sleep?: (ms: number) => Promise<void>;
} = {},
): Promise<number> {
const out = io.stdout ?? ((s: string) => process.stdout.write(s + (s.endsWith("\n") ? "" : "\n")));
const err = io.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
const sleep =
io.sleep ??
((ms: number) => new Promise((r) => setTimeout(r, ms)));
const opts = parseArgs(argv);
const cfg = loadConfig();
if (opts.help) {
out(helpText());
return 0;
}
if (opts.version) {
out("0.2.0");
return 0;
}
if (opts.preset) {
const p = opts.preset.toLowerCase();
if (p !== "full" && p !== "essential" && p !== "minimal") {
err("Preset must be: full | essential | minimal");
return 1;
}
const cfg = applyPreset(p as HudPreset);
// keep readability defaults when applying presets
cfg.bold = true;
cfg.barWidth = Math.max(cfg.barWidth ?? 12, 12);
const saved = saveHudConfig(cfg, opts.grokHome ?? defaultGrokHome());
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`,
);
return 0;
}
if (opts.theme) {
const mode = resolveThemeMode(opts.theme);
persistTheme(mode);
process.env.GROK_HUD_THEME = mode;
clearAppearanceCache();
const grokHome = opts.grokHome ?? defaultGrokHome();
const ui = readGrokUiConfig(grokHome);
const t = resolveTheme(mode, process.env, { grokHome });
writeTmuxConfFile(grokHome);
applyTmuxStatusBar({ grokHome });
await refreshDashboard({ grokHome });
stopDashboard(grokHome);
ensureDashboardDaemon({
grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
if (mode === "auto") {
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`,
);
} else {
out(
`Theme locked to "${t.name}". Use --theme auto to follow Grok [ui].theme again.`,
);
}
return 0;
}
if (opts.installDashboard) {
try {
const r = installDashboard({
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)`);
out(
` daemon: ${r.daemon.alreadyRunning ? "already running" : r.daemon.started ? `started pid ${r.daemon.pid}` : "not started"}`,
);
await refreshDashboard({ grokHome: opts.grokHome });
if (isInsideTmux()) {
applyTmuxStatusBar({ grokHome: opts.grokHome });
out("\nAlready inside tmux — status bar applied to THIS window.");
} else {
out(
"\n★ 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.",
);
}
return 0;
} catch (e) {
err(e instanceof Error ? e.message : String(e));
return 1;
}
}
if (opts.runInTerminal) {
ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: 500,
});
await refreshDashboard({ grokHome: opts.grokHome });
// never returns
execGrokInSameWindowTmux({
grokHome: opts.grokHome,
extraGrokArgs: [],
});
return 0;
}
if (opts.uninstallDashboard) {
uninstallDashboard({ grokHome: opts.grokHome });
uninstallGlobalHooks(opts.grokHome);
out("Dashboard uninstalled (daemon stopped, LaunchAgent removed).");
return 0;
}
if (opts.dashboardStop) {
const ok = stopDashboard(opts.grokHome);
out(ok ? "Dashboard daemon stopped." : "No dashboard daemon was running.");
return 0;
}
if (opts.dashboardStart) {
const r = ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
intervalMs: opts.refreshMs ?? 2000,
});
if (r.alreadyRunning) out("Dashboard already running.");
else if (r.started) out(`Dashboard started (pid ${r.pid}).`);
else out("Failed to start dashboard.");
await refreshDashboard({ grokHome: opts.grokHome });
return r.started || r.alreadyRunning ? 0 : 1;
}
if (opts.dashboard) {
return runDashboardLoop({
grokHome: opts.grokHome,
intervalMs: opts.refreshMs ?? 2000,
maxIterations: opts.maxIterations,
noUsage: opts.noUsage,
writePid: true,
});
}
if (opts.installHooks) {
try {
const { hooksPath, hookJs } = installGlobalHooks({
grokHome: opts.grokHome,
root: packageRoot(),
});
// also ensure dashboard daemon for title bar
ensureDashboardDaemon({
grokHome: opts.grokHome,
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
});
out(`Installed live HUD hooks:\n ${hooksPath}\n entry: ${hookJs}`);
out(
"\nNext: restart this Grok session (or open /hooks and press r to reload).\nThen keep working — after each turn you should see [hud] annotations in scrollback.",
);
return 0;
} catch (e) {
err(e instanceof Error ? e.message : String(e));
return 1;
}
}
if (opts.uninstallHooks) {
const { removed, hooksPath } = uninstallGlobalHooks(opts.grokHome);
out(removed ? `Removed ${hooksPath}` : `No hooks file at ${hooksPath}`);
return 0;
}
const grokHome = opts.grokHome ?? defaultGrokHome();
const discoverOpts = {
grokHome,
sessionId: opts.session,
cwd: opts.cwd,
sessionDir: opts.sessionDir,
};
const resolveSessions = (): SessionSnapshot[] => {
if (opts.sessionDir) {
const one = pickBestSession(discoverOpts);
return one ? [one] : [];
}
if (opts.followActive || (!opts.cwd && !opts.session)) {
// Prefer any live active session first
const active = loadActiveSessions(grokHome);
const liveSnaps: SessionSnapshot[] = [];
for (const a of active) {
const dir = findSessionDirById(grokHome, a.session_id);
if (!dir) continue;
const snap = loadSnapshotFromDir(dir, { active });
if (snap) liveSnaps.push(snap);
}
if (liveSnaps.length) {
if (opts.cwd) {
const matched = liveSnaps.filter((s) =>
s.cwd ? s.cwd === opts.cwd || s.cwd.includes(opts.cwd!) : false,
);
// use path equality via discover as fallback
const viaCwd = discoverSessions({ ...discoverOpts, cwd: opts.cwd });
if (viaCwd.length) return viaCwd;
if (matched.length) return matched;
}
if (opts.session) {
const m = liveSnaps.filter((s) => s.sessionId === opts.session);
if (m.length) return m;
}
if (!opts.cwd && !opts.session) return liveSnaps;
}
}
return discoverSessions(discoverOpts);
};
const renderOnce = async (): Promise<number> => {
const sessions = resolveSessions();
const usage = await resolveUsage(grokHome, opts, cfg);
if (!sessions.length) {
err("grok-build-hud: no sessions found");
printFrame([], usage, opts, cfg, out);
return 1;
}
// Always refresh status files so external readers stay current
try {
writeStatusFiles(sessions[0]!, opts.noUsage ? null : usage, grokHome);
} catch {
/* ignore */
}
if (opts.tick) {
out(writeStatusFiles(sessions[0]!, null, grokHome).compact);
return 0;
}
printFrame(sessions, usage, opts, cfg, out);
return 0;
};
if (opts.watch) {
const interval = opts.refreshMs && opts.refreshMs > 0 ? opts.refreshMs : cfg.refreshMs;
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
i += 1;
if (!opts.tmux && !opts.json && process.stdout.isTTY) {
// clear screen lightly for watch
process.stdout.write("\x1b[2J\x1b[H");
}
await renderOnce();
if (opts.maxIterations && i >= opts.maxIterations) {
return 0;
}
await sleep(interval);
}
}
return renderOnce();
}
// Direct `node dist/src/index.js ...` entry. When imported by tests, argv[1]
// is the test file path, so this block does not run.
function isDirectCliRun(): boolean {
const entry = process.argv[1];
if (!entry) return false;
try {
return path.resolve(entry) === fileURLToPath(import.meta.url);
} catch {
return false;
}
}
if (isDirectCliRun()) {
runCli(process.argv.slice(2))
.then((code) => {
process.exit(typeof code === "number" ? code : 0);
})
.catch((e) => {
console.error(e);
process.exit(1);
});
}
+195
View File
@@ -0,0 +1,195 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
import { packageRoot, installGlobalHooks } from "./install.js";
import { defaultGrokHome } from "./session.js";
import {
ensureDashboardDaemon,
stopDashboard,
} from "./dashboard.js";
import { writeTmuxConfFile } from "./tmux-hud.js";
export interface DashboardInstallResult {
binPath: string;
tmuxConf: string;
launchAgent?: string;
hooksPath: string;
daemon: { started: boolean; alreadyRunning: boolean; pid?: number };
}
export function installDashboard(options: {
grokHome?: string;
binDir?: string;
startDaemon?: boolean;
} = {}): DashboardInstallResult {
const grokHome = options.grokHome ?? defaultGrokHome();
const root = packageRoot();
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
fs.mkdirSync(binDir, { recursive: true });
// Ensure hooks (update status files on every turn)
const { hooksPath } = installGlobalHooks({ grokHome, root });
const entry = path.join(root, "bin", "grok-build-hud.js");
const node = process.execPath;
// CLI shim always on PATH
const binPath = path.join(binDir, "grok-hud");
const shim = `#!/bin/zsh
# grok-hud — always-on terminal dashboard for Grok Build
set -e
NODE=${JSON.stringify(node)}
ENTRY=${JSON.stringify(entry)}
case "\${1:-}" in
stop)
exec "$NODE" "$ENTRY" --dashboard-stop
;;
status)
if [[ -f "$HOME/.grok/hud/status.txt" ]]; then
cat "$HOME/.grok/hud/status.txt"
else
exec "$NODE" "$ENTRY" --once --follow-active --no-color
fi
;;
watch)
exec "$NODE" "$ENTRY" --watch --follow-active --no-color
;;
""|start|status)
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
exec "$NODE" "$ENTRY" --once --follow-active --no-color
;;
run)
exec "$NODE" "$ENTRY" --run-in-terminal
;;
*)
exec "$NODE" "$ENTRY" "$@"
;;
esac
`;
fs.writeFileSync(binPath, shim, { mode: 0o755 });
// tmux status bar fragment (cohesive Clear Dark palette)
const tmuxConf = writeTmuxConfFile(grokHome);
// Soft-append to ~/.tmux.conf if present or create minimal
const userTmux = path.join(os.homedir(), ".tmux.conf");
const marker = "# >>> grok-build-hud >>>";
const block = `${marker}
# live Grok context + quota in status bar
if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf'
# <<< grok-build-hud <<<
`;
if (fs.existsSync(userTmux)) {
const cur = fs.readFileSync(userTmux, "utf8");
if (!cur.includes(marker)) {
fs.writeFileSync(userTmux, cur.replace(/\s*$/, "\n\n") + block + "\n", "utf8");
}
} else {
fs.writeFileSync(userTmux, block + "\n", "utf8");
}
// LaunchAgent keeps dashboard alive across reboots (macOS)
let launchAgent: string | undefined;
if (process.platform === "darwin") {
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 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>
<key>ProgramArguments</key>
<array>
<string>${node}</string>
<string>${entry}</string>
<string>--dashboard</string>
<string>--follow-active</string>
<string>--interval</string>
<string>500</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>${log}</string>
<key>StandardErrorPath</key>
<string>${log}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>${os.homedir()}</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
</dict>
</dict>
</plist>
`;
fs.writeFileSync(launchAgent, plist, "utf8");
try {
execSilent(["launchctl", "unload", launchAgent]);
} catch {
/* not loaded */
}
try {
execSilent(["launchctl", "load", launchAgent]);
} catch {
/* may need user session */
}
}
let daemon: DashboardInstallResult["daemon"] = {
started: false,
alreadyRunning: false,
};
if (options.startDaemon !== false) {
daemon = ensureDashboardDaemon({
grokHome,
entryJs: path.join(root, "dist", "src", "index.js"),
intervalMs: 2000,
});
}
return { binPath, tmuxConf, launchAgent, hooksPath, daemon };
}
function execSilent(args: string[]): void {
execFileSync(args[0]!, args.slice(1), {
stdio: "ignore",
timeout: 5000,
});
}
export function uninstallDashboard(options: {
grokHome?: string;
} = {}): void {
const grokHome = options.grokHome ?? defaultGrokHome();
stopDashboard(grokHome);
if (process.platform === "darwin") {
const launchAgent = path.join(
os.homedir(),
"Library",
"LaunchAgents",
"com.dex.grok-hud-dashboard.plist",
);
try {
execSilent(["launchctl", "unload", launchAgent]);
} catch {
/* ignore */
}
try {
if (fs.existsSync(launchAgent)) fs.unlinkSync(launchAgent);
} catch {
/* ignore */
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { defaultGrokHome } from "./session.js";
export function packageRoot(): string {
// dist/src/install.js -> package root is ../..
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
}
export function hookEntryPath(root = packageRoot()): string {
return path.join(root, "dist", "src", "hook.js");
}
export function buildHookManifest(hookJs: string): object {
// Use node + absolute path so it works when Grok spawns hooks without PATH tricks
const node = process.execPath;
const cmd = `${JSON.stringify(node)} ${JSON.stringify(hookJs)}`;
const handler = {
type: "command",
command: cmd,
timeout: 8,
};
return {
description:
"grok-build-hud — live context/tool stats in scrollback while you work",
hooks: {
SessionStart: [{ hooks: [handler] }],
UserPromptSubmit: [{ hooks: [handler] }],
PostToolUse: [{ hooks: [handler] }],
Stop: [{ hooks: [handler] }],
SessionEnd: [{ hooks: [handler] }],
},
};
}
export function installGlobalHooks(
options: { grokHome?: string; root?: string } = {},
): { hooksPath: string; hookJs: string } {
const grokHome = options.grokHome ?? defaultGrokHome();
const root = options.root ?? packageRoot();
const hookJs = hookEntryPath(root);
if (!fs.existsSync(hookJs)) {
throw new Error(
`Hook entry not built: ${hookJs}. Run: npm run build (in ${root})`,
);
}
const hooksDir = path.join(grokHome, "hooks");
fs.mkdirSync(hooksDir, { recursive: true });
const hooksPath = path.join(hooksDir, "grok-build-hud.json");
const manifest = buildHookManifest(hookJs);
fs.writeFileSync(hooksPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
// Ensure hud data dir exists
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
fs.writeFileSync(
path.join(grokHome, "hud", "README.txt"),
[
"grok-build-hud live status files",
" status-line.txt — one-line compact",
" status.txt — multi-line",
" status.json — machine readable",
"",
"In-session: hooks print [hud] lines into Grok scrollback annotations",
"after each turn (Stop) and periodically after tools.",
"",
`Installed hooks: ${hooksPath}`,
`Hook binary: ${hookJs}`,
"",
].join("\n"),
"utf8",
);
return { hooksPath, hookJs };
}
export function uninstallGlobalHooks(
grokHome = defaultGrokHome(),
): { removed: boolean; hooksPath: string } {
const hooksPath = path.join(grokHome, "hooks", "grok-build-hud.json");
if (fs.existsSync(hooksPath)) {
fs.unlinkSync(hooksPath);
return { removed: true, hooksPath };
}
return { removed: false, hooksPath };
}
void os;
+180
View File
@@ -0,0 +1,180 @@
import {
formatDuration,
formatTokenCount,
projectLabel,
renderBar,
} from "./bar.js";
import { formatToolLine } from "./activity.js";
import type {
RenderOptions,
SessionSnapshot,
UsageSnapshot,
} from "./types.js";
const ANSI = {
reset: "\x1b[0m",
dim: "\x1b[2m",
bold: "\x1b[1m",
green: "\x1b[32m",
yellow: "\x1b[33m",
red: "\x1b[31m",
cyan: "\x1b[36m",
magenta: "\x1b[35m",
blue: "\x1b[34m",
};
function colorize(
text: string,
color: string | null,
enabled: boolean,
): string {
if (!enabled || !color) return text;
return `${color}${text}${ANSI.reset}`;
}
function severityColor(
percent: number,
opts: RenderOptions,
): string {
if (percent >= opts.criticalThreshold) return ANSI.red;
if (percent >= opts.warningThreshold) return ANSI.yellow;
return ANSI.green;
}
export function renderHud(
session: SessionSnapshot,
usage: UsageSnapshot | null,
opts: Partial<RenderOptions> = {},
): string {
const options: RenderOptions = {
color: opts.color ?? true,
tmux: opts.tmux ?? false,
compact: opts.compact ?? false,
pathLevels: opts.pathLevels ?? 2,
warningThreshold: opts.warningThreshold ?? 70,
criticalThreshold: opts.criticalThreshold ?? 90,
};
if (options.tmux) {
return renderTmux(session, usage, options);
}
const c = options.color;
const model = colorize(displayModel(session.model), ANSI.cyan, c);
const project = projectLabel(session.cwd, options.pathLevels);
const branchPart = session.branch
? ` git:(${session.branch}${session.gitDirty ? "*" : ""})`
: "";
const live = session.live
? colorize("● live", ANSI.green, c)
: colorize("○ stale", ANSI.dim, c);
const line1 = `[${model}] │ ${project}${branchPart}${live}`;
const pct = Math.round(session.contextPercent);
const barColor = severityColor(session.contextPercent, options);
const bar = colorize(renderBar(session.contextPercent), barColor, c);
const tokenPart =
session.contextWindowTokens > 0
? ` (${formatTokenCount(session.contextTokensUsed)}/${formatTokenCount(session.contextWindowTokens)})`
: "";
const ctxLabel = colorize("Context", ANSI.bold, c);
const meta: string[] = [];
if (session.durationSeconds > 0) {
meta.push(`Time ${formatDuration(session.durationSeconds)}`);
}
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 lines = [line1, line2];
const usageLine = formatUsageLine(usage, options);
if (usageLine) lines.push(usageLine);
const toolLine = formatToolLine(session.tools);
if (toolLine) {
lines.push(toolLine);
}
if (session.agents?.length) {
const a = session.agents[session.agents.length - 1]!;
lines.push(
`◎ agent ${a.title ?? "agent"}${a.detail ? `: ${a.detail}` : ""}`,
);
}
if (options.compact) {
return lines.slice(0, 2).join(" · ");
}
return lines.join("\n");
}
function displayModel(model: string): string {
if (!model || model === "unknown") return "Grok";
// grok-4.5 -> Grok 4.5
return model
.replace(/^grok-/i, "Grok ")
.replace(/-/g, " ");
}
function formatUsageLine(
usage: UsageSnapshot | null | undefined,
opts: RenderOptions,
): string {
if (!usage) {
return `Quota ${colorize("—", ANSI.dim, opts.color)} unavailable`;
}
if (!usage.available) {
return `Quota ${colorize("—", ANSI.dim, opts.color)} ${usage.message ?? "unavailable"}`;
}
const pct = usage.percent ?? 0;
const bar = colorize(renderBar(pct), severityColor(pct, opts), opts.color);
const nums =
usage.used != null && usage.limit != null
? ` · month ${formatTokenCount(usage.used)}/${formatTokenCount(usage.limit)}`
: "";
const period = usage.period ? ` (${usage.period})` : "";
const reset = usage.resetsIn ? ` · resets ${usage.resetsIn}` : "";
const prod = usage.message ? ` · ${usage.message}` : "";
return `Quota ${bar} ${Math.round(pct)}%${period}${nums}${reset}${prod}`;
}
export function renderTmux(
session: SessionSnapshot,
usage: UsageSnapshot | null,
opts: RenderOptions,
): string {
const pct = Math.round(session.contextPercent);
const project = projectLabel(session.cwd, opts.pathLevels);
const u =
usage?.available && usage.percent != null
? ` u${Math.round(usage.percent)}%`
: "";
return `ctx ${pct}%${u} ${displayModel(session.model)} ${project}`;
}
export function renderJson(
session: SessionSnapshot,
usage: UsageSnapshot | null,
): string {
return JSON.stringify(
{
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,
tools: session.tools,
agents: session.agents,
usage,
},
null,
2,
);
}
+326
View File
@@ -0,0 +1,326 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { contextPercentFromSignals } from "./bar.js";
import { parseUpdatesFile } from "./activity.js";
import { readGitInfo } from "./git.js";
import type {
ActiveSessionEntry,
SessionSignals,
SessionSnapshot,
SessionSummary,
} from "./types.js";
export function defaultGrokHome(): string {
return process.env.GROK_HOME?.trim() || path.join(os.homedir(), ".grok");
}
export function readJsonFile<T>(filePath: string): T | null {
try {
if (!fs.existsSync(filePath)) return null;
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
} catch {
return null;
}
}
export function loadActiveSessions(grokHome: string): ActiveSessionEntry[] {
const p = path.join(grokHome, "active_sessions.json");
const data = readJsonFile<ActiveSessionEntry[] | { sessions?: ActiveSessionEntry[] }>(p);
if (!data) return [];
if (Array.isArray(data)) return data;
if (Array.isArray(data.sessions)) return data.sessions;
return [];
}
export function isPidAlive(pid?: number): boolean {
if (!pid || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/** Encode cwd the way Grok names session parent dirs. */
export function encodeCwdDirName(cwd: string): string {
return encodeURIComponent(cwd).replace(/%20/g, "%20");
}
export function listSessionDirs(grokHome: string): string[] {
const root = path.join(grokHome, "sessions");
if (!fs.existsSync(root)) return [];
const out: string[] = [];
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".")) continue;
const parent = path.join(root, entry.name);
// Sessions may be parent-dir/session-id or flat
for (const child of fs.readdirSync(parent, { withFileTypes: true })) {
if (!child.isDirectory()) continue;
const dir = path.join(parent, child.name);
if (fs.existsSync(path.join(dir, "signals.json")) || fs.existsSync(path.join(dir, "summary.json"))) {
out.push(dir);
}
}
// Also allow session files directly under sessions/<id>/
if (
fs.existsSync(path.join(parent, "signals.json")) ||
fs.existsSync(path.join(parent, "summary.json"))
) {
out.push(parent);
}
}
return out;
}
export function mtimeMs(filePath: string): number {
try {
return fs.statSync(filePath).mtimeMs;
} catch {
return 0;
}
}
export function loadSnapshotFromDir(
sessionDir: string,
options: {
active?: ActiveSessionEntry[];
preferLive?: boolean;
} = {},
): SessionSnapshot | null {
if (!sessionDir || !fs.existsSync(sessionDir)) return null;
const signalsPath = path.join(sessionDir, "signals.json");
const summaryPath = path.join(sessionDir, "summary.json");
// Require at least one of the core session files
if (!fs.existsSync(signalsPath) && !fs.existsSync(summaryPath)) return null;
const signals =
readJsonFile<SessionSignals>(signalsPath) ?? {};
const summary =
readJsonFile<SessionSummary>(summaryPath) ?? undefined;
const sessionId =
summary?.info?.id ??
path.basename(sessionDir) ??
"unknown";
const cwd =
summary?.info?.cwd ??
decodeCwdGuess(sessionDir) ??
"";
const active = options.active ?? [];
const activeHit = active.find((a) => a.session_id === sessionId);
const pid = activeHit?.pid;
const live = Boolean(activeHit && isPidAlive(pid));
const model =
(typeof signals.primaryModelId === "string" && signals.primaryModelId) ||
(Array.isArray(signals.modelsUsed) && signals.modelsUsed[0]) ||
summary?.current_model_id ||
"unknown";
const percent = contextPercentFromSignals(signals);
const contextTokensUsed =
typeof signals.contextTokensUsed === "number" ? signals.contextTokensUsed : 0;
const contextWindowTokens =
typeof signals.contextWindowTokens === "number"
? signals.contextWindowTokens
: 0;
const activity = parseUpdatesFile(path.join(sessionDir, "updates.jsonl"));
// Fallback: toolsUsed from signals when updates empty
let tools = activity.tools;
if (!tools.length && Array.isArray(signals.toolsUsed) && signals.toolsUsed.length) {
tools = signals.toolsUsed.map((name, i) => ({
id: `sig-${i}`,
name,
status: "completed" as const,
count: 1,
}));
}
const git = cwd ? readGitInfo(cwd) : { dirty: false };
const branch = git.branch ?? summary?.head_branch;
return {
sessionId,
sessionDir,
cwd: cwd || activeHit?.cwd || "",
model: String(model),
title: summary?.generated_title ?? summary?.session_summary,
branch,
gitDirty: git.dirty,
gitAhead: git.ahead,
gitBehind: git.behind,
live,
pid,
contextPercent: percent,
contextTokensUsed,
contextWindowTokens,
turnCount: typeof signals.turnCount === "number" ? signals.turnCount : 0,
userMessageCount:
typeof signals.userMessageCount === "number" ? signals.userMessageCount : 0,
toolCallCount:
typeof signals.toolCallCount === "number" ? signals.toolCallCount : 0,
toolFailureCount:
typeof signals.toolFailureCount === "number" ? signals.toolFailureCount : 0,
errorCount: typeof signals.errorCount === "number" ? signals.errorCount : 0,
durationSeconds:
typeof signals.sessionDurationSeconds === "number"
? signals.sessionDurationSeconds
: 0,
agentLinesAdded:
typeof signals.agentLinesAdded === "number" ? signals.agentLinesAdded : 0,
agentLinesRemoved:
typeof signals.agentLinesRemoved === "number"
? signals.agentLinesRemoved
: 0,
compactionCount:
typeof signals.compactionCount === "number" ? signals.compactionCount : 0,
avgTtftMs:
typeof signals.avgTimeToFirstTokenMs === "number"
? signals.avgTimeToFirstTokenMs
: 0,
agentName:
typeof summary?.agent_name === "string" ? summary.agent_name : undefined,
reasoningEffort:
typeof summary?.reasoning_effort === "string"
? summary.reasoning_effort
: undefined,
tools,
agents: activity.agents,
todos: activity.todos ?? [],
signals,
summary,
};
}
function decodeCwdGuess(sessionDir: string): string | undefined {
const parent = path.basename(path.dirname(sessionDir));
try {
if (parent.includes("%")) return decodeURIComponent(parent);
} catch {
/* ignore */
}
return undefined;
}
export interface DiscoverOptions {
grokHome?: string;
sessionId?: string;
cwd?: string;
sessionDir?: string;
}
export function discoverSessions(options: DiscoverOptions = {}): SessionSnapshot[] {
const grokHome = options.grokHome ?? defaultGrokHome();
const active = loadActiveSessions(grokHome);
if (options.sessionDir) {
const snap = loadSnapshotFromDir(options.sessionDir, { active });
return snap ? [snap] : [];
}
const dirs = listSessionDirs(grokHome);
const snaps: SessionSnapshot[] = [];
for (const dir of dirs) {
const snap = loadSnapshotFromDir(dir, { active });
if (snap) snaps.push(snap);
}
let filtered = snaps;
if (options.sessionId) {
filtered = snaps.filter(
(s) =>
s.sessionId === options.sessionId ||
s.sessionDir.endsWith(options.sessionId!),
);
}
if (options.cwd) {
filtered = filtered.filter((s) => {
if (!s.cwd) return false;
return pathsEqual(s.cwd, options.cwd!);
});
// Also match active_sessions cwd even if summary cwd is missing/stale
if (!filtered.length) {
for (const a of active) {
if (a.cwd && pathsEqual(a.cwd, options.cwd)) {
const dir = findSessionDirById(grokHome, a.session_id);
if (dir) {
const snap = loadSnapshotFromDir(dir, { active });
if (snap) filtered.push(snap);
}
}
}
}
}
// 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;
}
export function pickBestSession(options: DiscoverOptions = {}): SessionSnapshot | null {
const all = discoverSessions(options);
return all[0] ?? null;
}
/** Find session directory by exact session id (basename). */
export function findSessionDirById(
grokHome: string,
sessionId: string,
): string | null {
if (!sessionId) return null;
for (const dir of listSessionDirs(grokHome)) {
if (path.basename(dir) === sessionId) return dir;
}
// Fast path: scan sessions/*/<id>
const root = path.join(grokHome, "sessions");
if (!fs.existsSync(root)) return null;
try {
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const candidate = path.join(root, entry.name, sessionId);
if (
fs.existsSync(path.join(candidate, "signals.json")) ||
fs.existsSync(path.join(candidate, "summary.json"))
) {
return candidate;
}
}
} catch {
return null;
}
return null;
}
/** Normalize paths for cwd matching (symlink / trailing slash / case). */
export function pathsEqual(a: string, b: string): boolean {
try {
const ra = fs.realpathSync(path.resolve(a));
const rb = fs.realpathSync(path.resolve(b));
return ra === rb;
} catch {
try {
return path.resolve(a) === path.resolve(b);
} catch {
return a === b;
}
}
}
+433
View File
@@ -0,0 +1,433 @@
/**
* Claude-HUD-parity multi-line status rendering for Grok Build.
*
* 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
*/
import fs from "node:fs";
import path from "node:path";
import {
formatDuration,
formatTokenCount,
projectLabel,
renderBar,
} from "./bar.js";
import { formatToolLine } from "./activity.js";
import type {
SessionSnapshot,
TodoItem,
UsageSnapshot,
} from "./types.js";
import { defaultGrokHome } from "./session.js";
import {
miniBar,
tmuxFg,
resolveTheme,
type HudTheme,
} from "./theme.js";
import {
loadHudConfig,
type HudDisplayConfig,
} from "./hud-config.js";
export function hudDataDir(grokHome = defaultGrokHome()): string {
return path.join(grokHome, "hud");
}
function displayModel(model: string): string {
if (!model || model === "unknown") return "Grok";
return model.replace(/^grok-/i, "Grok ").replace(/-/g, " ");
}
function contextValueText(
session: SessionSnapshot,
mode: HudDisplayConfig["display"]["contextValue"],
): string {
const pct = Math.round(session.contextPercent);
const tokens =
session.contextWindowTokens > 0
? `${formatTokenCount(session.contextTokensUsed)}/${formatTokenCount(session.contextWindowTokens)}`
: "";
if (mode === "tokens") return tokens || `${pct}%`;
if (mode === "both") return tokens ? `${pct}% (${tokens})` : `${pct}%`;
return `${pct}%`;
}
function formatTodosLine(todos: TodoItem[]): string {
if (!todos.length) return "";
const done = todos.filter((t) => t.status === "completed").length;
const cur =
todos.find((t) => t.status === "in_progress") ??
todos.find((t) => t.status === "pending");
const label = cur?.content
? truncate(cur.content, 42)
: "todos";
return `${label} (${done}/${todos.length})`;
}
function formatAgentsLine(session: SessionSnapshot): string {
if (!session.agents?.length) return "";
const a = session.agents[session.agents.length - 1]!;
const title = a.title ?? "agent";
const detail = a.detail ? `: ${truncate(a.detail, 36)}` : "";
return `${title}${detail}`;
}
function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}
/** Plain multi-line Claude-HUD parity block (for files / /hud / hooks). */
export function formatStatusBlock(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
cfg: HudDisplayConfig = loadHudConfig(),
): string {
const d = cfg.display;
const lines: string[] = [];
// Line 1 — model │ project git │ live (+ optional title)
const l1: string[] = [];
if (d.showModel) l1.push(`[${displayModel(session.model)}]`);
if (d.showProject) {
let proj = projectLabel(session.cwd, cfg.pathLevels);
if (d.showGit && session.branch) {
const dirty = d.showGitDirty && session.gitDirty ? "*" : "";
let ab = "";
if (session.gitAhead) ab += `${session.gitAhead}`;
if (session.gitBehind) ab += `${session.gitBehind}`;
proj += ` git:(${session.branch}${dirty}${ab})`;
}
l1.push(proj);
}
if (d.showLive) l1.push(session.live ? "● live" : "○ stale");
if (d.showTitle && session.title) l1.push(truncate(session.title, 36));
if (session.reasoningEffort) l1.push(`effort:${session.reasoningEffort}`);
lines.push(l1.join(" │ "));
// Line 2 — Context + Usage (Claude default merge)
const pct = Math.round(session.contextPercent);
const cBar = d.showContextBar ? renderBar(session.contextPercent) + " " : "";
const ctxPart = `Context ${cBar}${contextValueText(session, d.contextValue)}`;
let usagePart = "";
if (d.showUsage) {
if (usage?.available && usage.percent != null) {
const uBar = d.showContextBar ? renderBar(usage.percent) + " " : "";
const abs =
usage.used != null && usage.limit != null
? ` ${formatTokenCount(usage.used)}/${formatTokenCount(usage.limit)}`
: "";
const reset = usage.resetsIn ? ` · ${usage.resetsIn} left` : "";
usagePart = `Usage ${uBar}${Math.round(usage.percent)}%${usage.period ? ` (${usage.period})` : ""}${abs}${reset}`;
} else {
usagePart = `Usage — ${usage?.message ?? "n/a"}`;
}
}
const meta: string[] = [];
if (d.showSessionTime && session.durationSeconds > 0) {
meta.push(`Time ${formatDuration(session.durationSeconds)}`);
}
if (d.showTurns && session.turnCount > 0) {
meta.push(`Turns ${session.turnCount}`);
}
if (d.showTools && session.toolCallCount > 0) {
meta.push(`Tools ${session.toolCallCount}`);
}
if (d.showErrors && (session.errorCount > 0 || session.toolFailureCount > 0)) {
meta.push(`Err ${session.errorCount || session.toolFailureCount}`);
}
if (
d.showDiffStats &&
(session.agentLinesAdded > 0 || session.agentLinesRemoved > 0)
) {
meta.push(`Δ +${session.agentLinesAdded}/-${session.agentLinesRemoved}`);
}
if (d.showProductBreakdown && usage?.message) {
const gb = usage.message
.split(",")
.map((s) => s.trim())
.find((s) => /GrokBuild/i.test(s));
if (gb) meta.push(gb);
}
const line2 = [ctxPart, usagePart, ...meta].filter(Boolean).join(" │ ");
lines.push(line2);
// Line 3 — tools / agents / todos
const activity: string[] = [];
if (d.showToolActivity) {
const tl = formatToolLine(session.tools);
if (tl) activity.push(tl);
}
if (d.showAgents) {
const al = formatAgentsLine(session);
if (al) activity.push(al);
}
if (d.showTodos) {
const td = formatTodosLine(session.todos);
if (td) activity.push(td);
}
if (activity.length) lines.push(activity.join(" · "));
if (cfg.lineLayout === "compact") {
return lines.slice(0, 2).join(" · ");
}
return lines.join("\n");
}
/** Compact single-line for hooks / titles. */
export function formatCompactLine(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
cfg: HudDisplayConfig = loadHudConfig(),
): string {
const pct = Math.round(session.contextPercent);
const q =
usage?.available && usage.percent != null
? `usage ${Math.round(usage.percent)}%`
: "usage —";
const git =
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}`;
}
/**
* Multi-line tmux status content (colour-coded).
* Written as separate lines for status-format[0..n].
*/
export function formatTmuxStatusLines(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
theme: HudTheme = resolveTheme(),
cfg: HudDisplayConfig = loadHudConfig(),
): 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[] = [];
// L0 model + project + git + live
const l0: string[] = [];
if (d.showModel) {
l0.push(accent(`[${displayModel(session.model)}]`));
}
if (d.showProject) {
let p = value(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 });
}
l0.push(p);
}
if (d.showLive) {
l0.push(
session.live
? tmuxFg(theme.live, " ● LIVE ", { bold: true })
: tmuxFg(theme.stale, " ○ stale ", { bold: false }),
);
}
// Extra leading/trailing spaces = more breathing room (reads larger)
lines.push(" " + l0.join(sep) + " ");
// L1 context + usage — thicker bars, bold percentages
const pct = Math.round(session.contextPercent);
const ctx =
label("CTX ") +
(d.showContextBar ? miniBar(pct, barW, theme, { bold }) + " " : "") +
value(contextValueText(session, d.contextValue));
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 meta: string[] = [];
if (d.showSessionTime && session.durationSeconds > 0) {
meta.push(label("TIME ") + value(formatDuration(session.durationSeconds)));
}
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.showDiffStats &&
(session.agentLinesAdded || session.agentLinesRemoved)
) {
meta.push(
value(`+${session.agentLinesAdded}/-${session.agentLinesRemoved}`),
);
}
lines.push(" " + [ctx, usagePart, ...meta].join(sep) + " ");
// L2 activity
if (cfg.statusLines >= 3) {
const bits: string[] = [];
if (d.showToolActivity) {
const tl = formatToolLine(session.tools);
if (tl) bits.push(value(tl));
}
if (d.showAgents) {
const al = formatAgentsLine(session);
if (al) bits.push(label(al));
}
if (d.showTodos) {
const td = formatTodosLine(session.todos);
if (td) bits.push(accent(td));
}
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(" " + bits.join(tmuxFg(theme.sep, " · ", { bold: false })) + " ");
}
const n = Math.max(1, Math.min(3, cfg.statusLines));
return lines.slice(0, n);
}
/** Single-line coloured status (fallback / compact). */
export function formatTmuxStatusLine(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
theme: HudTheme = resolveTheme(),
cfg: HudDisplayConfig = loadHudConfig(),
): string {
if (cfg.lineLayout === "compact" || cfg.statusLines === 1) {
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");
}
export interface WriteStatusResult {
dir: string;
compactPath: string;
fullPath: string;
compact: string;
full: string;
tmuxLines: string[];
}
export function writeStatusFiles(
session: SessionSnapshot,
usage?: UsageSnapshot | null,
grokHome = defaultGrokHome(),
): WriteStatusResult {
const cfg = loadHudConfig(grokHome);
const theme = resolveTheme(undefined, process.env, { grokHome });
const dir = hudDataDir(grokHome);
fs.mkdirSync(dir, { recursive: true });
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 compactPath = path.join(dir, "status-line.txt");
const fullPath = path.join(dir, "status.txt");
const jsonPath = path.join(dir, "status.json");
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",
);
return {
dir,
compactPath,
fullPath,
compact,
full,
tmuxLines,
};
}
+497
View File
@@ -0,0 +1,497 @@
/**
* HUD palettes follow the active **Grok Build UI theme** (config.toml [ui].theme),
* so the status strip matches what you see in the TUI — not a generic guess.
*/
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { execFileSync } from "node:child_process";
export interface HudTheme {
name: string;
statusBg: string;
statusFg: string;
label: string;
value: string;
sep: string;
mark: string;
ok: string;
warn: string;
crit: string;
barEmpty: string;
live: string;
stale: string;
}
export type ThemeMode = "auto" | "light" | "dark" | string;
const THEME_FILE = () => path.join(os.homedir(), ".grok", "hud", "theme");
/** Tokyo Night — matches Grok `tokyonight` */
export const THEME_TOKYONIGHT: HudTheme = {
name: "tokyonight",
statusBg: "default",
statusFg: "#a9b1d6",
label: "#565f89",
value: "#c0caf5",
sep: "#3b4261",
mark: "#7aa2f7",
ok: "#9ece6a",
warn: "#e0af68",
crit: "#f7768e",
barEmpty: "#292e42",
live: "#7aa2f7",
stale: "#565f89",
};
/** GrokNight — neutral dark */
export const THEME_GROKNIGHT: HudTheme = {
name: "groknight",
statusBg: "default",
statusFg: "#c8c8c8",
label: "#7a7a7a",
value: "#e8e8e8",
sep: "#3a3a3a",
mark: "#b267e6",
ok: "#6dbf6d",
warn: "#c9a227",
crit: "#d06060",
barEmpty: "#2a2a2a",
live: "#b267e6",
stale: "#7a7a7a",
};
/** GrokDay — light / 白底黑字 */
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",
};
/** Rose Pine Moon */
export const THEME_ROSEPINE: HudTheme = {
name: "rosepinemoon",
statusBg: "default",
statusFg: "#e0def4",
label: "#6e6a86",
value: "#e0def4",
sep: "#393552",
mark: "#c4a7e7",
ok: "#9ccfd8",
warn: "#f6c177",
crit: "#eb6f92",
barEmpty: "#2a273f",
live: "#c4a7e7",
stale: "#6e6a86",
};
/** Oscura Midnight */
export const THEME_OSCURA: HudTheme = {
name: "oscuramidnight",
statusBg: "default",
statusFg: "#e6e6e6",
label: "#6a6a6a",
value: "#f0f0f0",
sep: "#2a2a2a",
mark: "#5A72A0",
ok: "#4a9a6a",
warn: "#c9a227",
crit: "#c05050",
barEmpty: "#1a1a1a",
live: "#5A72A0",
stale: "#6a6a6a",
};
/** Generic light (白底) */
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",
};
/** Generic dark */
export const THEME_CLEAR_DARK: HudTheme = {
name: "dark",
statusBg: "default",
statusFg: "#9aa3ad",
label: "#6b7380",
value: "#c5ced6",
sep: "#3a4048",
mark: "#5a6570",
ok: "#7d9a8a",
warn: "#b9a07a",
crit: "#b07a7a",
barEmpty: "#2e333a",
live: "#7d9a8a",
stale: "#6b7380",
};
export const THEME_DEFAULT: HudTheme = THEME_TOKYONIGHT;
const GROK_THEME_ALIASES: Record<string, string> = {
tokyonight: "tokyonight",
"tokyo-night": "tokyonight",
tokyo_night: "tokyonight",
groknight: "groknight",
"grok-night": "groknight",
dark: "groknight",
night: "groknight",
grokday: "grokday",
"grok-day": "grokday",
light: "grokday",
day: "grokday",
rosepinemoon: "rosepinemoon",
"rose-pine-moon": "rosepinemoon",
"rose_pine_moon": "rosepinemoon",
rosepine: "rosepinemoon",
"rose-pine": "rosepinemoon",
oscuramindnight: "oscuramidnight",
"oscura-midnight": "oscuramidnight",
oscura: "oscuramidnight",
auto: "auto",
system: "auto",
};
export function normalizeGrokThemeName(raw: string): string {
const k = raw.trim().toLowerCase().replace(/\s+/g, "");
return GROK_THEME_ALIASES[k] ?? k;
}
export function paletteForGrokTheme(rawName: string): HudTheme {
const name = normalizeGrokThemeName(rawName);
switch (name) {
case "tokyonight":
return THEME_TOKYONIGHT;
case "groknight":
return THEME_GROKNIGHT;
case "grokday":
return THEME_GROKDAY;
case "rosepinemoon":
return THEME_ROSEPINE;
case "oscuramidnight":
return THEME_OSCURA;
case "light":
return THEME_CLEAR_LIGHT;
case "dark":
return THEME_CLEAR_DARK;
default:
// unknown → dark-neutral, still cohesive
return { ...THEME_GROKNIGHT, name };
}
}
export interface GrokUiConfig {
theme: string;
autoDarkTheme: string;
autoLightTheme: string;
}
/** Parse ~/.grok/config.toml [ui] theme fields (minimal TOML reader). */
export function readGrokUiConfig(
grokHome = path.join(os.homedir(), ".grok"),
): GrokUiConfig {
const defaults: GrokUiConfig = {
theme: "groknight",
autoDarkTheme: "groknight",
autoLightTheme: "grokday",
};
try {
const p = path.join(grokHome, "config.toml");
if (!fs.existsSync(p)) return defaults;
const text = fs.readFileSync(p, "utf8");
let inUi = false;
const cfg = { ...defaults };
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.replace(/#.*$/, "").trim();
if (!line) continue;
if (line.startsWith("[")) {
inUi = line === "[ui]" || line.startsWith("[ui.");
continue;
}
if (!inUi) continue;
const m = line.match(/^([a-zA-Z0-9_]+)\s*=\s*"([^"]*)"/);
if (!m) continue;
const key = m[1]!;
const val = m[2]!;
if (key === "theme") cfg.theme = val;
if (key === "auto_dark_theme") cfg.autoDarkTheme = val;
if (key === "auto_light_theme") cfg.autoLightTheme = val;
}
return cfg;
} catch {
return defaults;
}
}
function readThemeFile(): string | null {
try {
const p = THEME_FILE();
if (!fs.existsSync(p)) return null;
return fs.readFileSync(p, "utf8").trim().split(/\s+/)[0] || null;
} catch {
return null;
}
}
export function luminanceFromRgb65535(r: number, g: number, b: number): number {
const R = clamp01(r / 65535);
const G = clamp01(g / 65535);
const B = clamp01(b / 65535);
const lin = (c: number) =>
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
}
function clamp01(n: number): number {
if (!Number.isFinite(n)) return 0;
if (n < 0) return 0;
if (n > 1) return 1;
return n;
}
export function appearanceFromLuminance(lum: number): "light" | "dark" {
return lum >= 0.45 ? "light" : "dark";
}
let appearanceCache: { at: number; appearance: "light" | "dark" } | null = null;
export function clearAppearanceCache(): void {
appearanceCache = null;
}
export function detectSystemAppearance(
options: { now?: number; ttlMs?: number } = {},
): "light" | "dark" {
const now = options.now ?? Date.now();
const ttl = options.ttlMs ?? 5000;
if (appearanceCache && now - appearanceCache.at < ttl) {
return appearanceCache.appearance;
}
// macOS system appearance
if (process.platform === "darwin") {
try {
const out = execFileSync(
"defaults",
["read", "-g", "AppleInterfaceStyle"],
{ encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"] },
).trim();
if (/dark/i.test(out)) {
appearanceCache = { at: now, appearance: "dark" };
return "dark";
}
} catch {
// key missing → light mode on macOS
appearanceCache = { at: now, appearance: "light" };
return "light";
}
}
appearanceCache = { at: now, appearance: "dark" };
return "dark";
}
/**
* 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
*/
export function resolveTheme(
name?: string | null,
env: NodeJS.ProcessEnv = process.env,
options: { grokHome?: string } = {},
): HudTheme {
const grokHome = options.grokHome ?? path.join(os.homedir(), ".grok");
const ui = readGrokUiConfig(grokHome);
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);
}
// Follow Grok's active theme (what the TUI paints)
const grokTheme = normalizeGrokThemeName(ui.theme || "groknight");
if (grokTheme === "auto") {
const sys = detectSystemAppearance();
const mapped =
sys === "dark"
? ui.autoDarkTheme || "groknight"
: ui.autoLightTheme || "grokday";
const theme = paletteForGrokTheme(mapped);
writeAppearanceSnapshot({
appearance: sys,
source: "grok-auto+" + sys,
grokTheme: ui.theme,
mappedTheme: mapped,
hudPalette: theme.name,
});
return theme;
}
const theme = paletteForGrokTheme(grokTheme);
writeAppearanceSnapshot({
appearance: theme.name === "grokday" || theme.name === "light" ? "light" : "dark",
source: "grok-config",
grokTheme: ui.theme,
mappedTheme: grokTheme,
hudPalette: theme.name,
});
return theme;
}
function writeAppearanceSnapshot(data: Record<string, unknown>): void {
try {
const dir = path.join(os.homedir(), ".grok", "hud");
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(
path.join(dir, "appearance.json"),
JSON.stringify({ ...data, at: new Date().toISOString() }, null, 2) + "\n",
);
} catch {
/* ignore */
}
}
export function resolveThemeMode(
name?: string | null,
env: NodeJS.ProcessEnv = process.env,
): string {
const raw = (
name ||
env.GROK_HUD_THEME ||
env.GROK_BUILD_HUD_THEME ||
readThemeFile() ||
"auto"
)
.toString()
.trim()
.toLowerCase();
return raw || "auto";
}
export function persistTheme(name: string): string {
const dir = path.join(os.homedir(), ".grok", "hud");
fs.mkdirSync(dir, { recursive: true });
const p = THEME_FILE();
fs.writeFileSync(p, name + "\n", "utf8");
return p;
}
export function severityColor(percent: number, theme: HudTheme): string {
if (percent >= 90) return theme.crit;
if (percent >= 70) return theme.warn;
return theme.ok;
}
/**
* Progress bar — heavy block glyphs for readability on Terminal.app.
* filled: █ (solid), empty: ░ (light track)
*/
export function miniBar(
percent: number,
width = 12,
theme: HudTheme = THEME_DEFAULT,
options: { bold?: boolean } = {},
): string {
const p = Math.max(0, Math.min(100, percent));
const filled = Math.round((p / 100) * width);
const empty = Math.max(0, width - filled);
const fg = severityColor(p, theme);
const bold = options.bold !== false ? "bold," : "";
return `#[${bold}fg=${fg}]${"█".repeat(filled)}#[fg=${theme.barEmpty}]${"░".repeat(empty)}#[default]`;
}
/** Tmux styled span. Values default to bold for readability. */
export function tmuxFg(
color: string,
text: string,
options: { bold?: boolean } = {},
): string {
const bold = options.bold ? "bold," : "";
return `#[${bold}fg=${color}]${text}#[default]`;
}
export function tmuxStatusChrome(theme: HudTheme = THEME_DEFAULT): {
statusStyle: string;
statusLeft: string;
statusRightTemplate: (filePath: string) => string;
statusInterval: string;
statusRightLength: string;
statusLeftLength: string;
statusPosition: string;
} {
return {
// bold + slightly brighter default fg for the whole strip
statusStyle: `bg=${theme.statusBg},fg=${theme.statusFg},bold`,
statusLeft: "",
statusRightTemplate: (filePath: string) =>
`#(cat ${filePath} 2>/dev/null)`,
statusInterval: "1",
statusRightLength: "200",
statusLeftLength: "0",
statusPosition: "bottom",
};
}
// Back-compat helpers
export function appearanceFromProfileName(
name: string,
): "light" | "dark" | null {
const n = name.toLowerCase();
if (/light|paper|white|basic|novel|day/.test(n)) return "light";
if (/dark|pro|homebrew|night|tokyo|oscura|pine/.test(n)) return "dark";
return null;
}
export function appearanceFromColorFgBg(
colorFgBg: string | undefined,
): "light" | "dark" | null {
if (!colorFgBg) return null;
const parts = colorFgBg.split(";");
if (parts.length < 2) return null;
const bg = Number(parts[parts.length - 1]);
if (!Number.isFinite(bg)) return null;
if (bg >= 7 && bg !== 8) return "light";
if (bg <= 1 || bg === 8) return "dark";
return bg >= 7 ? "light" : "dark";
}
export function detectTerminalAppearance(): {
appearance: "light" | "dark";
source: string;
} {
return {
appearance: detectSystemAppearance(),
source: "system",
};
}
+229
View File
@@ -0,0 +1,229 @@
/**
* Same-window always-visible HUD via tmux status bar.
* One Terminal.app window: bottom line shows ctx + quota, Grok fills the rest.
* Visual theme: soft Clear Dark palette (see theme.ts).
*/
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { defaultGrokHome } from "./session.js";
import { packageRoot } from "./install.js";
import { resolveTheme, tmuxStatusChrome } from "./theme.js";
import { loadHudConfig } from "./hud-config.js";
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
return Boolean(env.TMUX && env.TMUX.length > 0);
}
export function tmuxAvailable(): boolean {
try {
execFileSync("which", ["tmux"], { stdio: "ignore" });
return true;
} catch {
return false;
}
}
/** Build the list of tmux set commands for a cohesive status bar. */
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 statusLines = Math.max(
1,
Math.min(3, loadHudConfig(grokHome).statusLines ?? 3),
);
const cmds: string[][] = [
["set", "-g", "status", String(statusLines)],
["set", "-g", "status-position", chrome.statusPosition],
["set", "-g", "status-interval", chrome.statusInterval],
["set", "-g", "status-style", chrome.statusStyle],
["set", "-g", "status-justify", "left"],
["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"],
["set", "-g", "status-left", ""],
["set", "-g", "status-right", ""],
];
if (statusLines === 1) {
cmds.push([
"set",
"-g",
"status-format[0]",
`#[align=left]#(cat ${singleFile} 2>/dev/null)`,
]);
} else {
for (let i = 0; i < statusLines; i++) {
cmds.push([
"set",
"-g",
`status-format[${i}]`,
`#[align=left]#(sed -n '${i + 1}p' ${linesFile} 2>/dev/null)`,
]);
}
}
return cmds;
}
/** Apply status bar to the current tmux server (same window). */
export function applyTmuxStatusBar(options: {
grokHome?: string;
} = {}): boolean {
if (!tmuxAvailable()) return false;
const grokHome = options.grokHome ?? defaultGrokHome();
try {
for (const args of tmuxStatusCommands(grokHome)) {
execFileSync("tmux", args, { stdio: "ignore", timeout: 2000 });
}
// force redraw
try {
execFileSync("tmux", ["refresh-client", "-S"], {
stdio: "ignore",
timeout: 1000,
});
} catch {
/* ignore */
}
return true;
} catch {
return false;
}
}
/** Write ~/.grok/hud/tmux.conf for persistence across restarts. */
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` +
`# Auto-sourced by ~/.tmux.conf when present.\n\n` +
cmds
.map((args) => {
// tmux.conf form: set -g key value
if (args[0] === "set" && args[1] === "-g") {
const key = args[2]!;
const val = args.slice(3).join(" ");
// quote values with spaces / special chars
const needsQuote = /[\s#'"]/.test(val) || val.includes("#(");
return `set -g ${key} ${needsQuote ? `'${val.replace(/'/g, `'\\''`)}'` : val}`;
}
return args.join(" ");
})
.join("\n") +
"\n";
fs.mkdirSync(path.dirname(confPath), { recursive: true });
fs.writeFileSync(confPath, body, "utf8");
return confPath;
}
/**
* Re-exec current process inside tmux with status bar, then run `grok`.
* Uses the SAME Terminal.app window (tmux replaces the foreground process).
*/
export function execGrokInSameWindowTmux(options: {
grokHome?: string;
grokBin?: string;
sessionName?: string;
extraGrokArgs?: string[];
}): 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 });
if (!fs.existsSync(statusFile)) {
fs.writeFileSync(statusFile, "ctx … · quota …\n", "utf8");
}
writeTmuxConfFile(grokHome);
if (isInsideTmux()) {
applyTmuxStatusBar({ grokHome });
const args = options.extraGrokArgs ?? [];
const r = spawnSync(grokBin, args, { stdio: "inherit" });
process.exit(r.status ?? 0);
}
if (!tmuxAvailable()) {
console.error(
"tmux not found. Install: brew install tmux\nThen re-run: grok-hud-run",
);
process.exit(1);
}
const grokArgs = options.extraGrokArgs ?? [];
const grokCmd = [grokBin, ...grokArgs].map((a) => shellQuote(a)).join(" ");
const conf = path.join(grokHome, "hud", "tmux.conf");
// Apply conf then exec grok — same tty, no second window
const wrapper = [
`tmux source-file ${shellQuote(conf)} 2>/dev/null || true`,
// re-apply in case source failed mid-session
...tmuxStatusCommands(grokHome).map(
(args) => `tmux ${args.map(shellQuote).join(" ")} 2>/dev/null || true`,
),
`exec ${grokCmd}`,
].join(" && ");
const r = spawnSync(
"tmux",
["new-session", "-A", "-s", session, "zsh", "-lc", wrapper],
{ stdio: "inherit" },
);
process.exit(r.status ?? 1);
}
function detectGrokBin(): string {
const candidates = [
path.join(os.homedir(), ".grok", "bin", "grok"),
"/usr/local/bin/grok",
"/opt/homebrew/bin/grok",
];
for (const c of candidates) {
if (fs.existsSync(c)) return c;
}
try {
return execFileSync("which", ["grok"], { encoding: "utf8" }).trim();
} catch {
return "grok";
}
}
function shellQuote(s: string): string {
if (/^[a-zA-Z0-9_\/\-\.\:=]+$/.test(s)) return s;
return `'${s.replace(/'/g, `'\\''`)}'`;
}
/** Install same-window launchers. */
export function installSameWindowLauncher(options: {
binDir?: string;
} = {}): { runPath: string; grokWrapPath: string } {
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
fs.mkdirSync(binDir, { recursive: true });
const root = packageRoot();
const node = process.execPath;
const entry = path.join(root, "bin", "grok-build-hud.js");
const runPath = path.join(binDir, "grok-hud-run");
const runShim = `#!/bin/zsh
# Start Grok in THIS Terminal window with a permanent bottom status bar
# (tmux). Does not open a second window.
set -e
NODE=${JSON.stringify(node)}
ENTRY=${JSON.stringify(entry)}
"$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 "$@"
`;
fs.writeFileSync(runPath, runShim, { mode: 0o755 });
const grokWrapPath = path.join(binDir, "grok-with-hud");
fs.writeFileSync(grokWrapPath, runShim, { mode: 0o755 });
return { runPath, grokWrapPath };
}
+127
View File
@@ -0,0 +1,127 @@
export interface ActiveSessionEntry {
session_id: string;
pid?: number;
cwd?: string;
opened_at?: string;
}
export interface SessionSignals {
contextWindowUsage?: number;
contextTokensUsed?: number;
contextWindowTokens?: number;
turnCount?: number;
toolCallCount?: number;
toolFailureCount?: number;
errorCount?: number;
userMessageCount?: number;
assistantMessageCount?: number;
toolsUsed?: string[];
modelsUsed?: string[];
primaryModelId?: string;
sessionDurationSeconds?: number;
agentLinesAdded?: number;
agentLinesRemoved?: number;
compactionCount?: number;
avgTimeToFirstTokenMs?: number;
[key: string]: unknown;
}
export interface SessionSummary {
info?: { id?: string; cwd?: string };
session_summary?: string;
generated_title?: string;
current_model_id?: string;
head_branch?: string;
git_root_dir?: string;
last_active_at?: string;
updated_at?: string;
created_at?: string;
agent_name?: string;
reasoning_effort?: string;
sandbox_profile?: string;
[key: string]: unknown;
}
export interface ToolActivityItem {
id: string;
name: string;
status: "running" | "completed" | "failed" | "unknown";
detail?: string;
count?: number;
}
export interface AgentActivityItem {
id?: string;
title?: string;
status?: string;
detail?: string;
}
export interface TodoItem {
content: string;
status: "pending" | "in_progress" | "completed" | "cancelled" | string;
id?: string;
}
export interface SessionSnapshot {
sessionId: string;
sessionDir: string;
cwd: string;
model: string;
title?: string;
branch?: string;
gitDirty?: boolean;
gitAhead?: number;
gitBehind?: number;
live: boolean;
pid?: number;
contextPercent: number;
contextTokensUsed: number;
contextWindowTokens: number;
turnCount: number;
userMessageCount: number;
toolCallCount: number;
toolFailureCount: number;
errorCount: number;
durationSeconds: number;
agentLinesAdded: number;
agentLinesRemoved: number;
compactionCount: number;
avgTtftMs: number;
agentName?: string;
reasoningEffort?: string;
tools: ToolActivityItem[];
agents: AgentActivityItem[];
todos: TodoItem[];
signals: SessionSignals;
summary?: SessionSummary;
}
export interface UsageSnapshot {
available: boolean;
percent?: number;
used?: number;
limit?: number;
period?: string;
resetsIn?: string;
message?: string;
source?: string;
}
export interface RenderOptions {
color: boolean;
tmux: boolean;
compact: boolean;
pathLevels: number;
warningThreshold: number;
criticalThreshold: number;
}
export interface HudConfig {
pathLevels: number;
refreshMs: number;
warningThreshold: number;
criticalThreshold: number;
showUsage: boolean;
usageCacheTtlMs: number;
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseUpdatesLines, formatToolLine } from "../src/activity.js";
// dist/tests -> package root is ../..
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const fixtureUpdates = path.join(root, "fixtures", "session", "updates.jsonl");
describe("activity", () => {
it("parses fixture updates into tool summary including read_file", () => {
const lines = fs.readFileSync(fixtureUpdates, "utf8").split(/\r?\n/);
const { tools, agents } = parseUpdatesLines(lines);
assert.ok(tools.length > 0, "expected tools from fixture");
const names = tools.map((t) => t.name);
assert.ok(
names.includes("read_file") || names.some((n) => n.includes("read")),
`expected read_file in ${names.join(",")}`,
);
// call-4 is running (no completed status)
const running = tools.filter((t) => t.status === "running");
assert.ok(running.length >= 1, "expected at least one running tool");
assert.ok(agents.length >= 1, "expected agent thought from fixture");
const line = formatToolLine(tools);
assert.match(line, /read_file|✓|◐/);
});
it("returns empty quietly for blank input", () => {
const { tools, agents, todos } = parseUpdatesLines(["", "not-json", "{}"]);
assert.deepEqual(tools, []);
assert.deepEqual(agents, []);
assert.deepEqual(todos, []);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import {
clampPercent,
contextPercentFromSignals,
formatTokenCount,
renderBar,
formatDuration,
projectLabel,
} from "../src/bar.js";
describe("bar", () => {
it("derives context percent from contextWindowUsage", () => {
assert.equal(
contextPercentFromSignals({
contextWindowUsage: 37,
contextTokensUsed: 190000,
contextWindowTokens: 500000,
}),
37,
);
});
it("falls back to used/window ratio", () => {
assert.equal(
contextPercentFromSignals({
contextTokensUsed: 250000,
contextWindowTokens: 500000,
}),
50,
);
});
it("clamps and formats", () => {
assert.equal(clampPercent(150), 100);
assert.equal(renderBar(37, 10).length, 10);
assert.match(formatTokenCount(190000), /190k|190\.0k/);
assert.equal(formatDuration(4620), "1h 17m");
assert.equal(projectLabel("/Users/dex/demo/CoachFlow", 2), "demo/CoachFlow");
});
});
+86
View File
@@ -0,0 +1,86 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizeBillingPayload,
getCreditUsage,
clearUsageCache,
} from "../src/billing.js";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
describe("billing", () => {
it("normalizes present monthly used/limit fixture ({val})", () => {
const body = JSON.parse(
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
);
const u = normalizeBillingPayload(body);
assert.equal(u.available, true);
assert.equal(u.used, 17510);
assert.equal(u.limit, 150000);
assert.ok(u.percent != null);
assert.ok(Math.abs((u.percent ?? 0) - (17510 / 150000) * 100) < 0.05);
assert.equal(u.period, "monthly");
});
it("normalizes weekly creditUsagePercent fixture", () => {
const body = JSON.parse(
fs.readFileSync(
path.join(root, "fixtures", "billing-weekly-percent.json"),
"utf8",
),
);
const u = normalizeBillingPayload(body);
assert.equal(u.available, true);
assert.equal(u.percent, 22);
assert.equal(u.period, "weekly");
assert.match(u.message ?? "", /GrokBuild/);
});
it("missing payload is unavailable without throw", () => {
const u = normalizeBillingPayload(null);
assert.equal(u.available, false);
assert.match(u.message ?? "", /unavailable/i);
});
it("getCreditUsage degrades when auth missing", async () => {
clearUsageCache();
const u = await getCreditUsage("/tmp/does-not-exist-grok-home-xyz", {
enabled: true,
cacheTtlMs: 1,
}, {
readAuth: () => null,
});
assert.equal(u.available, false);
assert.match(u.message ?? "", /unavailable|auth/i);
});
it("getCreditUsage uses injected fetch payload", async () => {
clearUsageCache();
const weekly = JSON.parse(
fs.readFileSync(
path.join(root, "fixtures", "billing-weekly-percent.json"),
"utf8",
),
);
const monthly = JSON.parse(
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
);
let n = 0;
const u = await getCreditUsage("/tmp/fake-home-billing-inject", { cacheTtlMs: 1 }, {
readAuth: () => ({ token: "test-token" }),
fetchJson: async (url) => {
n += 1;
const body = url.includes("format=credits") ? weekly : monthly;
return { ok: true, status: 200, body };
},
});
assert.equal(u.available, true);
assert.equal(u.percent, 22);
assert.equal(u.used, 17510);
assert.equal(u.limit, 150000);
assert.ok(n >= 1);
});
});
+73
View File
@@ -0,0 +1,73 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs, runCli, helpText } from "../src/index.js";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const fixtureSession = path.join(root, "fixtures", "session");
describe("cli", () => {
it("parseArgs handles watch and session-dir", () => {
const o = parseArgs([
"--watch",
"--session-dir",
"/tmp/x",
"--no-usage",
"--max-iterations",
"2",
]);
assert.equal(o.watch, true);
assert.equal(o.sessionDir, "/tmp/x");
assert.equal(o.noUsage, true);
assert.equal(o.maxIterations, 2);
});
it("runCli one-shot against fixture exits 0 with context", async () => {
const chunks: string[] = [];
const code = await runCli(
["--once", "--session-dir", fixtureSession, "--no-usage", "--no-color"],
{
stdout: (s) => chunks.push(s),
stderr: () => {},
},
);
assert.equal(code, 0);
const text = chunks.join("\n");
assert.match(text, /Context/);
assert.match(text, /37%/);
assert.match(text, /190k|500k/);
});
it("runCli watch with max-iterations produces frames", async () => {
const chunks: string[] = [];
const code = await runCli(
[
"--watch",
"--session-dir",
fixtureSession,
"--no-usage",
"--no-color",
"--max-iterations",
"2",
"--interval",
"10",
],
{
stdout: (s) => chunks.push(s),
stderr: () => {},
sleep: async () => {},
},
);
assert.equal(code, 0);
const text = chunks.join("\n");
assert.match(text, /37%/);
// at least two frames
const hits = text.match(/37%/g) ?? [];
assert.ok(hits.length >= 2, `expected ≥2 frames, got ${hits.length}`);
});
it("help is non-empty", () => {
assert.match(helpText(), /grok-build-hud/);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
titleLine,
refreshDashboard,
runDashboardLoop,
ttyForPid,
} from "../src/dashboard.js";
import { loadSnapshotFromDir } from "../src/session.js";
const pkgRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
describe("dashboard", () => {
it("titleLine includes ctx percent", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
const t = titleLine(snap, {
available: true,
percent: 22,
period: "weekly",
used: 17510,
limit: 150000,
});
assert.match(t, /◆/);
assert.match(t, /37%/);
assert.match(t, /22%|quota/);
});
it("ttyForPid returns null for bogus pid", () => {
assert.equal(ttyForPid(99999999), null);
});
it("refreshDashboard writes status files for fixture home", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-dash-"));
const sid = "fixture-session-001";
const dir = path.join(tmp, "sessions", "proj", sid);
fs.mkdirSync(dir, { recursive: true });
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
}
fs.writeFileSync(
path.join(tmp, "active_sessions.json"),
JSON.stringify([
{ session_id: sid, pid: process.pid, cwd: "/Users/dex/demo/CoachFlow" },
]),
);
const r = await refreshDashboard({ grokHome: tmp, noUsage: true });
assert.ok(r.session);
assert.match(r.title, /37%/);
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-status.txt")));
fs.rmSync(tmp, { recursive: true, force: true });
});
it("runDashboardLoop respects maxIterations", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-loop-"));
const sid = "fixture-session-001";
const dir = path.join(tmp, "sessions", "proj", sid);
fs.mkdirSync(dir, { recursive: true });
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
}
const code = await runDashboardLoop({
grokHome: tmp,
intervalMs: 1,
maxIterations: 2,
noUsage: true,
writePid: true,
sleep: async () => {},
});
assert.equal(code, 0);
fs.rmSync(tmp, { recursive: true, force: true });
});
});
+140
View File
@@ -0,0 +1,140 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
parseHookPayload,
resolveSessionForHook,
runHookTick,
shouldEmitAnnotation,
} from "../src/hook.js";
import { findSessionDirById } from "../src/session.js";
import { formatCompactLine, writeStatusFiles } from "../src/status.js";
import { loadSnapshotFromDir } from "../src/session.js";
import { installGlobalHooks, buildHookManifest } from "../src/install.js";
const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
describe("hook / live status", () => {
it("parses hook payload sessionId", () => {
const p = parseHookPayload(
JSON.stringify({ sessionId: "abc", cwd: "/tmp/x", hookEventName: "stop" }),
);
assert.equal(p.sessionId, "abc");
assert.equal(p.cwd, "/tmp/x");
});
it("formatCompactLine includes context percent from fixture", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
assert.ok(snap);
const line = formatCompactLine(snap, null);
assert.match(line, /\[hud\]/);
assert.match(line, /37%/);
assert.match(line, /Grok 4\.5|ctx|tools/);
});
it("writeStatusFiles creates status-line.txt", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-"));
const snap = loadSnapshotFromDir(fixtureSession)!;
const r = writeStatusFiles(snap, null, tmp);
assert.ok(fs.existsSync(r.compactPath));
const text = fs.readFileSync(r.compactPath, "utf8");
assert.match(text, /37%/);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("runHookTick against fixture via session-dir home layout", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-home-"));
// Build mini grok home with fixture session
const sid = "fixture-session-001";
const dir = path.join(
tmp,
"sessions",
encodeURIComponent("/Users/dex/demo/CoachFlow"),
sid,
);
fs.mkdirSync(dir, { recursive: true });
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
}
fs.writeFileSync(
path.join(tmp, "active_sessions.json"),
JSON.stringify([
{
session_id: sid,
pid: process.pid,
cwd: "/Users/dex/demo/CoachFlow",
},
]),
);
const found = findSessionDirById(tmp, sid);
assert.ok(found);
const result = await runHookTick({
payloadRaw: JSON.stringify({ sessionId: sid, hookEventName: "stop" }),
env: {
GROK_SESSION_ID: sid,
GROK_HOOK_EVENT: "stop",
},
grokHome: tmp,
forceAnnotate: true,
noUsage: true,
});
assert.equal(result.code, 0);
assert.ok(result.compact);
assert.match(result.compact!, /37%/);
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
fs.rmSync(tmp, { recursive: true, force: true });
});
it("shouldEmitAnnotation always true for Stop", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-th-"));
assert.equal(shouldEmitAnnotation("stop", tmp), true);
assert.equal(shouldEmitAnnotation("Stop", tmp), true);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("buildHookManifest points at node + hook.js", () => {
const m = buildHookManifest("/abs/hook.js") as {
hooks: { Stop: { hooks: { command: string }[] }[] };
};
assert.match(m.hooks.Stop[0]!.hooks[0]!.command, /hook\.js/);
});
it("installGlobalHooks writes ~/.grok style hooks file", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-inst-"));
// ensure dist hook exists (built)
const hookJs = path.join(pkgRoot, "dist", "src", "hook.js");
assert.ok(fs.existsSync(hookJs), "dist must be built before this test");
const { hooksPath } = installGlobalHooks({ grokHome: tmp, root: pkgRoot });
assert.ok(fs.existsSync(hooksPath));
const body = fs.readFileSync(hooksPath, "utf8");
assert.match(body, /Stop/);
assert.match(body, /hook\.js/);
fs.rmSync(tmp, { recursive: true, force: true });
});
it("resolveSessionForHook uses env session id", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-res-"));
const sid = "fixture-session-001";
const dir = path.join(tmp, "sessions", "proj", sid);
fs.mkdirSync(dir, { recursive: true });
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
}
const snap = resolveSessionForHook(
{},
{ GROK_SESSION_ID: sid },
tmp,
);
assert.ok(snap);
assert.equal(snap!.sessionId, sid);
assert.equal(snap!.contextPercent, 37);
fs.rmSync(tmp, { recursive: true, force: true });
});
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadSnapshotFromDir } from "../src/session.js";
import { renderHud, renderTmux } from "../src/render.js";
import { contextPercentFromSignals } from "../src/bar.js";
import type { UsageSnapshot } from "../src/types.js";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const fixtureSession = path.join(root, "fixtures", "session");
describe("session + render", () => {
it("loads fixture session with known context values", () => {
const snap = loadSnapshotFromDir(fixtureSession);
assert.ok(snap);
assert.equal(snap!.sessionId, "fixture-session-001");
assert.equal(snap!.model, "grok-4.5");
assert.equal(snap!.cwd, "/Users/dex/demo/CoachFlow");
assert.equal(snap!.contextPercent, 37);
assert.equal(snap!.contextTokensUsed, 190000);
assert.equal(snap!.contextWindowTokens, 500000);
// Must use same formula as production bar module
assert.equal(
snap!.contextPercent,
contextPercentFromSignals(snap!.signals),
);
assert.ok(snap!.tools.length > 0);
});
it("renders HUD with context bar matching fixture percent", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
const usage: UsageSnapshot = {
available: true,
percent: 22,
used: 17510,
limit: 150000,
period: "weekly",
message: "GrokBuild 9%",
};
const text = renderHud(snap, usage, { color: false });
assert.match(text, /Context/);
assert.match(text, /37%/);
assert.match(text, /190k\/500k|190\.0k\/500k/);
assert.match(text, /Usage|Quota|22%/);
assert.match(text, /22%/);
assert.match(text, /Grok|grok/i);
assert.match(text, /CoachFlow/);
});
it("renders when usage unavailable without crash", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
const text = renderHud(
snap,
{ available: false, message: "usage unavailable" },
{ color: false },
);
assert.match(text, /37%/);
assert.match(text, /unavailable/i);
});
it("tmux line includes context percent", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
const line = renderTmux(snap, null, {
color: false,
tmux: true,
compact: false,
pathLevels: 2,
warningThreshold: 70,
criticalThreshold: 90,
});
assert.match(line, /ctx 37%/);
});
});
+83
View File
@@ -0,0 +1,83 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadSnapshotFromDir } from "../src/session.js";
import {
formatStatusBlock,
formatTmuxStatusLines,
writeStatusFiles,
} from "../src/status.js";
import { PRESET_FULL } from "../src/hud-config.js";
import { THEME_TOKYONIGHT } from "../src/theme.js";
import fs from "node:fs";
import os from "node:os";
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const fixture = path.join(root, "fixtures", "session");
describe("Claude-HUD parity status", () => {
it("formatStatusBlock has model line + context + usage lines", () => {
const snap = loadSnapshotFromDir(fixture)!;
const text = formatStatusBlock(
snap,
{
available: true,
percent: 23,
period: "weekly",
used: 18000,
limit: 150000,
resetsIn: "4d",
message: "GrokBuild 10%",
},
PRESET_FULL,
);
assert.match(text, /\[Grok 4\.5\]/);
assert.match(text, /Context/);
assert.match(text, /37%/);
assert.match(text, /Usage/);
assert.match(text, /23%/);
// multi-line
assert.ok(text.split("\n").length >= 2);
});
it("formatTmuxStatusLines returns 3 rows in full preset", () => {
const snap = loadSnapshotFromDir(fixture)!;
// inject todos/agents for line 3
snap.todos = [
{ content: "Ship HUD parity", status: "in_progress" },
{ content: "Write tests", status: "completed" },
];
const lines = formatTmuxStatusLines(
snap,
{ available: true, percent: 22, period: "weekly" },
THEME_TOKYONIGHT,
PRESET_FULL,
);
assert.equal(lines.length, 3);
assert.match(lines[0]!, /Grok 4\.5|CoachFlow/);
assert.match(lines[1]!, /Context|Usage|22%|37%/);
});
it("writeStatusFiles emits tmux-lines.txt", () => {
const snap = loadSnapshotFromDir(fixture)!;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-full-"));
// point grok home at tmp with config
fs.mkdirSync(path.join(tmp, "hud"), { recursive: true });
fs.writeFileSync(
path.join(tmp, "hud", "config.json"),
JSON.stringify(PRESET_FULL, null, 2),
);
// session needs to be written via writeStatusFiles with grokHome=tmp
// but session is loaded from fixture; OK
const r = writeStatusFiles(
snap,
{ available: true, percent: 20, period: "weekly" },
tmp,
);
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-lines.txt")));
assert.ok(r.tmuxLines.length >= 2);
assert.match(r.full, /Context/);
fs.rmSync(tmp, { recursive: true, force: true });
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import fs from "node:fs";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { formatTmuxStatusLine } from "../src/status.js";
import { loadSnapshotFromDir } from "../src/session.js";
import {
miniBar,
THEME_TOKYONIGHT,
THEME_GROKDAY,
paletteForGrokTheme,
readGrokUiConfig,
resolveTheme,
normalizeGrokThemeName,
} from "../src/theme.js";
const pkgRoot = path.join(
path.dirname(fileURLToPath(import.meta.url)),
"..",
"..",
);
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
describe("theme", () => {
it("maps Grok theme names to palettes", () => {
assert.equal(normalizeGrokThemeName("TokyoNight"), "tokyonight");
assert.equal(paletteForGrokTheme("tokyonight").name, "tokyonight");
assert.equal(paletteForGrokTheme("grokday").name, "grokday");
assert.equal(paletteForGrokTheme("light").name, "grokday");
assert.equal(paletteForGrokTheme("dark").name, "groknight");
});
it("tokyonight palette uses Tokyo Night ink", () => {
assert.equal(THEME_TOKYONIGHT.value, "#c0caf5");
assert.equal(THEME_TOKYONIGHT.ok, "#9ece6a");
assert.equal(THEME_TOKYONIGHT.mark, "#7aa2f7");
});
it("readGrokUiConfig parses [ui] theme", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg-"));
fs.writeFileSync(
path.join(tmp, "config.toml"),
`[ui]\ntheme = "tokyonight"\nauto_dark_theme = "tokyonight"\nauto_light_theme = "grokday"\n`,
);
const cfg = readGrokUiConfig(tmp);
assert.equal(cfg.theme, "tokyonight");
assert.equal(cfg.autoLightTheme, "grokday");
fs.rmSync(tmp, { recursive: true, force: true });
});
it("resolveTheme follows Grok config when mode is auto", () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg2-"));
fs.writeFileSync(
path.join(tmp, "config.toml"),
`[ui]\ntheme = "tokyonight"\n`,
);
const t = resolveTheme("auto", { GROK_HUD_THEME: "auto" }, { grokHome: tmp });
assert.equal(t.name, "tokyonight");
fs.rmSync(tmp, { recursive: true, force: true });
});
it("miniBar works with tokyonight", () => {
const bar = miniBar(50, 8, THEME_TOKYONIGHT);
assert.match(bar, /#9ece6a|#e0af68|#f7768e/);
});
it("formatTmuxStatusLine uses provided palette colours", () => {
const snap = loadSnapshotFromDir(fixtureSession)!;
const line = formatTmuxStatusLine(
snap,
{ available: true, percent: 22, message: "GrokBuild 9%" },
THEME_TOKYONIGHT,
);
assert.match(line, /#c0caf5|#565f89|#7aa2f7|#9ece6a/);
assert.match(line, /37%/);
});
it("grokday is light-paper friendly", () => {
assert.equal(THEME_GROKDAY.value, "#111111");
assert.match(THEME_GROKDAY.barEmpty, /#dcdcdc/);
});
});
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"resolveJsonModule": true
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"]
}