commit bb6a1321fb73e874ab48df4c7df7c17568d54c37 Author: red chen Date: Fri Jul 17 15:45:25 2026 +0800 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f4e1c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules/ +dist/ +*.log +.DS_Store +*.tsbuildinfo +.coverage +.env +.env.* +*.local +.idea/ +.vscode/ +*.swp +.hud-debug/ diff --git a/.grok-plugin/marketplace.json b/.grok-plugin/marketplace.json new file mode 100644 index 0000000..8200c1c --- /dev/null +++ b/.grok-plugin/marketplace.json @@ -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" + } + ] +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..cfc32fb --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ef2a0ff --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..14fac91 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MIGRATION-FROM-CLAUDE.md b/MIGRATION-FROM-CLAUDE.md new file mode 100644 index 0000000..eb193e9 --- /dev/null +++ b/MIGRATION-FROM-CLAUDE.md @@ -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 **2–3 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 Grok’s speed and pricing. diff --git a/README.md b/README.md new file mode 100644 index 0000000..44552e7 --- /dev/null +++ b/README.md @@ -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 Terminal’s 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 (1–3 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. diff --git a/README.zh-CN.md b/README.zh-CN.md new file mode 100644 index 0000000..3acdc19 --- /dev/null +++ b/README.zh-CN.md @@ -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 为准,可能变更。 diff --git a/bin/grok-build-hud.js b/bin/grok-build-hud.js new file mode 100755 index 0000000..b7ac3ff --- /dev/null +++ b/bin/grok-build-hud.js @@ -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); diff --git a/bin/grok-hud-run.js b/bin/grok-hud-run.js new file mode 100755 index 0000000..16a9cad --- /dev/null +++ b/bin/grok-hud-run.js @@ -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); diff --git a/bin/grok-hud.js b/bin/grok-hud.js new file mode 100755 index 0000000..f0d99d3 --- /dev/null +++ b/bin/grok-hud.js @@ -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); diff --git a/bin/hud-hook.js b/bin/hud-hook.js new file mode 100755 index 0000000..3a149a1 --- /dev/null +++ b/bin/hud-hook.js @@ -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); diff --git a/commands/hud.md b/commands/hud.md new file mode 100644 index 0000000..50e4aca --- /dev/null +++ b/commands/hud.md @@ -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). diff --git a/commands/preset.md b/commands/preset.md new file mode 100644 index 0000000..804b006 --- /dev/null +++ b/commands/preset.md @@ -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 +``` diff --git a/commands/quota.md b/commands/quota.md new file mode 100644 index 0000000..d643199 --- /dev/null +++ b/commands/quota.md @@ -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`. diff --git a/commands/setup.md b/commands/setup.md new file mode 100644 index 0000000..416c91f --- /dev/null +++ b/commands/setup.md @@ -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 **2–3 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 +``` diff --git a/commands/status.md b/commands/status.md new file mode 100644 index 0000000..e7a19a5 --- /dev/null +++ b/commands/status.md @@ -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 +``` diff --git a/commands/watch.md b/commands/watch.md new file mode 100644 index 0000000..9da1934 --- /dev/null +++ b/commands/watch.md @@ -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 +``` diff --git a/fixtures/billing-present.json b/fixtures/billing-present.json new file mode 100644 index 0000000..6071e7a --- /dev/null +++ b/fixtures/billing-present.json @@ -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" + } +} diff --git a/fixtures/billing-weekly-percent.json b/fixtures/billing-weekly-percent.json new file mode 100644 index 0000000..949330e --- /dev/null +++ b/fixtures/billing-weekly-percent.json @@ -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" + } +} diff --git a/fixtures/session/signals.json b/fixtures/session/signals.json new file mode 100644 index 0000000..31a46b3 --- /dev/null +++ b/fixtures/session/signals.json @@ -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 +} diff --git a/fixtures/session/summary.json b/fixtures/session/summary.json new file mode 100644 index 0000000..fcbe1b2 --- /dev/null +++ b/fixtures/session/summary.json @@ -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" +} diff --git a/fixtures/session/updates.jsonl b/fixtures/session/updates.jsonl new file mode 100644 index 0000000..d1b16b8 --- /dev/null +++ b/fixtures/session/updates.jsonl @@ -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"}}} diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..16d88b6 --- /dev/null +++ b/hooks/hooks.json @@ -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 + } + ] + } + ] + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..2568c73 --- /dev/null +++ b/package-lock.json @@ -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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..571d45d --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/plugin.json b/plugin.json new file mode 100644 index 0000000..31ea622 --- /dev/null +++ b/plugin.json @@ -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" +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000..e21ca44 --- /dev/null +++ b/scripts/install.sh @@ -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\"" diff --git a/skills/grok-build-hud/SKILL.md b/skills/grok-build-hud/SKILL.md new file mode 100644 index 0000000..fd35828 --- /dev/null +++ b/skills/grok-build-hud/SKILL.md @@ -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) diff --git a/src/activity.ts b/src/activity.ts new file mode 100644 index 0000000..3ba38d4 --- /dev/null +++ b/src/activity.ts @@ -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(); + 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; + try { + obj = JSON.parse(line) as Record; + } catch { + continue; + } + + const params = (obj.params as Record | undefined) ?? {}; + const update = + (params.update as Record | undefined) ?? + (obj.update as Record | 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(); + 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; + 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; + 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; + 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) + "…"; +} diff --git a/src/bar.ts b/src/bar.ts new file mode 100644 index 0000000..58f999c --- /dev/null +++ b/src/bar.ts @@ -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("/"); +} diff --git a/src/billing.ts b/src/billing.ts new file mode 100644 index 0000000..fa406ad --- /dev/null +++ b/src/billing.ts @@ -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, + ) => 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(); + +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 + >; + 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 | null { + return v && typeof v === "object" ? (v as Record) : 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; + 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, +): 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 { + 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 = { + 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" }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..ae0342c --- /dev/null +++ b/src/config.ts @@ -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 }; +} diff --git a/src/dashboard.ts b/src/dashboard.ts new file mode 100644 index 0000000..03d398d --- /dev/null +++ b/src/dashboard.ts @@ -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; + writePid?: boolean; +}): Promise { + 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); + } +} diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..5bdadde --- /dev/null +++ b/src/git.ts @@ -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 }; + } +} diff --git a/src/hook.ts b/src/hook.ts new file mode 100644 index 0000000..45139b3 --- /dev/null +++ b/src/hook.ts @@ -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 { + 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)); +} diff --git a/src/hud-config.ts b/src/hud-config.ts new file mode 100644 index 0000000..f63f611 --- /dev/null +++ b/src/hud-config.ts @@ -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 (1–3). 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; + 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 = { + "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", +}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..477d5a2 --- /dev/null +++ b/src/index.ts @@ -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, +): Promise { + 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, + 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; + } = {}, +): Promise { + 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 => { + 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); + }); +} + diff --git a/src/install-dashboard.ts b/src/install-dashboard.ts new file mode 100644 index 0000000..511199a --- /dev/null +++ b/src/install-dashboard.ts @@ -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 = ` + + + + Label + com.dex.grok-hud-dashboard + ProgramArguments + + ${node} + ${entry} + --dashboard + --follow-active + --interval + 500 + + RunAtLoad + + KeepAlive + + StandardOutPath + ${log} + StandardErrorPath + ${log} + EnvironmentVariables + + HOME + ${os.homedir()} + PATH + /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + + + +`; + 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 */ + } + } +} diff --git a/src/install.ts b/src/install.ts new file mode 100644 index 0000000..3e37253 --- /dev/null +++ b/src/install.ts @@ -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; diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..d067e62 --- /dev/null +++ b/src/render.ts @@ -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 = {}, +): 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, + ); +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..f276f02 --- /dev/null +++ b/src/session.ts @@ -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(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(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// + 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(signalsPath) ?? {}; + const summary = + readJsonFile(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/*/ + 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; + } + } +} diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..ae75b59 --- /dev/null +++ b/src/status.ts @@ -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, + }; +} diff --git a/src/theme.ts b/src/theme.ts new file mode 100644 index 0000000..7d0e3ba --- /dev/null +++ b/src/theme.ts @@ -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 = { + 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): 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", + }; +} diff --git a/src/tmux-hud.ts b/src/tmux-hud.ts new file mode 100644 index 0000000..44e4c86 --- /dev/null +++ b/src/tmux-hud.ts @@ -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 }; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..931294a --- /dev/null +++ b/src/types.ts @@ -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; +} diff --git a/tests/activity.test.ts b/tests/activity.test.ts new file mode 100644 index 0000000..8094dea --- /dev/null +++ b/tests/activity.test.ts @@ -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, []); + }); +}); diff --git a/tests/bar.test.ts b/tests/bar.test.ts new file mode 100644 index 0000000..3d932b2 --- /dev/null +++ b/tests/bar.test.ts @@ -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"); + }); +}); diff --git a/tests/billing.test.ts b/tests/billing.test.ts new file mode 100644 index 0000000..61a2c8f --- /dev/null +++ b/tests/billing.test.ts @@ -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); + }); +}); diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..647a5d7 --- /dev/null +++ b/tests/cli.test.ts @@ -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/); + }); +}); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts new file mode 100644 index 0000000..ba4489f --- /dev/null +++ b/tests/dashboard.test.ts @@ -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 }); + }); +}); diff --git a/tests/hook.test.ts b/tests/hook.test.ts new file mode 100644 index 0000000..dd0eea0 --- /dev/null +++ b/tests/hook.test.ts @@ -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 }); + }); +}); diff --git a/tests/session-render.test.ts b/tests/session-render.test.ts new file mode 100644 index 0000000..b607a33 --- /dev/null +++ b/tests/session-render.test.ts @@ -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%/); + }); +}); diff --git a/tests/status-parity.test.ts b/tests/status-parity.test.ts new file mode 100644 index 0000000..551123e --- /dev/null +++ b/tests/status-parity.test.ts @@ -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 }); + }); +}); diff --git a/tests/theme.test.ts b/tests/theme.test.ts new file mode 100644 index 0000000..40b7880 --- /dev/null +++ b/tests/theme.test.ts @@ -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/); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8deb75 --- /dev/null +++ b/tsconfig.json @@ -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"] +}