Release grok-build-hud v0.3.9: multi-terminal HUD, i18n settings, theme follow

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