Unified status line for Claude Code with multi-CLI (Claude, Codex, Gemini, Antigravity, z.ai) usage monitoring, context, rate limits, and cost tracking

Comprehensive status line plugin for Claude Code — unified usage monitoring across Claude, Codex, Gemini, Antigravity, and z.ai CLIs, with context, rate limits, cost tracking, and a modular widget system.

Requirements: Claude Code v1.0.80+ / Node.js 18+
/plugin marketplace add uppinote20/claude-dashboard
/plugin install claude-dashboard
/claude-dashboard:setup
git clone https://github.com/uppinote20/claude-dashboard.git "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/claude-dashboard"
/claude-dashboard:setup
/claude-dashboard:setuplooks for the plugin underplugins/cache/claude-dashboard/claude-dashboard/, which a manual clone intoplugins/claude-dashboard/does not match — the setup command's shim lookup will find nothing to copy. PointstatusLine.commandinsettings.jsondirectly at your clone'sdist/index.jsinstead (e.g.node "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/claude-dashboard/dist/index.js"), and note that manual installs do not get automatic version pickup —git pulland update the path yourself after each release.
Compact (1 line) - Default:

Model, context progress bar, cost, rate limits (5h/7d) or z.ai usage (mutually exclusive by provider)
Normal (2 lines):

Adds project info, session ID, session duration, burn rate, todo progress
Detailed (6 lines):

