SLOPSHOPPER

claude-dashboard

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

newpanebandcommandprocesstimer
★ 579v1.35.1MITupdated 2026-10-06uppinote20/claude-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-dashboard
│ ┃ Dashboard ✕ › fix the failing auth test and add an audit log call │ ┃ dev │ ● claude-dashboard: Dashboard render failed: JSON Parse error: Unexpe │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /claude-dashboard-pane │ ⎿ claude-dashboard: Dashboard pane opened. │ │ dev ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
dev
Pane · Dashboard
dev
README

claude-dashboard

License Stars

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.

Demo

Demo

Installation

Requirements: Claude Code v1.0.80+ / Node.js 18+

From Plugin Marketplace

/plugin marketplace add uppinote20/claude-dashboard
/plugin install claude-dashboard
/claude-dashboard:setup

Manual Installation

git clone https://github.com/uppinote20/claude-dashboard.git "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/claude-dashboard"
/claude-dashboard:setup

/claude-dashboard:setup looks for the plugin under plugins/cache/claude-dashboard/claude-dashboard/, which a manual clone into plugins/claude-dashboard/ does not match — the setup command's shim lookup will find nothing to copy. Point statusLine.command in settings.json directly at your clone's dist/index.js instead (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 pull and update the path yourself after each release.

Display Modes

Compact (1 line) - Default:

Compact

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

Normal (2 lines):

Normal

Adds project info, session ID, session duration, burn rate, todo progress

Detailed (6 lines):

Detailed

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.

Widgets

CategoryWidgetDescription
CoremodelModel 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
contextProgress bar, percentage, tokens (🟢 0-50% / 🟡 51-80% / 🔴 81-100%)
contextBarProgress bar only (sub-widget of context)
contextPercentagePercentage only (sub-widget of context)
contextUsageToken count only, e.g. 42K/200K (sub-widget of context)
costSession cost in USD
projectInfoDirectory + git branch (clickable OSC8 link) + ahead/behind (↑↓), subpath from project_dir, worktree indicator (🌳)
Rate LimitsrateLimit5h5-hour rate limit with reset countdown
rateLimit7d7-day rate limit (Pro and Max)
rateLimit7dSonnet7-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
rateLimit7dFable7-day Fable limit (Max only)
SessionsessionIdSession ID (short 8-char)
sessionIdFullSession ID (full UUID)
sessionNameSession name from /rename command
sessionDurationSession duration
lastPromptLast user prompt with timestamp
configCountsCLAUDE.md, AGENTS.md, rules, MCPs, hooks, +Dirs
ActivitytoolActivityRunning/completed tools with targets (e.g., Read(app.ts))
agentStatusSubagent progress with resolved model, e.g. Explore(Opus) (from the Agent tool's model param or CLAUDE_CODE_SUBAGENT_MODEL)
todoProgressTodo completion rate
AnalyticsburnRateToken consumption per minute, session average (input + cache write + output)
cacheHitCache hit rate percentage (last request)
promptCacheSession 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
promptCacheStateWarm/cold icon + time left only, e.g. ♨️ 4m (sub-widget of promptCache)
promptCacheHitSession cache hit ratio only, e.g. 91% (sub-widget of promptCache)
promptCacheMissesMiss count only, e.g. miss 2; hidden at 0 (sub-widget of promptCache)
depletionTimeEstimated time to rate limit (approx)¹
Multi-CLIcodexUsageOpenAI Codex CLI usage (auto-hide if not installed)²
geminiUsageGoogle Gemini CLI - current model (auto-hide if not installed)³
geminiUsageAllGoogle Gemini CLI - all models (auto-hide if not installed)³
antigravityUsageGoogle Antigravity CLI - weekly quota by model family (auto-hide if not installed)⁸
antigravityUsageAllGoogle Antigravity CLI - per-model quota (auto-hide if not installed)⁸
zaiUsagez.ai/ZHIPU usage (auto-hide if not using z.ai)⁴
InsightstokenBreakdownInput/output/cache write/read token breakdown
performanceComposite efficiency badge (cache hit + output ratio)
forecastEstimated hourly cost based on session rate
budgetDaily spending vs configured budget limit⁵
tokenSpeedOutput token generation speed (tok/s), session average
tokenSpeedLastOutput token generation speed of the most recent response only
todayCostTotal spending across all sessions today
StatuspeakHoursPeak hours indicator with countdown (based on PeakClaude)⁶
tagStatusCommits ahead of matched git tags (default pattern v*, customize via tagPatterns)⁷
slashCommandActive slash command for the current turn (🎯), cleared by next plain-text message
agentModeSession agent identity: 👤 custom agent (via /agent <name>) or 🤖 subagent type
InfolinesChangedUncommitted lines added/removed, including untracked files (+N -N)
outputStyleCurrent output style (hidden when "default")
versionClaude Code version display
vimModeVim mode (NORMAL/INSERT), auto-hides when vim disabled
apiDurationAPI time as % of total session time

¹ Assumes all utilization came from this session; improves as session runs longer. ² Auto-hides if ~/.codex/auth.json not found. ³ Auto-hides if ~/.gemini/oauth_creds.json not found — Gemini CLI personal tiers were retired 2026-06-18 in favor of Antigravity CLI (enterprise still supported). ⁴ Auto-hides if not detected via ANTHROPIC_BASE_URL. ⁵ Requires "dailyBudget" in config. ⁶ Peak = weekdays 5-11 AM PT; shows countdown to next transition. ⁷ Uses tagPatterns config (default ["v*"]); auto-hides when no pattern matches a reachable tag. ⁸ Auto-hides if ~/.gemini/antigravity-cli/antigravity-oauth-token not found.

i18n: English and Korean supported (auto-detect or set via setup).

Configuration

# 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:

FeatureMaxPro
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):

ModeLinesWidgets
compact1model, context, cost, rateLimit5h/7d/7dSonnet, zaiUsage
normal2+ projectInfo, sessionId, sessionDuration, burnRate, todoProgress
detailed6+ 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

Commands

/claude-dashboard:setup

Configure the status line display mode, language, and plan. See Configuration.

/claude-dashboard:check-usage

Check 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

check-usage

/claude-dashboard:setup-alias

Add 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:update

Repair 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

Mods (pane & band)

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).

CommandWhat 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.
  • Interactive /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.
  • How hiding works: the band writes a per-session heartbeat marker (~/.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.
  • Theme, language, and 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).
  • Data comes from the mod API (context, rate limits, and cost pushed by Claude Code) and the unchanged renderer dist/index.js, run as a subprocess.

Troubleshooting

  1. Check if plugin is installed: /plugin list
  2. Verify settings.json has a statusLine entry pointing at plugins/data/claude-dashboard-claude-dashboard/statusline.mjs
  3. If it's still not showing, try restarting Claude Code
  • API token may be expired - re-login to Claude Code
  • Network issue - check internet connection
  • API rate limited - wait for the cache to refresh (cache.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:

  • The variable must be a single directory path, and must be exported to the session.
  • On macOS the OAuth token comes from the Keychain, which holds one entry for all accounts — 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.

Development

npm install && npm run build

See CONTRIBUTING.md for details.

Star History

Star History Chart

☕ Support

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>

License

MIT

Source 2 files
dist/mod.js 620 lines
1// 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};
620
types/mod-state.d.ts 12 lines
1// 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