Release grok-build-hud v0.3.0: Claude-HUD-style same-window status for Grok Build
Multi-line tmux strip (context, quota, tools, todos, git), theme sync with Grok UI, full/essential/minimal presets, one-shot installer, EN+ZH docs, and 40 unit tests.
This commit is contained in:
+13
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.coverage
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
.hud-debug/
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+186
@@ -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 为准,可能变更。
|
||||
Executable
+26
@@ -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);
|
||||
Executable
+24
@@ -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);
|
||||
Executable
+37
@@ -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);
|
||||
Executable
+8
@@ -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);
|
||||
@@ -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).
|
||||
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"}}}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Generated
+54
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "grok-build-hud",
|
||||
"version": "0.3.0",
|
||||
"description": "Claude-HUD-style live status for Grok Build: multi-line context, quota, tools, todos, git — same-window tmux strip + theme sync",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
"grok-build-hud": "bin/grok-build-hud.js",
|
||||
"grok-hud": "bin/grok-hud.js",
|
||||
"grok-hud-run": "bin/grok-hud-run.js"
|
||||
},
|
||||
"files": [
|
||||
"dist/src/",
|
||||
"bin/",
|
||||
"commands/",
|
||||
"skills/",
|
||||
"hooks/",
|
||||
"plugin.json",
|
||||
".grok-plugin/",
|
||||
"README.md",
|
||||
"README.zh-CN.md",
|
||||
"MIGRATION-FROM-CLAUDE.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"scripts/",
|
||||
"fixtures/"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "npm run build && node --test dist/tests/*.test.js",
|
||||
"start": "node dist/src/index.js",
|
||||
"once": "node dist/src/index.js --once --follow-active",
|
||||
"watch": "node dist/src/index.js --watch --follow-active",
|
||||
"install-local": "npm run build && node dist/src/index.js --install-dashboard && node dist/src/index.js --theme auto && node dist/src/index.js --preset full",
|
||||
"setup": "bash scripts/install.sh",
|
||||
"plugin:validate": "grok plugin validate ."
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"keywords": [
|
||||
"grok",
|
||||
"xai",
|
||||
"grok-build",
|
||||
"hud",
|
||||
"statusline",
|
||||
"claude-hud",
|
||||
"tmux",
|
||||
"cli",
|
||||
"context",
|
||||
"quota"
|
||||
],
|
||||
"author": "dex",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/sooneocean/grok-build-hud.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/sooneocean/grok-build-hud/issues"
|
||||
},
|
||||
"homepage": "https://github.com/sooneocean/grok-build-hud#readme",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.13.10",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
Executable
+57
@@ -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\""
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: grok-build-hud
|
||||
description: Explain and operate the Claude-HUD-style external status HUD for Grok Build (same-window multi-line context, quota, tools, todos, theme sync).
|
||||
---
|
||||
|
||||
# grok-build-hud
|
||||
|
||||
Grok Build has **no** Claude Code statusline API. This package provides a Claude-HUD-like always-on strip via **same-window tmux status** plus optional scrollback annotations.
|
||||
|
||||
## What it shows
|
||||
|
||||
- Line 1: model · project · git · live · title · effort
|
||||
- Line 2: context bar + tokens · weekly/monthly usage · time · turns · tools · errors · diff
|
||||
- Line 3: tool activity · agents · todos · GrokBuild product share
|
||||
|
||||
Data sources (local):
|
||||
|
||||
- `~/.grok/sessions/**/signals.json` — context window
|
||||
- `updates.jsonl` — tools / agents / todos
|
||||
- `summary.json` — title, effort, model
|
||||
- Grok auth → `cli-chat-proxy` billing for quota
|
||||
|
||||
## Install once
|
||||
|
||||
```bash
|
||||
cd /path/to/grok-build-hud
|
||||
bash scripts/install.sh
|
||||
# or: npm run install-local
|
||||
```
|
||||
|
||||
## Daily
|
||||
|
||||
```bash
|
||||
grok-hud-run # Grok + bottom HUD (same tab)
|
||||
grok-hud status # one-shot print
|
||||
grok-build-hud --preset full|essential|minimal
|
||||
grok-build-hud --theme auto # follow Grok [ui].theme
|
||||
grok-hud stop # stop dashboard daemon
|
||||
```
|
||||
|
||||
## In-session slash commands (if plugin enabled)
|
||||
|
||||
- `/hud` `/status` `/quota` `/preset` `/setup` `/watch` — see `commands/`
|
||||
|
||||
## Config
|
||||
|
||||
`~/.grok/hud/config.json` — presets, `bold`, `barWidth`, display toggles.
|
||||
|
||||
Full docs: [README.md](../../README.md) · 中文: [README.zh-CN.md](../../README.zh-CN.md) · Claude migrate: [MIGRATION-FROM-CLAUDE.md](../../MIGRATION-FROM-CLAUDE.md)
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import fs from "node:fs";
|
||||
import type {
|
||||
AgentActivityItem,
|
||||
TodoItem,
|
||||
ToolActivityItem,
|
||||
} from "./types.js";
|
||||
|
||||
interface ToolState {
|
||||
id: string;
|
||||
name: string;
|
||||
status: ToolActivityItem["status"];
|
||||
detail?: string;
|
||||
lastTs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Grok `updates.jsonl` for tool / agent activity.
|
||||
* Pure-ish: accepts optional pre-split lines for tests.
|
||||
*/
|
||||
export function parseUpdatesLines(lines: string[]): {
|
||||
tools: ToolActivityItem[];
|
||||
agents: AgentActivityItem[];
|
||||
todos: TodoItem[];
|
||||
} {
|
||||
const tools = new Map<string, ToolState>();
|
||||
const agents: AgentActivityItem[] = [];
|
||||
let todos: TodoItem[] = [];
|
||||
let lineNo = 0;
|
||||
|
||||
for (const raw of lines) {
|
||||
lineNo += 1;
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
let obj: Record<string, unknown>;
|
||||
try {
|
||||
obj = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const params = (obj.params as Record<string, unknown> | undefined) ?? {};
|
||||
const update =
|
||||
(params.update as Record<string, unknown> | undefined) ??
|
||||
(obj.update as Record<string, unknown> | undefined) ??
|
||||
{};
|
||||
const kind =
|
||||
(update.sessionUpdate as string | undefined) ??
|
||||
(obj.sessionUpdate as string | undefined) ??
|
||||
"";
|
||||
|
||||
const ts =
|
||||
typeof obj.timestamp === "number"
|
||||
? obj.timestamp
|
||||
: typeof obj.ts === "string"
|
||||
? Date.parse(obj.ts) / 1000
|
||||
: lineNo;
|
||||
|
||||
if (kind === "tool_call" || kind === "tool_call_update") {
|
||||
const id =
|
||||
(update.toolCallId as string | undefined) ??
|
||||
(update.id as string | undefined) ??
|
||||
`tool-${lineNo}`;
|
||||
const title = (update.title as string | undefined) ?? "";
|
||||
const statusRaw = (update.status as string | undefined) ?? "";
|
||||
const prev = tools.get(id);
|
||||
|
||||
let name = prev?.name ?? "tool";
|
||||
if (title && !title.includes(" ") && title.length < 64) {
|
||||
name = title;
|
||||
} else if (title && prev?.name === "tool") {
|
||||
// Prefer short tool name from earlier tool_call event
|
||||
name = prev.name;
|
||||
}
|
||||
// On initial tool_call, title is the tool name
|
||||
if (kind === "tool_call" && title) {
|
||||
name = title.split(/\s+/)[0] || title;
|
||||
}
|
||||
|
||||
let status: ToolActivityItem["status"] = prev?.status ?? "running";
|
||||
if (statusRaw === "completed" || statusRaw === "complete" || statusRaw === "success") {
|
||||
status = "completed";
|
||||
} else if (statusRaw === "failed" || statusRaw === "error") {
|
||||
status = "failed";
|
||||
} else if (kind === "tool_call") {
|
||||
status = "running";
|
||||
}
|
||||
|
||||
let detail = prev?.detail;
|
||||
if (kind === "tool_call_update" && title && title.length < 120) {
|
||||
detail = title;
|
||||
}
|
||||
const rawInput = update.rawInput;
|
||||
if (!detail && rawInput != null) {
|
||||
detail = summarizeRawInput(rawInput);
|
||||
}
|
||||
|
||||
tools.set(id, {
|
||||
id,
|
||||
name,
|
||||
status,
|
||||
detail,
|
||||
lastTs: ts,
|
||||
});
|
||||
|
||||
// Capture latest todo list from todo_write
|
||||
if (
|
||||
name === "todo_write" ||
|
||||
name === "TodoWrite" ||
|
||||
(typeof title === "string" && /todo/i.test(title))
|
||||
) {
|
||||
const extracted = extractTodos(rawInput ?? update);
|
||||
if (extracted.length) todos = extracted;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
kind === "agent_message_chunk" ||
|
||||
kind === "agent_thought_chunk" ||
|
||||
kind.includes("agent")
|
||||
) {
|
||||
const title =
|
||||
(update.title as string | undefined) ??
|
||||
(update.agentType as string | undefined) ??
|
||||
(update.name as string | undefined);
|
||||
if (title || kind === "agent_message_chunk") {
|
||||
agents.push({
|
||||
id: (update.agentId as string | undefined) ?? (update.toolCallId as string | undefined),
|
||||
title: title ?? kind,
|
||||
status: (update.status as string | undefined) ?? "active",
|
||||
detail:
|
||||
typeof update.content === "string"
|
||||
? update.content.slice(0, 80)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate completed tools by name for a compact summary
|
||||
const sorted = [...tools.values()].sort((a, b) => b.lastTs - a.lastTs);
|
||||
const recent = sorted.slice(0, 12);
|
||||
const completedCounts = new Map<string, number>();
|
||||
const items: ToolActivityItem[] = [];
|
||||
|
||||
for (const t of recent) {
|
||||
if (t.status === "running") {
|
||||
items.push({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
status: "running",
|
||||
detail: t.detail,
|
||||
});
|
||||
} else {
|
||||
completedCounts.set(t.name, (completedCounts.get(t.name) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, count] of completedCounts) {
|
||||
items.push({
|
||||
id: `agg-${name}`,
|
||||
name,
|
||||
status: "completed",
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
// Keep running first, then completed aggregates
|
||||
items.sort((a, b) => {
|
||||
if (a.status === "running" && b.status !== "running") return -1;
|
||||
if (b.status === "running" && a.status !== "running") return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return {
|
||||
tools: items.slice(0, 8),
|
||||
agents: agents.slice(-3),
|
||||
todos,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUpdatesFile(
|
||||
filePath: string,
|
||||
options: { maxTailBytes?: number } = {},
|
||||
): {
|
||||
tools: ToolActivityItem[];
|
||||
agents: AgentActivityItem[];
|
||||
todos: TodoItem[];
|
||||
} {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { tools: [], agents: [], todos: [] };
|
||||
}
|
||||
const maxTail = options.maxTailBytes ?? 256_000;
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
let content: string;
|
||||
if (stat.size <= maxTail) {
|
||||
content = fs.readFileSync(filePath, "utf8");
|
||||
} else {
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
try {
|
||||
const buf = Buffer.alloc(maxTail);
|
||||
fs.readSync(fd, buf, 0, maxTail, stat.size - maxTail);
|
||||
content = buf.toString("utf8");
|
||||
// Drop partial first line
|
||||
const nl = content.indexOf("\n");
|
||||
if (nl >= 0) content = content.slice(nl + 1);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
return parseUpdatesLines(content.split(/\r?\n/));
|
||||
} catch {
|
||||
return { tools: [], agents: [], todos: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function extractTodos(raw: unknown): TodoItem[] {
|
||||
if (raw == null) return [];
|
||||
let obj: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
obj = JSON.parse(raw.replace(/'/g, '"'));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!obj || typeof obj !== "object") return [];
|
||||
const o = obj as Record<string, unknown>;
|
||||
const list = (o.todos ?? o.TodoWrite ?? o.items) as unknown;
|
||||
if (!Array.isArray(list)) return [];
|
||||
const out: TodoItem[] = [];
|
||||
for (const item of list) {
|
||||
if (!item || typeof item !== "object") continue;
|
||||
const t = item as Record<string, unknown>;
|
||||
const content = String(t.content ?? t.title ?? t.text ?? "").trim();
|
||||
if (!content) continue;
|
||||
out.push({
|
||||
content,
|
||||
status: String(t.status ?? "pending"),
|
||||
id: t.id != null ? String(t.id) : undefined,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function summarizeRawInput(raw: unknown): string | undefined {
|
||||
if (raw == null) return undefined;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
return summarizeRawInput(JSON.parse(raw.replace(/'/g, '"')));
|
||||
} catch {
|
||||
return raw.slice(0, 60);
|
||||
}
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
const o = raw as Record<string, unknown>;
|
||||
const command = o.command ?? o.target_file ?? o.path ?? o.query ?? o.pattern;
|
||||
if (typeof command === "string") return command.slice(0, 60);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */
|
||||
export function formatToolLine(tools: ToolActivityItem[]): string {
|
||||
if (!tools.length) return "";
|
||||
const parts: string[] = [];
|
||||
for (const t of tools) {
|
||||
if (t.status === "running") {
|
||||
const d = t.detail ? `: ${truncate(t.detail, 40)}` : "";
|
||||
parts.push(`◐ ${t.name}${d}`);
|
||||
} else if (t.status === "failed") {
|
||||
parts.push(`✗ ${t.name}`);
|
||||
} else {
|
||||
const n = t.count && t.count > 1 ? ` ×${t.count}` : "";
|
||||
parts.push(`✓ ${t.name}${n}`);
|
||||
}
|
||||
}
|
||||
return parts.join(" | ");
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n - 1) + "…";
|
||||
}
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Progress bar helpers (pure).
|
||||
*/
|
||||
|
||||
export function clampPercent(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
if (value < 0) return 0;
|
||||
if (value > 100) return 100;
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Derive percent from explicit usage or used/window tokens. */
|
||||
export function contextPercentFromSignals(input: {
|
||||
contextWindowUsage?: number;
|
||||
contextTokensUsed?: number;
|
||||
contextWindowTokens?: number;
|
||||
}): number {
|
||||
if (
|
||||
typeof input.contextWindowUsage === "number" &&
|
||||
Number.isFinite(input.contextWindowUsage)
|
||||
) {
|
||||
return clampPercent(input.contextWindowUsage);
|
||||
}
|
||||
const used = input.contextTokensUsed ?? 0;
|
||||
const window = input.contextWindowTokens ?? 0;
|
||||
if (window > 0) {
|
||||
return clampPercent((used / window) * 100);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function formatTokenCount(n: number): string {
|
||||
if (!Number.isFinite(n) || n < 0) return "0";
|
||||
if (n < 1000) return String(Math.round(n));
|
||||
if (n < 1_000_000) {
|
||||
const k = n / 1000;
|
||||
return `${k >= 100 ? Math.round(k) : k.toFixed(k >= 10 ? 0 : 1)}k`;
|
||||
}
|
||||
const m = n / 1_000_000;
|
||||
return `${m.toFixed(m >= 10 ? 0 : 1)}M`;
|
||||
}
|
||||
|
||||
export function renderBar(
|
||||
percent: number,
|
||||
width = 10,
|
||||
filledChar = "█",
|
||||
emptyChar = "░",
|
||||
): string {
|
||||
const p = clampPercent(percent);
|
||||
const filled = Math.round((p / 100) * width);
|
||||
const empty = Math.max(0, width - filled);
|
||||
return filledChar.repeat(filled) + emptyChar.repeat(empty);
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return "0s";
|
||||
const s = Math.floor(seconds);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const rem = s % 60;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${rem}s`;
|
||||
return `${rem}s`;
|
||||
}
|
||||
|
||||
export function projectLabel(cwd: string, levels = 2): string {
|
||||
if (!cwd) return "unknown";
|
||||
const parts = cwd.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||
if (parts.length === 0) return cwd;
|
||||
const take = Math.max(1, levels);
|
||||
return parts.slice(-take).join("/");
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { UsageSnapshot } from "./types.js";
|
||||
|
||||
export interface BillingAuth {
|
||||
token: string;
|
||||
teamId?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface BillingFetchDeps {
|
||||
readAuth?: (grokHome: string) => BillingAuth | null;
|
||||
fetchJson?: (
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
) => Promise<{ ok: boolean; status: number; body: unknown }>;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
/** Real Grok Build billing endpoints (session OIDC token). */
|
||||
export const BILLING_CREDITS_URL =
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits";
|
||||
export const BILLING_MONTHLY_URL =
|
||||
"https://cli-chat-proxy.grok.com/v1/billing";
|
||||
|
||||
const cache = new Map<string, { at: number; value: UsageSnapshot }>();
|
||||
|
||||
export function readGrokAuth(grokHome: string): BillingAuth | null {
|
||||
const authPath = path.join(grokHome, "auth.json");
|
||||
try {
|
||||
if (!fs.existsSync(authPath)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(authPath, "utf8")) as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
for (const entry of Object.values(raw)) {
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const token =
|
||||
(entry.key as string | undefined) ??
|
||||
(entry.access_token as string | undefined) ??
|
||||
(entry.token as string | undefined);
|
||||
if (token && typeof token === "string") {
|
||||
return {
|
||||
token,
|
||||
teamId: entry.team_id as string | undefined,
|
||||
email: entry.email as string | undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Unwrap xAI `{ val: number }` or plain number/string. */
|
||||
export function numVal(v: unknown): number | undefined {
|
||||
if (typeof v === "number" && Number.isFinite(v)) return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && !Number.isNaN(Number(v))) {
|
||||
return Number(v);
|
||||
}
|
||||
if (v && typeof v === "object" && "val" in (v as object)) {
|
||||
return numVal((v as { val: unknown }).val);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | null {
|
||||
return v && typeof v === "object" ? (v as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize Grok cli-chat-proxy billing JSON (and generic shapes).
|
||||
*/
|
||||
export function normalizeBillingPayload(body: unknown): UsageSnapshot {
|
||||
if (body == null || typeof body !== "object") {
|
||||
return {
|
||||
available: false,
|
||||
message: "usage unavailable",
|
||||
source: "empty",
|
||||
};
|
||||
}
|
||||
const root = body as Record<string, unknown>;
|
||||
const config = asRecord(root.config) ?? asRecord(root.credits) ?? root;
|
||||
|
||||
// Weekly / unified credits percent (preferred for "quota bar")
|
||||
const creditPct = numVal(config.creditUsagePercent ?? config.percent_used ?? config.percent);
|
||||
let productBreakdown: string | undefined;
|
||||
const products = config.productUsage;
|
||||
if (Array.isArray(products)) {
|
||||
const bits: string[] = [];
|
||||
for (const p of products) {
|
||||
const pr = asRecord(p);
|
||||
if (!pr) continue;
|
||||
const name = String(pr.product ?? "");
|
||||
const pct = numVal(pr.usagePercent);
|
||||
if (name && pct != null) bits.push(`${name} ${Math.round(pct)}%`);
|
||||
}
|
||||
if (bits.length) productBreakdown = bits.join(", ");
|
||||
}
|
||||
|
||||
// Monthly absolute credits
|
||||
const used =
|
||||
numVal(config.used) ??
|
||||
numVal(config.used_credits) ??
|
||||
numVal(config.monthly_used) ??
|
||||
numVal(root.used);
|
||||
const limit =
|
||||
numVal(config.monthlyLimit) ??
|
||||
numVal(config.monthly_limit) ??
|
||||
numVal(config.limit) ??
|
||||
numVal(root.monthlyLimit) ??
|
||||
numVal(root.limit);
|
||||
|
||||
let percent = creditPct;
|
||||
if (percent == null && used != null && limit != null && limit > 0) {
|
||||
percent = (used / limit) * 100;
|
||||
}
|
||||
|
||||
const periodTypeRaw =
|
||||
(asRecord(config.currentPeriod)?.type as string | undefined) ??
|
||||
(config.period as string | undefined);
|
||||
let period = "monthly";
|
||||
if (periodTypeRaw && /week/i.test(periodTypeRaw)) period = "weekly";
|
||||
else if (creditPct != null && used == null) period = "weekly";
|
||||
else if (limit != null) period = "monthly";
|
||||
|
||||
const periodEnd =
|
||||
(asRecord(config.currentPeriod)?.end as string | undefined) ??
|
||||
(config.billingPeriodEnd as string | undefined) ??
|
||||
(config.periodEnd as string | undefined);
|
||||
const resetsIn = formatResets(periodEnd ?? config.reset_at);
|
||||
|
||||
if (percent == null && used == null && limit == null) {
|
||||
return {
|
||||
available: false,
|
||||
message: "usage unavailable",
|
||||
source: "unrecognized",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
percent: percent != null ? Math.max(0, Math.min(100, percent)) : undefined,
|
||||
used: used ?? undefined,
|
||||
limit: limit ?? undefined,
|
||||
period,
|
||||
resetsIn,
|
||||
source: "billing",
|
||||
message: productBreakdown,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge weekly credits view + monthly absolute into one snapshot.
|
||||
* Prefer weekly % for the bar when present; keep monthly used/limit as detail.
|
||||
*/
|
||||
export function mergeBillingSnapshots(
|
||||
credits: UsageSnapshot,
|
||||
monthly: UsageSnapshot,
|
||||
): UsageSnapshot {
|
||||
if (!credits.available && !monthly.available) {
|
||||
return credits.available === false ? credits : monthly;
|
||||
}
|
||||
const percent =
|
||||
credits.available && credits.percent != null
|
||||
? credits.percent
|
||||
: monthly.percent;
|
||||
const period =
|
||||
credits.available && credits.percent != null
|
||||
? credits.period ?? "weekly"
|
||||
: monthly.period ?? "monthly";
|
||||
return {
|
||||
available: true,
|
||||
percent,
|
||||
used: monthly.used ?? credits.used,
|
||||
limit: monthly.limit ?? credits.limit,
|
||||
period,
|
||||
resetsIn: credits.resetsIn ?? monthly.resetsIn,
|
||||
source: "billing",
|
||||
message: credits.message ?? monthly.message,
|
||||
};
|
||||
}
|
||||
|
||||
function formatResets(v: unknown): string | undefined {
|
||||
if (v == null) return undefined;
|
||||
if (typeof v === "string") {
|
||||
const t = Date.parse(v);
|
||||
if (!Number.isNaN(t)) {
|
||||
const sec = Math.max(0, Math.floor((t - Date.now()) / 1000));
|
||||
return humanDuration(sec);
|
||||
}
|
||||
return v;
|
||||
}
|
||||
if (typeof v === "number") {
|
||||
const sec =
|
||||
v > 1e12
|
||||
? Math.floor((v - Date.now()) / 1000)
|
||||
: v > 1e10
|
||||
? Math.floor(v / 1000)
|
||||
: v;
|
||||
return humanDuration(Math.max(0, sec));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function humanDuration(sec: number): string {
|
||||
const d = Math.floor(sec / 86400);
|
||||
const h = Math.floor((sec % 86400) / 3600);
|
||||
if (d > 0) return `${d}d`;
|
||||
if (h > 0) return `${h}h`;
|
||||
const m = Math.floor((sec % 3600) / 60);
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
async function defaultFetchJson(
|
||||
url: string,
|
||||
headers: Record<string, string>,
|
||||
): Promise<{ ok: boolean; status: number; body: unknown }> {
|
||||
const res = await fetch(url, { headers });
|
||||
let body: unknown = null;
|
||||
const text = await res.text();
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = { raw: text.slice(0, 200) };
|
||||
}
|
||||
return { ok: res.ok, status: res.status, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch credit/quota usage. Never throws.
|
||||
* Uses cli-chat-proxy (same as Grok `/usage`).
|
||||
*/
|
||||
export async function getCreditUsage(
|
||||
grokHome: string,
|
||||
options: {
|
||||
cacheTtlMs?: number;
|
||||
enabled?: boolean;
|
||||
} = {},
|
||||
deps: BillingFetchDeps = {},
|
||||
): Promise<UsageSnapshot> {
|
||||
if (options.enabled === false) {
|
||||
return {
|
||||
available: false,
|
||||
message: "usage disabled",
|
||||
source: "disabled",
|
||||
};
|
||||
}
|
||||
|
||||
const cacheTtl = options.cacheTtlMs ?? 60_000;
|
||||
const now = deps.now?.() ?? Date.now();
|
||||
const cacheKey = grokHome;
|
||||
const hit = cache.get(cacheKey);
|
||||
if (hit && now - hit.at < cacheTtl) {
|
||||
return hit.value;
|
||||
}
|
||||
|
||||
// Disk cache for hook processes (each process has empty memory cache)
|
||||
const disk = readDiskCache(grokHome, cacheTtl, now);
|
||||
if (disk) {
|
||||
cache.set(cacheKey, { at: now, value: disk });
|
||||
return disk;
|
||||
}
|
||||
|
||||
const readAuth = deps.readAuth ?? readGrokAuth;
|
||||
const auth = readAuth(grokHome);
|
||||
if (!auth?.token) {
|
||||
const value: UsageSnapshot = {
|
||||
available: false,
|
||||
message: "usage unavailable (no auth)",
|
||||
source: "no-auth",
|
||||
};
|
||||
cache.set(cacheKey, { at: now, value });
|
||||
return value;
|
||||
}
|
||||
|
||||
const fetchJson = deps.fetchJson ?? defaultFetchJson;
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${auth.token}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": "grok-build-hud/0.2",
|
||||
};
|
||||
if (auth.teamId) headers["x-team-id"] = auth.teamId;
|
||||
|
||||
let creditsSnap: UsageSnapshot = {
|
||||
available: false,
|
||||
message: "usage unavailable",
|
||||
source: "fetch-failed",
|
||||
};
|
||||
let monthlySnap: UsageSnapshot = {
|
||||
available: false,
|
||||
message: "usage unavailable",
|
||||
source: "fetch-failed",
|
||||
};
|
||||
|
||||
try {
|
||||
const r = await fetchJson(BILLING_CREDITS_URL, headers);
|
||||
if (r.ok) creditsSnap = normalizeBillingPayload(r.body);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
const r = await fetchJson(BILLING_MONTHLY_URL, headers);
|
||||
if (r.ok) monthlySnap = normalizeBillingPayload(r.body);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Fallback URLs (older / alternate)
|
||||
if (!creditsSnap.available && !monthlySnap.available) {
|
||||
for (const url of [
|
||||
"https://api.x.ai/v1/billing?format=credits",
|
||||
"https://api.x.ai/v1/billing",
|
||||
]) {
|
||||
try {
|
||||
const res = await fetchJson(url, headers);
|
||||
if (!res.ok) continue;
|
||||
const n = normalizeBillingPayload(res.body);
|
||||
if (n.available) {
|
||||
cache.set(cacheKey, { at: now, value: n });
|
||||
writeDiskCache(grokHome, n);
|
||||
return n;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const value = mergeBillingSnapshots(creditsSnap, monthlySnap);
|
||||
cache.set(cacheKey, { at: now, value });
|
||||
if (value.available) writeDiskCache(grokHome, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
function diskCachePath(grokHome: string): string {
|
||||
return path.join(grokHome, "hud", "billing-cache.json");
|
||||
}
|
||||
|
||||
function readDiskCache(
|
||||
grokHome: string,
|
||||
ttlMs: number,
|
||||
now: number,
|
||||
): UsageSnapshot | null {
|
||||
try {
|
||||
const p = diskCachePath(grokHome);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
const raw = JSON.parse(fs.readFileSync(p, "utf8")) as {
|
||||
at: number;
|
||||
value: UsageSnapshot;
|
||||
};
|
||||
if (!raw?.value?.available) return null;
|
||||
if (now - raw.at > ttlMs) return null;
|
||||
return { ...raw.value, source: "billing" };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeDiskCache(grokHome: string, value: UsageSnapshot): void {
|
||||
try {
|
||||
const dir = path.join(grokHome, "hud");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
diskCachePath(grokHome),
|
||||
JSON.stringify({ at: Date.now(), value }, null, 2) + "\n",
|
||||
"utf8",
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function clearUsageCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export function unavailableUsage(message = "usage unavailable"): UsageSnapshot {
|
||||
return { available: false, message, source: "offline" };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Always-on terminal dashboard: pushes ctx+quota into
|
||||
* - Grok TTY window title (OSC)
|
||||
* - Apple Terminal custom title (osascript fallback)
|
||||
* - ~/.grok/hud/status-line.txt (tmux / external)
|
||||
*/
|
||||
import { execFileSync, spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
defaultGrokHome,
|
||||
findSessionDirById,
|
||||
loadActiveSessions,
|
||||
loadSnapshotFromDir,
|
||||
pickBestSession,
|
||||
} from "./session.js";
|
||||
import { getCreditUsage } from "./billing.js";
|
||||
import { writeStatusFiles, formatCompactLine } from "./status.js";
|
||||
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
|
||||
|
||||
export function dashboardPidPath(grokHome = defaultGrokHome()): string {
|
||||
return path.join(grokHome, "hud", "dashboard.pid");
|
||||
}
|
||||
|
||||
export function titleLine(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
): string {
|
||||
const compact = formatCompactLine(session, usage);
|
||||
// Window titles are short — strip [hud] prefix noise
|
||||
return compact.replace(/^\[hud\]\s*/, "◆ ");
|
||||
}
|
||||
|
||||
/** Resolve tty device for a process (e.g. ttys001 → /dev/ttys001). */
|
||||
export function ttyForPid(pid?: number): string | null {
|
||||
if (!pid || pid <= 0) return null;
|
||||
try {
|
||||
const out = execFileSync("ps", ["-p", String(pid), "-o", "tty="], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 1000,
|
||||
}).trim();
|
||||
if (!out || out === "??" || out === "-") return null;
|
||||
const dev = out.startsWith("/dev/") ? out : `/dev/${out}`;
|
||||
if (fs.existsSync(dev)) return dev;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Write OSC title sequence to a tty (updates Terminal/iTerm tab title). */
|
||||
export function writeOscTitle(ttyPath: string, title: string): boolean {
|
||||
try {
|
||||
const safe = title.replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 120);
|
||||
// OSC 0 = icon+title, OSC 2 = title only
|
||||
const seq = `\x1b]0;${safe}\x07`;
|
||||
fs.writeFileSync(ttyPath, seq, { encoding: "utf8", flag: "a" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apple Terminal: set tab/window custom title so it appears in the title bar.
|
||||
* Terminal composes: "cwd — custom title — process — size"
|
||||
* We cannot reliably toggle title-display flags on all locales, but custom
|
||||
* title is always shown when set on the tab.
|
||||
*/
|
||||
export function setAppleTerminalTitle(
|
||||
title: string,
|
||||
options: { ttyHint?: string | null } = {},
|
||||
): boolean {
|
||||
if (process.platform !== "darwin") return false;
|
||||
try {
|
||||
const safe = title
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/[\x00-\x1f\x7f]/g, " ")
|
||||
.slice(0, 100);
|
||||
const tty = (options.ttyHint || "")
|
||||
.replace(/^\/dev\//, "")
|
||||
.replace(/"/g, "");
|
||||
// Prefer matching tab by tty; else set every Terminal tab/window.
|
||||
const script = `
|
||||
tell application "Terminal"
|
||||
set hud to "${safe}"
|
||||
set targetTty to "${tty}"
|
||||
set matched to false
|
||||
if targetTty is not "" then
|
||||
repeat with w in windows
|
||||
repeat with tb in tabs of w
|
||||
try
|
||||
set t to tty of tb as string
|
||||
if t contains targetTty then
|
||||
set custom title of tb to hud
|
||||
try
|
||||
set custom title of w to hud
|
||||
end try
|
||||
set matched to true
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end if
|
||||
if matched is false then
|
||||
repeat with w in windows
|
||||
try
|
||||
set custom title of w to hud
|
||||
end try
|
||||
repeat with tb in tabs of w
|
||||
try
|
||||
set custom title of tb to hud
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end if
|
||||
end tell
|
||||
`;
|
||||
execFileSync("osascript", ["-e", script], {
|
||||
stdio: ["ignore", "ignore", "ignore"],
|
||||
timeout: 4000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshDashboard(options: {
|
||||
grokHome?: string;
|
||||
noUsage?: boolean;
|
||||
}): Promise<{ title: string; session: SessionSnapshot | null }> {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const active = loadActiveSessions(grokHome);
|
||||
let session: SessionSnapshot | null = null;
|
||||
|
||||
for (const a of active) {
|
||||
const dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (!dir) continue;
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap?.live) {
|
||||
session = snap;
|
||||
break;
|
||||
}
|
||||
if (!session && snap) session = snap;
|
||||
}
|
||||
if (!session) session = pickBestSession({ grokHome });
|
||||
|
||||
if (!session) {
|
||||
return { title: "◆ grok-hud: no session", session: null };
|
||||
}
|
||||
|
||||
let usage: UsageSnapshot | null = null;
|
||||
if (!options.noUsage) {
|
||||
try {
|
||||
usage = await getCreditUsage(grokHome, { cacheTtlMs: 60_000 });
|
||||
} catch {
|
||||
usage = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Theme follows Grok [ui].theme; re-apply tmux chrome when palette flips
|
||||
const { resolveTheme } = await import("./theme.js");
|
||||
const theme = resolveTheme(undefined, process.env, { grokHome });
|
||||
writeStatusFiles(session, usage, grokHome);
|
||||
try {
|
||||
const stamp = path.join(grokHome, "hud", ".last-theme");
|
||||
const prev = fs.existsSync(stamp)
|
||||
? fs.readFileSync(stamp, "utf8").trim()
|
||||
: "";
|
||||
if (prev !== theme.name) {
|
||||
fs.writeFileSync(stamp, theme.name + "\n", "utf8");
|
||||
const { writeTmuxConfFile, applyTmuxStatusBar } = await import(
|
||||
"./tmux-hud.js"
|
||||
);
|
||||
writeTmuxConfFile(grokHome);
|
||||
applyTmuxStatusBar({ grokHome });
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const title = titleLine(session, usage);
|
||||
|
||||
// 1) OSC into Grok's TTY (updates process title component)
|
||||
const tty = ttyForPid(session.pid);
|
||||
if (tty) writeOscTitle(tty, title);
|
||||
|
||||
// 2) Apple Terminal custom title (always visible as middle segment of window title)
|
||||
// e.g. "0717 — ◆ ctx 44% | quota 23% — zsh — 101×50"
|
||||
const ttyHint = tty ? path.basename(tty) : null;
|
||||
setAppleTerminalTitle(title, { ttyHint });
|
||||
|
||||
// tmux-status.txt is written by writeStatusFiles (formatTmuxStatusLine)
|
||||
|
||||
// If already inside tmux (same-window mode), keep status bar config applied
|
||||
if (process.env.TMUX) {
|
||||
try {
|
||||
execFileSync(
|
||||
"tmux",
|
||||
[
|
||||
"set",
|
||||
"-g",
|
||||
"status-right",
|
||||
`#(cat ${path.join(grokHome, "hud", "tmux-status.txt")} 2>/dev/null)`,
|
||||
],
|
||||
{ stdio: "ignore", timeout: 1000 },
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
return { title, session };
|
||||
}
|
||||
|
||||
export function isDashboardRunning(grokHome = defaultGrokHome()): boolean {
|
||||
const p = dashboardPidPath(grokHome);
|
||||
try {
|
||||
if (!fs.existsSync(p)) return false;
|
||||
const pid = Number(fs.readFileSync(p, "utf8").trim());
|
||||
if (!Number.isFinite(pid) || pid <= 0) return false;
|
||||
process.kill(pid, 0);
|
||||
// Confirm it's actually our dashboard (not a recycled pid)
|
||||
try {
|
||||
const cmd = execFileSync("ps", ["-p", String(pid), "-o", "command="], {
|
||||
encoding: "utf8",
|
||||
timeout: 1000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
if (!cmd.includes("dashboard") && !cmd.includes("grok-build-hud")) {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Kill every dashboard daemon we can find (prevents multi-writer theme fights). */
|
||||
export function stopAllDashboardDaemons(grokHome = defaultGrokHome()): number {
|
||||
let killed = 0;
|
||||
const p = dashboardPidPath(grokHome);
|
||||
try {
|
||||
if (fs.existsSync(p)) {
|
||||
const pid = Number(fs.readFileSync(p, "utf8").trim());
|
||||
if (Number.isFinite(pid) && pid > 0 && pid !== process.pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
killed += 1;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(p);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
// Sweep orphans matching our entry pattern
|
||||
try {
|
||||
// Match "--dashboard" as its own argv token (not "--dashboard-start")
|
||||
const out = execFileSync(
|
||||
"pgrep",
|
||||
["-f", "grok-build-hud.*(dist/src/index\\.js|bin/grok-build-hud\\.js) --dashboard "],
|
||||
{
|
||||
encoding: "utf8",
|
||||
timeout: 2000,
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
},
|
||||
).trim();
|
||||
for (const line of out.split(/\n/)) {
|
||||
const pid = Number(line.trim());
|
||||
if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) continue;
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
killed += 1;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* no matches */
|
||||
}
|
||||
return killed;
|
||||
}
|
||||
|
||||
export function stopDashboard(grokHome = defaultGrokHome()): boolean {
|
||||
return stopAllDashboardDaemons(grokHome) > 0;
|
||||
}
|
||||
|
||||
/** Fork a detached dashboard daemon if not already running. */
|
||||
export function ensureDashboardDaemon(options: {
|
||||
grokHome?: string;
|
||||
entryJs?: string;
|
||||
intervalMs?: number;
|
||||
}): { started: boolean; alreadyRunning: boolean; pid?: number } {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
if (isDashboardRunning(grokHome)) {
|
||||
return { started: false, alreadyRunning: true };
|
||||
}
|
||||
|
||||
// Clean orphans before starting a single writer
|
||||
stopAllDashboardDaemons(grokHome);
|
||||
|
||||
const entry =
|
||||
options.entryJs ??
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"index.js",
|
||||
);
|
||||
const interval = options.intervalMs ?? 500;
|
||||
const logPath = path.join(grokHome, "hud", "dashboard.log");
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
||||
const logFd = fs.openSync(logPath, "a");
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
entry,
|
||||
"--dashboard",
|
||||
"--follow-active",
|
||||
"--interval",
|
||||
String(interval),
|
||||
"--grok-home",
|
||||
grokHome,
|
||||
],
|
||||
{
|
||||
detached: true,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
env: {
|
||||
...process.env,
|
||||
GROK_HUD_THEME: process.env.GROK_HUD_THEME || "auto",
|
||||
},
|
||||
},
|
||||
);
|
||||
child.unref();
|
||||
if (child.pid) {
|
||||
fs.writeFileSync(dashboardPidPath(grokHome), String(child.pid) + "\n");
|
||||
return { started: true, alreadyRunning: false, pid: child.pid };
|
||||
}
|
||||
return { started: false, alreadyRunning: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a dedicated Apple Terminal window that only shows the live HUD.
|
||||
* Grok itself overwrites its own window title with tool activity — a separate
|
||||
* window is the reliable "always visible dashboard" on Terminal.app.
|
||||
*/
|
||||
export function openTerminalDashboardWindow(options: {
|
||||
entryBin?: string;
|
||||
intervalMs?: number;
|
||||
} = {}): boolean {
|
||||
if (process.platform !== "darwin") return false;
|
||||
try {
|
||||
const bin =
|
||||
options.entryBin ??
|
||||
path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"bin",
|
||||
"grok-build-hud.js",
|
||||
);
|
||||
const node = process.execPath;
|
||||
const interval = options.intervalMs ?? 2000;
|
||||
// clear+redraw dual bars forever; title fixed so user can find the window
|
||||
const cmd = `${JSON.stringify(node)} ${JSON.stringify(bin)} --watch --follow-active --no-color --interval ${interval}; echo '[hud] stopped — press enter'; read`;
|
||||
const safeCmd = cmd.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
const script = `
|
||||
tell application "Terminal"
|
||||
activate
|
||||
set tabRef to do script "${safeCmd}"
|
||||
delay 0.3
|
||||
try
|
||||
set custom title of tabRef to "◆ Grok HUD"
|
||||
end try
|
||||
try
|
||||
set custom title of front window to "◆ Grok HUD"
|
||||
end try
|
||||
end tell
|
||||
`;
|
||||
execFileSync("osascript", ["-e", script], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 8000,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDashboardLoop(options: {
|
||||
grokHome?: string;
|
||||
intervalMs?: number;
|
||||
maxIterations?: number;
|
||||
noUsage?: boolean;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
writePid?: boolean;
|
||||
}): Promise<number> {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const interval = options.intervalMs ?? 500;
|
||||
const sleep =
|
||||
options.sleep ?? ((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
||||
|
||||
if (options.writePid !== false) {
|
||||
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
|
||||
fs.writeFileSync(dashboardPidPath(grokHome), String(process.pid) + "\n");
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
const p = dashboardPidPath(grokHome);
|
||||
if (fs.existsSync(p)) {
|
||||
const cur = Number(fs.readFileSync(p, "utf8").trim());
|
||||
if (cur === process.pid) fs.unlinkSync(p);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
process.on("SIGTERM", () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
i += 1;
|
||||
try {
|
||||
await refreshDashboard({
|
||||
grokHome,
|
||||
noUsage: options.noUsage,
|
||||
});
|
||||
} catch {
|
||||
/* keep looping */
|
||||
}
|
||||
if (options.maxIterations && i >= options.maxIterations) {
|
||||
cleanup();
|
||||
return 0;
|
||||
}
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
export interface GitInfo {
|
||||
branch?: string;
|
||||
dirty: boolean;
|
||||
ahead?: number;
|
||||
behind?: number;
|
||||
}
|
||||
|
||||
export function readGitInfo(cwd: string): GitInfo {
|
||||
if (!cwd || !fs.existsSync(cwd)) {
|
||||
return { dirty: false };
|
||||
}
|
||||
try {
|
||||
const branch = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 1500,
|
||||
}).trim();
|
||||
const status = execFileSync("git", ["status", "--porcelain"], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 1500,
|
||||
});
|
||||
let ahead: number | undefined;
|
||||
let behind: number | undefined;
|
||||
try {
|
||||
const ab = execFileSync(
|
||||
"git",
|
||||
["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
|
||||
{
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: 1500,
|
||||
},
|
||||
).trim();
|
||||
// behind ahead (left=upstream, right=HEAD)
|
||||
const m = ab.match(/^(\d+)\s+(\d+)$/);
|
||||
if (m) {
|
||||
behind = Number(m[1]);
|
||||
ahead = Number(m[2]);
|
||||
}
|
||||
} catch {
|
||||
/* no upstream */
|
||||
}
|
||||
return {
|
||||
branch: branch || undefined,
|
||||
dirty: status.trim().length > 0,
|
||||
ahead,
|
||||
behind,
|
||||
};
|
||||
} catch {
|
||||
try {
|
||||
const headPath = path.join(cwd, ".git", "HEAD");
|
||||
if (fs.existsSync(headPath)) {
|
||||
const head = fs.readFileSync(headPath, "utf8").trim();
|
||||
const m = head.match(/ref: refs\/heads\/(.+)/);
|
||||
return { branch: m?.[1], dirty: false };
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { dirty: false };
|
||||
}
|
||||
}
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Grok Build hook entry — runs on SessionStart / PostToolUse / Stop / UserPromptSubmit.
|
||||
* Writes live status files and prints a compact line to stderr so TUI scrollback
|
||||
* annotations show context + quota while you work (plugin-style live stats).
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
defaultGrokHome,
|
||||
findSessionDirById,
|
||||
loadActiveSessions,
|
||||
loadSnapshotFromDir,
|
||||
pickBestSession,
|
||||
} from "./session.js";
|
||||
import { writeStatusFiles } from "./status.js";
|
||||
import { getCreditUsage } from "./billing.js";
|
||||
import { ensureDashboardDaemon } from "./dashboard.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export interface HookPayload {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
hookEventName?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function readStdinSync(): string {
|
||||
try {
|
||||
return fs.readFileSync(0, "utf8");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseHookPayload(raw: string): HookPayload {
|
||||
if (!raw.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(raw) as HookPayload;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSessionForHook(
|
||||
payload: HookPayload,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
grokHome = defaultGrokHome(),
|
||||
): ReturnType<typeof loadSnapshotFromDir> {
|
||||
const sessionId =
|
||||
env.GROK_SESSION_ID?.trim() ||
|
||||
payload.sessionId ||
|
||||
undefined;
|
||||
const cwd =
|
||||
env.GROK_WORKSPACE_ROOT?.trim() ||
|
||||
env.CLAUDE_PROJECT_DIR?.trim() ||
|
||||
payload.workspaceRoot ||
|
||||
payload.cwd ||
|
||||
undefined;
|
||||
|
||||
const active = loadActiveSessions(grokHome);
|
||||
|
||||
if (sessionId) {
|
||||
const dir = findSessionDirById(grokHome, sessionId);
|
||||
if (dir) {
|
||||
return loadSnapshotFromDir(dir, { active });
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer live active session matching cwd
|
||||
if (cwd) {
|
||||
const hit = pickBestSession({ grokHome, cwd });
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// Any live session
|
||||
for (const a of active) {
|
||||
const dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (dir) {
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap) return snap;
|
||||
}
|
||||
}
|
||||
|
||||
return pickBestSession({ grokHome });
|
||||
}
|
||||
|
||||
/** Throttle PostToolUse floods so we don't spam scrollback. */
|
||||
export function shouldEmitAnnotation(
|
||||
event: string,
|
||||
grokHome = defaultGrokHome(),
|
||||
minIntervalMs = 4000,
|
||||
): boolean {
|
||||
// Always show on turn boundaries / prompt / session start
|
||||
if (
|
||||
event === "stop" ||
|
||||
event === "Stop" ||
|
||||
event === "session_start" ||
|
||||
event === "SessionStart" ||
|
||||
event === "user_prompt_submit" ||
|
||||
event === "UserPromptSubmit" ||
|
||||
event === "session_end" ||
|
||||
event === "SessionEnd"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
const stampPath = path.join(hudThrottlePath(grokHome));
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(stampPath), { recursive: true });
|
||||
if (fs.existsSync(stampPath)) {
|
||||
const prev = Number(fs.readFileSync(stampPath, "utf8"));
|
||||
if (Number.isFinite(prev) && Date.now() - prev < minIntervalMs) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(stampPath, String(Date.now()), "utf8");
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function hudThrottlePath(grokHome: string): string {
|
||||
return path.join(grokHome, "hud", ".annotate-throttle");
|
||||
}
|
||||
|
||||
export async function runHookTick(
|
||||
options: {
|
||||
payloadRaw?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
grokHome?: string;
|
||||
/** force annotation even if throttled */
|
||||
forceAnnotate?: boolean;
|
||||
/** skip network quota fetch (tests) */
|
||||
noUsage?: boolean;
|
||||
} = {},
|
||||
): Promise<{ code: number; compact?: string; annotated: boolean }> {
|
||||
const env = options.env ?? process.env;
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const raw = options.payloadRaw ?? readStdinSync();
|
||||
const payload = parseHookPayload(raw);
|
||||
const event =
|
||||
env.GROK_HOOK_EVENT ||
|
||||
payload.hookEventName ||
|
||||
"unknown";
|
||||
|
||||
try {
|
||||
const snap = resolveSessionForHook(payload, env, grokHome);
|
||||
if (!snap) {
|
||||
// Still exit 0 — never block the agent
|
||||
return { code: 0, annotated: false };
|
||||
}
|
||||
|
||||
// Keep title-bar dashboard alive (no-op if already running)
|
||||
try {
|
||||
const entryJs = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"index.js",
|
||||
);
|
||||
ensureDashboardDaemon({ grokHome, entryJs, intervalMs: 2000 });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
// Fetch quota (disk-cached ~60s so hooks stay fast)
|
||||
let usage = null;
|
||||
if (!options.noUsage && env.GROK_HUD_NO_USAGE !== "1") {
|
||||
try {
|
||||
usage = await getCreditUsage(grokHome, {
|
||||
cacheTtlMs: 60_000,
|
||||
enabled: true,
|
||||
});
|
||||
} catch {
|
||||
usage = null;
|
||||
}
|
||||
}
|
||||
|
||||
const written = writeStatusFiles(snap, usage, grokHome);
|
||||
const annotate =
|
||||
options.forceAnnotate || shouldEmitAnnotation(String(event), grokHome);
|
||||
if (annotate) {
|
||||
// stderr shows up in Grok hook scrollback annotations
|
||||
process.stderr.write(written.compact + "\n");
|
||||
// Also print dual-bar block on Stop / SessionStart for stronger visibility
|
||||
if (
|
||||
/stop|session_start|SessionStart|UserPromptSubmit|user_prompt/i.test(
|
||||
String(event),
|
||||
)
|
||||
) {
|
||||
process.stderr.write(written.full + "\n");
|
||||
}
|
||||
}
|
||||
return { code: 0, compact: written.compact, annotated: annotate };
|
||||
} catch (e) {
|
||||
try {
|
||||
process.stderr.write(
|
||||
`[hud] error: ${e instanceof Error ? e.message : String(e)}\n`,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return { code: 0, annotated: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Direct run as hook command
|
||||
function isDirect(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirect()) {
|
||||
runHookTick()
|
||||
.then((result) => process.exit(result.code))
|
||||
.catch(() => process.exit(0));
|
||||
}
|
||||
@@ -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<HudDisplayConfig>;
|
||||
const base =
|
||||
raw.preset === "minimal"
|
||||
? PRESET_MINIMAL
|
||||
: raw.preset === "essential"
|
||||
? PRESET_ESSENTIAL
|
||||
: PRESET_FULL;
|
||||
return {
|
||||
...base,
|
||||
...raw,
|
||||
bold: raw.bold ?? base.bold ?? true,
|
||||
barWidth: raw.barWidth ?? base.barWidth ?? 14,
|
||||
statusLines: raw.statusLines ?? base.statusLines ?? 3,
|
||||
display: { ...base.display, ...(raw.display ?? {}) },
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
...PRESET_FULL,
|
||||
display: { ...PRESET_FULL.display },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function saveHudConfig(
|
||||
cfg: HudDisplayConfig,
|
||||
grokHome = path.join(os.homedir(), ".grok"),
|
||||
): string {
|
||||
const p = configPath(grokHome);
|
||||
fs.mkdirSync(path.dirname(p), { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
||||
return p;
|
||||
}
|
||||
|
||||
export function applyPreset(preset: HudPreset): HudDisplayConfig {
|
||||
if (preset === "minimal") return { ...PRESET_MINIMAL, display: { ...PRESET_MINIMAL.display } };
|
||||
if (preset === "essential")
|
||||
return { ...PRESET_ESSENTIAL, display: { ...PRESET_ESSENTIAL.display } };
|
||||
return { ...PRESET_FULL, display: { ...PRESET_FULL.display } };
|
||||
}
|
||||
|
||||
/** Claude-HUD option name → Grok HUD config path (migration map). */
|
||||
export const CLAUDE_HUD_MIGRATION: Record<string, string> = {
|
||||
"display.showModel": "display.showModel",
|
||||
"display.showContextBar": "display.showContextBar",
|
||||
"display.contextValue": "display.contextValue",
|
||||
"display.showUsage": "display.showUsage",
|
||||
"display.showSessionTime / sessionTime": "display.showSessionTime",
|
||||
"gitStatus.enabled": "display.showGit",
|
||||
"gitStatus.showDirty": "display.showGitDirty",
|
||||
"display tools activity": "display.showToolActivity",
|
||||
"display agents": "display.showAgents",
|
||||
"display todos": "display.showTodos",
|
||||
lineLayout: "lineLayout",
|
||||
pathLevels: "pathLevels",
|
||||
language: "language",
|
||||
"presets Full/Essential/Minimal": "preset",
|
||||
};
|
||||
+603
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
pickBestSession,
|
||||
discoverSessions,
|
||||
defaultGrokHome,
|
||||
findSessionDirById,
|
||||
loadActiveSessions,
|
||||
loadSnapshotFromDir,
|
||||
} from "./session.js";
|
||||
import { getCreditUsage, unavailableUsage } from "./billing.js";
|
||||
import { renderHud, renderJson } from "./render.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { writeStatusFiles } from "./status.js";
|
||||
import {
|
||||
installGlobalHooks,
|
||||
uninstallGlobalHooks,
|
||||
packageRoot,
|
||||
} from "./install.js";
|
||||
import { installDashboard, uninstallDashboard } from "./install-dashboard.js";
|
||||
import {
|
||||
runDashboardLoop,
|
||||
ensureDashboardDaemon,
|
||||
stopDashboard,
|
||||
isDashboardRunning,
|
||||
refreshDashboard,
|
||||
} from "./dashboard.js";
|
||||
import {
|
||||
execGrokInSameWindowTmux,
|
||||
applyTmuxStatusBar,
|
||||
installSameWindowLauncher,
|
||||
isInsideTmux,
|
||||
writeTmuxConfFile,
|
||||
} from "./tmux-hud.js";
|
||||
import {
|
||||
persistTheme,
|
||||
resolveTheme,
|
||||
resolveThemeMode,
|
||||
readGrokUiConfig,
|
||||
clearAppearanceCache,
|
||||
} from "./theme.js";
|
||||
import {
|
||||
applyPreset,
|
||||
saveHudConfig,
|
||||
loadHudConfig,
|
||||
type HudPreset,
|
||||
} from "./hud-config.js";
|
||||
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export { contextPercentFromSignals, renderBar, formatTokenCount } from "./bar.js";
|
||||
export { parseUpdatesLines, formatToolLine } from "./activity.js";
|
||||
export { normalizeBillingPayload } from "./billing.js";
|
||||
export {
|
||||
loadSnapshotFromDir,
|
||||
pickBestSession,
|
||||
discoverSessions,
|
||||
findSessionDirById,
|
||||
} from "./session.js";
|
||||
export { renderHud, renderJson } from "./render.js";
|
||||
export { runHookTick } from "./hook.js";
|
||||
export { writeStatusFiles, formatCompactLine } from "./status.js";
|
||||
export { installGlobalHooks, uninstallGlobalHooks } from "./install.js";
|
||||
export { installDashboard, uninstallDashboard } from "./install-dashboard.js";
|
||||
|
||||
export interface CliOptions {
|
||||
watch: boolean;
|
||||
once: boolean;
|
||||
tmux: boolean;
|
||||
json: boolean;
|
||||
all: boolean;
|
||||
noColor: boolean;
|
||||
cwd?: string;
|
||||
session?: string;
|
||||
sessionDir?: string;
|
||||
grokHome?: string;
|
||||
refreshMs?: number;
|
||||
maxIterations?: number;
|
||||
help: boolean;
|
||||
version: boolean;
|
||||
noUsage: boolean;
|
||||
installHooks: boolean;
|
||||
uninstallHooks: boolean;
|
||||
tick: boolean;
|
||||
followActive: boolean;
|
||||
installDashboard: boolean;
|
||||
uninstallDashboard: boolean;
|
||||
dashboard: boolean;
|
||||
dashboardStart: boolean;
|
||||
dashboardStop: boolean;
|
||||
dashboardWindow: boolean;
|
||||
runInTerminal: boolean;
|
||||
theme?: string;
|
||||
preset?: string;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliOptions {
|
||||
const opts: CliOptions = {
|
||||
watch: false,
|
||||
once: true,
|
||||
tmux: false,
|
||||
json: false,
|
||||
all: false,
|
||||
noColor: false,
|
||||
help: false,
|
||||
version: false,
|
||||
noUsage: false,
|
||||
installHooks: false,
|
||||
uninstallHooks: false,
|
||||
tick: false,
|
||||
followActive: false,
|
||||
installDashboard: false,
|
||||
uninstallDashboard: false,
|
||||
dashboard: false,
|
||||
dashboardStart: false,
|
||||
dashboardStop: false,
|
||||
dashboardWindow: false,
|
||||
runInTerminal: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]!;
|
||||
switch (arg) {
|
||||
case "-w":
|
||||
case "--watch":
|
||||
opts.watch = true;
|
||||
opts.once = false;
|
||||
break;
|
||||
case "--once":
|
||||
opts.once = true;
|
||||
opts.watch = false;
|
||||
break;
|
||||
case "--tmux":
|
||||
opts.tmux = true;
|
||||
break;
|
||||
case "--json":
|
||||
opts.json = true;
|
||||
break;
|
||||
case "--all":
|
||||
opts.all = true;
|
||||
break;
|
||||
case "--no-color":
|
||||
case "--plain":
|
||||
opts.noColor = true;
|
||||
break;
|
||||
case "--no-usage":
|
||||
opts.noUsage = true;
|
||||
break;
|
||||
case "--cwd":
|
||||
opts.cwd = argv[++i];
|
||||
break;
|
||||
case "--session":
|
||||
opts.session = argv[++i];
|
||||
break;
|
||||
case "--session-dir":
|
||||
opts.sessionDir = argv[++i];
|
||||
break;
|
||||
case "--grok-home":
|
||||
opts.grokHome = argv[++i];
|
||||
break;
|
||||
case "--interval":
|
||||
case "--refresh-ms":
|
||||
opts.refreshMs = Number(argv[++i]);
|
||||
break;
|
||||
case "--max-iterations":
|
||||
opts.maxIterations = Number(argv[++i]);
|
||||
break;
|
||||
case "--install-hooks":
|
||||
case "--install":
|
||||
opts.installHooks = true;
|
||||
break;
|
||||
case "--uninstall-hooks":
|
||||
case "--uninstall":
|
||||
opts.uninstallHooks = true;
|
||||
break;
|
||||
case "--tick":
|
||||
opts.tick = true;
|
||||
opts.noUsage = true;
|
||||
break;
|
||||
case "--follow-active":
|
||||
opts.followActive = true;
|
||||
break;
|
||||
case "--install-dashboard":
|
||||
opts.installDashboard = true;
|
||||
break;
|
||||
case "--uninstall-dashboard":
|
||||
opts.uninstallDashboard = true;
|
||||
break;
|
||||
case "--dashboard":
|
||||
opts.dashboard = true;
|
||||
opts.once = false;
|
||||
break;
|
||||
case "--dashboard-start":
|
||||
opts.dashboardStart = true;
|
||||
break;
|
||||
case "--dashboard-stop":
|
||||
opts.dashboardStop = true;
|
||||
break;
|
||||
case "--dashboard-window":
|
||||
case "--open-window":
|
||||
// deprecated / rejected by user — ignore, prefer same-window tmux
|
||||
opts.dashboardWindow = false;
|
||||
break;
|
||||
case "--run-in-terminal":
|
||||
case "--run":
|
||||
opts.runInTerminal = true;
|
||||
break;
|
||||
case "--theme":
|
||||
opts.theme = argv[++i];
|
||||
break;
|
||||
case "--preset":
|
||||
opts.preset = argv[++i];
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
opts.help = true;
|
||||
break;
|
||||
case "-V":
|
||||
case "--version":
|
||||
opts.version = true;
|
||||
break;
|
||||
default:
|
||||
if (arg.startsWith("-")) {
|
||||
// unknown flag: ignore softly
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
export function helpText(): string {
|
||||
return `grok-build-hud — live context + quota in the SAME Terminal window
|
||||
|
||||
Same-window mode (recommended, no second window):
|
||||
grok-hud-run
|
||||
# or: grok-build-hud --run
|
||||
# Starts Grok inside this tab with a permanent BOTTOM status bar:
|
||||
# ctx 44% │ quota 23% │ GrokBuild 10% │ t8 │ tools 144
|
||||
|
||||
Install once:
|
||||
grok-build-hud --install-dashboard
|
||||
|
||||
Then:
|
||||
grok-hud-run # start Grok + bottom bar (same window)
|
||||
grok-hud status # print full dual bars once
|
||||
grok-hud stop # stop background updater
|
||||
grok-build-hud --theme auto # follow Grok [ui].theme
|
||||
grok-build-hud --preset full|essential|minimal # Claude-HUD presets
|
||||
grok-hud-run # same-window Grok + multi-line HUD
|
||||
|
||||
Claude HUD users: see MIGRATION-FROM-CLAUDE.md
|
||||
`;
|
||||
}
|
||||
|
||||
async function resolveUsage(
|
||||
grokHome: string,
|
||||
opts: CliOptions,
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
): Promise<UsageSnapshot> {
|
||||
if (opts.noUsage || !cfg.showUsage) {
|
||||
return unavailableUsage("usage disabled");
|
||||
}
|
||||
try {
|
||||
return await getCreditUsage(grokHome, {
|
||||
cacheTtlMs: cfg.usageCacheTtlMs,
|
||||
enabled: true,
|
||||
});
|
||||
} catch {
|
||||
return unavailableUsage("usage unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
function printFrame(
|
||||
sessions: SessionSnapshot[],
|
||||
usage: UsageSnapshot,
|
||||
opts: CliOptions,
|
||||
cfg: ReturnType<typeof loadConfig>,
|
||||
out: (s: string) => void,
|
||||
): void {
|
||||
if (!sessions.length) {
|
||||
out("No Grok Build sessions found under grok home.");
|
||||
return;
|
||||
}
|
||||
const list = opts.all ? sessions : [sessions[0]!];
|
||||
const chunks: string[] = [];
|
||||
for (const s of list) {
|
||||
if (opts.json) {
|
||||
chunks.push(renderJson(s, usage));
|
||||
} else {
|
||||
chunks.push(
|
||||
renderHud(s, usage, {
|
||||
color: !opts.noColor && !opts.tmux,
|
||||
tmux: opts.tmux,
|
||||
pathLevels: cfg.pathLevels,
|
||||
warningThreshold: cfg.warningThreshold,
|
||||
criticalThreshold: cfg.criticalThreshold,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
out(chunks.join(opts.json ? "\n" : "\n\n"));
|
||||
}
|
||||
|
||||
export async function runCli(
|
||||
argv: string[],
|
||||
io: {
|
||||
stdout?: (s: string) => void;
|
||||
stderr?: (s: string) => void;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
} = {},
|
||||
): Promise<number> {
|
||||
const out = io.stdout ?? ((s: string) => process.stdout.write(s + (s.endsWith("\n") ? "" : "\n")));
|
||||
const err = io.stderr ?? ((s: string) => process.stderr.write(s + "\n"));
|
||||
const sleep =
|
||||
io.sleep ??
|
||||
((ms: number) => new Promise((r) => setTimeout(r, ms)));
|
||||
|
||||
const opts = parseArgs(argv);
|
||||
const cfg = loadConfig();
|
||||
|
||||
if (opts.help) {
|
||||
out(helpText());
|
||||
return 0;
|
||||
}
|
||||
if (opts.version) {
|
||||
out("0.2.0");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.preset) {
|
||||
const p = opts.preset.toLowerCase();
|
||||
if (p !== "full" && p !== "essential" && p !== "minimal") {
|
||||
err("Preset must be: full | essential | minimal");
|
||||
return 1;
|
||||
}
|
||||
const cfg = applyPreset(p as HudPreset);
|
||||
// keep readability defaults when applying presets
|
||||
cfg.bold = true;
|
||||
cfg.barWidth = Math.max(cfg.barWidth ?? 12, 12);
|
||||
const saved = saveHudConfig(cfg, opts.grokHome ?? defaultGrokHome());
|
||||
writeTmuxConfFile(opts.grokHome);
|
||||
applyTmuxStatusBar({ grokHome: opts.grokHome });
|
||||
await refreshDashboard({ grokHome: opts.grokHome });
|
||||
out(
|
||||
`HUD preset → ${p} (${cfg.statusLines} status row(s), bold, barWidth=${cfg.barWidth})\n config: ${saved}\n` +
|
||||
`Claude HUD map: Full≈full, Essential≈essential, Minimal≈minimal`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.theme) {
|
||||
const mode = resolveThemeMode(opts.theme);
|
||||
persistTheme(mode);
|
||||
process.env.GROK_HUD_THEME = mode;
|
||||
clearAppearanceCache();
|
||||
const grokHome = opts.grokHome ?? defaultGrokHome();
|
||||
const ui = readGrokUiConfig(grokHome);
|
||||
const t = resolveTheme(mode, process.env, { grokHome });
|
||||
writeTmuxConfFile(grokHome);
|
||||
applyTmuxStatusBar({ grokHome });
|
||||
await refreshDashboard({ grokHome });
|
||||
stopDashboard(grokHome);
|
||||
ensureDashboardDaemon({
|
||||
grokHome,
|
||||
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
|
||||
intervalMs: 500,
|
||||
});
|
||||
if (mode === "auto") {
|
||||
out(
|
||||
`HUD follows Grok theme: [ui] theme="${ui.theme}" → palette "${t.name}"\n` +
|
||||
`Change with /theme inside Grok; HUD updates within ~1s.\n` +
|
||||
`Force lock: grok-build-hud --theme tokyonight|grokday|light|dark`,
|
||||
);
|
||||
} else {
|
||||
out(
|
||||
`Theme locked to "${t.name}". Use --theme auto to follow Grok [ui].theme again.`,
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.installDashboard) {
|
||||
try {
|
||||
const r = installDashboard({
|
||||
grokHome: opts.grokHome,
|
||||
startDaemon: true,
|
||||
});
|
||||
const launchers = installSameWindowLauncher();
|
||||
out("Installed SAME-WINDOW dashboard (no second window):");
|
||||
out(` start: ${launchers.runPath}`);
|
||||
out(` → run: grok-hud-run`);
|
||||
out(` also: ${launchers.grokWrapPath}`);
|
||||
out(` hooks: ${r.hooksPath}`);
|
||||
out(` tmux cfg: ${r.tmuxConf}`);
|
||||
if (r.launchAgent) out(` launchd: ${r.launchAgent} (file updater)`);
|
||||
out(
|
||||
` daemon: ${r.daemon.alreadyRunning ? "already running" : r.daemon.started ? `started pid ${r.daemon.pid}` : "not started"}`,
|
||||
);
|
||||
await refreshDashboard({ grokHome: opts.grokHome });
|
||||
if (isInsideTmux()) {
|
||||
applyTmuxStatusBar({ grokHome: opts.grokHome });
|
||||
out("\nAlready inside tmux — status bar applied to THIS window.");
|
||||
} else {
|
||||
out(
|
||||
"\n★ To show the bar in THIS Terminal window:\n 1. End current Grok session (/quit)\n 2. Run: grok-hud-run\n Bottom line will always show ctx + quota — no second window.",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
} catch (e) {
|
||||
err(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.runInTerminal) {
|
||||
ensureDashboardDaemon({
|
||||
grokHome: opts.grokHome,
|
||||
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
|
||||
intervalMs: 500,
|
||||
});
|
||||
await refreshDashboard({ grokHome: opts.grokHome });
|
||||
// never returns
|
||||
execGrokInSameWindowTmux({
|
||||
grokHome: opts.grokHome,
|
||||
extraGrokArgs: [],
|
||||
});
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.uninstallDashboard) {
|
||||
uninstallDashboard({ grokHome: opts.grokHome });
|
||||
uninstallGlobalHooks(opts.grokHome);
|
||||
out("Dashboard uninstalled (daemon stopped, LaunchAgent removed).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.dashboardStop) {
|
||||
const ok = stopDashboard(opts.grokHome);
|
||||
out(ok ? "Dashboard daemon stopped." : "No dashboard daemon was running.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.dashboardStart) {
|
||||
const r = ensureDashboardDaemon({
|
||||
grokHome: opts.grokHome,
|
||||
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
|
||||
intervalMs: opts.refreshMs ?? 2000,
|
||||
});
|
||||
if (r.alreadyRunning) out("Dashboard already running.");
|
||||
else if (r.started) out(`Dashboard started (pid ${r.pid}).`);
|
||||
else out("Failed to start dashboard.");
|
||||
await refreshDashboard({ grokHome: opts.grokHome });
|
||||
return r.started || r.alreadyRunning ? 0 : 1;
|
||||
}
|
||||
|
||||
if (opts.dashboard) {
|
||||
return runDashboardLoop({
|
||||
grokHome: opts.grokHome,
|
||||
intervalMs: opts.refreshMs ?? 2000,
|
||||
maxIterations: opts.maxIterations,
|
||||
noUsage: opts.noUsage,
|
||||
writePid: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.installHooks) {
|
||||
try {
|
||||
const { hooksPath, hookJs } = installGlobalHooks({
|
||||
grokHome: opts.grokHome,
|
||||
root: packageRoot(),
|
||||
});
|
||||
// also ensure dashboard daemon for title bar
|
||||
ensureDashboardDaemon({
|
||||
grokHome: opts.grokHome,
|
||||
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
|
||||
});
|
||||
out(`Installed live HUD hooks:\n ${hooksPath}\n entry: ${hookJs}`);
|
||||
out(
|
||||
"\nNext: restart this Grok session (or open /hooks and press r to reload).\nThen keep working — after each turn you should see [hud] annotations in scrollback.",
|
||||
);
|
||||
return 0;
|
||||
} catch (e) {
|
||||
err(e instanceof Error ? e.message : String(e));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.uninstallHooks) {
|
||||
const { removed, hooksPath } = uninstallGlobalHooks(opts.grokHome);
|
||||
out(removed ? `Removed ${hooksPath}` : `No hooks file at ${hooksPath}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
const grokHome = opts.grokHome ?? defaultGrokHome();
|
||||
const discoverOpts = {
|
||||
grokHome,
|
||||
sessionId: opts.session,
|
||||
cwd: opts.cwd,
|
||||
sessionDir: opts.sessionDir,
|
||||
};
|
||||
|
||||
const resolveSessions = (): SessionSnapshot[] => {
|
||||
if (opts.sessionDir) {
|
||||
const one = pickBestSession(discoverOpts);
|
||||
return one ? [one] : [];
|
||||
}
|
||||
if (opts.followActive || (!opts.cwd && !opts.session)) {
|
||||
// Prefer any live active session first
|
||||
const active = loadActiveSessions(grokHome);
|
||||
const liveSnaps: SessionSnapshot[] = [];
|
||||
for (const a of active) {
|
||||
const dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (!dir) continue;
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap) liveSnaps.push(snap);
|
||||
}
|
||||
if (liveSnaps.length) {
|
||||
if (opts.cwd) {
|
||||
const matched = liveSnaps.filter((s) =>
|
||||
s.cwd ? s.cwd === opts.cwd || s.cwd.includes(opts.cwd!) : false,
|
||||
);
|
||||
// use path equality via discover as fallback
|
||||
const viaCwd = discoverSessions({ ...discoverOpts, cwd: opts.cwd });
|
||||
if (viaCwd.length) return viaCwd;
|
||||
if (matched.length) return matched;
|
||||
}
|
||||
if (opts.session) {
|
||||
const m = liveSnaps.filter((s) => s.sessionId === opts.session);
|
||||
if (m.length) return m;
|
||||
}
|
||||
if (!opts.cwd && !opts.session) return liveSnaps;
|
||||
}
|
||||
}
|
||||
return discoverSessions(discoverOpts);
|
||||
};
|
||||
|
||||
const renderOnce = async (): Promise<number> => {
|
||||
const sessions = resolveSessions();
|
||||
const usage = await resolveUsage(grokHome, opts, cfg);
|
||||
if (!sessions.length) {
|
||||
err("grok-build-hud: no sessions found");
|
||||
printFrame([], usage, opts, cfg, out);
|
||||
return 1;
|
||||
}
|
||||
// Always refresh status files so external readers stay current
|
||||
try {
|
||||
writeStatusFiles(sessions[0]!, opts.noUsage ? null : usage, grokHome);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (opts.tick) {
|
||||
out(writeStatusFiles(sessions[0]!, null, grokHome).compact);
|
||||
return 0;
|
||||
}
|
||||
printFrame(sessions, usage, opts, cfg, out);
|
||||
return 0;
|
||||
};
|
||||
|
||||
if (opts.watch) {
|
||||
const interval = opts.refreshMs && opts.refreshMs > 0 ? opts.refreshMs : cfg.refreshMs;
|
||||
let i = 0;
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
i += 1;
|
||||
if (!opts.tmux && !opts.json && process.stdout.isTTY) {
|
||||
// clear screen lightly for watch
|
||||
process.stdout.write("\x1b[2J\x1b[H");
|
||||
}
|
||||
await renderOnce();
|
||||
if (opts.maxIterations && i >= opts.maxIterations) {
|
||||
return 0;
|
||||
}
|
||||
await sleep(interval);
|
||||
}
|
||||
}
|
||||
|
||||
return renderOnce();
|
||||
}
|
||||
|
||||
// Direct `node dist/src/index.js ...` entry. When imported by tests, argv[1]
|
||||
// is the test file path, so this block does not run.
|
||||
function isDirectCliRun(): boolean {
|
||||
const entry = process.argv[1];
|
||||
if (!entry) return false;
|
||||
try {
|
||||
return path.resolve(entry) === fileURLToPath(import.meta.url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectCliRun()) {
|
||||
runCli(process.argv.slice(2))
|
||||
.then((code) => {
|
||||
process.exit(typeof code === "number" ? code : 0);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { packageRoot, installGlobalHooks } from "./install.js";
|
||||
import { defaultGrokHome } from "./session.js";
|
||||
import {
|
||||
ensureDashboardDaemon,
|
||||
stopDashboard,
|
||||
} from "./dashboard.js";
|
||||
import { writeTmuxConfFile } from "./tmux-hud.js";
|
||||
|
||||
export interface DashboardInstallResult {
|
||||
binPath: string;
|
||||
tmuxConf: string;
|
||||
launchAgent?: string;
|
||||
hooksPath: string;
|
||||
daemon: { started: boolean; alreadyRunning: boolean; pid?: number };
|
||||
}
|
||||
|
||||
export function installDashboard(options: {
|
||||
grokHome?: string;
|
||||
binDir?: string;
|
||||
startDaemon?: boolean;
|
||||
} = {}): DashboardInstallResult {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const root = packageRoot();
|
||||
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
|
||||
// Ensure hooks (update status files on every turn)
|
||||
const { hooksPath } = installGlobalHooks({ grokHome, root });
|
||||
|
||||
const entry = path.join(root, "bin", "grok-build-hud.js");
|
||||
const node = process.execPath;
|
||||
|
||||
// CLI shim always on PATH
|
||||
const binPath = path.join(binDir, "grok-hud");
|
||||
const shim = `#!/bin/zsh
|
||||
# grok-hud — always-on terminal dashboard for Grok Build
|
||||
set -e
|
||||
NODE=${JSON.stringify(node)}
|
||||
ENTRY=${JSON.stringify(entry)}
|
||||
case "\${1:-}" in
|
||||
stop)
|
||||
exec "$NODE" "$ENTRY" --dashboard-stop
|
||||
;;
|
||||
status)
|
||||
if [[ -f "$HOME/.grok/hud/status.txt" ]]; then
|
||||
cat "$HOME/.grok/hud/status.txt"
|
||||
else
|
||||
exec "$NODE" "$ENTRY" --once --follow-active --no-color
|
||||
fi
|
||||
;;
|
||||
watch)
|
||||
exec "$NODE" "$ENTRY" --watch --follow-active --no-color
|
||||
;;
|
||||
""|start|status)
|
||||
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
|
||||
exec "$NODE" "$ENTRY" --once --follow-active --no-color
|
||||
;;
|
||||
run)
|
||||
exec "$NODE" "$ENTRY" --run-in-terminal
|
||||
;;
|
||||
*)
|
||||
exec "$NODE" "$ENTRY" "$@"
|
||||
;;
|
||||
esac
|
||||
`;
|
||||
fs.writeFileSync(binPath, shim, { mode: 0o755 });
|
||||
|
||||
// tmux status bar fragment (cohesive Clear Dark palette)
|
||||
const tmuxConf = writeTmuxConfFile(grokHome);
|
||||
|
||||
// Soft-append to ~/.tmux.conf if present or create minimal
|
||||
const userTmux = path.join(os.homedir(), ".tmux.conf");
|
||||
const marker = "# >>> grok-build-hud >>>";
|
||||
const block = `${marker}
|
||||
# live Grok context + quota in status bar
|
||||
if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf'
|
||||
# <<< grok-build-hud <<<
|
||||
`;
|
||||
if (fs.existsSync(userTmux)) {
|
||||
const cur = fs.readFileSync(userTmux, "utf8");
|
||||
if (!cur.includes(marker)) {
|
||||
fs.writeFileSync(userTmux, cur.replace(/\s*$/, "\n\n") + block + "\n", "utf8");
|
||||
}
|
||||
} else {
|
||||
fs.writeFileSync(userTmux, block + "\n", "utf8");
|
||||
}
|
||||
|
||||
// LaunchAgent keeps dashboard alive across reboots (macOS)
|
||||
let launchAgent: string | undefined;
|
||||
if (process.platform === "darwin") {
|
||||
const agentsDir = path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"LaunchAgents",
|
||||
);
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
launchAgent = path.join(agentsDir, "com.dex.grok-hud-dashboard.plist");
|
||||
const log = path.join(grokHome, "hud", "dashboard.log");
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.dex.grok-hud-dashboard</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${node}</string>
|
||||
<string>${entry}</string>
|
||||
<string>--dashboard</string>
|
||||
<string>--follow-active</string>
|
||||
<string>--interval</string>
|
||||
<string>500</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${log}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
<string>${log}</string>
|
||||
<key>EnvironmentVariables</key>
|
||||
<dict>
|
||||
<key>HOME</key>
|
||||
<string>${os.homedir()}</string>
|
||||
<key>PATH</key>
|
||||
<string>/usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
fs.writeFileSync(launchAgent, plist, "utf8");
|
||||
try {
|
||||
execSilent(["launchctl", "unload", launchAgent]);
|
||||
} catch {
|
||||
/* not loaded */
|
||||
}
|
||||
try {
|
||||
execSilent(["launchctl", "load", launchAgent]);
|
||||
} catch {
|
||||
/* may need user session */
|
||||
}
|
||||
}
|
||||
|
||||
let daemon: DashboardInstallResult["daemon"] = {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
};
|
||||
if (options.startDaemon !== false) {
|
||||
daemon = ensureDashboardDaemon({
|
||||
grokHome,
|
||||
entryJs: path.join(root, "dist", "src", "index.js"),
|
||||
intervalMs: 2000,
|
||||
});
|
||||
}
|
||||
|
||||
return { binPath, tmuxConf, launchAgent, hooksPath, daemon };
|
||||
}
|
||||
|
||||
function execSilent(args: string[]): void {
|
||||
execFileSync(args[0]!, args.slice(1), {
|
||||
stdio: "ignore",
|
||||
timeout: 5000,
|
||||
});
|
||||
}
|
||||
|
||||
export function uninstallDashboard(options: {
|
||||
grokHome?: string;
|
||||
} = {}): void {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
stopDashboard(grokHome);
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const launchAgent = path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"LaunchAgents",
|
||||
"com.dex.grok-hud-dashboard.plist",
|
||||
);
|
||||
try {
|
||||
execSilent(["launchctl", "unload", launchAgent]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(launchAgent)) fs.unlinkSync(launchAgent);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defaultGrokHome } from "./session.js";
|
||||
|
||||
export function packageRoot(): string {
|
||||
// dist/src/install.js -> package root is ../..
|
||||
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
}
|
||||
|
||||
export function hookEntryPath(root = packageRoot()): string {
|
||||
return path.join(root, "dist", "src", "hook.js");
|
||||
}
|
||||
|
||||
export function buildHookManifest(hookJs: string): object {
|
||||
// Use node + absolute path so it works when Grok spawns hooks without PATH tricks
|
||||
const node = process.execPath;
|
||||
const cmd = `${JSON.stringify(node)} ${JSON.stringify(hookJs)}`;
|
||||
const handler = {
|
||||
type: "command",
|
||||
command: cmd,
|
||||
timeout: 8,
|
||||
};
|
||||
return {
|
||||
description:
|
||||
"grok-build-hud — live context/tool stats in scrollback while you work",
|
||||
hooks: {
|
||||
SessionStart: [{ hooks: [handler] }],
|
||||
UserPromptSubmit: [{ hooks: [handler] }],
|
||||
PostToolUse: [{ hooks: [handler] }],
|
||||
Stop: [{ hooks: [handler] }],
|
||||
SessionEnd: [{ hooks: [handler] }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function installGlobalHooks(
|
||||
options: { grokHome?: string; root?: string } = {},
|
||||
): { hooksPath: string; hookJs: string } {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const root = options.root ?? packageRoot();
|
||||
const hookJs = hookEntryPath(root);
|
||||
if (!fs.existsSync(hookJs)) {
|
||||
throw new Error(
|
||||
`Hook entry not built: ${hookJs}. Run: npm run build (in ${root})`,
|
||||
);
|
||||
}
|
||||
const hooksDir = path.join(grokHome, "hooks");
|
||||
fs.mkdirSync(hooksDir, { recursive: true });
|
||||
const hooksPath = path.join(hooksDir, "grok-build-hud.json");
|
||||
const manifest = buildHookManifest(hookJs);
|
||||
fs.writeFileSync(hooksPath, JSON.stringify(manifest, null, 2) + "\n", "utf8");
|
||||
|
||||
// Ensure hud data dir exists
|
||||
fs.mkdirSync(path.join(grokHome, "hud"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(grokHome, "hud", "README.txt"),
|
||||
[
|
||||
"grok-build-hud live status files",
|
||||
" status-line.txt — one-line compact",
|
||||
" status.txt — multi-line",
|
||||
" status.json — machine readable",
|
||||
"",
|
||||
"In-session: hooks print [hud] lines into Grok scrollback annotations",
|
||||
"after each turn (Stop) and periodically after tools.",
|
||||
"",
|
||||
`Installed hooks: ${hooksPath}`,
|
||||
`Hook binary: ${hookJs}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
return { hooksPath, hookJs };
|
||||
}
|
||||
|
||||
export function uninstallGlobalHooks(
|
||||
grokHome = defaultGrokHome(),
|
||||
): { removed: boolean; hooksPath: string } {
|
||||
const hooksPath = path.join(grokHome, "hooks", "grok-build-hud.json");
|
||||
if (fs.existsSync(hooksPath)) {
|
||||
fs.unlinkSync(hooksPath);
|
||||
return { removed: true, hooksPath };
|
||||
}
|
||||
return { removed: false, hooksPath };
|
||||
}
|
||||
|
||||
void os;
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
formatDuration,
|
||||
formatTokenCount,
|
||||
projectLabel,
|
||||
renderBar,
|
||||
} from "./bar.js";
|
||||
import { formatToolLine } from "./activity.js";
|
||||
import type {
|
||||
RenderOptions,
|
||||
SessionSnapshot,
|
||||
UsageSnapshot,
|
||||
} from "./types.js";
|
||||
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
dim: "\x1b[2m",
|
||||
bold: "\x1b[1m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
red: "\x1b[31m",
|
||||
cyan: "\x1b[36m",
|
||||
magenta: "\x1b[35m",
|
||||
blue: "\x1b[34m",
|
||||
};
|
||||
|
||||
function colorize(
|
||||
text: string,
|
||||
color: string | null,
|
||||
enabled: boolean,
|
||||
): string {
|
||||
if (!enabled || !color) return text;
|
||||
return `${color}${text}${ANSI.reset}`;
|
||||
}
|
||||
|
||||
function severityColor(
|
||||
percent: number,
|
||||
opts: RenderOptions,
|
||||
): string {
|
||||
if (percent >= opts.criticalThreshold) return ANSI.red;
|
||||
if (percent >= opts.warningThreshold) return ANSI.yellow;
|
||||
return ANSI.green;
|
||||
}
|
||||
|
||||
export function renderHud(
|
||||
session: SessionSnapshot,
|
||||
usage: UsageSnapshot | null,
|
||||
opts: Partial<RenderOptions> = {},
|
||||
): string {
|
||||
const options: RenderOptions = {
|
||||
color: opts.color ?? true,
|
||||
tmux: opts.tmux ?? false,
|
||||
compact: opts.compact ?? false,
|
||||
pathLevels: opts.pathLevels ?? 2,
|
||||
warningThreshold: opts.warningThreshold ?? 70,
|
||||
criticalThreshold: opts.criticalThreshold ?? 90,
|
||||
};
|
||||
|
||||
if (options.tmux) {
|
||||
return renderTmux(session, usage, options);
|
||||
}
|
||||
|
||||
const c = options.color;
|
||||
const model = colorize(displayModel(session.model), ANSI.cyan, c);
|
||||
const project = projectLabel(session.cwd, options.pathLevels);
|
||||
const branchPart = session.branch
|
||||
? ` git:(${session.branch}${session.gitDirty ? "*" : ""})`
|
||||
: "";
|
||||
const live = session.live
|
||||
? colorize("● live", ANSI.green, c)
|
||||
: colorize("○ stale", ANSI.dim, c);
|
||||
|
||||
const line1 = `[${model}] │ ${project}${branchPart} │ ${live}`;
|
||||
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const barColor = severityColor(session.contextPercent, options);
|
||||
const bar = colorize(renderBar(session.contextPercent), barColor, c);
|
||||
const tokenPart =
|
||||
session.contextWindowTokens > 0
|
||||
? ` (${formatTokenCount(session.contextTokensUsed)}/${formatTokenCount(session.contextWindowTokens)})`
|
||||
: "";
|
||||
const ctxLabel = colorize("Context", ANSI.bold, c);
|
||||
const meta: string[] = [];
|
||||
if (session.durationSeconds > 0) {
|
||||
meta.push(`Time ${formatDuration(session.durationSeconds)}`);
|
||||
}
|
||||
if (session.turnCount > 0) meta.push(`Turns ${session.turnCount}`);
|
||||
if (session.toolCallCount > 0) meta.push(`Tools ${session.toolCallCount}`);
|
||||
const line2 = `${ctxLabel} ${bar} ${pct}%${tokenPart}${meta.length ? ` │ ${meta.join(" │ ")}` : ""}`;
|
||||
|
||||
const lines = [line1, line2];
|
||||
|
||||
const usageLine = formatUsageLine(usage, options);
|
||||
if (usageLine) lines.push(usageLine);
|
||||
|
||||
const toolLine = formatToolLine(session.tools);
|
||||
if (toolLine) {
|
||||
lines.push(toolLine);
|
||||
}
|
||||
|
||||
if (session.agents?.length) {
|
||||
const a = session.agents[session.agents.length - 1]!;
|
||||
lines.push(
|
||||
`◎ agent ${a.title ?? "agent"}${a.detail ? `: ${a.detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (options.compact) {
|
||||
return lines.slice(0, 2).join(" · ");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function displayModel(model: string): string {
|
||||
if (!model || model === "unknown") return "Grok";
|
||||
// grok-4.5 -> Grok 4.5
|
||||
return model
|
||||
.replace(/^grok-/i, "Grok ")
|
||||
.replace(/-/g, " ");
|
||||
}
|
||||
|
||||
function formatUsageLine(
|
||||
usage: UsageSnapshot | null | undefined,
|
||||
opts: RenderOptions,
|
||||
): string {
|
||||
if (!usage) {
|
||||
return `Quota ${colorize("—", ANSI.dim, opts.color)} unavailable`;
|
||||
}
|
||||
if (!usage.available) {
|
||||
return `Quota ${colorize("—", ANSI.dim, opts.color)} ${usage.message ?? "unavailable"}`;
|
||||
}
|
||||
const pct = usage.percent ?? 0;
|
||||
const bar = colorize(renderBar(pct), severityColor(pct, opts), opts.color);
|
||||
const nums =
|
||||
usage.used != null && usage.limit != null
|
||||
? ` · month ${formatTokenCount(usage.used)}/${formatTokenCount(usage.limit)}`
|
||||
: "";
|
||||
const period = usage.period ? ` (${usage.period})` : "";
|
||||
const reset = usage.resetsIn ? ` · resets ${usage.resetsIn}` : "";
|
||||
const prod = usage.message ? ` · ${usage.message}` : "";
|
||||
return `Quota ${bar} ${Math.round(pct)}%${period}${nums}${reset}${prod}`;
|
||||
}
|
||||
|
||||
export function renderTmux(
|
||||
session: SessionSnapshot,
|
||||
usage: UsageSnapshot | null,
|
||||
opts: RenderOptions,
|
||||
): string {
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const project = projectLabel(session.cwd, opts.pathLevels);
|
||||
const u =
|
||||
usage?.available && usage.percent != null
|
||||
? ` u${Math.round(usage.percent)}%`
|
||||
: "";
|
||||
return `ctx ${pct}%${u} ${displayModel(session.model)} ${project}`;
|
||||
}
|
||||
|
||||
export function renderJson(
|
||||
session: SessionSnapshot,
|
||||
usage: UsageSnapshot | null,
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
sessionId: session.sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
live: session.live,
|
||||
contextPercent: session.contextPercent,
|
||||
contextTokensUsed: session.contextTokensUsed,
|
||||
contextWindowTokens: session.contextWindowTokens,
|
||||
turnCount: session.turnCount,
|
||||
toolCallCount: session.toolCallCount,
|
||||
tools: session.tools,
|
||||
agents: session.agents,
|
||||
usage,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
+326
@@ -0,0 +1,326 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { contextPercentFromSignals } from "./bar.js";
|
||||
import { parseUpdatesFile } from "./activity.js";
|
||||
import { readGitInfo } from "./git.js";
|
||||
import type {
|
||||
ActiveSessionEntry,
|
||||
SessionSignals,
|
||||
SessionSnapshot,
|
||||
SessionSummary,
|
||||
} from "./types.js";
|
||||
|
||||
export function defaultGrokHome(): string {
|
||||
return process.env.GROK_HOME?.trim() || path.join(os.homedir(), ".grok");
|
||||
}
|
||||
|
||||
export function readJsonFile<T>(filePath: string): T | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf8")) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadActiveSessions(grokHome: string): ActiveSessionEntry[] {
|
||||
const p = path.join(grokHome, "active_sessions.json");
|
||||
const data = readJsonFile<ActiveSessionEntry[] | { sessions?: ActiveSessionEntry[] }>(p);
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.sessions)) return data.sessions;
|
||||
return [];
|
||||
}
|
||||
|
||||
export function isPidAlive(pid?: number): boolean {
|
||||
if (!pid || pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Encode cwd the way Grok names session parent dirs. */
|
||||
export function encodeCwdDirName(cwd: string): string {
|
||||
return encodeURIComponent(cwd).replace(/%20/g, "%20");
|
||||
}
|
||||
|
||||
export function listSessionDirs(grokHome: string): string[] {
|
||||
const root = path.join(grokHome, "sessions");
|
||||
if (!fs.existsSync(root)) return [];
|
||||
const out: string[] = [];
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
if (entry.name.startsWith(".")) continue;
|
||||
const parent = path.join(root, entry.name);
|
||||
// Sessions may be parent-dir/session-id or flat
|
||||
for (const child of fs.readdirSync(parent, { withFileTypes: true })) {
|
||||
if (!child.isDirectory()) continue;
|
||||
const dir = path.join(parent, child.name);
|
||||
if (fs.existsSync(path.join(dir, "signals.json")) || fs.existsSync(path.join(dir, "summary.json"))) {
|
||||
out.push(dir);
|
||||
}
|
||||
}
|
||||
// Also allow session files directly under sessions/<id>/
|
||||
if (
|
||||
fs.existsSync(path.join(parent, "signals.json")) ||
|
||||
fs.existsSync(path.join(parent, "summary.json"))
|
||||
) {
|
||||
out.push(parent);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function mtimeMs(filePath: string): number {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSnapshotFromDir(
|
||||
sessionDir: string,
|
||||
options: {
|
||||
active?: ActiveSessionEntry[];
|
||||
preferLive?: boolean;
|
||||
} = {},
|
||||
): SessionSnapshot | null {
|
||||
if (!sessionDir || !fs.existsSync(sessionDir)) return null;
|
||||
|
||||
const signalsPath = path.join(sessionDir, "signals.json");
|
||||
const summaryPath = path.join(sessionDir, "summary.json");
|
||||
// Require at least one of the core session files
|
||||
if (!fs.existsSync(signalsPath) && !fs.existsSync(summaryPath)) return null;
|
||||
|
||||
const signals =
|
||||
readJsonFile<SessionSignals>(signalsPath) ?? {};
|
||||
const summary =
|
||||
readJsonFile<SessionSummary>(summaryPath) ?? undefined;
|
||||
|
||||
const sessionId =
|
||||
summary?.info?.id ??
|
||||
path.basename(sessionDir) ??
|
||||
"unknown";
|
||||
|
||||
const cwd =
|
||||
summary?.info?.cwd ??
|
||||
decodeCwdGuess(sessionDir) ??
|
||||
"";
|
||||
|
||||
const active = options.active ?? [];
|
||||
const activeHit = active.find((a) => a.session_id === sessionId);
|
||||
const pid = activeHit?.pid;
|
||||
const live = Boolean(activeHit && isPidAlive(pid));
|
||||
|
||||
const model =
|
||||
(typeof signals.primaryModelId === "string" && signals.primaryModelId) ||
|
||||
(Array.isArray(signals.modelsUsed) && signals.modelsUsed[0]) ||
|
||||
summary?.current_model_id ||
|
||||
"unknown";
|
||||
|
||||
const percent = contextPercentFromSignals(signals);
|
||||
const contextTokensUsed =
|
||||
typeof signals.contextTokensUsed === "number" ? signals.contextTokensUsed : 0;
|
||||
const contextWindowTokens =
|
||||
typeof signals.contextWindowTokens === "number"
|
||||
? signals.contextWindowTokens
|
||||
: 0;
|
||||
|
||||
const activity = parseUpdatesFile(path.join(sessionDir, "updates.jsonl"));
|
||||
// Fallback: toolsUsed from signals when updates empty
|
||||
let tools = activity.tools;
|
||||
if (!tools.length && Array.isArray(signals.toolsUsed) && signals.toolsUsed.length) {
|
||||
tools = signals.toolsUsed.map((name, i) => ({
|
||||
id: `sig-${i}`,
|
||||
name,
|
||||
status: "completed" as const,
|
||||
count: 1,
|
||||
}));
|
||||
}
|
||||
|
||||
const git = cwd ? readGitInfo(cwd) : { dirty: false };
|
||||
const branch = git.branch ?? summary?.head_branch;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
sessionDir,
|
||||
cwd: cwd || activeHit?.cwd || "",
|
||||
model: String(model),
|
||||
title: summary?.generated_title ?? summary?.session_summary,
|
||||
branch,
|
||||
gitDirty: git.dirty,
|
||||
gitAhead: git.ahead,
|
||||
gitBehind: git.behind,
|
||||
live,
|
||||
pid,
|
||||
contextPercent: percent,
|
||||
contextTokensUsed,
|
||||
contextWindowTokens,
|
||||
turnCount: typeof signals.turnCount === "number" ? signals.turnCount : 0,
|
||||
userMessageCount:
|
||||
typeof signals.userMessageCount === "number" ? signals.userMessageCount : 0,
|
||||
toolCallCount:
|
||||
typeof signals.toolCallCount === "number" ? signals.toolCallCount : 0,
|
||||
toolFailureCount:
|
||||
typeof signals.toolFailureCount === "number" ? signals.toolFailureCount : 0,
|
||||
errorCount: typeof signals.errorCount === "number" ? signals.errorCount : 0,
|
||||
durationSeconds:
|
||||
typeof signals.sessionDurationSeconds === "number"
|
||||
? signals.sessionDurationSeconds
|
||||
: 0,
|
||||
agentLinesAdded:
|
||||
typeof signals.agentLinesAdded === "number" ? signals.agentLinesAdded : 0,
|
||||
agentLinesRemoved:
|
||||
typeof signals.agentLinesRemoved === "number"
|
||||
? signals.agentLinesRemoved
|
||||
: 0,
|
||||
compactionCount:
|
||||
typeof signals.compactionCount === "number" ? signals.compactionCount : 0,
|
||||
avgTtftMs:
|
||||
typeof signals.avgTimeToFirstTokenMs === "number"
|
||||
? signals.avgTimeToFirstTokenMs
|
||||
: 0,
|
||||
agentName:
|
||||
typeof summary?.agent_name === "string" ? summary.agent_name : undefined,
|
||||
reasoningEffort:
|
||||
typeof summary?.reasoning_effort === "string"
|
||||
? summary.reasoning_effort
|
||||
: undefined,
|
||||
tools,
|
||||
agents: activity.agents,
|
||||
todos: activity.todos ?? [],
|
||||
signals,
|
||||
summary,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeCwdGuess(sessionDir: string): string | undefined {
|
||||
const parent = path.basename(path.dirname(sessionDir));
|
||||
try {
|
||||
if (parent.includes("%")) return decodeURIComponent(parent);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface DiscoverOptions {
|
||||
grokHome?: string;
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
sessionDir?: string;
|
||||
}
|
||||
|
||||
export function discoverSessions(options: DiscoverOptions = {}): SessionSnapshot[] {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const active = loadActiveSessions(grokHome);
|
||||
|
||||
if (options.sessionDir) {
|
||||
const snap = loadSnapshotFromDir(options.sessionDir, { active });
|
||||
return snap ? [snap] : [];
|
||||
}
|
||||
|
||||
const dirs = listSessionDirs(grokHome);
|
||||
const snaps: SessionSnapshot[] = [];
|
||||
for (const dir of dirs) {
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap) snaps.push(snap);
|
||||
}
|
||||
|
||||
let filtered = snaps;
|
||||
if (options.sessionId) {
|
||||
filtered = snaps.filter(
|
||||
(s) =>
|
||||
s.sessionId === options.sessionId ||
|
||||
s.sessionDir.endsWith(options.sessionId!),
|
||||
);
|
||||
}
|
||||
if (options.cwd) {
|
||||
filtered = filtered.filter((s) => {
|
||||
if (!s.cwd) return false;
|
||||
return pathsEqual(s.cwd, options.cwd!);
|
||||
});
|
||||
// Also match active_sessions cwd even if summary cwd is missing/stale
|
||||
if (!filtered.length) {
|
||||
for (const a of active) {
|
||||
if (a.cwd && pathsEqual(a.cwd, options.cwd)) {
|
||||
const dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (dir) {
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap) filtered.push(snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: live first, then by signals/summary mtime
|
||||
filtered.sort((a, b) => {
|
||||
if (a.live !== b.live) return a.live ? -1 : 1;
|
||||
const am = Math.max(
|
||||
mtimeMs(path.join(a.sessionDir, "signals.json")),
|
||||
mtimeMs(path.join(a.sessionDir, "summary.json")),
|
||||
);
|
||||
const bm = Math.max(
|
||||
mtimeMs(path.join(b.sessionDir, "signals.json")),
|
||||
mtimeMs(path.join(b.sessionDir, "summary.json")),
|
||||
);
|
||||
return bm - am;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
export function pickBestSession(options: DiscoverOptions = {}): SessionSnapshot | null {
|
||||
const all = discoverSessions(options);
|
||||
return all[0] ?? null;
|
||||
}
|
||||
|
||||
/** Find session directory by exact session id (basename). */
|
||||
export function findSessionDirById(
|
||||
grokHome: string,
|
||||
sessionId: string,
|
||||
): string | null {
|
||||
if (!sessionId) return null;
|
||||
for (const dir of listSessionDirs(grokHome)) {
|
||||
if (path.basename(dir) === sessionId) return dir;
|
||||
}
|
||||
// Fast path: scan sessions/*/<id>
|
||||
const root = path.join(grokHome, "sessions");
|
||||
if (!fs.existsSync(root)) return null;
|
||||
try {
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const candidate = path.join(root, entry.name, sessionId);
|
||||
if (
|
||||
fs.existsSync(path.join(candidate, "signals.json")) ||
|
||||
fs.existsSync(path.join(candidate, "summary.json"))
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Normalize paths for cwd matching (symlink / trailing slash / case). */
|
||||
export function pathsEqual(a: string, b: string): boolean {
|
||||
try {
|
||||
const ra = fs.realpathSync(path.resolve(a));
|
||||
const rb = fs.realpathSync(path.resolve(b));
|
||||
return ra === rb;
|
||||
} catch {
|
||||
try {
|
||||
return path.resolve(a) === path.resolve(b);
|
||||
} catch {
|
||||
return a === b;
|
||||
}
|
||||
}
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* Claude-HUD-parity multi-line status rendering for Grok Build.
|
||||
*
|
||||
* Default expanded layout (mirrors Claude HUD):
|
||||
* [Grok 4.5] │ project git:(main*) │ ● live
|
||||
* Context ████░░░░ 45% (224k/500k) │ Usage ██░░░░ 23% (weekly · resets 4d)
|
||||
* ◐ read_file: x │ ✓ grep ×3 │ ▸ todos 2/5
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
formatDuration,
|
||||
formatTokenCount,
|
||||
projectLabel,
|
||||
renderBar,
|
||||
} from "./bar.js";
|
||||
import { formatToolLine } from "./activity.js";
|
||||
import type {
|
||||
SessionSnapshot,
|
||||
TodoItem,
|
||||
UsageSnapshot,
|
||||
} from "./types.js";
|
||||
import { defaultGrokHome } from "./session.js";
|
||||
import {
|
||||
miniBar,
|
||||
tmuxFg,
|
||||
resolveTheme,
|
||||
type HudTheme,
|
||||
} from "./theme.js";
|
||||
import {
|
||||
loadHudConfig,
|
||||
type HudDisplayConfig,
|
||||
} from "./hud-config.js";
|
||||
|
||||
export function hudDataDir(grokHome = defaultGrokHome()): string {
|
||||
return path.join(grokHome, "hud");
|
||||
}
|
||||
|
||||
function displayModel(model: string): string {
|
||||
if (!model || model === "unknown") return "Grok";
|
||||
return model.replace(/^grok-/i, "Grok ").replace(/-/g, " ");
|
||||
}
|
||||
|
||||
function contextValueText(
|
||||
session: SessionSnapshot,
|
||||
mode: HudDisplayConfig["display"]["contextValue"],
|
||||
): string {
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const tokens =
|
||||
session.contextWindowTokens > 0
|
||||
? `${formatTokenCount(session.contextTokensUsed)}/${formatTokenCount(session.contextWindowTokens)}`
|
||||
: "";
|
||||
if (mode === "tokens") return tokens || `${pct}%`;
|
||||
if (mode === "both") return tokens ? `${pct}% (${tokens})` : `${pct}%`;
|
||||
return `${pct}%`;
|
||||
}
|
||||
|
||||
function formatTodosLine(todos: TodoItem[]): string {
|
||||
if (!todos.length) return "";
|
||||
const done = todos.filter((t) => t.status === "completed").length;
|
||||
const cur =
|
||||
todos.find((t) => t.status === "in_progress") ??
|
||||
todos.find((t) => t.status === "pending");
|
||||
const label = cur?.content
|
||||
? truncate(cur.content, 42)
|
||||
: "todos";
|
||||
return `▸ ${label} (${done}/${todos.length})`;
|
||||
}
|
||||
|
||||
function formatAgentsLine(session: SessionSnapshot): string {
|
||||
if (!session.agents?.length) return "";
|
||||
const a = session.agents[session.agents.length - 1]!;
|
||||
const title = a.title ?? "agent";
|
||||
const detail = a.detail ? `: ${truncate(a.detail, 36)}` : "";
|
||||
return `◐ ${title}${detail}`;
|
||||
}
|
||||
|
||||
function truncate(s: string, n: number): string {
|
||||
if (s.length <= n) return s;
|
||||
return s.slice(0, n - 1) + "…";
|
||||
}
|
||||
|
||||
/** Plain multi-line Claude-HUD parity block (for files / /hud / hooks). */
|
||||
export function formatStatusBlock(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
): string {
|
||||
const d = cfg.display;
|
||||
const lines: string[] = [];
|
||||
|
||||
// Line 1 — model │ project git │ live (+ optional title)
|
||||
const l1: string[] = [];
|
||||
if (d.showModel) l1.push(`[${displayModel(session.model)}]`);
|
||||
if (d.showProject) {
|
||||
let proj = projectLabel(session.cwd, cfg.pathLevels);
|
||||
if (d.showGit && session.branch) {
|
||||
const dirty = d.showGitDirty && session.gitDirty ? "*" : "";
|
||||
let ab = "";
|
||||
if (session.gitAhead) ab += `↑${session.gitAhead}`;
|
||||
if (session.gitBehind) ab += `↓${session.gitBehind}`;
|
||||
proj += ` git:(${session.branch}${dirty}${ab})`;
|
||||
}
|
||||
l1.push(proj);
|
||||
}
|
||||
if (d.showLive) l1.push(session.live ? "● live" : "○ stale");
|
||||
if (d.showTitle && session.title) l1.push(truncate(session.title, 36));
|
||||
if (session.reasoningEffort) l1.push(`effort:${session.reasoningEffort}`);
|
||||
lines.push(l1.join(" │ "));
|
||||
|
||||
// Line 2 — Context + Usage (Claude default merge)
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const cBar = d.showContextBar ? renderBar(session.contextPercent) + " " : "";
|
||||
const ctxPart = `Context ${cBar}${contextValueText(session, d.contextValue)}`;
|
||||
|
||||
let usagePart = "";
|
||||
if (d.showUsage) {
|
||||
if (usage?.available && usage.percent != null) {
|
||||
const uBar = d.showContextBar ? renderBar(usage.percent) + " " : "";
|
||||
const abs =
|
||||
usage.used != null && usage.limit != null
|
||||
? ` ${formatTokenCount(usage.used)}/${formatTokenCount(usage.limit)}`
|
||||
: "";
|
||||
const reset = usage.resetsIn ? ` · ${usage.resetsIn} left` : "";
|
||||
usagePart = `Usage ${uBar}${Math.round(usage.percent)}%${usage.period ? ` (${usage.period})` : ""}${abs}${reset}`;
|
||||
} else {
|
||||
usagePart = `Usage — ${usage?.message ?? "n/a"}`;
|
||||
}
|
||||
}
|
||||
|
||||
const meta: string[] = [];
|
||||
if (d.showSessionTime && session.durationSeconds > 0) {
|
||||
meta.push(`Time ${formatDuration(session.durationSeconds)}`);
|
||||
}
|
||||
if (d.showTurns && session.turnCount > 0) {
|
||||
meta.push(`Turns ${session.turnCount}`);
|
||||
}
|
||||
if (d.showTools && session.toolCallCount > 0) {
|
||||
meta.push(`Tools ${session.toolCallCount}`);
|
||||
}
|
||||
if (d.showErrors && (session.errorCount > 0 || session.toolFailureCount > 0)) {
|
||||
meta.push(`Err ${session.errorCount || session.toolFailureCount}`);
|
||||
}
|
||||
if (
|
||||
d.showDiffStats &&
|
||||
(session.agentLinesAdded > 0 || session.agentLinesRemoved > 0)
|
||||
) {
|
||||
meta.push(`Δ +${session.agentLinesAdded}/-${session.agentLinesRemoved}`);
|
||||
}
|
||||
if (d.showProductBreakdown && usage?.message) {
|
||||
const gb = usage.message
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.find((s) => /GrokBuild/i.test(s));
|
||||
if (gb) meta.push(gb);
|
||||
}
|
||||
|
||||
const line2 = [ctxPart, usagePart, ...meta].filter(Boolean).join(" │ ");
|
||||
lines.push(line2);
|
||||
|
||||
// Line 3 — tools / agents / todos
|
||||
const activity: string[] = [];
|
||||
if (d.showToolActivity) {
|
||||
const tl = formatToolLine(session.tools);
|
||||
if (tl) activity.push(tl);
|
||||
}
|
||||
if (d.showAgents) {
|
||||
const al = formatAgentsLine(session);
|
||||
if (al) activity.push(al);
|
||||
}
|
||||
if (d.showTodos) {
|
||||
const td = formatTodosLine(session.todos);
|
||||
if (td) activity.push(td);
|
||||
}
|
||||
if (activity.length) lines.push(activity.join(" · "));
|
||||
|
||||
if (cfg.lineLayout === "compact") {
|
||||
return lines.slice(0, 2).join(" · ");
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/** Compact single-line for hooks / titles. */
|
||||
export function formatCompactLine(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
): string {
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const q =
|
||||
usage?.available && usage.percent != null
|
||||
? `usage ${Math.round(usage.percent)}%`
|
||||
: "usage —";
|
||||
const git =
|
||||
cfg.display.showGit && session.branch
|
||||
? ` git:(${session.branch}${session.gitDirty && cfg.display.showGitDirty ? "*" : ""})`
|
||||
: "";
|
||||
return `[hud] [${displayModel(session.model)}] ${projectLabel(session.cwd, cfg.pathLevels)}${git} │ ctx ${pct}% │ ${q} │ t${session.turnCount} │ tools ${session.toolCallCount}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Multi-line tmux status content (colour-coded).
|
||||
* Written as separate lines for status-format[0..n].
|
||||
*/
|
||||
export function formatTmuxStatusLines(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
theme: HudTheme = resolveTheme(),
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
): string[] {
|
||||
const d = cfg.display;
|
||||
const bold = cfg.bold !== false;
|
||||
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
|
||||
// wider separators + bold values = easier to scan at a glance
|
||||
const sep = tmuxFg(theme.sep, " │ ", { bold: false });
|
||||
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
|
||||
const value = (s: string) => tmuxFg(theme.value, s, { bold });
|
||||
const accent = (s: string) => tmuxFg(theme.mark, s, { bold });
|
||||
const lines: string[] = [];
|
||||
|
||||
// L0 model + project + git + live
|
||||
const l0: string[] = [];
|
||||
if (d.showModel) {
|
||||
l0.push(accent(`[${displayModel(session.model)}]`));
|
||||
}
|
||||
if (d.showProject) {
|
||||
let p = value(projectLabel(session.cwd, cfg.pathLevels));
|
||||
if (d.showGit && session.branch) {
|
||||
const dirty = d.showGitDirty && session.gitDirty ? "*" : "";
|
||||
p +=
|
||||
tmuxFg(theme.label, " git:(", { bold: false }) +
|
||||
value(`${session.branch}${dirty}`) +
|
||||
tmuxFg(theme.label, ")", { bold: false });
|
||||
}
|
||||
l0.push(p);
|
||||
}
|
||||
if (d.showLive) {
|
||||
l0.push(
|
||||
session.live
|
||||
? tmuxFg(theme.live, " ● LIVE ", { bold: true })
|
||||
: tmuxFg(theme.stale, " ○ stale ", { bold: false }),
|
||||
);
|
||||
}
|
||||
// Extra leading/trailing spaces = more breathing room (reads larger)
|
||||
lines.push(" " + l0.join(sep) + " ");
|
||||
|
||||
// L1 context + usage — thicker bars, bold percentages
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const ctx =
|
||||
label("CTX ") +
|
||||
(d.showContextBar ? miniBar(pct, barW, theme, { bold }) + " " : "") +
|
||||
value(contextValueText(session, d.contextValue));
|
||||
|
||||
let usagePart = label("USE ") + value("—");
|
||||
if (d.showUsage && usage?.available && usage.percent != null) {
|
||||
const q = Math.round(usage.percent);
|
||||
usagePart =
|
||||
label("USE ") +
|
||||
(d.showContextBar ? miniBar(q, barW, theme, { bold }) + " " : "") +
|
||||
value(
|
||||
`${q}%${usage.period ? ` ${usage.period}` : ""}${usage.resetsIn ? ` · ${usage.resetsIn}` : ""}`,
|
||||
);
|
||||
}
|
||||
const meta: string[] = [];
|
||||
if (d.showSessionTime && session.durationSeconds > 0) {
|
||||
meta.push(label("TIME ") + value(formatDuration(session.durationSeconds)));
|
||||
}
|
||||
if (d.showTurns) meta.push(label("T ") + value(String(session.turnCount)));
|
||||
if (d.showTools)
|
||||
meta.push(label("TOOLS ") + value(String(session.toolCallCount)));
|
||||
if (d.showErrors && session.toolFailureCount > 0) {
|
||||
meta.push(tmuxFg(theme.crit, ` ERR ${session.toolFailureCount} `, { bold: true }));
|
||||
}
|
||||
if (
|
||||
d.showDiffStats &&
|
||||
(session.agentLinesAdded || session.agentLinesRemoved)
|
||||
) {
|
||||
meta.push(
|
||||
value(`+${session.agentLinesAdded}/-${session.agentLinesRemoved}`),
|
||||
);
|
||||
}
|
||||
lines.push(" " + [ctx, usagePart, ...meta].join(sep) + " ");
|
||||
|
||||
// L2 activity
|
||||
if (cfg.statusLines >= 3) {
|
||||
const bits: string[] = [];
|
||||
if (d.showToolActivity) {
|
||||
const tl = formatToolLine(session.tools);
|
||||
if (tl) bits.push(value(tl));
|
||||
}
|
||||
if (d.showAgents) {
|
||||
const al = formatAgentsLine(session);
|
||||
if (al) bits.push(label(al));
|
||||
}
|
||||
if (d.showTodos) {
|
||||
const td = formatTodosLine(session.todos);
|
||||
if (td) bits.push(accent(td));
|
||||
}
|
||||
if (d.showProductBreakdown && usage?.message) {
|
||||
const gb = usage.message
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.find((s) => /GrokBuild/i.test(s));
|
||||
if (gb) bits.push(label(gb));
|
||||
}
|
||||
if (bits.length)
|
||||
lines.push(" " + bits.join(tmuxFg(theme.sep, " · ", { bold: false })) + " ");
|
||||
}
|
||||
|
||||
const n = Math.max(1, Math.min(3, cfg.statusLines));
|
||||
return lines.slice(0, n);
|
||||
}
|
||||
|
||||
/** Single-line coloured status (fallback / compact). */
|
||||
export function formatTmuxStatusLine(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
theme: HudTheme = resolveTheme(),
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
): string {
|
||||
if (cfg.lineLayout === "compact" || cfg.statusLines === 1) {
|
||||
const lines = formatTmuxStatusLines(session, usage, theme, {
|
||||
...cfg,
|
||||
statusLines: 1,
|
||||
});
|
||||
// collapse expanded L0+L1 into one dense line for single-row mode
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const q =
|
||||
usage?.available && usage.percent != null
|
||||
? Math.round(usage.percent)
|
||||
: null;
|
||||
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
|
||||
const bold = cfg.bold !== false;
|
||||
const sep = tmuxFg(theme.sep, " · ", { bold: false });
|
||||
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
|
||||
const value = (s: string) => tmuxFg(theme.value, s, { bold });
|
||||
const parts = [
|
||||
tmuxFg(theme.mark, `[${displayModel(session.model)}]`, { bold: true }),
|
||||
value(projectLabel(session.cwd, cfg.pathLevels)),
|
||||
label("CTX ") + miniBar(pct, barW, theme, { bold }) + " " + value(`${pct}%`),
|
||||
q != null
|
||||
? label("USE ") + miniBar(q, barW, theme, { bold }) + " " + value(`${q}%`)
|
||||
: label("USE ") + value("—"),
|
||||
label("T ") + value(String(session.turnCount)),
|
||||
label("TOOLS ") + value(String(session.toolCallCount)),
|
||||
session.live
|
||||
? tmuxFg(theme.live, "● LIVE", { bold: true })
|
||||
: tmuxFg(theme.stale, "○", { bold: false }),
|
||||
];
|
||||
return " " + parts.join(sep) + " ";
|
||||
}
|
||||
return formatTmuxStatusLines(session, usage, theme, cfg).join("\n");
|
||||
}
|
||||
|
||||
export interface WriteStatusResult {
|
||||
dir: string;
|
||||
compactPath: string;
|
||||
fullPath: string;
|
||||
compact: string;
|
||||
full: string;
|
||||
tmuxLines: string[];
|
||||
}
|
||||
|
||||
export function writeStatusFiles(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
grokHome = defaultGrokHome(),
|
||||
): WriteStatusResult {
|
||||
const cfg = loadHudConfig(grokHome);
|
||||
const theme = resolveTheme(undefined, process.env, { grokHome });
|
||||
const dir = hudDataDir(grokHome);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
const compact = formatCompactLine(session, usage, cfg);
|
||||
const full = formatStatusBlock(session, usage, cfg);
|
||||
const tmuxLines = formatTmuxStatusLines(session, usage, theme, cfg);
|
||||
const single = formatTmuxStatusLine(session, usage, theme, cfg);
|
||||
|
||||
const compactPath = path.join(dir, "status-line.txt");
|
||||
const fullPath = path.join(dir, "status.txt");
|
||||
const jsonPath = path.join(dir, "status.json");
|
||||
const tmuxPath = path.join(dir, "tmux-status.txt");
|
||||
const tmuxLinesPath = path.join(dir, "tmux-lines.txt");
|
||||
|
||||
fs.writeFileSync(compactPath, compact + "\n", "utf8");
|
||||
fs.writeFileSync(fullPath, full + "\n", "utf8");
|
||||
fs.writeFileSync(tmuxPath, single + "\n", "utf8");
|
||||
// pad to 3 lines for stable sed -n
|
||||
const padded = [...tmuxLines];
|
||||
while (padded.length < 3) padded.push("");
|
||||
fs.writeFileSync(tmuxLinesPath, padded.join("\n") + "\n", "utf8");
|
||||
|
||||
fs.writeFileSync(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
updatedAt: new Date().toISOString(),
|
||||
sessionId: session.sessionId,
|
||||
model: session.model,
|
||||
cwd: session.cwd,
|
||||
live: session.live,
|
||||
contextPercent: session.contextPercent,
|
||||
contextTokensUsed: session.contextTokensUsed,
|
||||
contextWindowTokens: session.contextWindowTokens,
|
||||
turnCount: session.turnCount,
|
||||
toolCallCount: session.toolCallCount,
|
||||
toolFailureCount: session.toolFailureCount,
|
||||
agentLinesAdded: session.agentLinesAdded,
|
||||
agentLinesRemoved: session.agentLinesRemoved,
|
||||
todos: session.todos,
|
||||
tools: session.tools,
|
||||
agents: session.agents,
|
||||
usage: usage ?? null,
|
||||
preset: cfg.preset,
|
||||
compact,
|
||||
full,
|
||||
tmuxLines,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
return {
|
||||
dir,
|
||||
compactPath,
|
||||
fullPath,
|
||||
compact,
|
||||
full,
|
||||
tmuxLines,
|
||||
};
|
||||
}
|
||||
+497
@@ -0,0 +1,497 @@
|
||||
/**
|
||||
* HUD palettes follow the active **Grok Build UI theme** (config.toml [ui].theme),
|
||||
* so the status strip matches what you see in the TUI — not a generic guess.
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
export interface HudTheme {
|
||||
name: string;
|
||||
statusBg: string;
|
||||
statusFg: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sep: string;
|
||||
mark: string;
|
||||
ok: string;
|
||||
warn: string;
|
||||
crit: string;
|
||||
barEmpty: string;
|
||||
live: string;
|
||||
stale: string;
|
||||
}
|
||||
|
||||
export type ThemeMode = "auto" | "light" | "dark" | string;
|
||||
|
||||
const THEME_FILE = () => path.join(os.homedir(), ".grok", "hud", "theme");
|
||||
|
||||
/** Tokyo Night — matches Grok `tokyonight` */
|
||||
export const THEME_TOKYONIGHT: HudTheme = {
|
||||
name: "tokyonight",
|
||||
statusBg: "default",
|
||||
statusFg: "#a9b1d6",
|
||||
label: "#565f89",
|
||||
value: "#c0caf5",
|
||||
sep: "#3b4261",
|
||||
mark: "#7aa2f7",
|
||||
ok: "#9ece6a",
|
||||
warn: "#e0af68",
|
||||
crit: "#f7768e",
|
||||
barEmpty: "#292e42",
|
||||
live: "#7aa2f7",
|
||||
stale: "#565f89",
|
||||
};
|
||||
|
||||
/** GrokNight — neutral dark */
|
||||
export const THEME_GROKNIGHT: HudTheme = {
|
||||
name: "groknight",
|
||||
statusBg: "default",
|
||||
statusFg: "#c8c8c8",
|
||||
label: "#7a7a7a",
|
||||
value: "#e8e8e8",
|
||||
sep: "#3a3a3a",
|
||||
mark: "#b267e6",
|
||||
ok: "#6dbf6d",
|
||||
warn: "#c9a227",
|
||||
crit: "#d06060",
|
||||
barEmpty: "#2a2a2a",
|
||||
live: "#b267e6",
|
||||
stale: "#7a7a7a",
|
||||
};
|
||||
|
||||
/** GrokDay — light / 白底黑字 */
|
||||
export const THEME_GROKDAY: HudTheme = {
|
||||
name: "grokday",
|
||||
statusBg: "default",
|
||||
statusFg: "#2a2a2a",
|
||||
label: "#6b6b6b",
|
||||
value: "#111111",
|
||||
sep: "#c8c8c8",
|
||||
mark: "#6C3EB2",
|
||||
ok: "#2f7a3e",
|
||||
warn: "#8a6a12",
|
||||
crit: "#a03030",
|
||||
barEmpty: "#dcdcdc",
|
||||
live: "#6C3EB2",
|
||||
stale: "#8a8a8a",
|
||||
};
|
||||
|
||||
/** Rose Pine Moon */
|
||||
export const THEME_ROSEPINE: HudTheme = {
|
||||
name: "rosepinemoon",
|
||||
statusBg: "default",
|
||||
statusFg: "#e0def4",
|
||||
label: "#6e6a86",
|
||||
value: "#e0def4",
|
||||
sep: "#393552",
|
||||
mark: "#c4a7e7",
|
||||
ok: "#9ccfd8",
|
||||
warn: "#f6c177",
|
||||
crit: "#eb6f92",
|
||||
barEmpty: "#2a273f",
|
||||
live: "#c4a7e7",
|
||||
stale: "#6e6a86",
|
||||
};
|
||||
|
||||
/** Oscura Midnight */
|
||||
export const THEME_OSCURA: HudTheme = {
|
||||
name: "oscuramidnight",
|
||||
statusBg: "default",
|
||||
statusFg: "#e6e6e6",
|
||||
label: "#6a6a6a",
|
||||
value: "#f0f0f0",
|
||||
sep: "#2a2a2a",
|
||||
mark: "#5A72A0",
|
||||
ok: "#4a9a6a",
|
||||
warn: "#c9a227",
|
||||
crit: "#c05050",
|
||||
barEmpty: "#1a1a1a",
|
||||
live: "#5A72A0",
|
||||
stale: "#6a6a6a",
|
||||
};
|
||||
|
||||
/** Generic light (白底) */
|
||||
export const THEME_CLEAR_LIGHT: HudTheme = {
|
||||
name: "light",
|
||||
statusBg: "default",
|
||||
statusFg: "#3c424a",
|
||||
label: "#7a828c",
|
||||
value: "#1a1d21",
|
||||
sep: "#c5cad1",
|
||||
mark: "#5a6570",
|
||||
ok: "#3d6b55",
|
||||
warn: "#8a6a2e",
|
||||
crit: "#8f4040",
|
||||
barEmpty: "#d8dce2",
|
||||
live: "#3d6b55",
|
||||
stale: "#9aa3ad",
|
||||
};
|
||||
|
||||
/** Generic dark */
|
||||
export const THEME_CLEAR_DARK: HudTheme = {
|
||||
name: "dark",
|
||||
statusBg: "default",
|
||||
statusFg: "#9aa3ad",
|
||||
label: "#6b7380",
|
||||
value: "#c5ced6",
|
||||
sep: "#3a4048",
|
||||
mark: "#5a6570",
|
||||
ok: "#7d9a8a",
|
||||
warn: "#b9a07a",
|
||||
crit: "#b07a7a",
|
||||
barEmpty: "#2e333a",
|
||||
live: "#7d9a8a",
|
||||
stale: "#6b7380",
|
||||
};
|
||||
|
||||
export const THEME_DEFAULT: HudTheme = THEME_TOKYONIGHT;
|
||||
|
||||
const GROK_THEME_ALIASES: Record<string, string> = {
|
||||
tokyonight: "tokyonight",
|
||||
"tokyo-night": "tokyonight",
|
||||
tokyo_night: "tokyonight",
|
||||
groknight: "groknight",
|
||||
"grok-night": "groknight",
|
||||
dark: "groknight",
|
||||
night: "groknight",
|
||||
grokday: "grokday",
|
||||
"grok-day": "grokday",
|
||||
light: "grokday",
|
||||
day: "grokday",
|
||||
rosepinemoon: "rosepinemoon",
|
||||
"rose-pine-moon": "rosepinemoon",
|
||||
"rose_pine_moon": "rosepinemoon",
|
||||
rosepine: "rosepinemoon",
|
||||
"rose-pine": "rosepinemoon",
|
||||
oscuramindnight: "oscuramidnight",
|
||||
"oscura-midnight": "oscuramidnight",
|
||||
oscura: "oscuramidnight",
|
||||
auto: "auto",
|
||||
system: "auto",
|
||||
};
|
||||
|
||||
export function normalizeGrokThemeName(raw: string): string {
|
||||
const k = raw.trim().toLowerCase().replace(/\s+/g, "");
|
||||
return GROK_THEME_ALIASES[k] ?? k;
|
||||
}
|
||||
|
||||
export function paletteForGrokTheme(rawName: string): HudTheme {
|
||||
const name = normalizeGrokThemeName(rawName);
|
||||
switch (name) {
|
||||
case "tokyonight":
|
||||
return THEME_TOKYONIGHT;
|
||||
case "groknight":
|
||||
return THEME_GROKNIGHT;
|
||||
case "grokday":
|
||||
return THEME_GROKDAY;
|
||||
case "rosepinemoon":
|
||||
return THEME_ROSEPINE;
|
||||
case "oscuramidnight":
|
||||
return THEME_OSCURA;
|
||||
case "light":
|
||||
return THEME_CLEAR_LIGHT;
|
||||
case "dark":
|
||||
return THEME_CLEAR_DARK;
|
||||
default:
|
||||
// unknown → dark-neutral, still cohesive
|
||||
return { ...THEME_GROKNIGHT, name };
|
||||
}
|
||||
}
|
||||
|
||||
export interface GrokUiConfig {
|
||||
theme: string;
|
||||
autoDarkTheme: string;
|
||||
autoLightTheme: string;
|
||||
}
|
||||
|
||||
/** Parse ~/.grok/config.toml [ui] theme fields (minimal TOML reader). */
|
||||
export function readGrokUiConfig(
|
||||
grokHome = path.join(os.homedir(), ".grok"),
|
||||
): GrokUiConfig {
|
||||
const defaults: GrokUiConfig = {
|
||||
theme: "groknight",
|
||||
autoDarkTheme: "groknight",
|
||||
autoLightTheme: "grokday",
|
||||
};
|
||||
try {
|
||||
const p = path.join(grokHome, "config.toml");
|
||||
if (!fs.existsSync(p)) return defaults;
|
||||
const text = fs.readFileSync(p, "utf8");
|
||||
let inUi = false;
|
||||
const cfg = { ...defaults };
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = rawLine.replace(/#.*$/, "").trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("[")) {
|
||||
inUi = line === "[ui]" || line.startsWith("[ui.");
|
||||
continue;
|
||||
}
|
||||
if (!inUi) continue;
|
||||
const m = line.match(/^([a-zA-Z0-9_]+)\s*=\s*"([^"]*)"/);
|
||||
if (!m) continue;
|
||||
const key = m[1]!;
|
||||
const val = m[2]!;
|
||||
if (key === "theme") cfg.theme = val;
|
||||
if (key === "auto_dark_theme") cfg.autoDarkTheme = val;
|
||||
if (key === "auto_light_theme") cfg.autoLightTheme = val;
|
||||
}
|
||||
return cfg;
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
function readThemeFile(): string | null {
|
||||
try {
|
||||
const p = THEME_FILE();
|
||||
if (!fs.existsSync(p)) return null;
|
||||
return fs.readFileSync(p, "utf8").trim().split(/\s+/)[0] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function luminanceFromRgb65535(r: number, g: number, b: number): number {
|
||||
const R = clamp01(r / 65535);
|
||||
const G = clamp01(g / 65535);
|
||||
const B = clamp01(b / 65535);
|
||||
const lin = (c: number) =>
|
||||
c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
return 0.2126 * lin(R) + 0.7152 * lin(G) + 0.0722 * lin(B);
|
||||
}
|
||||
|
||||
function clamp01(n: number): number {
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
if (n < 0) return 0;
|
||||
if (n > 1) return 1;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function appearanceFromLuminance(lum: number): "light" | "dark" {
|
||||
return lum >= 0.45 ? "light" : "dark";
|
||||
}
|
||||
|
||||
let appearanceCache: { at: number; appearance: "light" | "dark" } | null = null;
|
||||
|
||||
export function clearAppearanceCache(): void {
|
||||
appearanceCache = null;
|
||||
}
|
||||
|
||||
export function detectSystemAppearance(
|
||||
options: { now?: number; ttlMs?: number } = {},
|
||||
): "light" | "dark" {
|
||||
const now = options.now ?? Date.now();
|
||||
const ttl = options.ttlMs ?? 5000;
|
||||
if (appearanceCache && now - appearanceCache.at < ttl) {
|
||||
return appearanceCache.appearance;
|
||||
}
|
||||
// macOS system appearance
|
||||
if (process.platform === "darwin") {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"defaults",
|
||||
["read", "-g", "AppleInterfaceStyle"],
|
||||
{ encoding: "utf8", timeout: 1500, stdio: ["ignore", "pipe", "ignore"] },
|
||||
).trim();
|
||||
if (/dark/i.test(out)) {
|
||||
appearanceCache = { at: now, appearance: "dark" };
|
||||
return "dark";
|
||||
}
|
||||
} catch {
|
||||
// key missing → light mode on macOS
|
||||
appearanceCache = { at: now, appearance: "light" };
|
||||
return "light";
|
||||
}
|
||||
}
|
||||
appearanceCache = { at: now, appearance: "dark" };
|
||||
return "dark";
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve HUD palette.
|
||||
* Priority:
|
||||
* 1. explicit CLI / GROK_HUD_THEME (if not auto)
|
||||
* 2. ~/.grok/hud/theme file (if not auto)
|
||||
* 3. Grok [ui].theme from config.toml ← main source of truth
|
||||
* 4. if Grok theme is auto → system light/dark → auto_light/dark_theme
|
||||
*/
|
||||
export function resolveTheme(
|
||||
name?: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
options: { grokHome?: string } = {},
|
||||
): HudTheme {
|
||||
const grokHome = options.grokHome ?? path.join(os.homedir(), ".grok");
|
||||
const ui = readGrokUiConfig(grokHome);
|
||||
|
||||
const override = (
|
||||
name ||
|
||||
env.GROK_HUD_THEME ||
|
||||
env.GROK_BUILD_HUD_THEME ||
|
||||
readThemeFile() ||
|
||||
""
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Explicit lock to a known palette name
|
||||
if (override && override !== "auto" && override !== "system") {
|
||||
return paletteForGrokTheme(override);
|
||||
}
|
||||
|
||||
// Follow Grok's active theme (what the TUI paints)
|
||||
const grokTheme = normalizeGrokThemeName(ui.theme || "groknight");
|
||||
if (grokTheme === "auto") {
|
||||
const sys = detectSystemAppearance();
|
||||
const mapped =
|
||||
sys === "dark"
|
||||
? ui.autoDarkTheme || "groknight"
|
||||
: ui.autoLightTheme || "grokday";
|
||||
const theme = paletteForGrokTheme(mapped);
|
||||
writeAppearanceSnapshot({
|
||||
appearance: sys,
|
||||
source: "grok-auto+" + sys,
|
||||
grokTheme: ui.theme,
|
||||
mappedTheme: mapped,
|
||||
hudPalette: theme.name,
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
|
||||
const theme = paletteForGrokTheme(grokTheme);
|
||||
writeAppearanceSnapshot({
|
||||
appearance: theme.name === "grokday" || theme.name === "light" ? "light" : "dark",
|
||||
source: "grok-config",
|
||||
grokTheme: ui.theme,
|
||||
mappedTheme: grokTheme,
|
||||
hudPalette: theme.name,
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
|
||||
function writeAppearanceSnapshot(data: Record<string, unknown>): void {
|
||||
try {
|
||||
const dir = path.join(os.homedir(), ".grok", "hud");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, "appearance.json"),
|
||||
JSON.stringify({ ...data, at: new Date().toISOString() }, null, 2) + "\n",
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveThemeMode(
|
||||
name?: string | null,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const raw = (
|
||||
name ||
|
||||
env.GROK_HUD_THEME ||
|
||||
env.GROK_BUILD_HUD_THEME ||
|
||||
readThemeFile() ||
|
||||
"auto"
|
||||
)
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return raw || "auto";
|
||||
}
|
||||
|
||||
export function persistTheme(name: string): string {
|
||||
const dir = path.join(os.homedir(), ".grok", "hud");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const p = THEME_FILE();
|
||||
fs.writeFileSync(p, name + "\n", "utf8");
|
||||
return p;
|
||||
}
|
||||
|
||||
export function severityColor(percent: number, theme: HudTheme): string {
|
||||
if (percent >= 90) return theme.crit;
|
||||
if (percent >= 70) return theme.warn;
|
||||
return theme.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress bar — heavy block glyphs for readability on Terminal.app.
|
||||
* filled: █ (solid), empty: ░ (light track)
|
||||
*/
|
||||
export function miniBar(
|
||||
percent: number,
|
||||
width = 12,
|
||||
theme: HudTheme = THEME_DEFAULT,
|
||||
options: { bold?: boolean } = {},
|
||||
): string {
|
||||
const p = Math.max(0, Math.min(100, percent));
|
||||
const filled = Math.round((p / 100) * width);
|
||||
const empty = Math.max(0, width - filled);
|
||||
const fg = severityColor(p, theme);
|
||||
const bold = options.bold !== false ? "bold," : "";
|
||||
return `#[${bold}fg=${fg}]${"█".repeat(filled)}#[fg=${theme.barEmpty}]${"░".repeat(empty)}#[default]`;
|
||||
}
|
||||
|
||||
/** Tmux styled span. Values default to bold for readability. */
|
||||
export function tmuxFg(
|
||||
color: string,
|
||||
text: string,
|
||||
options: { bold?: boolean } = {},
|
||||
): string {
|
||||
const bold = options.bold ? "bold," : "";
|
||||
return `#[${bold}fg=${color}]${text}#[default]`;
|
||||
}
|
||||
|
||||
export function tmuxStatusChrome(theme: HudTheme = THEME_DEFAULT): {
|
||||
statusStyle: string;
|
||||
statusLeft: string;
|
||||
statusRightTemplate: (filePath: string) => string;
|
||||
statusInterval: string;
|
||||
statusRightLength: string;
|
||||
statusLeftLength: string;
|
||||
statusPosition: string;
|
||||
} {
|
||||
return {
|
||||
// bold + slightly brighter default fg for the whole strip
|
||||
statusStyle: `bg=${theme.statusBg},fg=${theme.statusFg},bold`,
|
||||
statusLeft: "",
|
||||
statusRightTemplate: (filePath: string) =>
|
||||
`#(cat ${filePath} 2>/dev/null)`,
|
||||
statusInterval: "1",
|
||||
statusRightLength: "200",
|
||||
statusLeftLength: "0",
|
||||
statusPosition: "bottom",
|
||||
};
|
||||
}
|
||||
|
||||
// Back-compat helpers
|
||||
export function appearanceFromProfileName(
|
||||
name: string,
|
||||
): "light" | "dark" | null {
|
||||
const n = name.toLowerCase();
|
||||
if (/light|paper|white|basic|novel|day/.test(n)) return "light";
|
||||
if (/dark|pro|homebrew|night|tokyo|oscura|pine/.test(n)) return "dark";
|
||||
return null;
|
||||
}
|
||||
export function appearanceFromColorFgBg(
|
||||
colorFgBg: string | undefined,
|
||||
): "light" | "dark" | null {
|
||||
if (!colorFgBg) return null;
|
||||
const parts = colorFgBg.split(";");
|
||||
if (parts.length < 2) return null;
|
||||
const bg = Number(parts[parts.length - 1]);
|
||||
if (!Number.isFinite(bg)) return null;
|
||||
if (bg >= 7 && bg !== 8) return "light";
|
||||
if (bg <= 1 || bg === 8) return "dark";
|
||||
return bg >= 7 ? "light" : "dark";
|
||||
}
|
||||
export function detectTerminalAppearance(): {
|
||||
appearance: "light" | "dark";
|
||||
source: string;
|
||||
} {
|
||||
return {
|
||||
appearance: detectSystemAppearance(),
|
||||
source: "system",
|
||||
};
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Same-window always-visible HUD via tmux status bar.
|
||||
* One Terminal.app window: bottom line shows ctx + quota, Grok fills the rest.
|
||||
* Visual theme: soft Clear Dark palette (see theme.ts).
|
||||
*/
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { defaultGrokHome } from "./session.js";
|
||||
import { packageRoot } from "./install.js";
|
||||
import { resolveTheme, tmuxStatusChrome } from "./theme.js";
|
||||
import { loadHudConfig } from "./hud-config.js";
|
||||
|
||||
export function isInsideTmux(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(env.TMUX && env.TMUX.length > 0);
|
||||
}
|
||||
|
||||
export function tmuxAvailable(): boolean {
|
||||
try {
|
||||
execFileSync("which", ["tmux"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the list of tmux set commands for a cohesive status bar. */
|
||||
export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
|
||||
const theme = resolveTheme(undefined, process.env, { grokHome });
|
||||
const chrome = tmuxStatusChrome(theme);
|
||||
const linesFile = path.join(grokHome, "hud", "tmux-lines.txt");
|
||||
const singleFile = path.join(grokHome, "hud", "tmux-status.txt");
|
||||
// Claude-HUD parity: up to 3 status rows in the SAME terminal window
|
||||
const statusLines = Math.max(
|
||||
1,
|
||||
Math.min(3, loadHudConfig(grokHome).statusLines ?? 3),
|
||||
);
|
||||
|
||||
const cmds: string[][] = [
|
||||
["set", "-g", "status", String(statusLines)],
|
||||
["set", "-g", "status-position", chrome.statusPosition],
|
||||
["set", "-g", "status-interval", chrome.statusInterval],
|
||||
["set", "-g", "status-style", chrome.statusStyle],
|
||||
["set", "-g", "status-justify", "left"],
|
||||
["set", "-g", "window-status-format", ""],
|
||||
["set", "-g", "window-status-current-format", ""],
|
||||
["set", "-g", "window-status-separator", ""],
|
||||
["set", "-g", "status-left-length", "0"],
|
||||
["set", "-g", "status-right-length", "0"],
|
||||
["set", "-g", "status-left", ""],
|
||||
["set", "-g", "status-right", ""],
|
||||
];
|
||||
|
||||
if (statusLines === 1) {
|
||||
cmds.push([
|
||||
"set",
|
||||
"-g",
|
||||
"status-format[0]",
|
||||
`#[align=left]#(cat ${singleFile} 2>/dev/null)`,
|
||||
]);
|
||||
} else {
|
||||
for (let i = 0; i < statusLines; i++) {
|
||||
cmds.push([
|
||||
"set",
|
||||
"-g",
|
||||
`status-format[${i}]`,
|
||||
`#[align=left]#(sed -n '${i + 1}p' ${linesFile} 2>/dev/null)`,
|
||||
]);
|
||||
}
|
||||
}
|
||||
return cmds;
|
||||
}
|
||||
|
||||
/** Apply status bar to the current tmux server (same window). */
|
||||
export function applyTmuxStatusBar(options: {
|
||||
grokHome?: string;
|
||||
} = {}): boolean {
|
||||
if (!tmuxAvailable()) return false;
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
try {
|
||||
for (const args of tmuxStatusCommands(grokHome)) {
|
||||
execFileSync("tmux", args, { stdio: "ignore", timeout: 2000 });
|
||||
}
|
||||
// force redraw
|
||||
try {
|
||||
execFileSync("tmux", ["refresh-client", "-S"], {
|
||||
stdio: "ignore",
|
||||
timeout: 1000,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Write ~/.grok/hud/tmux.conf for persistence across restarts. */
|
||||
export function writeTmuxConfFile(grokHome = defaultGrokHome()): string {
|
||||
const confPath = path.join(grokHome, "hud", "tmux.conf");
|
||||
const cmds = tmuxStatusCommands(grokHome);
|
||||
const body =
|
||||
`# grok-build-hud — Claude-HUD-parity multi-line status (same window)\n` +
|
||||
`# Follows Grok [ui].theme. Preset: ~/.grok/hud/config.json\n` +
|
||||
`# Auto-sourced by ~/.tmux.conf when present.\n\n` +
|
||||
cmds
|
||||
.map((args) => {
|
||||
// tmux.conf form: set -g key value
|
||||
if (args[0] === "set" && args[1] === "-g") {
|
||||
const key = args[2]!;
|
||||
const val = args.slice(3).join(" ");
|
||||
// quote values with spaces / special chars
|
||||
const needsQuote = /[\s#'"]/.test(val) || val.includes("#(");
|
||||
return `set -g ${key} ${needsQuote ? `'${val.replace(/'/g, `'\\''`)}'` : val}`;
|
||||
}
|
||||
return args.join(" ");
|
||||
})
|
||||
.join("\n") +
|
||||
"\n";
|
||||
fs.mkdirSync(path.dirname(confPath), { recursive: true });
|
||||
fs.writeFileSync(confPath, body, "utf8");
|
||||
return confPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exec current process inside tmux with status bar, then run `grok`.
|
||||
* Uses the SAME Terminal.app window (tmux replaces the foreground process).
|
||||
*/
|
||||
export function execGrokInSameWindowTmux(options: {
|
||||
grokHome?: string;
|
||||
grokBin?: string;
|
||||
sessionName?: string;
|
||||
extraGrokArgs?: string[];
|
||||
}): never | void {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const grokBin = options.grokBin ?? detectGrokBin();
|
||||
const session = options.sessionName ?? "grok-hud";
|
||||
const statusFile = path.join(grokHome, "hud", "tmux-status.txt");
|
||||
fs.mkdirSync(path.dirname(statusFile), { recursive: true });
|
||||
if (!fs.existsSync(statusFile)) {
|
||||
fs.writeFileSync(statusFile, "ctx … · quota …\n", "utf8");
|
||||
}
|
||||
writeTmuxConfFile(grokHome);
|
||||
|
||||
if (isInsideTmux()) {
|
||||
applyTmuxStatusBar({ grokHome });
|
||||
const args = options.extraGrokArgs ?? [];
|
||||
const r = spawnSync(grokBin, args, { stdio: "inherit" });
|
||||
process.exit(r.status ?? 0);
|
||||
}
|
||||
|
||||
if (!tmuxAvailable()) {
|
||||
console.error(
|
||||
"tmux not found. Install: brew install tmux\nThen re-run: grok-hud-run",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const grokArgs = options.extraGrokArgs ?? [];
|
||||
const grokCmd = [grokBin, ...grokArgs].map((a) => shellQuote(a)).join(" ");
|
||||
const conf = path.join(grokHome, "hud", "tmux.conf");
|
||||
|
||||
// Apply conf then exec grok — same tty, no second window
|
||||
const wrapper = [
|
||||
`tmux source-file ${shellQuote(conf)} 2>/dev/null || true`,
|
||||
// re-apply in case source failed mid-session
|
||||
...tmuxStatusCommands(grokHome).map(
|
||||
(args) => `tmux ${args.map(shellQuote).join(" ")} 2>/dev/null || true`,
|
||||
),
|
||||
`exec ${grokCmd}`,
|
||||
].join(" && ");
|
||||
|
||||
const r = spawnSync(
|
||||
"tmux",
|
||||
["new-session", "-A", "-s", session, "zsh", "-lc", wrapper],
|
||||
{ stdio: "inherit" },
|
||||
);
|
||||
process.exit(r.status ?? 1);
|
||||
}
|
||||
|
||||
function detectGrokBin(): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), ".grok", "bin", "grok"),
|
||||
"/usr/local/bin/grok",
|
||||
"/opt/homebrew/bin/grok",
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (fs.existsSync(c)) return c;
|
||||
}
|
||||
try {
|
||||
return execFileSync("which", ["grok"], { encoding: "utf8" }).trim();
|
||||
} catch {
|
||||
return "grok";
|
||||
}
|
||||
}
|
||||
|
||||
function shellQuote(s: string): string {
|
||||
if (/^[a-zA-Z0-9_\/\-\.\:=]+$/.test(s)) return s;
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Install same-window launchers. */
|
||||
export function installSameWindowLauncher(options: {
|
||||
binDir?: string;
|
||||
} = {}): { runPath: string; grokWrapPath: string } {
|
||||
const binDir = options.binDir ?? path.join(os.homedir(), ".local", "bin");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const root = packageRoot();
|
||||
const node = process.execPath;
|
||||
const entry = path.join(root, "bin", "grok-build-hud.js");
|
||||
|
||||
const runPath = path.join(binDir, "grok-hud-run");
|
||||
const runShim = `#!/bin/zsh
|
||||
# Start Grok in THIS Terminal window with a permanent bottom status bar
|
||||
# (tmux). Does not open a second window.
|
||||
set -e
|
||||
NODE=${JSON.stringify(node)}
|
||||
ENTRY=${JSON.stringify(entry)}
|
||||
"$NODE" "$ENTRY" --dashboard-start >/dev/null 2>&1 || true
|
||||
"$NODE" "$ENTRY" --once --follow-active --no-color >/dev/null 2>&1 || true
|
||||
exec "$NODE" "$ENTRY" --run-in-terminal "$@"
|
||||
`;
|
||||
fs.writeFileSync(runPath, runShim, { mode: 0o755 });
|
||||
const grokWrapPath = path.join(binDir, "grok-with-hud");
|
||||
fs.writeFileSync(grokWrapPath, runShim, { mode: 0o755 });
|
||||
return { runPath, grokWrapPath };
|
||||
}
|
||||
+127
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseUpdatesLines, formatToolLine } from "../src/activity.js";
|
||||
|
||||
// dist/tests -> package root is ../..
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureUpdates = path.join(root, "fixtures", "session", "updates.jsonl");
|
||||
|
||||
describe("activity", () => {
|
||||
it("parses fixture updates into tool summary including read_file", () => {
|
||||
const lines = fs.readFileSync(fixtureUpdates, "utf8").split(/\r?\n/);
|
||||
const { tools, agents } = parseUpdatesLines(lines);
|
||||
assert.ok(tools.length > 0, "expected tools from fixture");
|
||||
const names = tools.map((t) => t.name);
|
||||
assert.ok(
|
||||
names.includes("read_file") || names.some((n) => n.includes("read")),
|
||||
`expected read_file in ${names.join(",")}`,
|
||||
);
|
||||
// call-4 is running (no completed status)
|
||||
const running = tools.filter((t) => t.status === "running");
|
||||
assert.ok(running.length >= 1, "expected at least one running tool");
|
||||
assert.ok(agents.length >= 1, "expected agent thought from fixture");
|
||||
|
||||
const line = formatToolLine(tools);
|
||||
assert.match(line, /read_file|✓|◐/);
|
||||
});
|
||||
|
||||
it("returns empty quietly for blank input", () => {
|
||||
const { tools, agents, todos } = parseUpdatesLines(["", "not-json", "{}"]);
|
||||
assert.deepEqual(tools, []);
|
||||
assert.deepEqual(agents, []);
|
||||
assert.deepEqual(todos, []);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
clampPercent,
|
||||
contextPercentFromSignals,
|
||||
formatTokenCount,
|
||||
renderBar,
|
||||
formatDuration,
|
||||
projectLabel,
|
||||
} from "../src/bar.js";
|
||||
|
||||
describe("bar", () => {
|
||||
it("derives context percent from contextWindowUsage", () => {
|
||||
assert.equal(
|
||||
contextPercentFromSignals({
|
||||
contextWindowUsage: 37,
|
||||
contextTokensUsed: 190000,
|
||||
contextWindowTokens: 500000,
|
||||
}),
|
||||
37,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to used/window ratio", () => {
|
||||
assert.equal(
|
||||
contextPercentFromSignals({
|
||||
contextTokensUsed: 250000,
|
||||
contextWindowTokens: 500000,
|
||||
}),
|
||||
50,
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps and formats", () => {
|
||||
assert.equal(clampPercent(150), 100);
|
||||
assert.equal(renderBar(37, 10).length, 10);
|
||||
assert.match(formatTokenCount(190000), /190k|190\.0k/);
|
||||
assert.equal(formatDuration(4620), "1h 17m");
|
||||
assert.equal(projectLabel("/Users/dex/demo/CoachFlow", 2), "demo/CoachFlow");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
normalizeBillingPayload,
|
||||
getCreditUsage,
|
||||
clearUsageCache,
|
||||
} from "../src/billing.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
describe("billing", () => {
|
||||
it("normalizes present monthly used/limit fixture ({val})", () => {
|
||||
const body = JSON.parse(
|
||||
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
|
||||
);
|
||||
const u = normalizeBillingPayload(body);
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.used, 17510);
|
||||
assert.equal(u.limit, 150000);
|
||||
assert.ok(u.percent != null);
|
||||
assert.ok(Math.abs((u.percent ?? 0) - (17510 / 150000) * 100) < 0.05);
|
||||
assert.equal(u.period, "monthly");
|
||||
});
|
||||
|
||||
it("normalizes weekly creditUsagePercent fixture", () => {
|
||||
const body = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "fixtures", "billing-weekly-percent.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const u = normalizeBillingPayload(body);
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.percent, 22);
|
||||
assert.equal(u.period, "weekly");
|
||||
assert.match(u.message ?? "", /GrokBuild/);
|
||||
});
|
||||
|
||||
it("missing payload is unavailable without throw", () => {
|
||||
const u = normalizeBillingPayload(null);
|
||||
assert.equal(u.available, false);
|
||||
assert.match(u.message ?? "", /unavailable/i);
|
||||
});
|
||||
|
||||
it("getCreditUsage degrades when auth missing", async () => {
|
||||
clearUsageCache();
|
||||
const u = await getCreditUsage("/tmp/does-not-exist-grok-home-xyz", {
|
||||
enabled: true,
|
||||
cacheTtlMs: 1,
|
||||
}, {
|
||||
readAuth: () => null,
|
||||
});
|
||||
assert.equal(u.available, false);
|
||||
assert.match(u.message ?? "", /unavailable|auth/i);
|
||||
});
|
||||
|
||||
it("getCreditUsage uses injected fetch payload", async () => {
|
||||
clearUsageCache();
|
||||
const weekly = JSON.parse(
|
||||
fs.readFileSync(
|
||||
path.join(root, "fixtures", "billing-weekly-percent.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
const monthly = JSON.parse(
|
||||
fs.readFileSync(path.join(root, "fixtures", "billing-present.json"), "utf8"),
|
||||
);
|
||||
let n = 0;
|
||||
const u = await getCreditUsage("/tmp/fake-home-billing-inject", { cacheTtlMs: 1 }, {
|
||||
readAuth: () => ({ token: "test-token" }),
|
||||
fetchJson: async (url) => {
|
||||
n += 1;
|
||||
const body = url.includes("format=credits") ? weekly : monthly;
|
||||
return { ok: true, status: 200, body };
|
||||
},
|
||||
});
|
||||
assert.equal(u.available, true);
|
||||
assert.equal(u.percent, 22);
|
||||
assert.equal(u.used, 17510);
|
||||
assert.equal(u.limit, 150000);
|
||||
assert.ok(n >= 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseArgs, runCli, helpText } from "../src/index.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("cli", () => {
|
||||
it("parseArgs handles watch and session-dir", () => {
|
||||
const o = parseArgs([
|
||||
"--watch",
|
||||
"--session-dir",
|
||||
"/tmp/x",
|
||||
"--no-usage",
|
||||
"--max-iterations",
|
||||
"2",
|
||||
]);
|
||||
assert.equal(o.watch, true);
|
||||
assert.equal(o.sessionDir, "/tmp/x");
|
||||
assert.equal(o.noUsage, true);
|
||||
assert.equal(o.maxIterations, 2);
|
||||
});
|
||||
|
||||
it("runCli one-shot against fixture exits 0 with context", async () => {
|
||||
const chunks: string[] = [];
|
||||
const code = await runCli(
|
||||
["--once", "--session-dir", fixtureSession, "--no-usage", "--no-color"],
|
||||
{
|
||||
stdout: (s) => chunks.push(s),
|
||||
stderr: () => {},
|
||||
},
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const text = chunks.join("\n");
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /190k|500k/);
|
||||
});
|
||||
|
||||
it("runCli watch with max-iterations produces frames", async () => {
|
||||
const chunks: string[] = [];
|
||||
const code = await runCli(
|
||||
[
|
||||
"--watch",
|
||||
"--session-dir",
|
||||
fixtureSession,
|
||||
"--no-usage",
|
||||
"--no-color",
|
||||
"--max-iterations",
|
||||
"2",
|
||||
"--interval",
|
||||
"10",
|
||||
],
|
||||
{
|
||||
stdout: (s) => chunks.push(s),
|
||||
stderr: () => {},
|
||||
sleep: async () => {},
|
||||
},
|
||||
);
|
||||
assert.equal(code, 0);
|
||||
const text = chunks.join("\n");
|
||||
assert.match(text, /37%/);
|
||||
// at least two frames
|
||||
const hits = text.match(/37%/g) ?? [];
|
||||
assert.ok(hits.length >= 2, `expected ≥2 frames, got ${hits.length}`);
|
||||
});
|
||||
|
||||
it("help is non-empty", () => {
|
||||
assert.match(helpText(), /grok-build-hud/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
titleLine,
|
||||
refreshDashboard,
|
||||
runDashboardLoop,
|
||||
ttyForPid,
|
||||
} from "../src/dashboard.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
|
||||
const pkgRoot = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("dashboard", () => {
|
||||
it("titleLine includes ctx percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const t = titleLine(snap, {
|
||||
available: true,
|
||||
percent: 22,
|
||||
period: "weekly",
|
||||
used: 17510,
|
||||
limit: 150000,
|
||||
});
|
||||
assert.match(t, /◆/);
|
||||
assert.match(t, /37%/);
|
||||
assert.match(t, /22%|quota/);
|
||||
});
|
||||
|
||||
it("ttyForPid returns null for bogus pid", () => {
|
||||
assert.equal(ttyForPid(99999999), null);
|
||||
});
|
||||
|
||||
it("refreshDashboard writes status files for fixture home", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-dash-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "active_sessions.json"),
|
||||
JSON.stringify([
|
||||
{ session_id: sid, pid: process.pid, cwd: "/Users/dex/demo/CoachFlow" },
|
||||
]),
|
||||
);
|
||||
|
||||
const r = await refreshDashboard({ grokHome: tmp, noUsage: true });
|
||||
assert.ok(r.session);
|
||||
assert.match(r.title, /37%/);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-status.txt")));
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runDashboardLoop respects maxIterations", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-loop-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
const code = await runDashboardLoop({
|
||||
grokHome: tmp,
|
||||
intervalMs: 1,
|
||||
maxIterations: 2,
|
||||
noUsage: true,
|
||||
writePid: true,
|
||||
sleep: async () => {},
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
parseHookPayload,
|
||||
resolveSessionForHook,
|
||||
runHookTick,
|
||||
shouldEmitAnnotation,
|
||||
} from "../src/hook.js";
|
||||
import { findSessionDirById } from "../src/session.js";
|
||||
import { formatCompactLine, writeStatusFiles } from "../src/status.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import { installGlobalHooks, buildHookManifest } from "../src/install.js";
|
||||
|
||||
const pkgRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("hook / live status", () => {
|
||||
it("parses hook payload sessionId", () => {
|
||||
const p = parseHookPayload(
|
||||
JSON.stringify({ sessionId: "abc", cwd: "/tmp/x", hookEventName: "stop" }),
|
||||
);
|
||||
assert.equal(p.sessionId, "abc");
|
||||
assert.equal(p.cwd, "/tmp/x");
|
||||
});
|
||||
|
||||
it("formatCompactLine includes context percent from fixture", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
assert.ok(snap);
|
||||
const line = formatCompactLine(snap, null);
|
||||
assert.match(line, /\[hud\]/);
|
||||
assert.match(line, /37%/);
|
||||
assert.match(line, /Grok 4\.5|ctx|tools/);
|
||||
});
|
||||
|
||||
it("writeStatusFiles creates status-line.txt", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-"));
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const r = writeStatusFiles(snap, null, tmp);
|
||||
assert.ok(fs.existsSync(r.compactPath));
|
||||
const text = fs.readFileSync(r.compactPath, "utf8");
|
||||
assert.match(text, /37%/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("runHookTick against fixture via session-dir home layout", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-home-"));
|
||||
// Build mini grok home with fixture session
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(
|
||||
tmp,
|
||||
"sessions",
|
||||
encodeURIComponent("/Users/dex/demo/CoachFlow"),
|
||||
sid,
|
||||
);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "active_sessions.json"),
|
||||
JSON.stringify([
|
||||
{
|
||||
session_id: sid,
|
||||
pid: process.pid,
|
||||
cwd: "/Users/dex/demo/CoachFlow",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const found = findSessionDirById(tmp, sid);
|
||||
assert.ok(found);
|
||||
|
||||
const result = await runHookTick({
|
||||
payloadRaw: JSON.stringify({ sessionId: sid, hookEventName: "stop" }),
|
||||
env: {
|
||||
GROK_SESSION_ID: sid,
|
||||
GROK_HOOK_EVENT: "stop",
|
||||
},
|
||||
grokHome: tmp,
|
||||
forceAnnotate: true,
|
||||
noUsage: true,
|
||||
});
|
||||
assert.equal(result.code, 0);
|
||||
assert.ok(result.compact);
|
||||
assert.match(result.compact!, /37%/);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "status-line.txt")));
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("shouldEmitAnnotation always true for Stop", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-th-"));
|
||||
assert.equal(shouldEmitAnnotation("stop", tmp), true);
|
||||
assert.equal(shouldEmitAnnotation("Stop", tmp), true);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("buildHookManifest points at node + hook.js", () => {
|
||||
const m = buildHookManifest("/abs/hook.js") as {
|
||||
hooks: { Stop: { hooks: { command: string }[] }[] };
|
||||
};
|
||||
assert.match(m.hooks.Stop[0]!.hooks[0]!.command, /hook\.js/);
|
||||
});
|
||||
|
||||
it("installGlobalHooks writes ~/.grok style hooks file", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-inst-"));
|
||||
// ensure dist hook exists (built)
|
||||
const hookJs = path.join(pkgRoot, "dist", "src", "hook.js");
|
||||
assert.ok(fs.existsSync(hookJs), "dist must be built before this test");
|
||||
const { hooksPath } = installGlobalHooks({ grokHome: tmp, root: pkgRoot });
|
||||
assert.ok(fs.existsSync(hooksPath));
|
||||
const body = fs.readFileSync(hooksPath, "utf8");
|
||||
assert.match(body, /Stop/);
|
||||
assert.match(body, /hook\.js/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolveSessionForHook uses env session id", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-res-"));
|
||||
const sid = "fixture-session-001";
|
||||
const dir = path.join(tmp, "sessions", "proj", sid);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const f of ["signals.json", "summary.json", "updates.jsonl"]) {
|
||||
fs.copyFileSync(path.join(fixtureSession, f), path.join(dir, f));
|
||||
}
|
||||
const snap = resolveSessionForHook(
|
||||
{},
|
||||
{ GROK_SESSION_ID: sid },
|
||||
tmp,
|
||||
);
|
||||
assert.ok(snap);
|
||||
assert.equal(snap!.sessionId, sid);
|
||||
assert.equal(snap!.contextPercent, 37);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import { renderHud, renderTmux } from "../src/render.js";
|
||||
import { contextPercentFromSignals } from "../src/bar.js";
|
||||
import type { UsageSnapshot } from "../src/types.js";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixtureSession = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("session + render", () => {
|
||||
it("loads fixture session with known context values", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession);
|
||||
assert.ok(snap);
|
||||
assert.equal(snap!.sessionId, "fixture-session-001");
|
||||
assert.equal(snap!.model, "grok-4.5");
|
||||
assert.equal(snap!.cwd, "/Users/dex/demo/CoachFlow");
|
||||
assert.equal(snap!.contextPercent, 37);
|
||||
assert.equal(snap!.contextTokensUsed, 190000);
|
||||
assert.equal(snap!.contextWindowTokens, 500000);
|
||||
// Must use same formula as production bar module
|
||||
assert.equal(
|
||||
snap!.contextPercent,
|
||||
contextPercentFromSignals(snap!.signals),
|
||||
);
|
||||
assert.ok(snap!.tools.length > 0);
|
||||
});
|
||||
|
||||
it("renders HUD with context bar matching fixture percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const usage: UsageSnapshot = {
|
||||
available: true,
|
||||
percent: 22,
|
||||
used: 17510,
|
||||
limit: 150000,
|
||||
period: "weekly",
|
||||
message: "GrokBuild 9%",
|
||||
};
|
||||
const text = renderHud(snap, usage, { color: false });
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /190k\/500k|190\.0k\/500k/);
|
||||
assert.match(text, /Usage|Quota|22%/);
|
||||
assert.match(text, /22%/);
|
||||
assert.match(text, /Grok|grok/i);
|
||||
assert.match(text, /CoachFlow/);
|
||||
});
|
||||
|
||||
it("renders when usage unavailable without crash", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const text = renderHud(
|
||||
snap,
|
||||
{ available: false, message: "usage unavailable" },
|
||||
{ color: false },
|
||||
);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /unavailable/i);
|
||||
});
|
||||
|
||||
it("tmux line includes context percent", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const line = renderTmux(snap, null, {
|
||||
color: false,
|
||||
tmux: true,
|
||||
compact: false,
|
||||
pathLevels: 2,
|
||||
warningThreshold: 70,
|
||||
criticalThreshold: 90,
|
||||
});
|
||||
assert.match(line, /ctx 37%/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import {
|
||||
formatStatusBlock,
|
||||
formatTmuxStatusLines,
|
||||
writeStatusFiles,
|
||||
} from "../src/status.js";
|
||||
import { PRESET_FULL } from "../src/hud-config.js";
|
||||
import { THEME_TOKYONIGHT } from "../src/theme.js";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const fixture = path.join(root, "fixtures", "session");
|
||||
|
||||
describe("Claude-HUD parity status", () => {
|
||||
it("formatStatusBlock has model line + context + usage lines", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
const text = formatStatusBlock(
|
||||
snap,
|
||||
{
|
||||
available: true,
|
||||
percent: 23,
|
||||
period: "weekly",
|
||||
used: 18000,
|
||||
limit: 150000,
|
||||
resetsIn: "4d",
|
||||
message: "GrokBuild 10%",
|
||||
},
|
||||
PRESET_FULL,
|
||||
);
|
||||
assert.match(text, /\[Grok 4\.5\]/);
|
||||
assert.match(text, /Context/);
|
||||
assert.match(text, /37%/);
|
||||
assert.match(text, /Usage/);
|
||||
assert.match(text, /23%/);
|
||||
// multi-line
|
||||
assert.ok(text.split("\n").length >= 2);
|
||||
});
|
||||
|
||||
it("formatTmuxStatusLines returns 3 rows in full preset", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
// inject todos/agents for line 3
|
||||
snap.todos = [
|
||||
{ content: "Ship HUD parity", status: "in_progress" },
|
||||
{ content: "Write tests", status: "completed" },
|
||||
];
|
||||
const lines = formatTmuxStatusLines(
|
||||
snap,
|
||||
{ available: true, percent: 22, period: "weekly" },
|
||||
THEME_TOKYONIGHT,
|
||||
PRESET_FULL,
|
||||
);
|
||||
assert.equal(lines.length, 3);
|
||||
assert.match(lines[0]!, /Grok 4\.5|CoachFlow/);
|
||||
assert.match(lines[1]!, /Context|Usage|22%|37%/);
|
||||
});
|
||||
|
||||
it("writeStatusFiles emits tmux-lines.txt", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-full-"));
|
||||
// point grok home at tmp with config
|
||||
fs.mkdirSync(path.join(tmp, "hud"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "hud", "config.json"),
|
||||
JSON.stringify(PRESET_FULL, null, 2),
|
||||
);
|
||||
// session needs to be written via writeStatusFiles with grokHome=tmp
|
||||
// but session is loaded from fixture; OK
|
||||
const r = writeStatusFiles(
|
||||
snap,
|
||||
{ available: true, percent: 20, period: "weekly" },
|
||||
tmp,
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(tmp, "hud", "tmux-lines.txt")));
|
||||
assert.ok(r.tmuxLines.length >= 2);
|
||||
assert.match(r.full, /Context/);
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { formatTmuxStatusLine } from "../src/status.js";
|
||||
import { loadSnapshotFromDir } from "../src/session.js";
|
||||
import {
|
||||
miniBar,
|
||||
THEME_TOKYONIGHT,
|
||||
THEME_GROKDAY,
|
||||
paletteForGrokTheme,
|
||||
readGrokUiConfig,
|
||||
resolveTheme,
|
||||
normalizeGrokThemeName,
|
||||
} from "../src/theme.js";
|
||||
|
||||
const pkgRoot = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
);
|
||||
const fixtureSession = path.join(pkgRoot, "fixtures", "session");
|
||||
|
||||
describe("theme", () => {
|
||||
it("maps Grok theme names to palettes", () => {
|
||||
assert.equal(normalizeGrokThemeName("TokyoNight"), "tokyonight");
|
||||
assert.equal(paletteForGrokTheme("tokyonight").name, "tokyonight");
|
||||
assert.equal(paletteForGrokTheme("grokday").name, "grokday");
|
||||
assert.equal(paletteForGrokTheme("light").name, "grokday");
|
||||
assert.equal(paletteForGrokTheme("dark").name, "groknight");
|
||||
});
|
||||
|
||||
it("tokyonight palette uses Tokyo Night ink", () => {
|
||||
assert.equal(THEME_TOKYONIGHT.value, "#c0caf5");
|
||||
assert.equal(THEME_TOKYONIGHT.ok, "#9ece6a");
|
||||
assert.equal(THEME_TOKYONIGHT.mark, "#7aa2f7");
|
||||
});
|
||||
|
||||
it("readGrokUiConfig parses [ui] theme", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "config.toml"),
|
||||
`[ui]\ntheme = "tokyonight"\nauto_dark_theme = "tokyonight"\nauto_light_theme = "grokday"\n`,
|
||||
);
|
||||
const cfg = readGrokUiConfig(tmp);
|
||||
assert.equal(cfg.theme, "tokyonight");
|
||||
assert.equal(cfg.autoLightTheme, "grokday");
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("resolveTheme follows Grok config when mode is auto", () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-cfg2-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "config.toml"),
|
||||
`[ui]\ntheme = "tokyonight"\n`,
|
||||
);
|
||||
const t = resolveTheme("auto", { GROK_HUD_THEME: "auto" }, { grokHome: tmp });
|
||||
assert.equal(t.name, "tokyonight");
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("miniBar works with tokyonight", () => {
|
||||
const bar = miniBar(50, 8, THEME_TOKYONIGHT);
|
||||
assert.match(bar, /#9ece6a|#e0af68|#f7768e/);
|
||||
});
|
||||
|
||||
it("formatTmuxStatusLine uses provided palette colours", () => {
|
||||
const snap = loadSnapshotFromDir(fixtureSession)!;
|
||||
const line = formatTmuxStatusLine(
|
||||
snap,
|
||||
{ available: true, percent: 22, message: "GrokBuild 9%" },
|
||||
THEME_TOKYONIGHT,
|
||||
);
|
||||
assert.match(line, /#c0caf5|#565f89|#7aa2f7|#9ece6a/);
|
||||
assert.match(line, /37%/);
|
||||
});
|
||||
|
||||
it("grokday is light-paper friendly", () => {
|
||||
assert.equal(THEME_GROKDAY.value, "#111111");
|
||||
assert.match(THEME_GROKDAY.barEmpty, /#dcdcdc/);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user