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
|
||||
|
||||
## 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
|
||||
|
||||
### 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 2: Context bar + tokens · Usage/quota · time · turns · tools · errors · diff
|
||||
- Line 3: tool activity · agents · todos · product breakdown
|
||||
- **Presets**: `full` / `essential` / `minimal` (Claude HUD naming)
|
||||
- **Presets**: `full` / `essential` / `minimal`
|
||||
- **Theme sync with Grok UI**: reads `~/.grok/config.toml` `[ui].theme`
|
||||
- Palettes: tokyonight, groknight, grokday, rosepinemoon, oscuramindnight
|
||||
- **Bold + wide bars** for readability (`bold`, `barWidth` in config)
|
||||
- **Quota** from real `cli-chat-proxy.grok.com` billing (weekly % + monthly + GrokBuild split)
|
||||
- CLI: `grok-build-hud`, `grok-hud`, `grok-hud-run`
|
||||
- One-shot installer: `bash scripts/install.sh` / `npm run setup`
|
||||
- Docs: README + **README.zh-CN.md**, MIGRATION, CONTRIBUTING, troubleshooting
|
||||
- Docs: README + **README.zh-CN.md**, CONTRIBUTING, troubleshooting
|
||||
|
||||
### Fixes
|
||||
- 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
|
||||
|
||||
**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)
|
||||
|
||||
Always-on strip in the **same Terminal window** (tmux status — not a second window):
|
||||
|
||||
```text
|
||||
[Grok 4.5] │ my-project git:(main*) │ ● LIVE
|
||||
CTX ██████████░░░░ 70% (351k/500k) │ USE ███░░░░░░░░░░░ 23% weekly · 4d │ TIME 1h │ T 16 │ TOOLS 299
|
||||
◐ read_file… | ✓ grep ×3 | ▸ Ship HUD (2/5)
|
||||
[Grok 4.5] · my-project git:(main*) · ●
|
||||
窗/ctx ██████████░░░░ 70% (351k/500k) · 入/i · 出/o · 缓/c · 额/use 23% · 轮/t 16
|
||||
◐ read_file… · ✓ grep ×3 · ▸ todos
|
||||
```
|
||||
|
||||
Built for Claude Code users who already know **context bar + usage bar + tools/todos** — same glances on Grok.
|
||||
|
||||
> Grok has no native statusline API. This is the supported equivalent: a multi-line status strip + optional scrollback annotations.
|
||||
Built for Grok Build sessions: context window, quota, **input/output/cache tokens**, tools, todos, git — with Chinese/English UI and theme follow.
|
||||
|
||||
---
|
||||
|
||||
@@ -83,11 +81,12 @@ Then `source ~/.zshrc` and verify: `which grok-hud-run`.
|
||||
|
||||
```bash
|
||||
# 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 |
|
||||
| 2 | **Context** bar + tokens · **Usage/quota** · time · turns · tools · errors · diff |
|
||||
| 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 |
|
||||
|--------|------|----------|
|
||||
| **full** (default) | 3 | Everything (Claude “Full”) |
|
||||
| **full** (default) | 3 | Everything |
|
||||
| **essential** | 2 | Model/git + context/usage + activity |
|
||||
| **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.
|
||||
|
||||
| Claude HUD | Grok Build HUD |
|
||||
|------------|----------------|
|
||||
| Statusline under prompt | Same-window tmux multi-line strip |
|
||||
| Full / Essential / Minimal | `--preset full\|essential\|minimal` |
|
||||
| Context + Usage bars | Same layout on line 2 |
|
||||
| Tools / agents / todos | Line 3 |
|
||||
| `/claude-hud:setup` | `--install-dashboard` + `grok-hud-run` |
|
||||
```bash
|
||||
grok-hud settings # interactive UI
|
||||
grok-hud lang zh # 简体中文 (default)
|
||||
grok-hud lang en # English
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
+42
-23
@@ -1,18 +1,18 @@
|
||||
# grok-build-hud
|
||||
|
||||
**给 [Grok Build](https://x.ai/cli) 用的 Claude-HUD 风格状态条。**
|
||||
**[Grok Build](https://x.ai/cli) 的实时多行状态条。**
|
||||
|
||||
在**同一个 Terminal 窗口底部**常驻显示(用 tmux 状态栏,不会多开窗口):
|
||||
在**同一个 Terminal 窗口底部**常驻显示(tmux 状态栏,不另开窗口):
|
||||
|
||||
```text
|
||||
[Grok 4.5] │ my-project git:(main*) │ ● LIVE
|
||||
CTX ██████████░░░░ 70% (351k/500k) │ USE ███░░░░░░░░░░░ 23% weekly · 4d │ TIME 1h │ T 16 │ TOOLS 299
|
||||
◐ read_file… | ✓ grep ×3 | ▸ Ship HUD (2/5)
|
||||
[Grok 4.5] · my-project git · ●
|
||||
窗 ██████░░░░ 70% (351k/500k) · 入 … 出 … 缓 … · 额 23% · 轮 16
|
||||
◐ 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
|
||||
# 在本标签页启动 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
|
||||
```
|
||||
|
||||
| 行 | 内容(类比 Claude HUD) |
|
||||
|----|-------------------------|
|
||||
| 第 1 行 | 模型 · 项目 · git · 是否 live · 标题 · effort |
|
||||
**生命周期**:开 **Terminal** 就绪,不是电脑开机自启。
|
||||
`~/.zshrc` 里会装一段 interactive hook;输入 `grok` 即带 HUD。
|
||||
|
||||
**并行开发**:每个 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 |
|
||||
| 第 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
|
||||
|
||||
@@ -31,6 +31,10 @@ else if (head === "status" || head === undefined) {
|
||||
mapped = ["--dashboard-start"];
|
||||
} else if (head === "install") {
|
||||
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);
|
||||
|
||||
+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
|
||||
|
||||
Print the multi-line HUD for the active Grok session:
|
||||
# HUD status
|
||||
|
||||
```bash
|
||||
grok-hud status
|
||||
# or:
|
||||
# or
|
||||
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
|
||||
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).
|
||||
For always-on same-window strip: start with `grok` (or `grok-hud-run`).
|
||||
|
||||
+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
|
||||
# Full — model, git, context+usage dual bars, tools, agents, todos, diff stats (3 rows)
|
||||
grok-build-hud --preset full
|
||||
# or: grok-hud preset full
|
||||
|
||||
# Essential — dual bars + activity (2 rows)
|
||||
grok-build-hud --preset essential
|
||||
|
||||
# Minimal — single dense row
|
||||
grok-build-hud --preset minimal
|
||||
grok-build-hud --preset full # 3 rows (default)
|
||||
grok-build-hud --preset essential # 2 rows
|
||||
grok-build-hud --preset minimal # 1 compact row
|
||||
```
|
||||
|
||||
Then refresh tmux (if already in a HUD session):
|
||||
Or open the settings UI:
|
||||
|
||||
```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",
|
||||
"version": "0.3.0",
|
||||
"description": "Claude-HUD-style live status for Grok Build: multi-line context, quota, tools, todos, git — same-window tmux strip + theme sync",
|
||||
"version": "0.3.9",
|
||||
"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",
|
||||
"main": "dist/src/index.js",
|
||||
"bin": {
|
||||
@@ -19,7 +19,6 @@
|
||||
".grok-plugin/",
|
||||
"README.md",
|
||||
"README.zh-CN.md",
|
||||
"MIGRATION-FROM-CLAUDE.md",
|
||||
"CHANGELOG.md",
|
||||
"LICENSE",
|
||||
"scripts/",
|
||||
@@ -44,11 +43,11 @@
|
||||
"grok-build",
|
||||
"hud",
|
||||
"statusline",
|
||||
"claude-hud",
|
||||
"tmux",
|
||||
"cli",
|
||||
"context",
|
||||
"quota"
|
||||
"quota",
|
||||
"tokens"
|
||||
],
|
||||
"author": "dex",
|
||||
"license": "MIT",
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "grok-build-hud",
|
||||
"version": "0.3.0",
|
||||
"description": "Claude-HUD-style multi-line live status for Grok Build (context, quota, tools, todos, git)",
|
||||
"version": "0.3.9",
|
||||
"description": "Live multi-line status strip for Grok Build: context, quota, tokens, tools, todos, git",
|
||||
"author": {
|
||||
"name": "sooneocean",
|
||||
"email": "redredchen01@gmail.com"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
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 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
|
||||
|
||||
@@ -16,7 +16,7 @@ Grok Build has **no** Claude Code statusline API. This package provides a Claude
|
||||
Data sources (local):
|
||||
|
||||
- `~/.grok/sessions/**/signals.json` — context window
|
||||
- `updates.jsonl` — tools / agents / todos
|
||||
- `updates.jsonl` — tools / agents / todos / token usage
|
||||
- `summary.json` — title, effort, model
|
||||
- Grok auth → `cli-chat-proxy` billing for quota
|
||||
|
||||
@@ -31,8 +31,10 @@ bash scripts/install.sh
|
||||
## Daily
|
||||
|
||||
```bash
|
||||
grok-hud-run # Grok + bottom HUD (same tab)
|
||||
grok # wrapped: Grok + bottom HUD (same tab)
|
||||
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 --theme auto # follow Grok [ui].theme
|
||||
grok-hud stop # stop dashboard daemon
|
||||
@@ -40,10 +42,10 @@ grok-hud stop # stop dashboard daemon
|
||||
|
||||
## 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
|
||||
|
||||
`~/.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;
|
||||
}
|
||||
|
||||
/** Format tool line like Claude HUD: ◐ Edit: x | ✓ Read ×3 */
|
||||
/** Format tool activity line: ◐ tool · ✓ tool ×3 */
|
||||
export function formatToolLine(tools: ToolActivityItem[]): string {
|
||||
if (!tools.length) return "";
|
||||
const parts: string[] = [];
|
||||
|
||||
+69
-74
@@ -10,13 +10,15 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
defaultGrokHome,
|
||||
findSessionDirById,
|
||||
loadActiveSessions,
|
||||
loadSnapshotFromDir,
|
||||
findSessionDirById,
|
||||
pickBestSession,
|
||||
pickFromActiveSessions,
|
||||
} from "./session.js";
|
||||
import { getCreditUsage } from "./billing.js";
|
||||
import { writeStatusFiles, formatCompactLine } from "./status.js";
|
||||
import { findTmuxSessionForPid } from "./multi-session.js";
|
||||
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
|
||||
|
||||
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.
|
||||
* Terminal composes: "cwd — custom title — process — size"
|
||||
* We cannot reliably toggle title-display flags on all locales, but custom
|
||||
* title is always shown when set on the tab.
|
||||
* Apple Terminal: set tab custom title for ONE tty only.
|
||||
* Never broadcast to all tabs — that broke parallel Terminal development.
|
||||
*/
|
||||
export function setAppleTerminalTitle(
|
||||
title: string,
|
||||
options: { ttyHint?: string | null } = {},
|
||||
): boolean {
|
||||
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 {
|
||||
const safe = title
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/"/g, '\\"')
|
||||
.replace(/[\x00-\x1f\x7f]/g, " ")
|
||||
.slice(0, 100);
|
||||
const tty = (options.ttyHint || "")
|
||||
.replace(/^\/dev\//, "")
|
||||
.replace(/"/g, "");
|
||||
// Prefer matching tab by tty; else set every Terminal tab/window.
|
||||
const script = `
|
||||
tell application "Terminal"
|
||||
set hud to "${safe}"
|
||||
set targetTty to "${tty}"
|
||||
set matched to false
|
||||
if targetTty is not "" then
|
||||
repeat with w in windows
|
||||
repeat with tb in tabs of w
|
||||
try
|
||||
set t to tty of tb as string
|
||||
if t contains targetTty then
|
||||
set custom title of tb to hud
|
||||
try
|
||||
set custom title of w to hud
|
||||
end try
|
||||
set matched to true
|
||||
end if
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end if
|
||||
if matched is false then
|
||||
repeat with w in windows
|
||||
try
|
||||
set custom title of w to hud
|
||||
end try
|
||||
repeat with tb in tabs of w
|
||||
try
|
||||
set custom title of tb to hud
|
||||
end try
|
||||
end repeat
|
||||
end repeat
|
||||
end if
|
||||
end tell
|
||||
`;
|
||||
execFileSync("osascript", ["-e", script], {
|
||||
@@ -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: {
|
||||
grokHome?: string;
|
||||
noUsage?: boolean;
|
||||
}): Promise<{ title: string; session: SessionSnapshot | null }> {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const active = loadActiveSessions(grokHome);
|
||||
let session: SessionSnapshot | null = null;
|
||||
// Parallel Terminals: update EVERY live session independently.
|
||||
// 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 dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (!dir) continue;
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap?.live) {
|
||||
session = snap;
|
||||
break;
|
||||
}
|
||||
if (!session && snap) session = snap;
|
||||
}
|
||||
if (!session) session = pickBestSession({ grokHome });
|
||||
const live = listLiveSessions(grokHome);
|
||||
const targets =
|
||||
live.length > 0 ? live : primary ? [primary] : [];
|
||||
|
||||
if (!session) {
|
||||
if (!targets.length) {
|
||||
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
|
||||
const { resolveTheme } = await import("./theme.js");
|
||||
// Theme always follows Grok [ui].theme (auto → OS light/dark maps).
|
||||
// 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 });
|
||||
writeStatusFiles(session, usage, grokHome);
|
||||
try {
|
||||
const stamp = path.join(grokHome, "hud", ".last-theme");
|
||||
const fp = themeFingerprint(theme, ui, process.env);
|
||||
const prev = fs.existsSync(stamp)
|
||||
? fs.readFileSync(stamp, "utf8").trim()
|
||||
: "";
|
||||
if (prev !== theme.name) {
|
||||
fs.writeFileSync(stamp, theme.name + "\n", "utf8");
|
||||
if (prev !== fp) {
|
||||
fs.writeFileSync(stamp, fp + "\n", "utf8");
|
||||
const { writeTmuxConfFile, applyTmuxStatusBar } = await import(
|
||||
"./tmux-hud.js"
|
||||
);
|
||||
@@ -182,38 +177,38 @@ export async function refreshDashboard(options: {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const title = titleLine(session, usage);
|
||||
|
||||
// 1) OSC into Grok's TTY (updates process title component)
|
||||
const tty = ttyForPid(session.pid);
|
||||
if (tty) writeOscTitle(tty, title);
|
||||
// Write per-tmux-session status so each Terminal bar shows ITS own Grok
|
||||
// Layout adapts to each window's client width.
|
||||
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)
|
||||
// e.g. "0717 — ◆ ctx 44% | quota 23% — zsh — 101×50"
|
||||
const ttyHint = tty ? path.basename(tty) : null;
|
||||
setAppleTerminalTitle(title, { ttyHint });
|
||||
|
||||
// tmux-status.txt is written by writeStatusFiles (formatTmuxStatusLine)
|
||||
|
||||
// If already inside tmux (same-window mode), keep status bar config applied
|
||||
if (process.env.TMUX) {
|
||||
try {
|
||||
execFileSync(
|
||||
"tmux",
|
||||
[
|
||||
"set",
|
||||
"-g",
|
||||
"status-right",
|
||||
`#(cat ${path.join(grokHome, "hud", "tmux-status.txt")} 2>/dev/null)`,
|
||||
],
|
||||
{ stdio: "ignore", timeout: 1000 },
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
const title = titleLine(snap, usage);
|
||||
if (tty) {
|
||||
writeOscTitle(tty, title);
|
||||
setAppleTerminalTitle(title, { ttyHint: path.basename(tty) });
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
loadSnapshotFromDir,
|
||||
pickBestSession,
|
||||
pickFromActiveSessions,
|
||||
} from "./session.js";
|
||||
import { writeStatusFiles } from "./status.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) {
|
||||
const hit = pickBestSession({ grokHome, cwd });
|
||||
const hit =
|
||||
pickFromActiveSessions(grokHome, { cwd }) ??
|
||||
pickBestSession({ grokHome, cwd });
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
// Any live session
|
||||
for (const a of active) {
|
||||
const dir = findSessionDirById(grokHome, a.session_id);
|
||||
if (dir) {
|
||||
const snap = loadSnapshotFromDir(dir, { active });
|
||||
if (snap) return snap;
|
||||
}
|
||||
}
|
||||
// Newest live session among active tabs
|
||||
const fromActive = pickFromActiveSessions(grokHome);
|
||||
if (fromActive) return fromActive;
|
||||
|
||||
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
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
@@ -40,6 +40,15 @@ export interface HudDisplayConfig {
|
||||
showDiffStats: boolean;
|
||||
showLive: 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;
|
||||
criticalThreshold: number;
|
||||
@@ -49,7 +58,8 @@ export const PRESET_FULL: HudDisplayConfig = {
|
||||
preset: "full",
|
||||
lineLayout: "expanded",
|
||||
pathLevels: 2,
|
||||
language: "en",
|
||||
/** Default UI language: 简体中文 (switch to en in settings) */
|
||||
language: "zh-Hans",
|
||||
statusLines: 3,
|
||||
bold: true,
|
||||
barWidth: 14,
|
||||
@@ -72,6 +82,9 @@ export const PRESET_FULL: HudDisplayConfig = {
|
||||
showDiffStats: true,
|
||||
showLive: true,
|
||||
showTitle: true,
|
||||
showTokenBreakdown: true,
|
||||
tokenScope: "both",
|
||||
tokenDigits: "exact",
|
||||
},
|
||||
warningThreshold: 70,
|
||||
criticalThreshold: 90,
|
||||
@@ -91,6 +104,9 @@ export const PRESET_ESSENTIAL: HudDisplayConfig = {
|
||||
showDiffStats: false,
|
||||
showTitle: false,
|
||||
contextValue: "percent",
|
||||
showTokenBreakdown: true,
|
||||
tokenScope: "last",
|
||||
tokenDigits: "exact",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,6 +132,9 @@ export const PRESET_MINIMAL: HudDisplayConfig = {
|
||||
showDiffStats: false,
|
||||
showTitle: false,
|
||||
contextValue: "percent",
|
||||
showTokenBreakdown: true,
|
||||
tokenScope: "last",
|
||||
tokenDigits: "short",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -131,6 +150,7 @@ export function loadHudConfig(
|
||||
if (!fs.existsSync(p)) {
|
||||
return {
|
||||
...PRESET_FULL,
|
||||
language: "zh-Hans",
|
||||
display: { ...PRESET_FULL.display },
|
||||
};
|
||||
}
|
||||
@@ -144,6 +164,8 @@ export function loadHudConfig(
|
||||
return {
|
||||
...base,
|
||||
...raw,
|
||||
// Prefer saved language; default 中文 when missing
|
||||
language: raw.language ?? base.language ?? "zh-Hans",
|
||||
bold: raw.bold ?? base.bold ?? true,
|
||||
barWidth: raw.barWidth ?? base.barWidth ?? 14,
|
||||
statusLines: raw.statusLines ?? base.statusLines ?? 3,
|
||||
@@ -152,11 +174,29 @@ export function loadHudConfig(
|
||||
} catch {
|
||||
return {
|
||||
...PRESET_FULL,
|
||||
language: "zh-Hans",
|
||||
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(
|
||||
cfg: HudDisplayConfig,
|
||||
grokHome = path.join(os.homedir(), ".grok"),
|
||||
@@ -174,20 +214,21 @@ export function applyPreset(preset: HudPreset): HudDisplayConfig {
|
||||
return { ...PRESET_FULL, display: { ...PRESET_FULL.display } };
|
||||
}
|
||||
|
||||
/** Claude-HUD option name → Grok HUD config path (migration map). */
|
||||
export const CLAUDE_HUD_MIGRATION: Record<string, string> = {
|
||||
"display.showModel": "display.showModel",
|
||||
"display.showContextBar": "display.showContextBar",
|
||||
"display.contextValue": "display.contextValue",
|
||||
"display.showUsage": "display.showUsage",
|
||||
"display.showSessionTime / sessionTime": "display.showSessionTime",
|
||||
"gitStatus.enabled": "display.showGit",
|
||||
"gitStatus.showDirty": "display.showGitDirty",
|
||||
"display tools activity": "display.showToolActivity",
|
||||
"display agents": "display.showAgents",
|
||||
"display todos": "display.showTodos",
|
||||
lineLayout: "lineLayout",
|
||||
pathLevels: "pathLevels",
|
||||
language: "language",
|
||||
"presets Full/Essential/Minimal": "preset",
|
||||
};
|
||||
/** Documented display option keys (for settings / docs). */
|
||||
export const DISPLAY_OPTION_KEYS = [
|
||||
"display.showModel",
|
||||
"display.showContextBar",
|
||||
"display.contextValue",
|
||||
"display.showUsage",
|
||||
"display.showSessionTime",
|
||||
"display.showGit",
|
||||
"display.showGitDirty",
|
||||
"display.showToolActivity",
|
||||
"display.showAgents",
|
||||
"display.showTodos",
|
||||
"display.showTokenBreakdown",
|
||||
"lineLayout",
|
||||
"pathLevels",
|
||||
"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,
|
||||
loadActiveSessions,
|
||||
loadSnapshotFromDir,
|
||||
pickFromActiveSessions,
|
||||
sortSessionsByRecency,
|
||||
} from "./session.js";
|
||||
import { getCreditUsage, unavailableUsage } from "./billing.js";
|
||||
import { renderHud, renderJson } from "./render.js";
|
||||
@@ -37,15 +39,19 @@ import {
|
||||
resolveThemeMode,
|
||||
readGrokUiConfig,
|
||||
clearAppearanceCache,
|
||||
ensureFollowMode,
|
||||
themeFingerprint,
|
||||
} from "./theme.js";
|
||||
import {
|
||||
applyPreset,
|
||||
saveHudConfig,
|
||||
loadHudConfig,
|
||||
ensureDefaultConfig,
|
||||
type HudPreset,
|
||||
} from "./hud-config.js";
|
||||
import type { SessionSnapshot, UsageSnapshot } from "./types.js";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export { contextPercentFromSignals, renderBar, formatTokenCount } from "./bar.js";
|
||||
@@ -54,8 +60,11 @@ export { normalizeBillingPayload } from "./billing.js";
|
||||
export {
|
||||
loadSnapshotFromDir,
|
||||
pickBestSession,
|
||||
pickFromActiveSessions,
|
||||
discoverSessions,
|
||||
findSessionDirById,
|
||||
sortSessionsByRecency,
|
||||
sessionRecencyMs,
|
||||
} from "./session.js";
|
||||
export { renderHud, renderJson } from "./render.js";
|
||||
export { runHookTick } from "./hook.js";
|
||||
@@ -92,6 +101,8 @@ export interface CliOptions {
|
||||
runInTerminal: boolean;
|
||||
theme?: string;
|
||||
preset?: string;
|
||||
settings: boolean;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliOptions {
|
||||
@@ -116,6 +127,7 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
dashboardStop: false,
|
||||
dashboardWindow: false,
|
||||
runInTerminal: false,
|
||||
settings: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -211,6 +223,16 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
case "--preset":
|
||||
opts.preset = argv[++i];
|
||||
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 "--help":
|
||||
opts.help = true;
|
||||
@@ -232,24 +254,31 @@ export function parseArgs(argv: string[]): CliOptions {
|
||||
export function helpText(): string {
|
||||
return `grok-build-hud — live context + quota in the SAME Terminal window
|
||||
|
||||
Same-window mode (recommended, no second window):
|
||||
grok-hud-run
|
||||
# or: grok-build-hud --run
|
||||
# Starts Grok inside this tab with a permanent BOTTOM status bar:
|
||||
# ctx 44% │ quota 23% │ GrokBuild 10% │ t8 │ tools 144
|
||||
After install:
|
||||
1. Open Terminal (shell hook ensures HUD daemon)
|
||||
2. Type: grok
|
||||
→ Grok + bottom HUD (IN/OUT/CACHE exact token counts)
|
||||
bare CLI: GROK_NO_HUD=1 grok -p "hi"
|
||||
|
||||
Install once:
|
||||
grok-build-hud --install-dashboard
|
||||
|
||||
Then:
|
||||
grok-hud-run # start Grok + bottom bar (same window)
|
||||
grok-hud status # print full dual bars once
|
||||
Also:
|
||||
grok-hud status # full dual bars + token breakdown
|
||||
grok-hud stop # stop background updater
|
||||
grok-build-hud --theme auto # follow Grok [ui].theme
|
||||
grok-build-hud --preset full|essential|minimal # Claude-HUD presets
|
||||
grok-hud-run # same-window Grok + multi-line HUD
|
||||
grok-build-hud --theme auto # follow Grok /theme (default, not locked)
|
||||
grok-build-hud --preset full|essential|minimal
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const p = opts.preset.toLowerCase();
|
||||
if (p !== "full" && p !== "essential" && p !== "minimal") {
|
||||
err("Preset must be: full | essential | minimal");
|
||||
return 1;
|
||||
}
|
||||
const grokHome = opts.grokHome ?? defaultGrokHome();
|
||||
const prev = loadHudConfig(grokHome);
|
||||
const cfg = applyPreset(p as HudPreset);
|
||||
// keep readability defaults when applying presets
|
||||
// keep readability defaults + user language
|
||||
cfg.bold = true;
|
||||
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);
|
||||
applyTmuxStatusBar({ grokHome: opts.grokHome });
|
||||
await refreshDashboard({ grokHome: opts.grokHome });
|
||||
out(
|
||||
`HUD preset → ${p} (${cfg.statusLines} status row(s), bold, barWidth=${cfg.barWidth})\n config: ${saved}\n` +
|
||||
`Claude HUD map: Full≈full, Essential≈essential, Minimal≈minimal`,
|
||||
`language: ${cfg.language}\n` +
|
||||
`presets: full | essential | minimal`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (opts.theme) {
|
||||
const mode = resolveThemeMode(opts.theme);
|
||||
persistTheme(mode);
|
||||
process.env.GROK_HUD_THEME = mode;
|
||||
clearAppearanceCache();
|
||||
const grokHome = opts.grokHome ?? defaultGrokHome();
|
||||
// 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 t = resolveTheme(mode, process.env, { grokHome });
|
||||
const t =
|
||||
mode === "auto" || mode === "system"
|
||||
? resolveTheme("auto", process.env, { grokHome })
|
||||
: resolveTheme(mode, process.env, { grokHome });
|
||||
writeTmuxConfFile(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 });
|
||||
stopDashboard(grokHome);
|
||||
ensureDashboardDaemon({
|
||||
@@ -366,15 +445,24 @@ export async function runCli(
|
||||
entryJs: path.join(packageRoot(), "dist", "src", "index.js"),
|
||||
intervalMs: 500,
|
||||
});
|
||||
if (mode === "auto") {
|
||||
if (mode === "auto" || mode === "system") {
|
||||
out(
|
||||
`HUD follows Grok theme: [ui] theme="${ui.theme}" → palette "${t.name}"\n` +
|
||||
`Change with /theme inside Grok; HUD updates within ~1s.\n` +
|
||||
`Force lock: grok-build-hud --theme tokyonight|grokday|light|dark`,
|
||||
`HUD follows Grok [ui].theme (not locked).\n` +
|
||||
` config: theme="${ui.theme}" → palette "${t.name}"\n` +
|
||||
` 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 {
|
||||
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;
|
||||
@@ -386,14 +474,36 @@ export async function runCli(
|
||||
grokHome: opts.grokHome,
|
||||
startDaemon: true,
|
||||
});
|
||||
const launchers = installSameWindowLauncher();
|
||||
out("Installed SAME-WINDOW dashboard (no second window):");
|
||||
out(` start: ${launchers.runPath}`);
|
||||
out(` → run: grok-hud-run`);
|
||||
out(` also: ${launchers.grokWrapPath}`);
|
||||
// Always follow Grok [ui].theme — clear any legacy locked palette
|
||||
const home = opts.grokHome ?? defaultGrokHome();
|
||||
ensureFollowMode(home);
|
||||
const hudCfg = ensureDefaultConfig(home);
|
||||
// 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(` 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(
|
||||
` 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.");
|
||||
} else {
|
||||
out(
|
||||
"\n★ To show the bar in THIS Terminal window:\n 1. End current Grok session (/quit)\n 2. Run: grok-hud-run\n Bottom line will always show ctx + quota — no second window.",
|
||||
"\n★ 打开 Terminal 后输入: grok\n 同标签页启动 Grok + 底部 HUD(含 IN/OUT/CACHE 精确数字)。\n 开终端会自动拉起 status 更新器;不是开机自启。",
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
@@ -506,7 +616,7 @@ export async function runCli(
|
||||
return one ? [one] : [];
|
||||
}
|
||||
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 liveSnaps: SessionSnapshot[] = [];
|
||||
for (const a of active) {
|
||||
@@ -516,8 +626,9 @@ export async function runCli(
|
||||
if (snap) liveSnaps.push(snap);
|
||||
}
|
||||
if (liveSnaps.length) {
|
||||
const ranked = sortSessionsByRecency(liveSnaps, active);
|
||||
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,
|
||||
);
|
||||
// use path equality via discover as fallback
|
||||
@@ -526,11 +637,14 @@ export async function runCli(
|
||||
if (matched.length) return matched;
|
||||
}
|
||||
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 (!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);
|
||||
};
|
||||
|
||||
+231
-19
@@ -9,13 +9,95 @@ import {
|
||||
stopDashboard,
|
||||
} from "./dashboard.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 {
|
||||
binPath: string;
|
||||
tmuxConf: string;
|
||||
launchAgent?: string;
|
||||
launchAgentLoaded?: boolean;
|
||||
terminalHook?: string;
|
||||
hooksPath: string;
|
||||
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: {
|
||||
@@ -89,23 +171,52 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
|
||||
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 launchAgentLoaded = false;
|
||||
if (process.platform === "darwin") {
|
||||
const agentsDir = path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"LaunchAgents",
|
||||
);
|
||||
const agentsDir = path.join(os.homedir(), "Library", "LaunchAgents");
|
||||
fs.mkdirSync(agentsDir, { recursive: true });
|
||||
launchAgent = path.join(agentsDir, "com.dex.grok-hud-dashboard.plist");
|
||||
const log = path.join(grokHome, "hud", "dashboard.log");
|
||||
const 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"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.dex.grok-hud-dashboard</string>
|
||||
<string>${label}</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>${node}</string>
|
||||
@@ -116,9 +227,11 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
|
||||
<string>500</string>
|
||||
</array>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>KeepAlive</key>
|
||||
<true/>
|
||||
<false/>
|
||||
<key>ThrottleInterval</key>
|
||||
<integer>5</integer>
|
||||
<key>StandardOutPath</key>
|
||||
<string>${log}</string>
|
||||
<key>StandardErrorPath</key>
|
||||
@@ -128,22 +241,32 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
|
||||
<key>HOME</key>
|
||||
<string>${os.homedir()}</string>
|
||||
<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>
|
||||
<key>WorkingDirectory</key>
|
||||
<string>${os.homedir()}</string>
|
||||
<key>ProcessType</key>
|
||||
<string>Background</string>
|
||||
</dict>
|
||||
</plist>
|
||||
`;
|
||||
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 {
|
||||
execSilent(["launchctl", "unload", launchAgent]);
|
||||
} 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"] = {
|
||||
@@ -154,38 +277,127 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf
|
||||
daemon = ensureDashboardDaemon({
|
||||
grokHome,
|
||||
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 {
|
||||
execFileSync(args[0]!, args.slice(1), {
|
||||
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: {
|
||||
grokHome?: string;
|
||||
restoreGrokCommand?: boolean;
|
||||
} = {}): void {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
stopDashboard(grokHome);
|
||||
|
||||
if (options.restoreGrokCommand !== false) {
|
||||
try {
|
||||
uninstallGrokCommandWrap({ grokHome });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
uninstallTerminalStartupHook();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (process.platform === "darwin") {
|
||||
const label = "com.dex.grok-hud-dashboard";
|
||||
const launchAgent = path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"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 {
|
||||
execSilent(["launchctl", "unload", launchAgent]);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(launchAgent)) fs.unlinkSync(launchAgent);
|
||||
} 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,
|
||||
} from "./bar.js";
|
||||
import { formatToolLine } from "./activity.js";
|
||||
import { formatTokenBreakdownLine } from "./token-usage.js";
|
||||
import type {
|
||||
RenderOptions,
|
||||
SessionSnapshot,
|
||||
@@ -85,9 +86,26 @@ export function renderHud(
|
||||
}
|
||||
if (session.turnCount > 0) meta.push(`Turns ${session.turnCount}`);
|
||||
if (session.toolCallCount > 0) meta.push(`Tools ${session.toolCallCount}`);
|
||||
const line2 = `${ctxLabel} ${bar} ${pct}%${tokenPart}${meta.length ? ` │ ${meta.join(" │ ")}` : ""}`;
|
||||
const tokLine = session.lastTurnTokens
|
||||
? formatTokenBreakdownLine(session.lastTurnTokens, { mode: "exact" })
|
||||
: "";
|
||||
const line2 = `${ctxLabel} ${bar} ${pct}%${tokenPart}${tokLine ? ` │ ${tokLine}` : ""}${meta.length ? ` │ ${meta.join(" │ ")}` : ""}`;
|
||||
|
||||
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);
|
||||
if (usageLine) lines.push(usageLine);
|
||||
|
||||
+94
-16
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { contextPercentFromSignals } from "./bar.js";
|
||||
import { parseUpdatesFile } from "./activity.js";
|
||||
import { parseTokenUsageFile } from "./token-usage.js";
|
||||
import { readGitInfo } from "./git.js";
|
||||
import type {
|
||||
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(
|
||||
sessionDir: string,
|
||||
options: {
|
||||
@@ -131,7 +216,9 @@ export function loadSnapshotFromDir(
|
||||
? signals.contextWindowTokens
|
||||
: 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
|
||||
let tools = activity.tools;
|
||||
if (!tools.length && Array.isArray(signals.toolsUsed) && signals.toolsUsed.length) {
|
||||
@@ -191,6 +278,9 @@ export function loadSnapshotFromDir(
|
||||
typeof summary?.reasoning_effort === "string"
|
||||
? summary.reasoning_effort
|
||||
: undefined,
|
||||
lastTurnTokens: tokenUsage.lastTurn,
|
||||
sessionTokens:
|
||||
tokenUsage.turnCount > 0 ? tokenUsage.session : null,
|
||||
tools,
|
||||
agents: activity.agents,
|
||||
todos: activity.todos ?? [],
|
||||
@@ -259,21 +349,9 @@ export function discoverSessions(options: DiscoverOptions = {}): SessionSnapshot
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: live first, then by signals/summary mtime
|
||||
filtered.sort((a, b) => {
|
||||
if (a.live !== b.live) return a.live ? -1 : 1;
|
||||
const am = Math.max(
|
||||
mtimeMs(path.join(a.sessionDir, "signals.json")),
|
||||
mtimeMs(path.join(a.sessionDir, "summary.json")),
|
||||
);
|
||||
const bm = Math.max(
|
||||
mtimeMs(path.join(b.sessionDir, "signals.json")),
|
||||
mtimeMs(path.join(b.sessionDir, "summary.json")),
|
||||
);
|
||||
return bm - am;
|
||||
});
|
||||
|
||||
return filtered;
|
||||
// Sort: live first, then by recency (opened_at / last_active / file mtimes)
|
||||
// Do NOT use active_sessions array order — after /new the old tab stays first.
|
||||
return sortSessionsByRecency(filtered, active);
|
||||
}
|
||||
|
||||
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):
|
||||
* [Grok 4.5] │ project git:(main*) │ ● live
|
||||
* Context ████░░░░ 45% (224k/500k) │ Usage ██░░░░ 23% (weekly · resets 4d)
|
||||
* ◐ read_file: x │ ✓ grep ×3 │ ▸ todos 2/5
|
||||
* Default expanded layout:
|
||||
* [Grok 4.5] · project git · ●
|
||||
* 窗/ctx ████ 45% · 入/i · 出/o · 缓/c · 额/use …
|
||||
* tool activity · agents · todos
|
||||
*/
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import {
|
||||
formatDuration,
|
||||
formatTokenCount,
|
||||
formatTokenCount as fmtTokShort,
|
||||
projectLabel,
|
||||
renderBar,
|
||||
} from "./bar.js";
|
||||
import { formatToolLine } from "./activity.js";
|
||||
import {
|
||||
formatTokenBreakdownLine,
|
||||
} from "./token-usage.js";
|
||||
import type {
|
||||
SessionSnapshot,
|
||||
TodoItem,
|
||||
TokenBreakdown,
|
||||
UsageSnapshot,
|
||||
} from "./types.js";
|
||||
import { defaultGrokHome } from "./session.js";
|
||||
import {
|
||||
miniBar,
|
||||
tmuxFg,
|
||||
tmuxRole,
|
||||
resolveTheme,
|
||||
type HudTheme,
|
||||
} from "./theme.js";
|
||||
@@ -31,6 +36,17 @@ import {
|
||||
loadHudConfig,
|
||||
type HudDisplayConfig,
|
||||
} 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 {
|
||||
return path.join(grokHome, "hud");
|
||||
@@ -80,7 +96,59 @@ function truncate(s: string, n: number): string {
|
||||
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(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
@@ -108,7 +176,7 @@ export function formatStatusBlock(
|
||||
if (session.reasoningEffort) l1.push(`effort:${session.reasoningEffort}`);
|
||||
lines.push(l1.join(" │ "));
|
||||
|
||||
// Line 2 — Context + Usage (Claude default merge)
|
||||
// Line 2 — Context + Usage
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const cBar = d.showContextBar ? renderBar(session.contextPercent) + " " : "";
|
||||
const ctxPart = `Context ${cBar}${contextValueText(session, d.contextValue)}`;
|
||||
@@ -139,7 +207,8 @@ export function formatStatusBlock(
|
||||
meta.push(`Tools ${session.toolCallCount}`);
|
||||
}
|
||||
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 (
|
||||
d.showDiffStats &&
|
||||
@@ -155,10 +224,16 @@ export function formatStatusBlock(
|
||||
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);
|
||||
// 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[] = [];
|
||||
if (d.showToolActivity) {
|
||||
const tl = formatToolLine(session.tools);
|
||||
@@ -195,120 +270,323 @@ export function formatCompactLine(
|
||||
cfg.display.showGit && session.branch
|
||||
? ` git:(${session.branch}${session.gitDirty && cfg.display.showGitDirty ? "*" : ""})`
|
||||
: "";
|
||||
return `[hud] [${displayModel(session.model)}] ${projectLabel(session.cwd, cfg.pathLevels)}${git} │ ctx ${pct}% │ ${q} │ t${session.turnCount} │ tools ${session.toolCallCount}`;
|
||||
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).
|
||||
* Written as separate lines for status-format[0..n].
|
||||
* Compact token cluster with mixed styles (not one big bold blob).
|
||||
* 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(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
theme: HudTheme = resolveTheme(),
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
options: { maxWidth?: number } = {},
|
||||
): string[] {
|
||||
const d = cfg.display;
|
||||
const bold = cfg.bold !== false;
|
||||
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
|
||||
// wider separators + bold values = easier to scan at a glance
|
||||
const sep = tmuxFg(theme.sep, " │ ", { bold: false });
|
||||
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
|
||||
const value = (s: string) => tmuxFg(theme.value, s, { bold });
|
||||
const accent = (s: string) => tmuxFg(theme.mark, s, { bold });
|
||||
const lines: string[] = [];
|
||||
const L = stringsFromConfig(cfg);
|
||||
const cols = options.maxWidth && options.maxWidth > 20 ? options.maxWidth : 100;
|
||||
const tier = widthTier(cols);
|
||||
const barW = adaptiveBarWidth(
|
||||
cols,
|
||||
cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12,
|
||||
);
|
||||
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
|
||||
const l0: string[] = [];
|
||||
// ── Line 0: model (bold) · project (italic) · live (bold accent)
|
||||
const l0: FitSegment[] = [];
|
||||
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) {
|
||||
let p = value(projectLabel(session.cwd, cfg.pathLevels));
|
||||
let plain = projectLabel(session.cwd, cfg.pathLevels);
|
||||
if (d.showGit && session.branch) {
|
||||
const dirty = d.showGitDirty && session.gitDirty ? "*" : "";
|
||||
p +=
|
||||
tmuxFg(theme.label, " git:(", { bold: false }) +
|
||||
value(`${session.branch}${dirty}`) +
|
||||
tmuxFg(theme.label, ")", { bold: false });
|
||||
plain += ` ${session.branch}${dirty}`;
|
||||
}
|
||||
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) {
|
||||
l0.push(
|
||||
session.live
|
||||
? tmuxFg(theme.live, " ● LIVE ", { bold: true })
|
||||
: tmuxFg(theme.stale, " ○ stale ", { bold: false }),
|
||||
);
|
||||
const t = session.live ? "●" : "○";
|
||||
l0.push({
|
||||
text: t,
|
||||
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 ctx =
|
||||
label("CTX ") +
|
||||
(d.showContextBar ? miniBar(pct, barW, theme, { bold }) + " " : "") +
|
||||
value(contextValueText(session, d.contextValue));
|
||||
const l1: FitSegment[] = [];
|
||||
|
||||
{
|
||||
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) {
|
||||
const q = Math.round(usage.percent);
|
||||
usagePart =
|
||||
label("USE ") +
|
||||
(d.showContextBar ? miniBar(q, barW, theme, { bold }) + " " : "") +
|
||||
value(
|
||||
`${q}%${usage.period ? ` ${usage.period}` : ""}${usage.resetsIn ? ` · ${usage.resetsIn}` : ""}`,
|
||||
);
|
||||
const bar = d.showContextBar
|
||||
? miniBar(q, Math.max(6, barW - 2), theme, { bold: true }) + " "
|
||||
: "";
|
||||
const periodWord =
|
||||
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) {
|
||||
meta.push(label("TIME ") + value(formatDuration(session.durationSeconds)));
|
||||
|
||||
if (d.showTurns && session.turnCount > 0 && tier !== "xs") {
|
||||
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)
|
||||
meta.push(label("TOOLS ") + value(String(session.toolCallCount)));
|
||||
if (d.showErrors && session.toolFailureCount > 0) {
|
||||
meta.push(tmuxFg(theme.crit, ` ERR ${session.toolFailureCount} `, { bold: true }));
|
||||
if (d.showTools && session.toolCallCount > 0 && (tier === "md" || tier === "lg")) {
|
||||
l1.push({
|
||||
text: `${L.tools}${session.toolCallCount}`,
|
||||
render:
|
||||
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 (
|
||||
d.showDiffStats &&
|
||||
(session.agentLinesAdded || session.agentLinesRemoved)
|
||||
d.showErrors &&
|
||||
(session.errorCount > 0 || session.toolFailureCount > 0)
|
||||
) {
|
||||
meta.push(
|
||||
value(`+${session.agentLinesAdded}/-${session.agentLinesRemoved}`),
|
||||
);
|
||||
const n = session.errorCount || session.toolFailureCount;
|
||||
l1.push({
|
||||
text: `!${n}`,
|
||||
render: tmuxRole(theme, "crit", `!${n}`),
|
||||
priority: 1,
|
||||
});
|
||||
}
|
||||
lines.push(" " + [ctx, usagePart, ...meta].join(sep) + " ");
|
||||
|
||||
// L2 activity
|
||||
if (cfg.statusLines >= 3) {
|
||||
const bits: string[] = [];
|
||||
const line1 = fitSegments(l1, maxW, sepP, sepR);
|
||||
|
||||
// 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) {
|
||||
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) {
|
||||
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) {
|
||||
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)
|
||||
lines.push(" " + bits.join(tmuxFg(theme.sep, " · ", { bold: false })) + " ");
|
||||
if (bits.length) {
|
||||
lines.push(fitSegments(bits, maxW, sepP, sepR));
|
||||
}
|
||||
}
|
||||
|
||||
const n = Math.max(1, Math.min(3, cfg.statusLines));
|
||||
return lines.slice(0, n);
|
||||
// Final safety trim each line to window
|
||||
return lines.map((ln) =>
|
||||
visibleLen(ln) > maxW ? trimVisible(ln, maxW) : ln,
|
||||
);
|
||||
}
|
||||
|
||||
/** Single-line coloured status (fallback / compact). */
|
||||
@@ -317,39 +595,22 @@ export function formatTmuxStatusLine(
|
||||
usage?: UsageSnapshot | null,
|
||||
theme: HudTheme = resolveTheme(),
|
||||
cfg: HudDisplayConfig = loadHudConfig(),
|
||||
options: { maxWidth?: number } = {},
|
||||
): string {
|
||||
if (cfg.lineLayout === "compact" || cfg.statusLines === 1) {
|
||||
const lines = formatTmuxStatusLines(session, usage, theme, {
|
||||
const lines = formatTmuxStatusLines(
|
||||
session,
|
||||
usage,
|
||||
theme,
|
||||
{
|
||||
...cfg,
|
||||
statusLines: 1,
|
||||
});
|
||||
// collapse expanded L0+L1 into one dense line for single-row mode
|
||||
const pct = Math.round(session.contextPercent);
|
||||
const q =
|
||||
usage?.available && usage.percent != null
|
||||
? Math.round(usage.percent)
|
||||
: null;
|
||||
const barW = cfg.barWidth && cfg.barWidth > 0 ? cfg.barWidth : 12;
|
||||
const bold = cfg.bold !== false;
|
||||
const sep = tmuxFg(theme.sep, " · ", { bold: false });
|
||||
const label = (s: string) => tmuxFg(theme.label, s, { bold: false });
|
||||
const value = (s: string) => tmuxFg(theme.value, s, { bold });
|
||||
const parts = [
|
||||
tmuxFg(theme.mark, `[${displayModel(session.model)}]`, { bold: true }),
|
||||
value(projectLabel(session.cwd, cfg.pathLevels)),
|
||||
label("CTX ") + miniBar(pct, barW, theme, { bold }) + " " + value(`${pct}%`),
|
||||
q != null
|
||||
? label("USE ") + miniBar(q, barW, theme, { bold }) + " " + value(`${q}%`)
|
||||
: label("USE ") + value("—"),
|
||||
label("T ") + value(String(session.turnCount)),
|
||||
label("TOOLS ") + value(String(session.toolCallCount)),
|
||||
session.live
|
||||
? tmuxFg(theme.live, "● LIVE", { bold: true })
|
||||
: tmuxFg(theme.stale, "○", { bold: false }),
|
||||
];
|
||||
return " " + parts.join(sep) + " ";
|
||||
}
|
||||
return formatTmuxStatusLines(session, usage, theme, cfg).join("\n");
|
||||
statusLines:
|
||||
cfg.lineLayout === "compact" || cfg.statusLines === 1
|
||||
? 1
|
||||
: cfg.statusLines,
|
||||
},
|
||||
options,
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export interface WriteStatusResult {
|
||||
@@ -365,16 +626,35 @@ export function writeStatusFiles(
|
||||
session: SessionSnapshot,
|
||||
usage?: UsageSnapshot | null,
|
||||
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 {
|
||||
const cfg = loadHudConfig(grokHome);
|
||||
const theme = resolveTheme(undefined, process.env, { grokHome });
|
||||
const dir = hudDataDir(grokHome);
|
||||
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 full = formatStatusBlock(session, usage, cfg);
|
||||
const tmuxLines = formatTmuxStatusLines(session, usage, theme, cfg);
|
||||
const single = formatTmuxStatusLine(session, usage, theme, cfg);
|
||||
const tmuxLines = formatTmuxStatusLines(session, usage, theme, cfg, {
|
||||
maxWidth,
|
||||
});
|
||||
const single = formatTmuxStatusLine(session, usage, theme, cfg, { maxWidth });
|
||||
|
||||
const compactPath = path.join(dir, "status-line.txt");
|
||||
const fullPath = path.join(dir, "status.txt");
|
||||
@@ -382,18 +662,11 @@ export function writeStatusFiles(
|
||||
const tmuxPath = path.join(dir, "tmux-status.txt");
|
||||
const tmuxLinesPath = path.join(dir, "tmux-lines.txt");
|
||||
|
||||
fs.writeFileSync(compactPath, compact + "\n", "utf8");
|
||||
fs.writeFileSync(fullPath, full + "\n", "utf8");
|
||||
fs.writeFileSync(tmuxPath, single + "\n", "utf8");
|
||||
// pad to 3 lines for stable sed -n
|
||||
const padded = [...tmuxLines];
|
||||
while (padded.length < 3) padded.push("");
|
||||
fs.writeFileSync(tmuxLinesPath, padded.join("\n") + "\n", "utf8");
|
||||
|
||||
fs.writeFileSync(
|
||||
jsonPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
const payload = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
sessionId: session.sessionId,
|
||||
model: session.model,
|
||||
@@ -407,6 +680,10 @@ export function writeStatusFiles(
|
||||
toolFailureCount: session.toolFailureCount,
|
||||
agentLinesAdded: session.agentLinesAdded,
|
||||
agentLinesRemoved: session.agentLinesRemoved,
|
||||
lastTurnTokens: session.lastTurnTokens ?? null,
|
||||
sessionTokens: session.sessionTokens ?? null,
|
||||
tmuxSession: options.tmuxSession ?? null,
|
||||
displayWidth: maxWidth,
|
||||
todos: session.todos,
|
||||
tools: session.tools,
|
||||
agents: session.agents,
|
||||
@@ -415,12 +692,32 @@ export function writeStatusFiles(
|
||||
compact,
|
||||
full,
|
||||
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",
|
||||
);
|
||||
};
|
||||
|
||||
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 {
|
||||
dir,
|
||||
|
||||
+236
-52
@@ -61,21 +61,30 @@ export const THEME_GROKNIGHT: HudTheme = {
|
||||
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 = {
|
||||
name: "grokday",
|
||||
statusBg: "default",
|
||||
statusFg: "#2a2a2a",
|
||||
label: "#6b6b6b",
|
||||
value: "#111111",
|
||||
sep: "#c8c8c8",
|
||||
mark: "#6C3EB2",
|
||||
ok: "#2f7a3e",
|
||||
warn: "#8a6a12",
|
||||
crit: "#a03030",
|
||||
barEmpty: "#dcdcdc",
|
||||
live: "#6C3EB2",
|
||||
stale: "#8a8a8a",
|
||||
// Warm paper bar — separates HUD from Grok content, readable black on cream
|
||||
statusBg: "#f4f1ea",
|
||||
statusFg: "#1c1917",
|
||||
// Labels: slate, dark enough without dim
|
||||
label: "#57534e",
|
||||
// Primary numbers: near-black ink
|
||||
value: "#0c0a09",
|
||||
// Separators: visible mid grey (not #c8c8c8 on white)
|
||||
sep: "#a8a29e",
|
||||
// Accent purple — deeper for WCAG-ish contrast on paper
|
||||
mark: "#5b21b6",
|
||||
ok: "#166534",
|
||||
warn: "#a16207",
|
||||
crit: "#b91c1c",
|
||||
barEmpty: "#d6d3d1",
|
||||
live: "#5b21b6",
|
||||
stale: "#78716c",
|
||||
};
|
||||
|
||||
/** Rose Pine Moon */
|
||||
@@ -112,21 +121,21 @@ export const THEME_OSCURA: HudTheme = {
|
||||
stale: "#6a6a6a",
|
||||
};
|
||||
|
||||
/** Generic light (白底) */
|
||||
/** Generic light (白底) — high contrast */
|
||||
export const THEME_CLEAR_LIGHT: HudTheme = {
|
||||
name: "light",
|
||||
statusBg: "default",
|
||||
statusFg: "#3c424a",
|
||||
label: "#7a828c",
|
||||
value: "#1a1d21",
|
||||
sep: "#c5cad1",
|
||||
mark: "#5a6570",
|
||||
ok: "#3d6b55",
|
||||
warn: "#8a6a2e",
|
||||
crit: "#8f4040",
|
||||
barEmpty: "#d8dce2",
|
||||
live: "#3d6b55",
|
||||
stale: "#9aa3ad",
|
||||
statusBg: "#f1f5f9",
|
||||
statusFg: "#0f172a",
|
||||
label: "#475569",
|
||||
value: "#020617",
|
||||
sep: "#94a3b8",
|
||||
mark: "#1e3a5f",
|
||||
ok: "#15803d",
|
||||
warn: "#a16207",
|
||||
crit: "#b91c1c",
|
||||
barEmpty: "#cbd5e1",
|
||||
live: "#1d4ed8",
|
||||
stale: "#64748b",
|
||||
};
|
||||
|
||||
/** Generic dark */
|
||||
@@ -310,12 +319,15 @@ export function detectSystemAppearance(
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve HUD palette.
|
||||
* Priority:
|
||||
* 1. explicit CLI / GROK_HUD_THEME (if not auto)
|
||||
* 2. ~/.grok/hud/theme file (if not auto)
|
||||
* 3. Grok [ui].theme from config.toml ← main source of truth
|
||||
* 4. if Grok theme is auto → system light/dark → auto_light/dark_theme
|
||||
* Resolve HUD palette from Grok's theme — never lock to one palette by default.
|
||||
*
|
||||
* Default (follow):
|
||||
* ~/.grok/config.toml [ui].theme
|
||||
* → if "auto"/"system": OS light/dark → auto_light_theme / auto_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(
|
||||
name?: string | null,
|
||||
@@ -324,9 +336,29 @@ export function resolveTheme(
|
||||
): HudTheme {
|
||||
const grokHome = options.grokHome ?? path.join(os.homedir(), ".grok");
|
||||
const ui = readGrokUiConfig(grokHome);
|
||||
const lock =
|
||||
env.GROK_HUD_LOCK === "1" ||
|
||||
env.GROK_HUD_LOCK === "true" ||
|
||||
Boolean(name && name !== "auto" && name !== "system");
|
||||
|
||||
const override = (
|
||||
name ||
|
||||
// Explicit CLI name (e.g. --theme tokyonight) = temporary override for that command
|
||||
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_BUILD_HUD_THEME ||
|
||||
readThemeFile() ||
|
||||
@@ -335,42 +367,98 @@ export function resolveTheme(
|
||||
.toString()
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
|
||||
// Explicit lock to a known palette name
|
||||
if (override && override !== "auto" && override !== "system") {
|
||||
return paletteForGrokTheme(override);
|
||||
if (locked && locked !== "auto" && locked !== "system") {
|
||||
const theme = paletteForGrokTheme(locked);
|
||||
writeAppearanceSnapshot({
|
||||
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");
|
||||
if (grokTheme === "auto") {
|
||||
const sys = detectSystemAppearance();
|
||||
const mapped =
|
||||
// Poll system appearance with short TTL so OS toggle tracks quickly
|
||||
const sys = detectSystemAppearance({ ttlMs: 1500 });
|
||||
const mappedRaw =
|
||||
sys === "dark"
|
||||
? ui.autoDarkTheme || "groknight"
|
||||
: ui.autoLightTheme || "grokday";
|
||||
const mapped = normalizeGrokThemeName(mappedRaw);
|
||||
const theme = paletteForGrokTheme(mapped);
|
||||
writeAppearanceSnapshot({
|
||||
appearance: sys,
|
||||
source: "grok-auto+" + sys,
|
||||
source: "follow-grok-auto+" + sys,
|
||||
grokTheme: ui.theme,
|
||||
autoDarkTheme: ui.autoDarkTheme,
|
||||
autoLightTheme: ui.autoLightTheme,
|
||||
mappedTheme: mapped,
|
||||
hudPalette: theme.name,
|
||||
follow: true,
|
||||
grokHome: grokHome ?? null,
|
||||
});
|
||||
return theme;
|
||||
}
|
||||
|
||||
const theme = paletteForGrokTheme(grokTheme);
|
||||
writeAppearanceSnapshot({
|
||||
appearance: theme.name === "grokday" || theme.name === "light" ? "light" : "dark",
|
||||
source: "grok-config",
|
||||
appearance: isLightTheme(theme) ? "light" : "dark",
|
||||
source: "follow-grok-config",
|
||||
grokTheme: ui.theme,
|
||||
mappedTheme: grokTheme,
|
||||
hudPalette: theme.name,
|
||||
follow: true,
|
||||
});
|
||||
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 {
|
||||
try {
|
||||
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]`;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
color: string,
|
||||
text: string,
|
||||
options: { bold?: boolean } = {},
|
||||
options: TmuxStyleOpts = {},
|
||||
): string {
|
||||
const bold = options.bold ? "bold," : "";
|
||||
return `#[${bold}fg=${color}]${text}#[default]`;
|
||||
const attrs: string[] = [];
|
||||
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): {
|
||||
@@ -452,15 +631,20 @@ export function tmuxStatusChrome(theme: HudTheme = THEME_DEFAULT): {
|
||||
statusLeftLength: 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 {
|
||||
// bold + slightly brighter default fg for the whole strip
|
||||
statusStyle: `bg=${theme.statusBg},fg=${theme.statusFg},bold`,
|
||||
statusStyle,
|
||||
statusLeft: "",
|
||||
statusRightTemplate: (filePath: string) =>
|
||||
`#(cat ${filePath} 2>/dev/null)`,
|
||||
statusInterval: "1",
|
||||
statusRightLength: "200",
|
||||
statusLeftLength: "0",
|
||||
// allow strip to use full client width (was 0 / cramped)
|
||||
statusRightLength: "500",
|
||||
statusLeftLength: "500",
|
||||
statusPosition: "bottom",
|
||||
};
|
||||
}
|
||||
|
||||
+171
-24
@@ -11,11 +11,35 @@ import { defaultGrokHome } from "./session.js";
|
||||
import { packageRoot } from "./install.js";
|
||||
import { resolveTheme, tmuxStatusChrome } from "./theme.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 {
|
||||
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 {
|
||||
try {
|
||||
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[][] {
|
||||
const theme = resolveTheme(undefined, process.env, { grokHome });
|
||||
const chrome = tmuxStatusChrome(theme);
|
||||
const linesFile = path.join(grokHome, "hud", "tmux-lines.txt");
|
||||
const singleFile = path.join(grokHome, "hud", "tmux-status.txt");
|
||||
// Claude-HUD parity: up to 3 status rows in the SAME terminal window
|
||||
const instRoot = path.join(grokHome, "hud", "tmux");
|
||||
// #{session_name} expanded by tmux before running #() — one bar per session
|
||||
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(
|
||||
1,
|
||||
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-current-format", ""],
|
||||
["set", "-g", "window-status-separator", ""],
|
||||
["set", "-g", "status-left-length", "0"],
|
||||
["set", "-g", "status-right-length", "0"],
|
||||
// Allow full client width (dashboard rewrites lines to fit each window)
|
||||
["set", "-g", "status-left-length", chrome.statusLeftLength],
|
||||
["set", "-g", "status-right-length", chrome.statusRightLength],
|
||||
["set", "-g", "status-left", ""],
|
||||
["set", "-g", "status-right", ""],
|
||||
];
|
||||
|
||||
// align=left; content already truncated to #{client_width} by dashboard
|
||||
if (statusLines === 1) {
|
||||
cmds.push([
|
||||
"set",
|
||||
"-g",
|
||||
"status-format[0]",
|
||||
`#[align=left]#(cat ${singleFile} 2>/dev/null)`,
|
||||
`#[align=left]#(cat ${scopedSingle} 2>/dev/null || cat ${fallbackSingle} 2>/dev/null)`,
|
||||
]);
|
||||
} else {
|
||||
for (let i = 0; i < statusLines; i++) {
|
||||
@@ -65,7 +99,7 @@ export function tmuxStatusCommands(grokHome = defaultGrokHome()): string[][] {
|
||||
"set",
|
||||
"-g",
|
||||
`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 cmds = tmuxStatusCommands(grokHome);
|
||||
const body =
|
||||
`# grok-build-hud — Claude-HUD-parity multi-line status (same window)\n` +
|
||||
`# Follows Grok [ui].theme. Preset: ~/.grok/hud/config.json\n` +
|
||||
`# grok-build-hud — multi-line live status (same window)\n` +
|
||||
`# Follows Grok [ui].theme. Config: ~/.grok/hud/config.json\n` +
|
||||
`# Auto-sourced by ~/.tmux.conf when present.\n\n` +
|
||||
cmds
|
||||
.map((args) => {
|
||||
@@ -127,6 +161,9 @@ export function writeTmuxConfFile(grokHome = defaultGrokHome()): string {
|
||||
/**
|
||||
* Re-exec current process inside tmux with status bar, then run `grok`.
|
||||
* 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: {
|
||||
grokHome?: string;
|
||||
@@ -136,24 +173,60 @@ export function execGrokInSameWindowTmux(options: {
|
||||
}): never | void {
|
||||
const grokHome = options.grokHome ?? defaultGrokHome();
|
||||
const grokBin = options.grokBin ?? detectGrokBin();
|
||||
const session = options.sessionName ?? "grok-hud";
|
||||
const statusFile = path.join(grokHome, "hud", "tmux-status.txt");
|
||||
fs.mkdirSync(path.dirname(statusFile), { recursive: true });
|
||||
// Unique per Terminal launch — never fixed "grok-hud" + -A (that synced all tabs)
|
||||
const session =
|
||||
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)) {
|
||||
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);
|
||||
|
||||
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 });
|
||||
writeTmuxInstanceMeta({
|
||||
tmuxSession: current,
|
||||
launcherPid: process.pid,
|
||||
startedAt: new Date().toISOString(),
|
||||
tty: process.env.TTY ?? null,
|
||||
}, grokHome);
|
||||
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);
|
||||
}
|
||||
|
||||
if (!tmuxAvailable()) {
|
||||
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);
|
||||
}
|
||||
@@ -163,31 +236,88 @@ export function execGrokInSameWindowTmux(options: {
|
||||
const conf = path.join(grokHome, "hud", "tmux.conf");
|
||||
|
||||
// Apply conf then exec grok — same tty, no second window
|
||||
// Export session name so dashboard can bind status files to this Terminal
|
||||
const wrapper = [
|
||||
`export GROK_HUD_TMUX_SESSION=${shellQuote(session)}`,
|
||||
`export GROK_HUD_ACTIVE=1`,
|
||||
`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(
|
||||
(args) => `tmux ${args.map(shellQuote).join(" ")} 2>/dev/null || true`,
|
||||
),
|
||||
`exec ${grokCmd}`,
|
||||
].join(" && ");
|
||||
|
||||
// NO -A: never attach to an existing session (that was the multi-tab sync bug)
|
||||
const r = spawnSync(
|
||||
"tmux",
|
||||
["new-session", "-A", "-s", session, "zsh", "-lc", wrapper],
|
||||
{ stdio: "inherit" },
|
||||
["new-session", "-s", session, "zsh", "-lc", wrapper],
|
||||
{
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
GROK_HUD_TMUX_SESSION: session,
|
||||
GROK_HUD_ACTIVE: "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 = [
|
||||
path.join(os.homedir(), ".grok", "bin", "grok"),
|
||||
path.join(grokHome, "bin", "grok"),
|
||||
"/usr/local/bin/grok",
|
||||
"/opt/homebrew/bin/grok",
|
||||
];
|
||||
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 {
|
||||
return execFileSync("which", ["grok"], { encoding: "utf8" }).trim();
|
||||
@@ -201,10 +331,16 @@ function shellQuote(s: string): string {
|
||||
return `'${s.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
/** Install same-window launchers. */
|
||||
/** Install same-window launchers (+ optional plain `grok` wrap). */
|
||||
export function installSameWindowLauncher(options: {
|
||||
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");
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
const root = packageRoot();
|
||||
@@ -215,7 +351,10 @@ export function installSameWindowLauncher(options: {
|
||||
const runShim = `#!/bin/zsh
|
||||
# Start Grok in THIS Terminal window with a permanent bottom status bar
|
||||
# (tmux). Does not open a second window.
|
||||
# Prefer plain: grok
|
||||
set -e
|
||||
export GROK_HUD_THEME="\${GROK_HUD_THEME:-auto}"
|
||||
export GROK_HUD_ACTIVE=1
|
||||
NODE=${JSON.stringify(node)}
|
||||
ENTRY=${JSON.stringify(entry)}
|
||||
"$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 });
|
||||
const grokWrapPath = path.join(binDir, "grok-with-hud");
|
||||
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;
|
||||
}
|
||||
|
||||
/** 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 {
|
||||
sessionId: string;
|
||||
sessionDir: string;
|
||||
@@ -90,6 +102,10 @@ export interface SessionSnapshot {
|
||||
avgTtftMs: number;
|
||||
agentName?: 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[];
|
||||
agents: AgentActivityItem[];
|
||||
todos: TodoItem[];
|
||||
|
||||
@@ -62,6 +62,81 @@ describe("dashboard", () => {
|
||||
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 () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "hud-loop-"));
|
||||
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 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", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
const text = formatStatusBlock(
|
||||
@@ -45,7 +45,7 @@ describe("Claude-HUD parity status", () => {
|
||||
const snap = loadSnapshotFromDir(fixture)!;
|
||||
// inject todos/agents for line 3
|
||||
snap.todos = [
|
||||
{ content: "Ship HUD parity", status: "in_progress" },
|
||||
{ content: "Ship HUD strip", status: "in_progress" },
|
||||
{ content: "Write tests", status: "completed" },
|
||||
];
|
||||
const lines = formatTmuxStatusLines(
|
||||
|
||||
+43
-5
@@ -50,17 +50,37 @@ describe("theme", () => {
|
||||
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-"));
|
||||
fs.writeFileSync(
|
||||
path.join(tmp, "config.toml"),
|
||||
`[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");
|
||||
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", () => {
|
||||
const bar = miniBar(50, 8, THEME_TOKYONIGHT);
|
||||
assert.match(bar, /#9ece6a|#e0af68|#f7768e/);
|
||||
@@ -77,8 +97,26 @@ describe("theme", () => {
|
||||
assert.match(line, /37%/);
|
||||
});
|
||||
|
||||
it("grokday is light-paper friendly", () => {
|
||||
assert.equal(THEME_GROKDAY.value, "#111111");
|
||||
assert.match(THEME_GROKDAY.barEmpty, /#dcdcdc/);
|
||||
it("grokday is light-paper friendly high contrast", () => {
|
||||
assert.equal(THEME_GROKDAY.name, "grokday");
|
||||
// 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