From df6a9e74d9ff734a832babfc0e3671740c53a8d9 Mon Sep 17 00:00:00 2001 From: red chen Date: Fri, 17 Jul 2026 16:27:35 +0800 Subject: [PATCH] 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. --- CHANGELOG.md | 94 +++++- MIGRATION-FROM-CLAUDE.md | 69 ---- README.md | 37 +- README.zh-CN.md | 65 ++-- bin/grok-hud.js | 4 + commands/hud.md | 24 +- commands/preset.md | 22 +- commands/settings.md | 21 ++ package.json | 9 +- plugin.json | 4 +- skills/grok-build-hud/SKILL.md | 22 +- src/activity.ts | 2 +- src/dashboard.ts | 157 +++++---- src/grok-wrap.ts | 327 ++++++++++++++++++ src/hook.ts | 18 +- src/hud-config.ts | 79 +++-- src/i18n.ts | 238 +++++++++++++ src/index.ts | 188 +++++++++-- src/install-dashboard.ts | 258 ++++++++++++-- src/layout.ts | 173 ++++++++++ src/multi-session.ts | 178 ++++++++++ src/render.ts | 20 +- src/session.ts | 110 +++++- src/settings-ui.ts | 243 ++++++++++++++ src/status.ts | 593 +++++++++++++++++++++++++-------- src/theme.ts | 304 +++++++++++++---- src/tmux-hud.ts | 195 +++++++++-- src/token-usage.ts | 184 ++++++++++ src/types.ts | 16 + tests/dashboard.test.ts | 75 +++++ tests/grok-wrap.test.ts | 97 ++++++ tests/i18n.test.ts | 97 ++++++ tests/layout.test.ts | 114 +++++++ tests/multi-session.test.ts | 51 +++ tests/status-parity.test.ts | 4 +- tests/theme.test.ts | 48 ++- tests/token-usage.test.ts | 74 ++++ 37 files changed, 3622 insertions(+), 592 deletions(-) delete mode 100644 MIGRATION-FROM-CLAUDE.md create mode 100644 commands/settings.md create mode 100644 src/grok-wrap.ts create mode 100644 src/i18n.ts create mode 100644 src/layout.ts create mode 100644 src/multi-session.ts create mode 100644 src/settings-ui.ts create mode 100644 src/token-usage.ts create mode 100644 tests/grok-wrap.test.ts create mode 100644 tests/i18n.test.ts create mode 100644 tests/layout.test.ts create mode 100644 tests/multi-session.test.ts create mode 100644 tests/token-usage.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cfc32fb..8867eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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//`; + 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) diff --git a/MIGRATION-FROM-CLAUDE.md b/MIGRATION-FROM-CLAUDE.md deleted file mode 100644 index eb193e9..0000000 --- a/MIGRATION-FROM-CLAUDE.md +++ /dev/null @@ -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. diff --git a/README.md b/README.md index 44552e7..28e6d26 100644 --- a/README.md +++ b/README.md @@ -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 +``` --- diff --git a/README.zh-CN.md b/README.zh-CN.md index 3acdc19..26ff354 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -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 diff --git a/bin/grok-hud.js b/bin/grok-hud.js index f0d99d3..80070f3 100755 --- a/bin/grok-hud.js +++ b/bin/grok-hud.js @@ -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); diff --git a/commands/hud.md b/commands/hud.md index 50e4aca..430e5c9 100644 --- a/commands/hud.md +++ b/commands/hud.md @@ -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`). diff --git a/commands/preset.md b/commands/preset.md index 804b006..53e5445 100644 --- a/commands/preset.md +++ b/commands/preset.md @@ -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). diff --git a/commands/settings.md b/commands/settings.md new file mode 100644 index 0000000..fcf5625 --- /dev/null +++ b/commands/settings.md @@ -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` diff --git a/package.json b/package.json index 571d45d..77990b3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugin.json b/plugin.json index 31ea622..d8eebd5 100644 --- a/plugin.json +++ b/plugin.json @@ -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" diff --git a/skills/grok-build-hud/SKILL.md b/skills/grok-build-hud/SKILL.md index fd35828..5c516a8 100644 --- a/skills/grok-build-hud/SKILL.md +++ b/skills/grok-build-hud/SKILL.md @@ -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,19 +31,21 @@ bash scripts/install.sh ## Daily ```bash -grok-hud-run # Grok + bottom HUD (same tab) -grok-hud status # one-shot print +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 +grok-build-hud --theme auto # follow Grok [ui].theme +grok-hud stop # stop dashboard daemon ``` ## In-session slash commands (if plugin enabled) -- `/hud` `/status` `/quota` `/preset` `/setup` `/watch` — see `commands/` +- `/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) diff --git a/src/activity.ts b/src/activity.ts index 3ba38d4..65915ac 100644 --- a/src/activity.ts +++ b/src/activity.ts @@ -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[] = []; diff --git a/src/dashboard.ts b/src/dashboard.ts index 03d398d..42e5c21 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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 + repeat with w in windows + repeat with tb in tabs of w try - set custom title of w to hud - end try - 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 - end try - end repeat + end if + end try end repeat - end if + end repeat 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 { diff --git a/src/grok-wrap.ts b/src/grok-wrap.ts new file mode 100644 index 0000000..8c6877d --- /dev/null +++ b/src/grok-wrap.ts @@ -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 }; +} diff --git a/src/hook.ts b/src/hook.ts index 45139b3..a10ab10 100644 --- a/src/hook.ts +++ b/src/hook.ts @@ -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 }); } diff --git a/src/hud-config.ts b/src/hud-config.ts index f63f611..e7f26e5 100644 --- a/src/hud-config.ts +++ b/src/hud-config.ts @@ -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 = { - "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; diff --git a/src/i18n.ts b/src/i18n.ts new file mode 100644 index 0000000..858bb05 --- /dev/null +++ b/src/i18n.ts @@ -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): 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; +} diff --git a/src/index.ts b/src/index.ts index 477d5a2..5061aee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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,16 +474,38 @@ 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}`); - out(` hooks: ${r.hooksPath}`); - out(` tmux cfg: ${r.tmuxConf}`); - if (r.launchAgent) out(` launchd: ${r.launchAgent} (file updater)`); + // 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( - ` daemon: ${r.daemon.alreadyRunning ? "already running" : r.daemon.started ? `started pid ${r.daemon.pid}` : "not started"}`, + ` 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.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"}`, ); await refreshDashboard({ grokHome: opts.grokHome }); if (isInsideTmux()) { @@ -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); }; diff --git a/src/install-dashboard.ts b/src/install-dashboard.ts index 511199a..7d6a7c0 100644 --- a/src/install-dashboard.ts +++ b/src/install-dashboard.ts @@ -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 = ` Label - com.dex.grok-hud-dashboard + ${label} ProgramArguments ${node} @@ -116,9 +227,11 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf 500 RunAtLoad - + KeepAlive - + + ThrottleInterval + 5 StandardOutPath ${log} StandardErrorPath @@ -128,22 +241,32 @@ if-shell '[ -f "$HOME/.grok/hud/tmux.conf" ]' 'source-file ~/.grok/hud/tmux.conf HOME ${os.homedir()} PATH - /usr/local/bin:/usr/bin:/bin:/opt/homebrew/bin + ${pathEnv} + GROK_HUD_THEME + auto + GROK_HOME + ${grokHome} + WorkingDirectory + ${os.homedir()} + ProcessType + Background `; fs.writeFileSync(launchAgent, plist, "utf8"); + // Unload login-boot agent if previously installed with KeepAlive try { - execSilent(["launchctl", "unload", launchAgent]); + const uid = typeof process.getuid === "function" ? process.getuid() : 501; + execSilent(["launchctl", "bootout", `gui/${uid}/${label}`]); } catch { - /* not loaded */ - } - try { - execSilent(["launchctl", "load", launchAgent]); - } catch { - /* may need user session */ + try { + execSilent(["launchctl", "unload", launchAgent]); + } catch { + /* ignore */ + } } + launchAgentLoaded = false; // intentional: Terminal-start driven, not login boot } let daemon: DashboardInstallResult["daemon"] = { @@ -154,37 +277,126 @@ 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", "unload", launchAgent]); + execSilent(["launchctl", "bootout", `gui/${uid}/${label}`]); } catch { - /* ignore */ + try { + execSilent(["launchctl", "unload", launchAgent]); + } catch { + /* ignore */ + } } try { if (fs.existsSync(launchAgent)) fs.unlinkSync(launchAgent); diff --git a/src/layout.ts b/src/layout.ts new file mode 100644 index 0000000..4d81a85 --- /dev/null +++ b/src/layout.ts @@ -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(); + 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); +} diff --git a/src/multi-session.ts b/src/multi-session.ts new file mode 100644 index 0000000..f11b7c9 --- /dev/null +++ b/src/multi-session.ts @@ -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 []; + } +} diff --git a/src/render.ts b/src/render.ts index d067e62..98d98db 100644 --- a/src/render.ts +++ b/src/render.ts @@ -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); diff --git a/src/session.ts b/src/session.ts index f276f02..38a2b41 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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 { diff --git a/src/settings-ui.ts b/src/settings-ui.ts new file mode 100644 index 0000000..7010858 --- /dev/null +++ b/src/settings-ui.ts @@ -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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/status.ts b/src/status.ts index ae75b59..eadd83f 100644 --- a/src/status.ts +++ b/src/status.ts @@ -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, +): { 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(fitSegments(bits, maxW, sepP, sepR)); } - if (bits.length) - lines.push(" " + bits.join(tmuxFg(theme.sep, " · ", { bold: false })) + " "); } - const n = Math.max(1, Math.min(3, cfg.statusLines)); - return lines.slice(0, n); + // 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// 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,45 +662,62 @@ 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( - { - updatedAt: new Date().toISOString(), - sessionId: session.sessionId, - model: session.model, - cwd: session.cwd, - live: session.live, - contextPercent: session.contextPercent, - contextTokensUsed: session.contextTokensUsed, - contextWindowTokens: session.contextWindowTokens, - turnCount: session.turnCount, - toolCallCount: session.toolCallCount, - toolFailureCount: session.toolFailureCount, - agentLinesAdded: session.agentLinesAdded, - agentLinesRemoved: session.agentLinesRemoved, - todos: session.todos, - tools: session.tools, - agents: session.agents, - usage: usage ?? null, - preset: cfg.preset, - compact, - full, - tmuxLines, - }, - null, - 2, - ), - "utf8", - ); + const payload = { + updatedAt: new Date().toISOString(), + sessionId: session.sessionId, + model: session.model, + cwd: session.cwd, + live: session.live, + contextPercent: session.contextPercent, + contextTokensUsed: session.contextTokensUsed, + contextWindowTokens: session.contextWindowTokens, + turnCount: session.turnCount, + toolCallCount: session.toolCallCount, + toolFailureCount: session.toolFailureCount, + agentLinesAdded: session.agentLinesAdded, + agentLinesRemoved: session.agentLinesRemoved, + lastTurnTokens: session.lastTurnTokens ?? null, + sessionTokens: session.sessionTokens ?? null, + tmuxSession: options.tmuxSession ?? null, + displayWidth: maxWidth, + todos: session.todos, + tools: session.tools, + agents: session.agents, + usage: usage ?? null, + preset: cfg.preset, + compact, + full, + tmuxLines, + }; + + 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, diff --git a/src/theme.ts b/src/theme.ts index 7d0e3ba..e4c2b69 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -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,53 +336,129 @@ 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 || - env.GROK_HUD_THEME || - env.GROK_BUILD_HUD_THEME || - readThemeFile() || - "" - ) - .toString() - .trim() - .toLowerCase(); - - // Explicit lock to a known palette name - if (override && override !== "auto" && override !== "system") { - return paletteForGrokTheme(override); + // 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; } - // Follow Grok's active theme (what the TUI paints) + // 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() || + "" + ) + .toString() + .trim() + .toLowerCase(); + 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 [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): 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", }; } diff --git a/src/tmux-hud.ts b/src/tmux-hud.ts index 44e4c86..db534a8 100644 --- a/src/tmux-hud.ts +++ b/src/tmux-hud.ts @@ -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 }; } diff --git a/src/token-usage.ts b/src/token-usage.ts new file mode 100644 index 0000000..f78e5b1 --- /dev/null +++ b/src/token-usage.ts @@ -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; + 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; + try { + obj = JSON.parse(line) as Record; + } catch { + continue; + } + const params = (obj.params as Record | undefined) ?? {}; + const update = + (params.update as Record | undefined) ?? + (obj.update as Record | 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(" · ")}`; +} diff --git a/src/types.ts b/src/types.ts index 931294a..2f2bd3e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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[]; diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index ba4489f..460d315 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -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"; diff --git a/tests/grok-wrap.test.ts b/tests/grok-wrap.test.ts new file mode 100644 index 0000000..b5fb380 --- /dev/null +++ b/tests/grok-wrap.test.ts @@ -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 }); + }); +}); diff --git a/tests/i18n.test.ts b/tests/i18n.test.ts new file mode 100644 index 0000000..4f814a7 --- /dev/null +++ b/tests/i18n.test.ts @@ -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 /); + }); +}); diff --git a/tests/layout.test.ts b/tests/layout.test.ts new file mode 100644 index 0000000..5f327d9 --- /dev/null +++ b/tests/layout.test.ts @@ -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, /入 |出 |缓 |窗 /); + }); +}); diff --git a/tests/multi-session.test.ts b/tests/multi-session.test.ts new file mode 100644 index 0000000..de245d9 --- /dev/null +++ b/tests/multi-session.test.ts @@ -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(); + 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); + } + }); +}); diff --git a/tests/status-parity.test.ts b/tests/status-parity.test.ts index 551123e..e87cbb8 100644 --- a/tests/status-parity.test.ts +++ b/tests/status-parity.test.ts @@ -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( diff --git a/tests/theme.test.ts b/tests/theme.test.ts index 40b7880..4853f83 100644 --- a/tests/theme.test.ts +++ b/tests/theme.test.ts @@ -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=/); }); }); diff --git a/tests/token-usage.test.ts b/tests/token-usage.test.ts new file mode 100644 index 0000000..58fc4e6 --- /dev/null +++ b/tests/token-usage.test.ts @@ -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/); + }); +});