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:
+91
-3
@@ -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)
|
||||||
|
|||||||
@@ -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 **2–3 status rows** (Claude-HUD style), not a second window.
|
|
||||||
5. Verify once: `grok-hud status`
|
|
||||||
|
|
||||||
## Mental model (why not identical)
|
|
||||||
|
|
||||||
Claude Code ships a **statusline hook** that redraws under the prompt.
|
|
||||||
Grok Build does not. We use a **same-window tmux status strip** that:
|
|
||||||
|
|
||||||
- stays always visible
|
|
||||||
- does not open a second window
|
|
||||||
- uses real Grok session signals + billing (same sources as `/context` and `/usage`)
|
|
||||||
|
|
||||||
## Config file
|
|
||||||
|
|
||||||
`~/.grok/hud/config.json` — presets and display toggles (Claude-HUD-like options).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grok-build-hud --preset full # everything
|
|
||||||
grok-build-hud --preset essential # model/ctx/usage/tools/todos
|
|
||||||
grok-build-hud --preset minimal # model + context + usage only
|
|
||||||
```
|
|
||||||
|
|
||||||
## Command cheatsheet
|
|
||||||
|
|
||||||
| Claude habit | Grok equivalent |
|
|
||||||
|--------------|-----------------|
|
|
||||||
| Glance HUD | bottom strip (always on with `grok-hud-run`) |
|
|
||||||
| `/context` | bottom Context bar **or** `/context` |
|
|
||||||
| rate-limit bar | Usage / quota bar (weekly + product split) |
|
|
||||||
| open configure | `~/.grok/hud/config.json` or `--preset` |
|
|
||||||
|
|
||||||
## Why this helps Grok grow
|
|
||||||
|
|
||||||
Claude users already trained on **context bar + usage bar + tools/todos**.
|
|
||||||
Matching that literacy lowers switch cost: same glances, same panic thresholds (70% / 90% context), same session hygiene — on Grok’s speed and pricing.
|
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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).
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(" · ")}`;
|
||||||
|
}
|
||||||
@@ -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 (0–100). */
|
||||||
|
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[];
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 /);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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, /入 |出 |缓 |窗 /);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
@@ -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=/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user