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