Adds depletion time, config counts, tool/agent status, cache hit, performance badge, token breakdown, forecast, budget, Codex/Gemini/Antigravity usage
Multi-provider support: z.ai/ZHIPU, Codex, Gemini, Antigravity auto-detected when installed.
| Category | Widget | Description |
|---|---|---|
| Core | model | Model name and version with emoji (e.g. Opus 5.5), effort level for Opus/Sonnet/Fable (MAX/X/H/M/L), fast mode for Opus (↯). Reads the live effort.level / fast_mode from stdin, falling back to settings.json on older Claude Code |
context | Progress bar, percentage, tokens (🟢 0-50% / 🟡 51-80% / 🔴 81-100%) | |
contextBar | Progress bar only (sub-widget of context) | |
contextPercentage | Percentage only (sub-widget of context) | |
contextUsage | Token count only, e.g. 42K/200K (sub-widget of context) | |
cost | Session cost in USD | |
projectInfo | Directory + git branch (clickable OSC8 link) + ahead/behind (↑↓), subpath from project_dir, worktree indicator (🌳) | |
| Rate Limits | rateLimit5h | 5-hour rate limit with reset countdown |
rateLimit7d | 7-day rate limit (Pro and Max) | |
rateLimit7dSonnet | 7-day Sonnet limit (Max only) — deprecated ~2026-06: Anthropic merged it into the unified weekly bucket at the Sonnet 5 launch, so the API returns null and the widget stays hidden until/unless the field returns | |
rateLimit7dFable | 7-day Fable limit (Max only) | |
| Session | sessionId | Session ID (short 8-char) |
sessionIdFull | Session ID (full UUID) | |
sessionName | Session name from /rename command | |
sessionDuration | Session duration | |
lastPrompt | Last user prompt with timestamp | |
configCounts | CLAUDE.md, AGENTS.md, rules, MCPs, hooks, +Dirs | |
| Activity | toolActivity | Running/completed tools with targets (e.g., Read(app.ts)) |
agentStatus | Subagent progress with resolved model, e.g. Explore(Opus) (from the Agent tool's model param or CLAUDE_CODE_SUBAGENT_MODEL) | |
todoProgress | Todo completion rate | |
| Analytics | burnRate | Token consumption per minute, session average (input + cache write + output) |
cacheHit | Cache hit rate percentage (last request) | |
promptCache | Session prompt-cache health: ♨️ warm with time left before it goes cold / ❄️ cold, hit ratio, miss count, e.g. ♨️ 4m 91% miss 2 (from prompt_cache, Claude Code ≥ 2.1.251). The warm→cold switch happens on its own; set statusLine.refreshInterval (e.g. 60) to keep the minute countdown ticking while idle | |
promptCacheState | Warm/cold icon + time left only, e.g. ♨️ 4m (sub-widget of promptCache) | |
promptCacheHit | Session cache hit ratio only, e.g. 91% (sub-widget of promptCache) | |
promptCacheMisses | Miss count only, e.g. miss 2; hidden at 0 (sub-widget of promptCache) | |
depletionTime | Estimated time to rate limit (approx)¹ | |
| Multi-CLI | codexUsage | OpenAI Codex CLI usage (auto-hide if not installed)² |
geminiUsage | Google Gemini CLI - current model (auto-hide if not installed)³ | |
geminiUsageAll | Google Gemini CLI - all models (auto-hide if not installed)³ | |
antigravityUsage | Google Antigravity CLI - weekly quota by model family (auto-hide if not installed)⁸ | |
antigravityUsageAll | Google Antigravity CLI - per-model quota (auto-hide if not installed)⁸ | |
zaiUsage | z.ai/ZHIPU usage (auto-hide if not using z.ai)⁴ | |
| Insights | tokenBreakdown | Input/output/cache write/read token breakdown |
performance | Composite efficiency badge (cache hit + output ratio) | |
forecast | Estimated hourly cost based on session rate | |
budget | Daily spending vs configured budget limit⁵ | |
tokenSpeed | Output token generation speed (tok/s), session average | |
tokenSpeedLast | Output token generation speed of the most recent response only | |
todayCost | Total spending across all sessions today | |
| Status | peakHours | Peak hours indicator with countdown (based on PeakClaude)⁶ |
tagStatus | Commits ahead of matched git tags (default pattern v*, customize via tagPatterns)⁷ | |
slashCommand | Active slash command for the current turn (🎯), cleared by next plain-text message | |
agentMode | Session agent identity: 👤 custom agent (via /agent <name>) or 🤖 subagent type | |
| Info | linesChanged | Uncommitted lines added/removed, including untracked files (+N -N) |
outputStyle | Current output style (hidden when "default") | |
version | Claude Code version display | |
vimMode | Vim mode (NORMAL/INSERT), auto-hides when vim disabled | |
apiDuration | API time as % of total session time |
¹ Assumes all utilization came from this session; improves as session runs longer. ² Auto-hides if
~/.codex/auth.jsonnot found. ³ Auto-hides if~/.gemini/oauth_creds.jsonnot found — Gemini CLI personal tiers were retired 2026-06-18 in favor of Antigravity CLI (enterprise still supported). ⁴ Auto-hides if not detected viaANTHROPIC_BASE_URL. ⁵ Requires"dailyBudget"in config. ⁶ Peak = weekdays 5-11 AM PT; shows countdown to next transition. ⁷ UsestagPatternsconfig (default["v*"]); auto-hides when no pattern matches a reachable tag. ⁸ Auto-hides if~/.gemini/antigravity-cli/antigravity-oauth-tokennot found.
i18n: English and Korean supported (auto-detect or set via setup).
# Preset modes
/claude-dashboard:setup compact # 1 line (default)
/claude-dashboard:setup normal en pro # 2 lines, English, Pro plan
/claude-dashboard:setup detailed ko max # 6 lines, Korean, Max plan
# Custom mode: control widget order and line composition
# Format: "widget1,widget2,...|widget3,widget4,..." (| separates lines)
/claude-dashboard:setup custom auto max "model,context,cost|projectInfo,todoProgress"
Plan differences:
| Feature | Max | Pro |
|---|---|---|
| 5h rate limit + countdown | ✅ | ✅ |
| 7d all models | ✅ | ❌ |
Interactive Mode: Run /claude-dashboard:setup without arguments. Best for preset selection; for full widget control, use Direct Mode or edit the JSON file directly.
Display Mode Presets (zaiUsage and rateLimit* are mutually exclusive based on provider):
| Mode | Lines | Widgets |
|---|---|---|
compact | 1 | model, context, cost, rateLimit5h/7d/7dSonnet, zaiUsage |
normal | 2 | + projectInfo, sessionId, sessionDuration, burnRate, todoProgress |
detailed | 6 | + depletionTime, configCounts, toolActivity, agentStatus, cacheHit, promptCache, performance, tokenBreakdown, forecast, budget, codexUsage, geminiUsage, antigravityUsage, linesChanged, outputStyle, version, peakHours, lastPrompt, vimMode, apiDuration, tagStatus |
Configuration file (~/.claude/claude-dashboard.local.json):
{
"language": "auto",
"plan": "max",
"displayMode": "custom",
"lines": [
["model", "context", "cost", "rateLimit5h"],
["projectInfo", "todoProgress"]
],
"theme": "default",
"separator": "pipe",
"dailyBudget": 15,
"disabledWidgets": [],
"modPane": "detailed",
"modBand": "MC$R|BDO",
"modBandDefault": false,
"cache": { "ttlSeconds": 60 }
}
Or use preset shorthand for quick configuration:
{
"preset": "MC$R|BDO",
"theme": "tokyoNight",
"separator": "dot"
}
Themes: default (pastel) / minimal (monochrome) / catppuccin / catppuccinLatte (light-mode) / dracula / gruvbox / nord / tokyoNight / solarized
Separators: pipe (│, default) / space / dot (·) / arrow (›)
Preset Shortcuts: Quick layout with single characters — "preset": "MC$R|BDO" (M=model, C=context, $=cost, R=rateLimit5h, etc.)
Budget Tracking: Set "dailyBudget": 15 to track daily spending. Shows ⚠️ at 80% and 🚨 at 95%.
Tag Status: Customize tagStatus patterns via "tagPatterns": ["v*", "release-*"]. Default is ["v*"]. The widget auto-hides when no pattern matches a reachable tag.
Widget Toggle: Add widget IDs to disabledWidgets to hide them from any display mode.
Mod Layouts: modPane (the /claude-dashboard-pane pane, default detailed) and modBand (the /claude-dashboard-band band, default = your status line layout) take a display mode name (compact / normal / detailed), a preset string like "MC$R|BDO", or custom widget lines like [["model","context"],["sessionIdFull"]] (same as the status line's lines). Set "modBandDefault": true to turn the band on at session start. See Mods.
Color Legend: 🟢 0-50% Safe / 🟡 51-80% Warning / 🔴 81-100% Critical
/claude-dashboard:setupConfigure the status line display mode, language, and plan. See Configuration.
/claude-dashboard:check-usageCheck usage limits for all AI CLIs (Claude, Codex, Gemini, Antigravity, z.ai) at once and get a recommendation for which CLI has the most available capacity.
/claude-dashboard:check-usage # Interactive output with colors
/claude-dashboard:check-usage --json # JSON output for scripting
/claude-dashboard:check-usage --lang ko # Specify language

/claude-dashboard:setup-aliasAdd a check-ai shell alias to quickly check all AI CLI usage from your terminal. Supports macOS/Linux (zsh/bash) and Windows (PowerShell).
/claude-dashboard:setup-alias
After setup:
check-ai # Pretty output
check-ai --json # JSON output for scripting
/claude-dashboard:updateRepair or verify the statusLine shim. Usually unnecessary — after /plugin update, the status line picks up the new version on its own. Use this if you have hooks disabled or the status line stops updating.
/claude-dashboard:update
claude-dashboard also ships as a Claude Code mod (function-hook plugin, early access, tested on Claude Code 2.1.289). It is bundled in the same plugin (hooks/hooks.json → "modules": ["../dist/mod.js"]), so there is nothing extra to install. Mods are drawn only in the terminal and the desktop Code tab (not the VS Code panel, not claude -p).
| Command | What it does | |
|---|---|---|
| `/claude-dashboard-pane [on\ | off]` | Open or close a dashboard pane beside the transcript (no argument flips it). Layout from modPane (default detailed). |
| `/claude-dashboard-band [on\ | off]` | Draw the dashboard above the prompt and hide the status line for this session only (settings.json is untouched). Layout from modBand (default = your status line layout). |
{
"modPane": "detailed",
"modBand": "MC$R|BDO",
"modBandDefault": true
}
modPane / modBand accept a display mode name (compact / normal / detailed), a preset string, or custom widget lines ([["model","context"],["sessionIdFull"]]). Unknown widget ids are dropped; if nothing is left, the default is used. modBandDefault: true turns the band on at session start./claude-dashboard:setup asks how you want to use the mod dashboard (status line only, peek with pane, replace with band, or both) and writes these keys; rerunning setup keeps keys it does not ask about.~/.cache/claude-dashboard/band-<sessionId>), refreshed on every render (at most every 60s) and ignored after 180s. It is deleted on off and at session end, so if the mod stops the status line comes back by itself within 3 minutes.disabledWidgets follow your normal config. cacheHit, tokenBreakdown, and performance are hidden in the pane and band (the mod API has no per-request cache usage).dist/index.js, run as a subprocess./plugin listsettings.json has a statusLine entry pointing at plugins/data/claude-dashboard-claude-dashboard/statusline.mjscache.ttlSeconds, default 300 seconds)Claude Code relocates its whole config directory — credentials, settings, and history — when CLAUDE_CONFIG_DIR is set. That is how two accounts run side by side:
# Personal Max account, kept separate from the default ~/.claude
CLAUDE_CONFIG_DIR=~/.claude-max claude
The status line follows the same variable, so each session reports the account that is actually rendering it. Leave it unset and the default ~/.claude is used, as before.
Notes:
CLAUDE_CONFIG_DIR separates settings.json and history.jsonl reads, but rate-limit API calls use whichever OAuth token the Keychain holds./claude-dashboard:setup and /claude-dashboard:update register the status line into the session's config dir ($CLAUDE_CONFIG_DIR if set, ~/.claude otherwise) — run them once per account. The dashboard's own display config ~/.claude/claude-dashboard.local.json is intentionally shared across accounts.Run setup with explicit language:
/claude-dashboard:setup normal ko # Korean
/claude-dashboard:setup normal en # English
API response cache is stored in ~/.cache/claude-dashboard/. To clear:
rm -rf ~/.cache/claude-dashboard/
Cache files are automatically cleaned up after 1 hour.
npm install && npm run build
See CONTRIBUTING.md for details.
If you find this plugin useful, support development:
<a href="https://ko-fi.com/uppinote" target="_blank"> <img src="https://storage.ko-fi.com/cdn/kofi5.png" alt="Buy Me a Coffee at ko-fi.com" style="height:60px; width:217px;"> </a> <a href="https://www.buymeacoffee.com/uppinote" target="_blank"> <img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" style="height:60px; width:217px;"> </a>
MIT
dist/mod.js 620 lines1// scripts/mod/register.tsx
2import { atom, read, update } from "claude-code";
3
4// locales/en.json
5var en_default = {
6 model: {
7 opus: "Opus",
8 sonnet: "Sonnet",
9 haiku: "Haiku"
10 },
11 labels: {
12 "5h": "5h",
13 "7d": "7d",
14 "7d_all": "7d",
15 "7d_sonnet": "7d-S",
16 "7d_fable": "7d-F",
17 codex: "Codex",
18 "1m": "1m"
19 },
20 time: {
21 days: "d",
22 hours: "h",
23 minutes: "m",
24 seconds: "s"
25 },
26 errors: {
27 no_context: "No context yet"
28 },
29 widgets: {
30 tools: "Tools",
31 done: "done",
32 running: "running",
33 agent: "Agent",
34 todos: "Tasks",
35 claudeMd: "CLAUDE.md",
36 agentsMd: "AGENTS.md",
37 addedDirs: "+Dirs",
38 rules: "Rules",
39 mcps: "MCP",
40 hooks: "Hooks",
41 burnRate: "Rate",
42 cache: "Cache",
43 tokenSpeedLast: "last",
44 cacheMiss: "miss",
45 toLimit: "to",
46 forecast: "Forecast",
47 budget: "Budget",
48 performance: "Perf",
49 tokenBreakdown: "Tokens",
50 todayCost: "Today",
51 apiDuration: "API",
52 peakHours: "Peak",
53 offPeak: "Off-Peak"
54 },
55 checkUsage: {
56 title: "CLI Usage Dashboard",
57 recommendation: "Recommendation",
58 lowestUsage: "Lowest usage",
59 used: "used",
60 notInstalled: "not installed",
61 errorFetching: "Error fetching data",
62 noData: "No usage data available"
63 },
64 mod: {
65 paneTitle: "Dashboard",
66 paneOpened: "Dashboard pane opened.",
67 paneClosed: "Dashboard pane closed.",
68 bandOn: "Dashboard band on \u2014 statusLine hidden for this session (terminal/desktop only).",
69 bandOff: "Dashboard band off \u2014 statusLine restored.",
70 paneUsage: "Usage: /claude-dashboard-pane [on|off]",
71 bandUsage: "Usage: /claude-dashboard-band [on|off]",
72 renderFailed: "Dashboard render failed"
73 }
74};
75
76// locales/ko.json
77var ko_default = {
78 model: {
79 opus: "Opus",
80 sonnet: "Sonnet",
81 haiku: "Haiku"
82 },
83 labels: {
84 "5h": "5\uC2DC\uAC04",
85 "7d": "7\uC77C",
86 "7d_all": "7\uC77C",
87 "7d_sonnet": "7\uC77C-S",
88 "7d_fable": "7\uC77C-F",
89 codex: "Codex",
90 "1m": "1\uAC1C\uC6D4"
91 },
92 time: {
93 days: "\uC77C",
94 hours: "\uC2DC\uAC04",
95 minutes: "\uBD84",
96 seconds: "\uCD08"
97 },
98 errors: {
99 no_context: "\uCEE8\uD14D\uC2A4\uD2B8 \uC5C6\uC74C"
100 },
101 widgets: {
102 tools: "\uB3C4\uAD6C",
103 done: "\uC644\uB8CC",
104 running: "\uC2E4\uD589\uC911",
105 agent: "\uC5D0\uC774\uC804\uD2B8",
106 todos: "\uD560\uC77C",
107 claudeMd: "CLAUDE.md",
108 agentsMd: "AGENTS.md",
109 addedDirs: "+\uB514\uB809\uD1A0\uB9AC",
110 rules: "\uADDC\uCE59",
111 mcps: "MCP",
112 hooks: "\uD6C5",
113 burnRate: "\uC18C\uBAA8\uC728",
114 cache: "\uCE90\uC2DC",
115 tokenSpeedLast: "\uCD5C\uADFC",
116 cacheMiss: "miss",
117 toLimit: "\uD6C4",
118 forecast: "\uC608\uCE21",
119 budget: "\uC608\uC0B0",
120 performance: "\uC131\uB2A5",
121 tokenBreakdown: "\uD1A0\uD070",
122 todayCost: "\uC624\uB298",
123 apiDuration: "API",
124 peakHours: "\uD53C\uD06C",
125 offPeak: "\uBE44\uD53C\uD06C"
126 },
127 checkUsage: {
128 title: "CLI \uC0AC\uC6A9\uB7C9 \uB300\uC2DC\uBCF4\uB4DC",
129 recommendation: "\uCD94\uCC9C",
130 lowestUsage: "\uAC00\uC7A5 \uC5EC\uC720",
131 used: "\uC0AC\uC6A9",
132 notInstalled: "\uC124\uCE58\uB418\uC9C0 \uC54A\uC74C",
133 errorFetching: "\uB370\uC774\uD130 \uAC00\uC838\uC624\uAE30 \uC624\uB958",
134 noData: "\uC0AC\uC6A9\uB7C9 \uB370\uC774\uD130 \uC5C6\uC74C"
135 },
136 mod: {
137 paneTitle: "\uB300\uC2DC\uBCF4\uB4DC",
138 paneOpened: "\uB300\uC2DC\uBCF4\uB4DC \uD328\uB110\uC744 \uC5F4\uC5C8\uC2B5\uB2C8\uB2E4.",
139 paneClosed: "\uB300\uC2DC\uBCF4\uB4DC \uD328\uB110\uC744 \uB2EB\uC558\uC2B5\uB2C8\uB2E4.",
140 bandOn: "\uB300\uC2DC\uBCF4\uB4DC \uBC34\uB4DC \uCF1C\uC9D0 \u2014 \uC774 \uC138\uC158\uC758 statusLine\uC744 \uC228\uAE41\uB2C8\uB2E4 (\uD130\uBBF8\uB110/\uB370\uC2A4\uD06C\uD1B1 \uC804\uC6A9).",
141 bandOff: "\uB300\uC2DC\uBCF4\uB4DC \uBC34\uB4DC \uAEBC\uC9D0 \u2014 statusLine\uC744 \uBCF5\uC6D0\uD588\uC2B5\uB2C8\uB2E4.",
142 paneUsage: "\uC0AC\uC6A9\uBC95: /claude-dashboard-pane [on|off]",
143 bandUsage: "\uC0AC\uC6A9\uBC95: /claude-dashboard-band [on|off]",
144 renderFailed: "\uB300\uC2DC\uBCF4\uB4DC \uB80C\uB354 \uC2E4\uD328"
145 }
146};
147
148// scripts/mod/ansi.ts
149var BASIC_HEX = [
150 "#000000",
151 "#cd0000",
152 "#00cd00",
153 "#cdcd00",
154 "#0000ee",
155 "#cd00cd",
156 "#00cdcd",
157 "#e5e5e5",
158 "#7f7f7f",
159 "#ff0000",
160 "#00ff00",
161 "#ffff00",
162 "#5c5cff",
163 "#ff00ff",
164 "#00ffff",
165 "#ffffff"
166];
167var CUBE = [0, 95, 135, 175, 215, 255];
168var hex = (r, g, b) => `#${[r, g, b].map((v) => Math.max(0, Math.min(255, v)).toString(16).padStart(2, "0")).join("")}`;
169function ansi256ToHex(n) {
170 if (n < 16)
171 return BASIC_HEX[n];
172 if (n < 232) {
173 const i = n - 16;
174 return hex(CUBE[Math.floor(i / 36)], CUBE[Math.floor(i / 6) % 6], CUBE[i % 6]);
175 }
176 const level = 8 + (n - 232) * 10;
177 return hex(level, level, level);
178}
179function applySgr(style, params) {
180 const codes = params === "" ? [0] : params.split(";").map(Number);
181 let next = { ...style };
182 for (let i = 0; i < codes.length; i++) {
183 const c = codes[i];
184 if (c === 0)
185 next = {};
186 else if (c === 1)
187 next.bold = true;
188 else if (c === 2)
189 next.dim = true;
190 else if (c === 22) {
191 delete next.bold;
192 delete next.dim;
193 } else if (c === 39)
194 delete next.color;
195 else if (c >= 30 && c <= 37)
196 next.color = BASIC_HEX[c - 30];
197 else if (c >= 90 && c <= 97)
198 next.color = BASIC_HEX[c - 90 + 8];
199 else if (c === 38 && codes[i + 1] === 5) {
200 next.color = ansi256ToHex(codes[i + 2]);
201 i += 2;
202 } else if (c === 38 && codes[i + 1] === 2) {
203 next.color = hex(codes[i + 2], codes[i + 3], codes[i + 4]);
204 i += 4;
205 }
206 }
207 return next;
208}
209var OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
210var CSI = /\x1b\[([0-9;]*)([A-Za-z])/g;
211var TRUNCATED_CSI = /\x1b\[[0-9;]*$/;
212var CONTROL = /[\x00-\x08\x0a-\x1f\x7f]/g;
213var toText = (raw) => raw.replace(/\t/g, " ").replace(CONTROL, "");
214function parseAnsi(line) {
215 const clean = line.replace(OSC, "").replace(TRUNCATED_CSI, "");
216 const out = [];
217 let style = {};
218 let last = 0;
219 const push = (raw) => {
220 const text = toText(raw);
221 if (text)
222 out.push({ text, ...style });
223 };
224 for (const m of clean.matchAll(CSI)) {
225 push(clean.slice(last, m.index));
226 if (m[2] === "m")
227 style = applySgr(style, m[1]);
228 last = m.index + m[0].length;
229 }
230 push(clean.slice(last));
231 return out;
232}
233
234// scripts/mod/toggle.ts
235function resolveToggle(args, current) {
236 const arg = args.trim().toLowerCase();
237 if (arg === "")
238 return !current;
239 if (arg === "on")
240 return true;
241 if (arg === "off")
242 return false;
243 return null;
244}
245
246// scripts/mod/stdin-builder.ts
247var MODEL_SCOPED = /opus|sonnet|fable/i;
248function windowForKind(kind) {
249 if (MODEL_SCOPED.test(kind))
250 return null;
251 if (/^(five_hour|5h|session)$/i.test(kind))
252 return "five_hour";
253 if (/^(seven_day|7d|weekly)$/i.test(kind))
254 return "seven_day";
255 return null;
256}
257function rateLimitsFrom(list) {
258 const out = {};
259 for (const rl of list) {
260 const window = windowForKind(rl.kind);
261 const resetsMs = rl.resetsAt ? Date.parse(rl.resetsAt) : NaN;
262 if (!window || Number.isNaN(resetsMs))
263 continue;
264 out[window] = { used_percentage: rl.percentUsed, resets_at: Math.floor(resetsMs / 1e3) };
265 }
266 return Object.keys(out).length > 0 ? out : void 0;
267}
268function buildStdin(s, now = Date.now()) {
269 const percent = s.context?.percent ?? null;
270 const stdin = {
271 session_id: s.sessionId,
272 model: { id: s.model, display_name: s.model },
273 workspace: { current_dir: s.cwd, ...s.root ? { project_dir: s.root } : {} },
274 context_window: {
275 total_input_tokens: s.context?.tokens ?? 0,
276 total_output_tokens: 0,
277 context_window_size: s.context?.window ?? 0,
278 used_percentage: percent,
279 remaining_percentage: percent === null ? null : 100 - percent,
280 current_usage: null
281 },
282 cost: { total_cost_usd: s.costUsd ?? 0 }
283 };
284 if (s.startedAt !== void 0 && now >= s.startedAt) {
285 stdin.cost.total_duration_ms = now - s.startedAt;
286 }
287 if (s.version)
288 stdin.version = s.version;
289 if (s.transcriptPath)
290 stdin.transcript_path = s.transcriptPath;
291 const rateLimits = rateLimitsFrom(s.rateLimits);
292 if (rateLimits)
293 stdin.rate_limits = rateLimits;
294 return stdin;
295}
296
297// scripts/mod/node-path.ts
298var NODE_LOOKUP_SCRIPT = `
299tried=
300for s in "$SHELL" bash zsh; do
301 [ -n "$s" ] && s=$(command -v "$s" 2>/dev/null) || continue
302 case " $tried " in *" $s "*) continue ;; esac
303 tried="$tried $s"
304 p=$("$s" -lic 'command -v node' </dev/null 2>/dev/null | tail -n 1)
305 case "$p" in /*) [ -x "$p" ] && { echo "$p"; exit 0; } ;; esac
306done
307found=
308for p in "$HOME"/.nvm/versions/node/*/bin/node; do [ -x "$p" ] && { echo "$p"; found=1; }; done
309[ -n "$found" ] && exit 0
310for p in "$HOME"/.local/share/fnm/aliases/default/bin/node "$HOME"/.volta/bin/node \\
311 "$HOME"/.asdf/shims/node /opt/homebrew/bin/node /usr/local/bin/node; do
312 [ -x "$p" ] && { echo "$p"; exit 0; }
313done
314exit 1
315`;
316function isMissingExecutable(err) {
317 return err instanceof Error && /\bENOENT\b/.test(err.message);
318}
319var NVM_VERSION = /\/v(\d+)\.(\d+)\.(\d+)\/bin\/node$/;
320function nvmVersion(nodePath) {
321 return NVM_VERSION.exec(nodePath)?.slice(1).map(Number) ?? [-1, -1, -1];
322}
323function isNewer(a, b) {
324 const i = a.findIndex((n, k) => n !== b[k]);
325 return i !== -1 && a[i] > b[i];
326}
327function parseNodePath(stdout) {
328 const paths = stdout.split("\n").map((l) => l.trim()).filter((l) => l.startsWith("/"));
329 if (paths.length === 0)
330 return null;
331 return paths.reduce((best, p) => isNewer(nvmVersion(p), nvmVersion(best)) ? p : best);
332}
333
334// scripts/mod/register.tsx
335var PANE = "claude-dashboard";
336var TICK_MS = 6e4;
337var RUN_TIMEOUT_MS = 5e3;
338var NODE_LOOKUP_TIMEOUT_MS = 1e4;
339var paneLines = atom({ plugin: "claude-dashboard", key: "paneLines" }, []);
340var bandLines = atom({ plugin: "claude-dashboard", key: "bandLines" }, []);
341var BAND_SURFACES = /* @__PURE__ */ new Set(["terminal", "desktop"]);
342var cachedStrings = null;
343function strings() {
344 if (cachedStrings)
345 return cachedStrings;
346 try {
347 cachedStrings = (Intl.DateTimeFormat().resolvedOptions().locale.startsWith("ko") ? ko_default : en_default).mod;
348 } catch {
349 cachedStrings = en_default.mod;
350 }
351 return cachedStrings;
352}
353var snapshot = { sessionId: "", model: "", cwd: "", rateLimits: [] };
354var isPaneOpen = false;
355var isBandOn = false;
356var bandRuns = /* @__PURE__ */ new Set();
357var ticker = null;
358var nodeCommand = "node";
359var nodeLookup = null;
360async function lookUpNode($) {
361 try {
362 const run = await $.process.run(["sh", "-c", NODE_LOOKUP_SCRIPT], { timeoutMs: NODE_LOOKUP_TIMEOUT_MS });
363 return run.exitCode === 0 ? parseNodePath(run.stdout) : null;
364 } catch (err) {
365 $.ui.log(`${strings().renderFailed}: node lookup: ${err.message}`, { to: "debug" });
366 return null;
367 }
368}
369async function runRenderer($, env) {
370 const argv = (node) => [node, `${$.plugin.root}/dist/index.js`];
371 const init = { stdin: JSON.stringify(buildStdin(snapshot)), env: { CLAUDE_DASHBOARD_MOD: "1", ...env }, timeoutMs: RUN_TIMEOUT_MS };
372 try {
373 return await $.process.run(argv(nodeCommand), init);
374 } catch (err) {
375 if (nodeCommand !== "node" || !isMissingExecutable(err))
376 throw err;
377 nodeLookup ??= lookUpNode($);
378 const found = await nodeLookup;
379 if (!found)
380 throw err;
381 nodeCommand = found;
382 return await $.process.run(argv(found), init);
383 }
384}
385async function renderLines($, env) {
386 try {
387 const run = await runRenderer($, env);
388 if (run.exitCode !== 0) {
389 $.ui.log(`${strings().renderFailed}: exit ${run.exitCode} ${run.stderr.slice(0, 200)}`);
390 return null;
391 }
392 return run.stdout.split("\n").filter((l) => l.length > 0).map(parseAnsi);
393 } catch (err) {
394 $.ui.log(`${strings().renderFailed}: ${err.message}`);
395 return null;
396 }
397}
398function keepOrWarn(lines) {
399 return (prev) => lines ?? (prev.length > 0 ? prev : [[{ text: `\u26A0\uFE0F ${strings().renderFailed}` }]]);
400}
401async function canDrawBand($) {
402 const surfaces = await $.session.surfaces();
403 return surfaces.some((s) => BAND_SURFACES.has(s));
404}
405async function refreshPane($) {
406 if (!isPaneOpen)
407 return;
408 const lines = await renderLines($, { CLAUDE_DASHBOARD_SURFACE: "pane" });
409 await update($, paneLines, keepOrWarn(lines));
410}
411async function runBand($) {
412 const lines = await renderLines($, { CLAUDE_DASHBOARD_BAND_SESSION: snapshot.sessionId });
413 await update($, bandLines, keepOrWarn(lines));
414}
415async function refreshBand($) {
416 if (!isBandOn || !await canDrawBand($))
417 return;
418 if (!isBandOn)
419 return;
420 const run = runBand($);
421 bandRuns.add(run);
422 try {
423 await run;
424 } finally {
425 bandRuns.delete(run);
426 }
427}
428async function stopBand($) {
429 isBandOn = false;
430 await Promise.allSettled([...bandRuns]);
431 await renderLines($, { CLAUDE_DASHBOARD_BAND_OFF: snapshot.sessionId });
432}
433async function refresh($) {
434 await Promise.all([refreshPane($), refreshBand($)]);
435}
436var refreshing = null;
437var refreshAgain = false;
438function requestRefresh($) {
439 if (refreshing) {
440 refreshAgain = true;
441 return refreshing;
442 }
443 refreshing = drainRefreshes($);
444 return refreshing;
445}
446async function drainRefreshes($) {
447 try {
448 do {
449 refreshAgain = false;
450 await refresh($);
451 } while (refreshAgain);
452 } catch (err) {
453 $.ui.log(`${strings().renderFailed}: ${err.message}`);
454 } finally {
455 refreshing = null;
456 }
457}
458async function bandDefault($) {
459 try {
460 const run = await runRenderer($, { CLAUDE_DASHBOARD_MOD_SETTINGS: "1" });
461 return run.exitCode === 0 && JSON.parse(run.stdout).bandDefault === true;
462 } catch (err) {
463 $.ui.log(`${strings().renderFailed}: ${err.message}`);
464 return false;
465 }
466}
467async function setBand($, on) {
468 if (on) {
469 isBandOn = true;
470 syncTicker($);
471 await requestRefresh($);
472 } else {
473 await stopBand($);
474 syncTicker($);
475 await update($, bandLines, () => []);
476 }
477}
478function syncTicker($) {
479 const needed = isPaneOpen || isBandOn;
480 if (needed && !ticker)
481 ticker = $.clock.every(TICK_MS, () => void requestRefresh($));
482 if (!needed && ticker) {
483 ticker.cancel();
484 ticker = null;
485 }
486}
487function applyUsage(u) {
488 if (u.startedAt !== void 0)
489 snapshot.startedAt = u.startedAt;
490 snapshot.context = { tokens: u.context.tokens, window: u.context.window, percent: u.context.percent };
491 snapshot.rateLimits = [...u.rateLimits];
492 snapshot.costUsd = u.cost?.usd;
493}
494async function setPane($, open) {
495 if (open === isPaneOpen)
496 return;
497 isPaneOpen = open;
498 if (open) {
499 await $.ui.open({ id: PANE, title: strings().paneTitle });
500 } else {
501 await $.ui.close({ id: PANE });
502 }
503 syncTicker($);
504 if (open)
505 await requestRefresh($);
506}
507async function switchSession($, sessionId) {
508 const wasBandOn = isBandOn;
509 if (wasBandOn)
510 await stopBand($);
511 snapshot.sessionId = sessionId;
512 const [usage, model] = await Promise.allSettled([$.session.usage(), $.session.model()]);
513 if (usage.status === "fulfilled")
514 applyUsage(usage.value);
515 else
516 $.ui.log(`${strings().renderFailed}: ${usage.reason.message}`);
517 if (model.status === "fulfilled")
518 snapshot.model = model.value;
519 else
520 $.ui.log(`${strings().renderFailed}: ${model.reason.message}`);
521 if (wasBandOn || await bandDefault($)) {
522 await setBand($, true);
523 } else {
524 syncTicker($);
525 await requestRefresh($);
526 }
527}
528function drawLines(Box, Text, lines) {
529 return /* @__PURE__ */ h(Box, { flexDirection: "column" }, lines.map((line) => /* @__PURE__ */ h(Text, null, line.map((seg) => /* @__PURE__ */ h(Text, { color: seg.color, bold: seg.bold, dimColor: seg.dim }, seg.text)))));
530}
531function register(on) {
532 on("session.start", async ($, e, next) => {
533 await $.command.register({
534 name: "claude-dashboard-pane",
535 description: "Show the dashboard in a pane beside the transcript (on|off, none toggles)",
536 argumentHint: "[on|off]"
537 });
538 const [id, model, cwd, root, version, usage] = await Promise.all([
539 $.session.id(),
540 $.session.model(),
541 $.session.cwd(),
542 $.session.root(),
543 $.session.version(),
544 $.session.usage()
545 ]);
546 Object.assign(snapshot, { sessionId: id, model, cwd, root, version: version.version });
547 applyUsage(usage);
548 await $.command.register({
549 name: "claude-dashboard-band",
550 description: "Show the dashboard above the prompt instead of the statusLine (on|off, none toggles)",
551 argumentHint: "[on|off]"
552 });
553 if (await bandDefault($))
554 await setBand($, true);
555 return next(e);
556 });
557 on("classic.SessionStart", async ($, e, next) => {
558 if (e.transcript_path)
559 snapshot.transcriptPath = e.transcript_path;
560 if (e.cwd)
561 snapshot.cwd = e.cwd;
562 if (e.session_id && snapshot.sessionId && e.session_id !== snapshot.sessionId) {
563 await switchSession($, e.session_id);
564 }
565 return next(e);
566 });
567 on("session.measure", async ($, e, next) => {
568 applyUsage(e);
569 try {
570 snapshot.model = await $.session.model();
571 } catch (err) {
572 $.ui.log(`${strings().renderFailed}: ${err.message}`);
573 }
574 void requestRefresh($);
575 return next(e);
576 });
577 on("command.run", { command: "claude-dashboard-pane" }, async ($, e) => {
578 const open = resolveToggle(e.args, isPaneOpen);
579 if (open === null)
580 return { text: strings().paneUsage };
581 await setPane($, open);
582 return { text: open ? strings().paneOpened : strings().paneClosed };
583 });
584 on("ui.close", { id: "claude-dashboard" }, ($, e, next) => {
585 isPaneOpen = false;
586 syncTicker($);
587 return next(e);
588 });
589 on("command.run", { command: "claude-dashboard-band" }, async ($, e) => {
590 const on2 = resolveToggle(e.args, isBandOn);
591 if (on2 === null)
592 return { text: strings().bandUsage };
593 await setBand($, on2);
594 return { text: on2 ? strings().bandOn : strings().bandOff };
595 });
596 on("ui.render", { component: "Pane", requestId: "claude-dashboard" }, async ($, e) => {
597 const { Box, Text } = $.ui.resolve(e);
598 return drawLines(Box, Text, await read($, paneLines));
599 });
600 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
601 if (!isBandOn || !BAND_SURFACES.has(e.surface) || e.props.hasSurvey)
602 return next(e);
603 const lines = await read($, bandLines);
604 if (lines.length === 0)
605 return next(e);
606 const { Box, Text } = $.ui.resolve(e);
607 return drawLines(Box, Text, lines);
608 });
609 on("session.end", async ($, e, next) => {
610 if (isBandOn)
611 await stopBand($);
612 isPaneOpen = false;
613 syncTicker($);
614 return next(e);
615 });
616}
617export {
618 register
619};
620types/mod-state.d.ts 12 lines1// Self-contained by the engine's rule (no imports); mirrors Segment in scripts/mod/ansi.ts.
2export type DashboardSegment = { text: string; color?: string; bold?: boolean; dim?: boolean };
3
4declare module 'claude-code' {
5 interface PluginState {
6 'claude-dashboard': {
7 paneLines: DashboardSegment[][];
8 bandLines: DashboardSegment[][];
9 };
10 }
11}
12