claude-usage-explorer 1.1.5 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,30 @@
1
1
  {
2
2
  "name": "claude-usage-explorer-marketplace",
3
- "owner": {"name": "Claude Usage Explorer contributors"},
4
- "plugins": [{"name": "claude-usage", "source": "./", "description": "Local Claude Code usage explorer"}]
3
+ "description": "Local, read-only Claude Code token usage analytics and terminal tools",
4
+ "owner": {
5
+ "name": "IliasPol",
6
+ "url": "https://github.com/IliasPol"
7
+ },
8
+ "plugins": [
9
+ {
10
+ "name": "claude-usage",
11
+ "source": "./",
12
+ "description": "Explore Claude Code projects, sessions, token usage, model breakdowns and estimated costs locally",
13
+ "version": "1.1.6",
14
+ "author": {
15
+ "name": "IliasPol"
16
+ },
17
+ "homepage": "https://github.com/IliasPol/claude-usage",
18
+ "repository": "https://github.com/IliasPol/claude-usage",
19
+ "license": "MIT",
20
+ "category": "development",
21
+ "tags": [
22
+ "claude-code",
23
+ "usage",
24
+ "analytics",
25
+ "tokens",
26
+ "terminal"
27
+ ]
28
+ }
29
+ ]
5
30
  }
@@ -1,5 +1,11 @@
1
1
  {
2
2
  "name": "claude-usage",
3
- "description": "Interactive native Claude Code usage sidebar and standalone TUI",
4
- "version": "1.0.11"
3
+ "description": "Local, read-only Claude Code usage analytics with a native sidebar and standalone terminal UI",
4
+ "version": "1.1.6",
5
+ "author": {
6
+ "name": "IliasPol"
7
+ },
8
+ "homepage": "https://github.com/IliasPol/claude-usage",
9
+ "repository": "https://github.com/IliasPol/claude-usage",
10
+ "license": "MIT"
5
11
  }
package/README.md CHANGED
@@ -1,125 +1,127 @@
1
- # Claude Usage Explorer v1.0.4
1
+ # Claude Usage Explorer
2
2
 
3
- Local, read-only analytics for Claude Code sessions. No external npm dependencies and no tmux requirement.
3
+ **Explore Claude Code token usage, estimated API-equivalent costs, projects, sessions, and agents — locally.** Use a native Claude Code sidebar or a standalone terminal dashboard. No uploads, telemetry, or external npm dependencies.
4
4
 
5
- ## Requirements
5
+ [![npm version](https://img.shields.io/npm/v/claude-usage-explorer.svg)](https://www.npmjs.com/package/claude-usage-explorer)
6
+ [![MIT License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
6
7
 
7
- - Node.js 20 or newer (22 LTS recommended).
8
- - Claude Code **2.1.287+** for native Mods (newer versions recommended).
9
- - Access to the same machine/filesystem where Claude stores its JSONL transcripts.
8
+ ## What's new in v1.1.6
10
9
 
11
- ## Install and run
10
+ - **Session actions:** open or copy session identifiers from supported views.
11
+ - **Account usage diagnostics:** optional official usage-limit and extra-spend information when accessible; not the same as estimated API-equivalent cost.
12
+ - **Cleaner agent navigation:** the main agent appears as **Agent (main)**; subagents use a recorded name when available, or **Subagent** when metadata is missing. Opaque agent IDs are shown only after opening an agent.
13
+ - **Native sidebar and standalone terminal explorer:** navigate projects, sessions, agents, and models with token and API-equivalent cost breakdowns.
14
+ - **Live refresh and flexible views:** filter and sort usage, switch reporting periods, and see per-model summaries.
15
+ - **Privacy-first:** transcript processing stays on your computer. Account plan-limit information, when available, is separate from local estimated API-equivalent cost.
12
16
 
13
- 1. Extract the ZIP into a permanent folder, e.g. `~/tools/claude-usage-explorer`.
14
- 2. Run `./claude-usage tui` in that folder for the standalone full-screen explorer. On macOS, optionally run `./scripts/install-command.sh` once, then use `claude-usage tui` from any folder (ensure `~/.local/bin` is on your PATH).
15
- 3. Start Claude with `claude --plugin-dir "$HOME/tools/claude-usage-explorer"`.
16
- 4. In Claude Code type **`/usage-explorer`** to open the **native interactive sidebar**. In CLI, run `/tui fullscreen` first and use a wide terminal (100+ columns). Claude Desktop Code may dock the pane automatically if mods are supported locally.
17
- 5. Optional: **`/claude-usage:open`** launches the standalone TUI in a separate terminal. This skill requires one model invocation; the native mod `/usage-explorer` is registered directly and does not need one.
17
+ **Important:** Subagent names depend on metadata in the local Claude transcript; names are not inferred when absent. Estimated API costs are not the amount billed for a Claude subscription.
18
18
 
19
- **asdf users:** switch to a compatible Node before starting Claude, e.g. `asdf shell nodejs 22.22.2`, or `export ASDF_NODEJS_VERSION=22.22.2` if your asdf lacks `shell`. The native mod starts `node` from Claude's environment.
19
+ ## Preview
20
20
 
21
- ## Native sidebar
21
+ ### Native Claude Code sidebar
22
22
 
23
- - Click projects → sessions → agents → models, and Back to return.
24
- - Switch periods (including current/previous billing cycle), filter entries, sort by tokens/cost, refresh.
25
- - Automatic refresh every five seconds while the pane is open.
26
- - Shows model usage and API-equivalent estimated cost (not subscription billing).
27
- - Billing renewal date/time/timezone and custom date ranges are configured in the standalone TUI (`B`); the sidebar reads those saved settings.
28
- - Unknown model prices are displayed as N/A rather than $0.
23
+ ![Claude Usage Explorer inside the Claude Code sidebar](docs/screenshots/native-sidebar.png)
29
24
 
30
- ## Standalone TUI
25
+ Use `/usage-explorer` to inspect local usage without leaving your Claude Code session.
31
26
 
32
- `./claude-usage tui`
27
+ ### Standalone terminal dashboard
33
28
 
34
- Supports all original navigation, custom dates, settings, live refresh and model breakdowns. `./claude-usage open` opens another terminal window when supported. `split` and its Claude skill were removed.
29
+ ![Claude Usage Explorer standalone terminal dashboard](docs/screenshots/standalone-tui.png)
35
30
 
36
- ## Notes and limitations
31
+ Run `claude-usage tui` to explore projects, sessions, token usage, model breakdowns, and estimated costs.
37
32
 
38
- The mod is implemented against Anthropic's current Mod API (session.start, command.run, ui.render, ui.open, ui.invalidate, process.run, clock.every). **It has not been verified in a live Claude Code or Claude Desktop installation**, so treat this as an integration candidate and report any host API errors. The standalone TUI is independently tested. The native sidebar uses clickable rows; arrow-key selection parity with the standalone TUI is not yet complete. Native mod availability depends on the Claude Code version and host.
33
+ ## Quick start
39
34
 
40
- The plugin reads local Claude transcript files and does not send them to an external service. Costs are estimates based on configured per-model pricing, not actual Claude subscription charges. Pricing defaults may need verification or overrides.
35
+ **Standalone CLI (Node.js 20+):**
41
36
 
42
- ## Validation
37
+ ```sh
38
+ npm install -g claude-usage-explorer
39
+ claude-usage tui
40
+ ```
43
41
 
44
- Run `npm test` and `npm run check`. For native mod validation on a machine with Claude Code installed: `claude plugin validate .`.
42
+ **Claude Code community plugin marketplace:**
45
43
 
46
- ## v1.0.4 sidebar fix
44
+ ```sh
45
+ claude plugin marketplace add IliasPol/claude-usage
46
+ claude plugin install claude-usage@claude-usage-explorer-marketplace
47
+ ```
48
+
49
+ Then run `/usage-explorer` in a compatible Claude Code session.
50
+
51
+ ## Requirements
47
52
 
48
- Adds the required `onSubmit` handler as well as a stable `key` for the filter `<Input>` component in Claude Code 2.1.311. If the sidebar remains blank after upgrading, restart Claude Code with the updated plugin folder. The native mod still invokes `node` from Claude’s own environment, so start Claude under Node 20+ (e.g. Node 22).
53
+ - Node.js 20+ (Node 22 recommended)
54
+ - Claude Code with native Mods support for the sidebar
55
+ - Local Claude Code JSONL transcript history
49
56
 
50
- ## Fixing old asdf commands and terminal flashes (v1.0.4)
57
+ ## Standalone CLI: other ways to run
51
58
 
52
- The installer now creates a small executable wrapper instead of a symlink. This avoids resolving `bin/claude-usage.mjs` relative to `~/.local/bin`.
59
+ Run without a global installation:
53
60
 
54
61
  ```sh
55
- ./scripts/install-command.sh
56
- export PATH="$HOME/.local/bin:$PATH"
57
- hash -r
58
- type -a claude-usage
59
- claude-usage tui
62
+ npx --yes --package claude-usage-explorer claude-usage tui
60
63
  ```
61
64
 
62
- If an old asdf shim still appears first, ensure `~/.local/bin` is prepended *after* asdf initialization in your shell startup file. The standalone TUI now clips terminal output, reserves the last terminal row, and ignores duplicate resize events to reduce blank-screen flashes.
65
+ **Note:** On Windows PowerShell, use `npm.cmd` and `claude-usage.cmd` if execution policy blocks the `.ps1` shims.
66
+
67
+ To run directly from a local checkout, use:
68
+
69
+ ```sh
70
+ node ./bin/claude-usage.mjs tui
71
+ ```
63
72
 
64
- ## Sidebar controls (v1.0.4)
73
+ Run the TUI in an **interactive terminal**, not inside a noninteractive Claude tool shell. The optional macOS/Linux installer `./scripts/install-command.sh` is still available for source checkouts but is not needed for npm installations.
65
74
 
66
- The sidebar keeps its period picker collapsed by default, with the usage summary and drill-down list at the top and actions at the bottom. Click **◷ Period** (or use its host-supported `p` hotkey while the panel is focused) to choose a billing period. Other actions: Back (`h`), Refresh (`r`), Models (`m`), and Sort (`s`). Click a project or session to drill down. These shortcuts depend on Claude Code focus and hotkey handling.
75
+ ## Claude Code plugin installation
67
76
 
68
- ### Native sidebar keyboard and exit (v1.0.6)
77
+ Alternatively, from inside Claude Code use `/plugin marketplace add IliasPol/claude-usage`, followed by `/plugin install claude-usage@claude-usage-explorer-marketplace`.
69
78
 
70
- - **Esc** closes the pane and returns focus to Claude (native `closeOnEscape`).
71
- - **X** or **× Exit** closes the pane; **H** or **← Back** navigates up one level.
72
- - **Backspace cannot be registered as a custom pane shortcut** in the current Claude mod interface.
73
- - **Ctrl+X, then X** is the host-level close shortcut.
74
- - The action bar is placed after a flex-growing results area, to anchor it near the bottom where supported by the host layout. Terminal dimensions and host rendering may affect placement.
75
- - Claude’s built-in **/usage** shows account plan usage limits; Usage Explorer shows local history and API-equivalent cost estimates.
79
+ This repository hosts a **community marketplace**, not an Anthropic-curated listing. After installation, use `/usage-explorer` in compatible Claude Code environments. Native mod/sidebar support may depend on the installed Claude Code version; if it is unavailable, the standalone `claude-usage tui` remains an option.
76
80
 
77
- ### Install both native sidebar and standalone CLI (v1.0.7)
81
+ ## Native sidebar: local development
78
82
 
79
- Extract the release archive into `~/claude-usage-explorer-1.0.7`, then:
83
+ Start Claude Code with the local plugin directory:
80
84
 
81
85
  ```sh
82
- cd ~/claude-usage-explorer-1.0.7
83
- ./scripts/install-command.sh
84
- export PATH="$HOME/.local/bin:$PATH"
85
- hash -r
86
- type -a claude-usage
87
- claude-usage tui
86
+ claude --plugin-dir /absolute/path/to/claude-usage-explorer
88
87
  ```
89
88
 
90
- Place `export PATH="$HOME/.local/bin:$PATH"` **after asdf initialization** in your shell startup file to keep the CLI command ahead of old asdf shims. Run `claude --plugin-dir "$HOME/claude-usage-explorer-1.0.7"`, enter `/tui fullscreen` if needed, then `/usage-explorer` to open the native sidebar.
89
+ Then use `/usage-explorer`. In Claude Code CLI, `/tui fullscreen` may be required for the sidebar to appear. The native command is **`/usage-explorer`**, not `/claude-usage:open`.
91
90
 
92
- ### v1.0.8 display updates
91
+ The optional `/claude-usage:tui` skill attempts to open a **separate** terminal. This requires one model invocation and depends on your terminal environment. If no terminal appears, launch `node ./bin/claude-usage.mjs tui` directly in PowerShell or Terminal.
93
92
 
94
- The selected period persists across standalone TUI and native sidebar launches in `~/.claude-usage-explorer/settings.json` (`lastPeriod`). Each usage row shows a token percentage of its parent scope; the standalone TUI reserves four terminal columns on the right. Enterprise plan limits remain unavailable when no supported source provides them; no estimated spend is misrepresented as an official limit.
93
+ ## Features
95
94
 
96
- ### Experimental official usage diagnostic (v1.0.9)
95
+ - Drill down into projects, sessions, agents and models
96
+ - Periods, sorting, filtering and auto-refresh
97
+ - Token totals and estimated API-equivalent cost by model
98
+ - Optional cached 5-hour and 7-day Claude Desktop plan-limit percentages (not live usage)
99
+ - Read-only, local processing; no uploads or external analytics
97
100
 
98
- Inside `/usage-explorer`, select **Check official limits (opt-in)**. It first reads `$.session.usage()` and then, if Claude supplies an opaque first-party authorization handle, calls Anthropic's undocumented OAuth usage endpoint through `$.http.fetch`. The mod never reads or stores the token and only renders allowlisted numeric usage fields and reset timestamps; no raw response is logged or persisted. Enterprise monthly spending limits may not be included. This is an experimental diagnostic, not a stable public API integration. No automatic polling.
101
+ **Important:** Estimated API-equivalent cost is not what your Claude subscription was charged. Cached plan percentages can be stale. Claude Desktop Chat history is not included in Claude Code's local transcript analytics.
99
102
 
100
- ### Official account usage (v1.0.10)
103
+ ## Windows
101
104
 
102
- Native sidebar automatically loads official utilization through Claude-hosted authorization when available, refreshes no more often than every 3 minutes, and supports manual refresh. It shows a percentage bar for recognized limits (including Enterprise `extra_usage`) but does not infer a dollar amount or billing cap. The endpoint is undocumented and may change; credentials and raw responses are never stored. Standalone TUI remains local-only.
105
+ In PowerShell, from the extracted project directory (or use the npm installation above):
103
106
 
104
- ### Usage response schema diagnostic (v1.0.11)
107
+ ```powershell
108
+ node .\bin\claude-usage.mjs tui
109
+ claude --plugin-dir "$PWD"
110
+ ```
105
111
 
106
- The native sidebar displays official account spending and available limit bars without the developer-only response field diagnostic. No automatic billing-period override is applied.
112
+ ## Development
107
113
 
108
- ## v1.1.0 changes
114
+ ```sh
115
+ npm test
116
+ npm run check
117
+ ```
109
118
 
110
- - Native sidebar now reads official `spend.used` and `spend.limit` in minor currency units, with `extra_usage` as a fallback, and displays actual used/limit/remaining amounts with a higher-contrast utilization bar. This is separate from estimated local API cost.
111
- - Native sidebar has a `? Help` control, and the summary follows the selected project/session scope.
112
- - Standalone TUI summary follows the selected scope; tiny nonzero shares display `<0.1%`.
113
- - In the standalone macOS TUI, select a session and press `e` to open it in Terminal.app; press uppercase `C` to copy its ID. These actions do not call a model. Terminal.app is used even if the current terminal is PortX.
114
- - Native in-place session resume is **not implemented** because a supported session-switching Mods API has not been verified. Official billing-cycle reset timestamps also remain unverified; locally configured dates are not represented as official.
115
- - `stats-cache.json` versus transcript token reconciliation remains open. Local transcript counts should not be treated as official account totals.
119
+ The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
116
120
 
117
- ## v1.1.1 hotfix
118
- - Fix native sidebar render failure: Help uses `z` instead of unsupported `?` button hotkey. Standalone TUI still uses `?` for Help.
119
- - Add a regression test for Mods UI hotkey restrictions.
121
+ ## Contributing, security, and license
120
122
 
121
- ### Native session actions (v1.1.2)
123
+ Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report vulnerabilities following [SECURITY.md](SECURITY.md). This project is licensed under the [MIT License](LICENSE).
122
124
 
123
- Drill into a project and select a session. Use **Open session in Terminal** to resume in a new macOS Terminal window from the original project directory, or **Copy session ID** to copy the identifier. In-place switching of the active Claude Code session is not supported.
125
+ ## Support
124
126
 
125
- Agent rows display a readable agent type when available in local transcripts, otherwise “Subagent”; raw subagent IDs appear only after opening an agent. Session shortcuts O/C apply inside a session, not to highlighted Sessions-list entries.
127
+ If Claude Usage Explorer is useful, consider [starring the repository](https://github.com/IliasPol/claude-usage). The tool is free and open source.
@@ -0,0 +1,12 @@
1
+ ---
2
+ description: Launch the interactive Claude Usage Explorer in a separate terminal window, without model calls for navigation
3
+ allowed-tools: Bash
4
+ ---
5
+
6
+ Launch the local TUI sidecar once, without asking the user any questions. Use the following command via Bash, passing the current project directory as CLAUDE_PROJECT_DIR when available:
7
+
8
+ ```sh
9
+ node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" open
10
+ ```
11
+
12
+ Do not run the `tui` command directly in the Bash tool (it needs an interactive terminal). The `open` command starts a separate terminal process and exits immediately. Report only whether the launch succeeded. This slash command requires one Claude invocation; subsequent navigation and refreshes use zero model calls. Do not pretend it embeds inside the Claude Code conversation. If a terminal emulator is unsupported, advise running `node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" tui` in a separate terminal.
@@ -1,5 +1,13 @@
1
1
  // Claude Code native mod. The UI uses host components; local analytics run in a child Node process.
2
2
  // No model requests are made when opening, navigating or refreshing this pane.
3
+ // Native hooks run in a restricted loader; use web-standard URL APIs instead of node: imports.
4
+ export function scriptPathFromModuleUrl(moduleUrl){
5
+ const url=new URL('../bin/claude-usage.mjs',moduleUrl);
6
+ let p=decodeURIComponent(url.pathname);
7
+ if(/^\/[A-Za-z]:\//.test(p))p=p.slice(1);
8
+ if(url.host&&url.host!=='localhost')p=`//${url.host}${p}`;
9
+ return p;
10
+ }
3
11
  const PANE='claude-usage-explorer';
4
12
  const PERIODS=[['Billing cycle','cycle'],['Previous cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['7 days','7d'],['30 days','30d'],['This month','month'],['Previous month','previous-month']];
5
13
  let state={period:'all',view:'projects',stack:[],project:null,session:null,agent:null,report:null,periodLabel:'All time',loading:false,error:'',updatedAt:0,sort:'tokens',query:'',periodMenu:false};
@@ -52,7 +60,7 @@ export function back(s){
52
60
  return {...s,...last,stack:s.stack.slice(0,-1),query:''};
53
61
  }
54
62
  async function persistPeriod($,period){
55
- try {const script=decodeURIComponent(new URL('../bin/claude-usage.mjs',import.meta.url).pathname);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
63
+ try {const script=scriptPathFromModuleUrl(import.meta.url);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
56
64
  }
57
65
  async function refresh($){
58
66
  if(inFlight)return inFlight;
@@ -60,7 +68,7 @@ async function refresh($){
60
68
  inFlight=(async()=>{
61
69
  try {
62
70
  // Module location resolves to the plugin's installed directory, independent of cwd.
63
- const script=decodeURIComponent(new URL('../bin/claude-usage.mjs',import.meta.url).pathname);
71
+ const script=scriptPathFromModuleUrl(import.meta.url);
64
72
  const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
65
73
  if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
66
74
  const snap=JSON.parse(result.stdout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-usage-explorer",
3
- "version": "1.1.5",
3
+ "version": "1.1.6",
4
4
  "description": "Local, read-only Claude Code usage analytics CLI and plugin",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,40 @@
9
9
  "scripts": {
10
10
  "test": "node --test",
11
11
  "start": "node ./bin/claude-usage.mjs",
12
- "check": "node --check ./src/core.mjs && node --check ./src/statusline.mjs && node --check ./bin/claude-usage.mjs && node --check ./src/tui.mjs && node --check ./src/launcher.mjs && node --check ./src/snapshot.mjs && node --check ./hooks/usage-explorer.mjs"
12
+ "check": "node --check ./src/core.mjs && node --check ./src/statusline.mjs && node --check ./bin/claude-usage.mjs && node --check ./src/tui.mjs && node --check ./src/launcher.mjs && node --check ./src/snapshot.mjs && node --check ./hooks/usage-explorer.mjs && node --check ./src/plan-usage.mjs",
13
+ "prepack": "npm run check && npm test"
13
14
  },
14
15
  "engines": {
15
16
  "node": ">=20"
16
17
  },
17
- "license": "MIT"
18
+ "license": "MIT",
19
+ "files": [
20
+ "bin/",
21
+ "src/",
22
+ "hooks/",
23
+ "commands/",
24
+ ".claude-plugin/",
25
+ "scripts/",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "homepage": "https://github.com/IliasPol/claude-usage#readme",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/IliasPol/claude-usage.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/IliasPol/claude-usage/issues"
36
+ },
37
+ "keywords": [
38
+ "claude-code",
39
+ "claude",
40
+ "usage-tracker",
41
+ "token-usage",
42
+ "cost-tracking",
43
+ "terminal-ui",
44
+ "analytics",
45
+ "developer-tools",
46
+ "cli"
47
+ ]
18
48
  }
package/src/core.mjs CHANGED
@@ -90,10 +90,7 @@ export function parseFile(file, rates=DEFAULT_RATES) {
90
90
  if(!r || typeof r!=='object') return;
91
91
  if(typeof r.cwd==='string') cwd=r.cwd;
92
92
  if(typeof r.sessionId==='string') sessionId=r.sessionId;
93
- if(id.isSubagent && !agentName){
94
- const candidate=r.agentType||r.agentName||r.agent_type||r.agent_name;
95
- if(typeof candidate==='string' && /^[a-zA-Z][a-zA-Z0-9_-]{0,60}$/.test(candidate))agentName=candidate;
96
- }
93
+ if(id.isSubagent&&!agentName){const candidate=r.agentType||r.agentName||r.agent_type||r.agent_name;if(typeof candidate==='string'&&candidate.trim()&&candidate.length<=100)agentName=candidate.trim();}
97
94
  const ts=Date.parse(r.timestamp);
98
95
  if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
99
96
  if(!title && r.type==='user') {
@@ -116,12 +113,12 @@ export function parseFile(file, rates=DEFAULT_RATES) {
116
113
  if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
117
114
  } else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
118
115
  });
119
- return {file,sessionId:sessionId||id.id,agent:id.agent,agentName,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
116
+ return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,agentName,title,cwd,start,end,bad,requests:[...requests.values()]};
120
117
  }
121
118
 
122
119
  // Cache derived metadata and token counters only. Never persist transcript text beyond
123
120
  // the short session title. Cache invalidates when file size/mtime changes.
124
- const CACHE_VERSION=3;
121
+ const CACHE_VERSION=4;
125
122
  const cacheDir=()=>path.join(os.homedir(),'.claude-usage-explorer','cache-v3');
126
123
  function readCachedFile(file){
127
124
  const st=fs.statSync(file);
@@ -156,7 +153,8 @@ export function ingest(roots,rates=DEFAULT_RATES) {
156
153
  if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
157
154
  let agent=s.agents.get(parsed.agent);
158
155
  if(!agent) {agent={id:parsed.agent,name:parsed.agentName||null,requests:[]};s.agents.set(parsed.agent,agent);}
159
- agent.name=agent.name||parsed.agentName||null;agent.requests.push(...parsed.requests);
156
+ if(!agent.name&&parsed.agentName)agent.name=parsed.agentName;
157
+ agent.requests.push(...parsed.requests);
160
158
  }
161
159
  return {sessions:[...sessions.values()],files:files.length,malformed,roots};
162
160
  }
@@ -0,0 +1,39 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Claude Desktop's plan-usage-history.json is undocumented, optional cached data.
5
+ // Read only known numeric utilization fields; never expose organization identifiers.
6
+ export function planUsageFromSamples(samples, now=Date.now()) {
7
+ if (!Array.isArray(samples)) return null;
8
+ const valid=samples.filter(s=>s && Number.isFinite(s.t) && s.u &&
9
+ (Number.isFinite(s.u.fh)||Number.isFinite(s.u.sd)))
10
+ .map(s=>({timestamp:s.t<1e12?s.t*1000:s.t,
11
+ fiveHour:Number.isFinite(s.u.fh)?s.u.fh:null,
12
+ sevenDay:Number.isFinite(s.u.sd)?s.u.sd:null}))
13
+ .filter(s=>s.timestamp>0 && s.timestamp<=now+86400000)
14
+ .sort((a,b)=>a.timestamp-b.timestamp);
15
+ if (!valid.length)return null;
16
+ const latest=valid.at(-1);
17
+ return { ...latest, stale:now-latest.timestamp>15*60*1000,
18
+ samples:valid.slice(-100),source:'Claude Desktop cached history'};
19
+ }
20
+ export function readPlanUsage({platform=process.platform,localAppData=process.env.LOCALAPPDATA,home=process.env.HOME,now=Date.now()}={}){
21
+ const roots=[];
22
+ if(platform==='win32'&&localAppData)roots.push(path.join(localAppData,'Claude-Data'));
23
+ if(platform==='darwin'&&home){roots.push(path.join(home,'Library','Application Support','Claude-Data'));roots.push(path.join(home,'Library','Application Support','Claude'));}
24
+ let best=null;
25
+ for(const root of roots){
26
+ let folders=[];
27
+ try{folders=fs.readdirSync(root,{withFileTypes:true}).filter(x=>x.isDirectory()&&x.name.startsWith('claude_')).map(x=>path.join(root,x.name));}catch{continue;}
28
+ for(const folder of folders){
29
+ try{
30
+ const file=path.join(folder,'plan-usage-history.json');
31
+ if(fs.statSync(file).size>2_000_000)continue;
32
+ const data=JSON.parse(fs.readFileSync(file,'utf8'));
33
+ const result=planUsageFromSamples(data.samples,now);
34
+ if(result&&(!best||result.timestamp>best.timestamp))best=result;
35
+ }catch{}
36
+ }
37
+ }
38
+ return best;
39
+ }
package/src/snapshot.mjs CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import {DEFAULT_RATES,ingest,projectRoots,summarize,timeWindow} from './core.mjs';
5
5
  import {describePeriod} from './tui.mjs';
6
+ import {readPlanUsage} from './plan-usage.mjs';
6
7
 
7
8
  export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projectRoots()}={}) {
8
9
  const periodIndex=argv.indexOf('--period');
@@ -13,5 +14,5 @@ export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projec
13
14
  const rates={...DEFAULT_RATES,...settings.rates};
14
15
  const window=timeWindow(period,now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
15
16
  const report=summarize(ingest(roots,rates),window,rates);
16
- return {savedPeriod:settings.lastPeriod||'all',period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report};
17
+ return {period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report,planUsage:readPlanUsage({now})};
17
18
  }
package/src/tui.mjs CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import readline from 'node:readline';
5
5
  import {spawnSync} from 'node:child_process';
6
+ import {readPlanUsage} from './plan-usage.mjs';
6
7
  import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
7
8
 
8
9
  const periods=[['Current billing cycle','cycle'],['Previous billing cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['Last 7 days','7d'],['Last 30 days','30d'],['This month','month'],['Previous month','previous-month'],['Custom dates','custom']];
@@ -25,7 +26,9 @@ function saveSettings(s){fs.mkdirSync(path.dirname(settingFile()),{recursive:tru
25
26
  const sortColumns=[['cost','Est. USD'],['tokens','Tokens'],['requests','Requests'],['input','Input'],['output','Output'],['cacheWrite','Cache write'],['cacheRead','Cache read'],['name','Name']];
26
27
  const value=(r,key)=>key==='tokens'?totalTokens(r.stats):key==='cost'?r.stats.unknownCostRequests?null:r.stats.knownCost:key==='name'?r.label.toLowerCase():r.stats[key];
27
28
  const empty={requests:0,input:0,output:0,cacheWrite:0,cacheRead:0,knownCost:0,unknownCostRequests:0};
28
- export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all'}={}){
29
+ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all',planReader=readPlanUsage}={}){
30
+ let planUsage=null,planCheckedAt=0;
31
+ function refreshPlan(now=Date.now()){if(now-planCheckedAt<4000)return;planCheckedAt=now;try{planUsage=planReader({now})||null;}catch{planUsage=null;}}
29
32
  let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
30
33
  let sourceData=data,view='projects',selection=0,offset=0,stack=[],sort='tokens',descending=true,filter='',message='',currentPeriod=period,project=null,session=null,agent=null,report=null;
31
34
  const rates=()=>({...DEFAULT_RATES,...settings.rates});
@@ -79,6 +82,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
79
82
  function setFilter(s){filter=s;selection=0;offset=0;}
80
83
  function setSort(key){if(sort===key)descending=!descending;else{sort=key;descending=key!=='name';}selection=0;offset=0;}
81
84
  function render(width=process.stdout.columns||110,height=process.stdout.rows||30){
85
+ refreshPlan();
82
86
  width=Math.max(20,width);height=Math.max(8,height);
83
87
  const tty=process.stdout.isTTY && !process.env.NO_COLOR;
84
88
  const c=(text,style)=>tty?`\x1b[${style}m${text}\x1b[0m`:text;
@@ -93,6 +97,8 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
93
97
  line(` ${c(crumb,'1')} ${c('PERIOD','90')} ${describePeriod(currentPeriod,settings)} ${c('SORT','90')} ${sort} ${descending?'↓':'↑'}`);
94
98
  const scoped=view==='sessions'?project?.stats:view==='agents'?session?.stats:view==='models'?(agent?.stats||session?.stats||project?.stats||report.overall):report.overall;
95
99
  line(` ${c('TOTAL','90')} ${fmt(totalTokens(scoped))} tokens ${fmt(scoped.requests)} requests ${c(money(scoped),'1;32')} API-equivalent`);
100
+ const planLabel=planUsage?`5h ${planUsage.fiveHour??'—'}% · 7d ${planUsage.sevenDay??'—'}% · ${planUsage.stale?'Cached, may be outdated':'Cached, recent'}`:'Claude Desktop plan usage unavailable';
101
+ line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
96
102
  rule();
97
103
  const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
98
104
  const selected=available[selection];
@@ -100,7 +106,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
100
106
  // Fixed-height table, model panel, and footer prevent vertical jumping on selection.
101
107
  const modelPanelHeight=7; // heading + five model rows + divider
102
108
  const footerHeight=5; // separator, two shortcuts, search, status
103
- const headerHeight=6;
109
+ const headerHeight=7;
104
110
  const visible=Math.max(0,height-headerHeight-modelPanelHeight-footerHeight-2);
105
111
  if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
106
112
  // Keep columns aligned by terminal width, shrinking the name first.
@@ -140,7 +146,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
140
146
  line(c(` ${liveState.enabled?'● LIVE':'Ⅱ PAUSED'} · Updated ${Math.max(0,Math.floor((Date.now()-liveState.lastUpdated)/1000))}s ago${liveState.error?' · '+liveState.error:''}`,'36')+(message?' '+c(truncate(message,Math.max(10,width-65)),'33'):''));
141
147
  return out.slice(0,Math.max(1,height-1)).join('\n');
142
148
  }
143
- return {render,rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get session(){return session;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
149
+ return {render,refreshPlan,get planUsage(){return planUsage;},rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get session(){return session;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
144
150
  }
145
151
  // Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
146
152
  export function clipAnsi(value, maxWidth) {
package/claude-usage DELETED
@@ -1,20 +0,0 @@
1
- #!/bin/sh
2
- # Portable launcher: run directly from this folder, without npm global shims.
3
- set -eu
4
- ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
5
- if [ -n "${CLAUDE_USAGE_NODE:-}" ]; then
6
- NODE="$CLAUDE_USAGE_NODE"
7
- elif command -v node >/dev/null 2>&1 && node -e 'process.exit(Number(process.versions.node.split(".")[0]) < 20)' >/dev/null 2>&1; then
8
- NODE=$(command -v node)
9
- else
10
- NODE=''
11
- for candidate in "$HOME"/.asdf/installs/nodejs/22.22.2/bin/node "$HOME"/.asdf/installs/nodejs/22.22.1/bin/node "$HOME"/.asdf/installs/nodejs/24.18.0/bin/node "$HOME"/.asdf/installs/nodejs/20.19.5/bin/node; do
12
- if [ -x "$candidate" ]; then NODE="$candidate"; break; fi
13
- done
14
- if [ -z "$NODE" ]; then
15
- printf '%s\n' 'Claude Usage Explorer requires Node.js 20+. Set CLAUDE_USAGE_NODE to its executable path.' >&2
16
- exit 1
17
- fi
18
- fi
19
- if [ "$#" -eq 0 ]; then set -- tui; fi
20
- exec "$NODE" "$ROOT/bin/claude-usage.mjs" "$@"
@@ -1,30 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import fs from 'node:fs';import os from 'node:os';import path from 'node:path';
4
- import {parseFile,ingest,summarize,timeWindow,totalTokens,projectRoots} from '../src/core.mjs';
5
- const dir=fs.mkdtempSync(path.join(os.tmpdir(),'claude-usage-test-'));
6
- const root=path.join(dir,'projects','-sample');fs.mkdirSync(root,{recursive:true});
7
- const file=path.join(root,'session-123.jsonl');
8
- const write=(f,rows)=>{fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,rows.map(x=>JSON.stringify(x)).join('\n')+'\n');};
9
- const assistant=(id,output,ts='2026-10-12T12:00:00Z')=>({type:'assistant',timestamp:ts,cwd:'/sample',sessionId:'session-123',message:{id,model:'claude-sonnet-4-5-20250929',usage:{input_tokens:100,output_tokens:output,cache_creation_input_tokens:20,cache_read_input_tokens:500}}});
10
- write(file,[{type:'user',timestamp:'2026-10-12T11:59:00Z',cwd:'/sample',sessionId:'session-123',message:{content:'Implement the API'}},assistant('msg1',3),assistant('msg1',10),assistant('msg2',5)]);
11
- test('deduplicates repeated message IDs using max observed counters',()=>{const p=parseFile(file);assert.equal(p.requests.length,2);assert.equal(p.requests[0].output,10);assert.equal(p.title,'Implement the API');});
12
- test('aggregates model usage without counting streaming duplicates',()=>{const d=ingest([path.join(dir,'projects')]);const s=summarize(d);assert.equal(s.overall.requests,2);assert.equal(s.overall.output,15);assert.equal(totalTokens(s.overall),100*2+15+20*2+500*2);assert.equal(s.projects.length,1);});
13
- test('subagent file appears in same session',()=>{const child=path.join(root,'session-123','subagents','agent-abc.jsonl');write(child,[assistant('msg3',8)]);const d=ingest([path.join(dir,'projects')]);assert.equal(d.sessions.length,1);assert.equal(d.sessions[0].agents.size,2);const s=summarize(d);assert.equal(s.overall.requests,3);assert.equal(s.sessions[0].agents.length,2);});
14
- test('date filtering attributes usage by request timestamp',()=>{const d=ingest([path.join(dir,'projects')]);assert.equal(summarize(d,timeWindow('2026-10-13:2026-10-14')).overall.requests,0);assert.equal(summarize(d,timeWindow('2026-10-12:2026-10-12')).overall.requests,3);});
15
- test('billing period clamps month end',()=>{const now=Date.parse('2026-03-02T12:00:00Z');const [a,b]=timeWindow('cycle',now,31);assert.equal(new Date(a).toISOString(),'2026-02-28T00:00:00.000Z');assert.equal(new Date(b).toISOString(),'2026-03-31T00:00:00.000Z');});
16
- test('config dir discovery honors custom root',()=>{const roots=projectRoots({CLAUDE_CONFIG_DIR:'/tmp/custom'},'/home/test');assert.ok(roots.includes(path.resolve('/tmp/custom/projects')));});
17
- test('unknown pricing is reported instead of zero-cost certainty',()=>{const unknown=path.join(root,'unknown.jsonl');write(unknown,[{...assistant('unknown',2),sessionId:'unknown',message:{...assistant('unknown',2).message,model:'claude-future-unknown'}}]);const s=summarize(ingest([path.join(dir,'projects')]));assert.equal(s.overall.unknownCostRequests,1);});
18
-
19
- test('Sonnet 5.5 official base prices yield nonzero API equivalent cost', async()=>{
20
- const {costFor}=await import('../src/core.mjs');
21
- const price=costFor('claude-sonnet-5-5',{input:1000000,output:1000000,cacheWrite:1000000,cacheRead:1000000});
22
- assert.equal(price,14.6);
23
- });
24
- test('billing timezone and local time determine cycle boundaries',async()=>{
25
- const {timeWindow}=await import('../src/core.mjs');
26
- const now=Date.parse('2026-11-05T10:00:00Z');
27
- const [start,end]=timeWindow('cycle',now,5,'Europe/Athens',9,0);
28
- assert.equal(new Date(start).toISOString(),'2026-11-05T07:00:00.000Z');
29
- assert.equal(new Date(end).toISOString(),'2026-12-05T07:00:00.000Z');
30
- });
@@ -1,12 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {launchSidecar} from '../src/launcher.mjs';
4
- test('macOS sidecar launches separate Terminal with current project',()=>{
5
- let invocation;
6
- const spawnProcess=(program,args,opts)=>{invocation={program,args,opts};return {on(){},unref(){}};};
7
- launchSidecar({platform:'darwin',cwd:'/tmp/my project',spawnProcess});
8
- assert.equal(invocation.program,'osascript');
9
- assert.match(invocation.args.join(' '),/Terminal/);
10
- assert.match(invocation.args.join(' '),/my project/);
11
- assert.equal(invocation.opts.detached,true);
12
- });
@@ -1,177 +0,0 @@
1
- import {readFileSync} from 'node:fs';
2
- import test from 'node:test';
3
- import assert from 'node:assert/strict';
4
- import {drill,back,itemsFor,modelScope,register} from '../hooks/usage-explorer.mjs';
5
- const stats={input:5,output:2,cacheWrite:0,cacheRead:10,knownCost:0.25,unknownCostRequests:0};
6
- const agent={id:'main',stats,models:[{name:'sonnet',stats}]};
7
- const session={id:'s1',title:'Build feature',project:'/repo',stats,agents:[agent],models:agent.models};
8
- const report={overall:stats,projects:[{name:'/repo',stats}],sessions:[session],models:agent.models,files:1};
9
- test('native mod drills projects, sessions, agents and models, and navigates back',()=>{
10
- let state={view:'projects',stack:[],project:null,session:null,agent:null,report,sort:'tokens',query:''};
11
- state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'sessions');
12
- state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'agents');
13
- state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'models');
14
- assert.equal(itemsFor(state)[0].label,'sonnet');
15
- state=back(state);assert.equal(state.view,'agents');
16
- state=back(state);assert.equal(state.view,'sessions');
17
- state=back(state);assert.equal(state.view,'projects');
18
- });
19
- test('native mod registers a model-free immediate command',async()=>{
20
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
21
- const start=hooks.find(x=>x.event==='session.start');
22
- let command;
23
- await start.fn({command:{register:async c=>{command=c;}}},{},async()=>({}));
24
- assert.equal(command.name,'usage-explorer');assert.equal(command.immediate,true);
25
- });
26
- test('native sidebar render includes clickable entries and period controls',async()=>{
27
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,matcher,fn}));
28
- const render=hooks.find(x=>x.event==='ui.render').fn;
29
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
30
- const element=render({ui},{surface:'terminal'},()=>null);
31
- assert.equal(element.type,'Box');
32
- assert.ok(element.children.some(x=>x?.type==='Box'));
33
- const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
34
- const input=flat(element).find(x=>x?.type==='Input');
35
- assert.equal(input?.key,'usage-filter','host requires a stable key on Input');
36
- assert.equal(typeof input?.onSubmit,'function','Claude Code 2.1.311 requires onSubmit for Input');
37
- });
38
-
39
- test('native sidebar collapses period choices and puts actions after entries',()=>{
40
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
41
- const render=hooks.find(x=>x.event==='ui.render').fn;
42
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
43
- const element=render({ui},{surface:'terminal'},()=>null);
44
- const flat=(node)=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
45
- const all=flat(element);
46
- assert.ok(all.some(x=>x.type==='Button'&&x.label==='◷ Period'));
47
- assert.ok(!all.some(x=>x.type==='Button'&&x.key==='period-cycle'));
48
- assert.ok(all.some(x=>x.type==='Input'&&x.key==='usage-filter'));
49
- const last=element.children[element.children.length-1];
50
- assert.ok(flat(last).some(x=>x.type==='Button'&&x.label==='↻ Refresh'));
51
- });
52
-
53
-
54
- test('model scope respects project, session and agent drilldown',()=>{
55
- const other={name:'opus',stats:{...stats,input:30}};
56
- const scoped={...report,models:[other],projects:[{name:'/repo',stats,models:[{name:'sonnet',stats}]}]};
57
- const base={view:'projects',stack:[],project:null,session:null,agent:null,report:scoped,sort:'tokens',query:''};
58
- assert.equal(modelScope(base)[0].name,'opus');
59
- const projectState={...base,project:'/repo'};
60
- assert.equal(modelScope(projectState)[0].name,'sonnet');
61
- const sessionState={...projectState,session};
62
- assert.equal(modelScope(sessionState)[0].name,'sonnet');
63
- const agentState={...sessionState,agent};
64
- assert.equal(modelScope(agentState)[0].name,'sonnet');
65
- });
66
-
67
- test('models screen has a prominent back control and footer close-models action',()=>{
68
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
69
- const render=hooks.find(x=>x.event==='ui.render').fn;
70
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
71
- const element=render({ui},{surface:'terminal'},()=>null);
72
- const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
73
- assert.match(requireSource(),/MODEL USAGE/);
74
- const modSource=requireSource();
75
- assert.match(modSource,/Back to previous view/);
76
- assert.match(modSource,/Close models/);
77
- });
78
- function requireSource(){return requireSource.text||(requireSource.text=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8'));}
79
-
80
-
81
- test('native sidebar uses host close-on-Escape and offers explicit exit action',async()=>{
82
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
83
- const run=hooks.find(x=>x.event==='command.run').fn;
84
- let opened;
85
- const $={session:{surfaces:async()=>['desktop']},ui:{open:async p=>{opened=p},invalidate:async()=>{},close:async()=>{}},process:{run:async()=>({exitCode:1,stderr:'fixture'})},clock:{every:()=>({cancel(){}})}};
86
- await run($,{presentation:{isFullscreen:true,columns:160}});
87
- assert.equal(opened.closeOnEscape,true);
88
- const render=hooks.find(x=>x.event==='ui.render').fn;
89
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
90
- const element=render({ui},{surface:'terminal'},()=>null);
91
- const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
92
- const exit=flat(element).find(x=>x.type==='Button'&&x.label==='× Exit');
93
- assert.equal(exit.hotkey,'x');assert.equal(typeof exit.onPress,'function');
94
- });
95
-
96
- test('period choices stack vertically and footer is outside expanding content',()=>{
97
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
98
- const render=hooks.find(x=>x.event==='ui.render').fn;
99
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
100
- const element=render({ui},{surface:'terminal'},()=>null);
101
- assert.equal(element.justifyContent,'space-between');
102
- assert.equal(element.height,'100%');
103
- assert.equal(element.children.length,2);
104
- assert.equal(element.children[0].flexGrow,1);
105
- assert.equal(element.children[1].flexShrink,0);
106
- const source=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8');
107
- assert.match(source,/flexDirection:'column',gap:0,children:periodButtons/);
108
- });
109
-
110
- test('official usage bars clamp percentages and show enterprise extra usage',async()=>{
111
- const {usageBar,officialLimitRows}=await import('../hooks/usage-explorer.mjs');
112
- assert.match(usageBar(37.66,10),/37\.7%/);
113
- assert.match(usageBar(130,10),/100\.0%/);
114
- assert.match(usageBar(-1,10),/0\.0%/);
115
- const rows=officialLimitRows({rateLimits:[],details:['extra_usage: utilization: 37.66 · is_enabled: true']});
116
- assert.deepEqual(rows,[{label:'extra_usage',percent:37.66,resetsAt:''}]);
117
- });
118
-
119
- test('usage response diagnostic exposes field names and types only',async()=>{
120
- const {usageSchema}=await import('../hooks/usage-explorer.mjs');
121
- const fields=usageSchema({extra_usage:{utilization:37.66,resets_at:'secret-date'},billing:{cycle_start:'private',cap:200},some_id:'private-id'});
122
- assert.ok(fields.includes('extra_usage.utilization: number'));
123
- assert.ok(fields.includes('billing.cycle_start: string'));
124
- assert.ok(!fields.join(' ').includes('private'));
125
- assert.ok(!fields.join(' ').includes('37.66'));
126
- });
127
-
128
-
129
- test('native mod uses only supported single-letter or digit hotkeys',()=>{
130
- const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
131
- const render=hooks.find(x=>x.event==='ui.render').fn;
132
- const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
133
- const element=render({ui},{surface:'terminal'},()=>null);
134
- const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
135
- const buttons=flat(element).filter(x=>x.type==='Button');
136
- for(const b of buttons){if(b.hotkey!==undefined)assert.match(b.hotkey,/^[a-z0-9]$/,`invalid hotkey on ${b.label}`);}
137
- assert.equal(buttons.some(b=>b.label==='Help'),false);
138
- });
139
-
140
-
141
- test('native sidebar removes diagnostic and provides session open actions',async()=>{
142
- const {sessionResumeCommand}=await import('../hooks/usage-explorer.mjs');
143
- const command=sessionResumeCommand({id:'bf9de351-f0e6-48cc-a941-21e3705fe379',project:'/Users/example/my repo'});
144
- assert.match(command,/claude --resume/);
145
- assert.match(command,/my repo/);
146
- assert.throws(()=>sessionResumeCommand({id:'bad',project:'/repo'}));
147
- const source=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8');
148
- assert.doesNotMatch(source,/Show response fields|Hide response fields|USAGE RESPONSE FIELD TYPES/);
149
- assert.doesNotMatch(source,/session-open-terminal|session-copy-id/);
150
- assert.match(source,/action\('Open session','o'/);
151
- assert.match(source,/action\('Copy ID','c'/);
152
- });
153
-
154
- import { readFileSync as readModSource } from "node:fs";
155
- import { fileURLToPath as modFilePath } from "node:url";
156
- import { dirname as modDirname, join as modJoin } from "node:path";
157
- test("session actions use focused session from list and parent session deeper", async () => {
158
- const {selectedSessionForAction}=await import('../hooks/usage-explorer.mjs');
159
- const a={...session,id:'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'};
160
- const b={...session,id:'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb'};
161
- const s={view:'sessions',project:'/repo',report:{...report,sessions:[a,b]}};
162
- assert.equal(selectedSessionForAction(s,b.id),b);
163
- assert.equal(selectedSessionForAction(s,null),null);
164
- assert.equal(selectedSessionForAction({...s,view:'agents',session:a},b.id),a);
165
- const source=requireSource();
166
- assert.match(source,/onFocus:\(\)=>\{if\(state.view==='sessions'\)focusedSessionId=item.id/);
167
- assert.doesNotMatch(source,/key:'session-open-terminal'|key:'session-copy-id'/);
168
- });
169
-
170
- test('agent rows hide IDs until drilldown and show main label',async()=>{
171
- const {agentLabel}=await import('../hooks/usage-explorer.mjs');
172
- assert.equal(agentLabel({id:'main'}),'Agent (main)');
173
- assert.equal(agentLabel({id:'af22fd9030fbd751f'}),'Subagent');
174
- assert.equal(agentLabel({id:'af22fd9030fbd751f',name:'code-reviewer'}),'code-reviewer');
175
- const state={view:'agents',stack:[],project:'/repo',session:{...session,id:'different-session',agents:[{...agent,id:'af22fd9030fbd751f',name:'code-reviewer'}]},agent:null,report,sort:'tokens',query:''};
176
- assert.equal(itemsFor(state)[0].label,'code-reviewer');
177
- });
@@ -1,17 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {parseOfficialSpend,usageBar} from '../hooks/usage-explorer.mjs';
4
- test('official spend normalizes minor currency units',()=>{
5
- const value=parseOfficialSpend({spend:{used:{amount_minor:7532,currency:'USD',exponent:2},limit:{amount_minor:20000,currency:'USD',exponent:2},percent:37.66}});
6
- assert.deepEqual(value,{used:75.32,limit:200,currency:'USD',percent:37.66,source:'spend'});
7
- });
8
- test('extra usage fallback and no double counting',()=>{
9
- const value=parseOfficialSpend({extra_usage:{used_credits:75.32,monthly_limit:200,currency:'USD',utilization:37.66}});
10
- assert.equal(value.used,75.32);assert.equal(value.limit,200);
11
- });
12
- test('mismatched currencies do not report official spend',()=>{
13
- assert.equal(parseOfficialSpend({spend:{used:{amount_minor:500,currency:'EUR',exponent:2},limit:{amount_minor:1000,currency:'USD',exponent:2}}}),null);
14
- });
15
- test('bar displays filled and empty segments',()=>{
16
- assert.match(usageBar(37.66,10),/^████░░░░░░ 37\.7%$/);
17
- });
@@ -1,23 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {screenDiff,clipAnsi,createExplorer} from '../src/tui.mjs';
4
- test('only updates changed lines and never clears the full screen',()=>{
5
- const result=screenDiff('one\ntwo\nthree','one\nTWO\nthree');
6
- assert.equal(result,'\x1b[2;1H\x1b[2KTWO');
7
- assert.ok(!result.includes('\x1b[2J'));
8
- });
9
- test('clears removed lines and renders initial frame',()=>{
10
- assert.equal(screenDiff('a\nb','a'),'\x1b[2;1H\x1b[2K');
11
- assert.equal(screenDiff(null,'a\nb'),'\x1b[1;1H\x1b[2Ka\x1b[2;1H\x1b[2Kb');
12
- });
13
-
14
- test('colored rows are clipped without breaking ANSI reset',()=>{
15
- const text='\x1b[31m'+('x'.repeat(100))+'\x1b[0m';
16
- assert.equal(clipAnsi(text,10),'\x1b[31mxxxxxxxxxx\x1b[0m');
17
- });
18
- test('short terminals never receive a frame filling the last row',()=>{
19
- const explorer=createExplorer({sessions:[],files:0,malformed:0,roots:[]},{cwd:process.cwd()});
20
- const lines=explorer.render(45,18).split('\n');
21
- assert.ok(lines.length<=17);
22
- assert.ok(lines.every(line=>line.replace(/\x1b\[[0-9;]*m/g,'').length<=44));
23
- });
@@ -1,15 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import {snapshot} from '../src/snapshot.mjs';
7
- test('snapshot reads configured billing cycle and reports no external data',()=>{
8
- const home=fs.mkdtempSync(path.join(os.tmpdir(),'usage-mod-'));
9
- try {
10
- fs.mkdirSync(path.join(home,'.claude-usage-explorer'));
11
- fs.writeFileSync(path.join(home,'.claude-usage-explorer','settings.json'),JSON.stringify({billingDay:1,billingTimeZone:'UTC'}));
12
- const data=snapshot(['--period','cycle'],{now:Date.UTC(2026,10,5),home,roots:[]});
13
- assert.match(data.periodLabel,/Nov 1, 2026/);assert.equal(data.report.overall.requests,0);
14
- }finally{fs.rmSync(home,{recursive:true,force:true});}
15
- });
@@ -1,19 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import fs from 'node:fs';
4
- import os from 'node:os';
5
- import path from 'node:path';
6
- import {formatStatusLine,installStatusLine} from '../src/statusline.mjs';
7
- test('statusline distinguishes live session cost and historical cycle estimate',()=>{
8
- const result=formatStatusLine({model:{display_name:'Claude Sonnet'},cost:{total_cost_usd:1.234}}, {cycle:{input:1000,output:0,cacheWrite:0,cacheRead:0,knownCost:12.5,unknownCostRequests:0}});
9
- assert.match(result,/Session \$1\.23/);assert.match(result,/Cycle \$12\.50/);
10
- });
11
- test('install preserves other settings, refuses overwrite, and backs up',()=>{
12
- const dir=fs.mkdtempSync(path.join(os.tmpdir(),'usage-status-'));
13
- try{const file=path.join(dir,'settings.json');fs.writeFileSync(file,JSON.stringify({theme:'dark',statusLine:{type:'command',command:'echo previous'}}));
14
- assert.throws(()=>installStatusLine({settingsPath:file,scriptPath:'/tmp/usage.mjs'}),/already/);
15
- installStatusLine({settingsPath:file,scriptPath:'/tmp/usage.mjs',force:true});
16
- const settings=JSON.parse(fs.readFileSync(file));assert.equal(settings.theme,'dark');assert.match(settings.statusLine.command,/statusline/);
17
- assert.ok(fs.readdirSync(dir).some(f=>f.includes('backup')));
18
- }finally{fs.rmSync(dir,{recursive:true,force:true});}
19
- });
@@ -1,104 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import {createExplorer,describePeriod} from '../src/tui.mjs';
4
- const z={input:3,output:2,cacheRead:5,cacheWrite:1,model:'claude-sonnet-4-5',timestamp:Date.now()};
5
- const data={sessions:[{id:'s1',title:'Example session',cwd:'/tmp/demo',start:Date.now(),end:Date.now(),agents:new Map([['main',{id:'main',requests:[z]}]])}],files:1,malformed:0,roots:[]};
6
- test('TUI drills from project to session to agent to model without LLM',()=>{
7
- const x=createExplorer(data,{period:'all',cwd:'/tmp/demo',settings:{}});
8
- assert.equal(x.rows().length,1);
9
- assert.match(x.render(115,30),/CLAUDE USAGE EXPLORER/);
10
- x.open();assert.equal(x.view,'sessions');assert.equal(x.rows()[0].title,'Example session');
11
- x.open();assert.equal(x.view,'agents');assert.equal(x.rows()[0].id,'main');
12
- x.open();assert.equal(x.view,'models');assert.equal(x.rows()[0].name,'claude-sonnet-4-5');
13
- x.restore();assert.equal(x.view,'agents');
14
- });
15
- test('TUI switches dates and shows model columns',()=>{
16
- const x=createExplorer(data,{period:'all',settings:{}});
17
- x.nav('periods');const i=x.rows().findIndex(x=>x.value==='today');x.selection=i;x.open();
18
- assert.equal(x.currentPeriod,'today');assert.match(x.render(120,30),/C.READ/);
19
- });
20
- test('TUI shows models for selected project without drilling into sessions',()=>{
21
- const x=createExplorer(data,{period:'all',settings:{}});
22
- assert.match(x.render(130,32),/MODELS IN SELECTED ROW/);
23
- assert.match(x.render(130,32),/claude-sonnet-4-5/);
24
- x.setSort('output');assert.equal(x.sort,'output');
25
- });
26
- test('TUI displays unknown model pricing as N/A, never a misleading zero',()=>{
27
- const unknown={...z,model:'claude-unknown-9-9'};
28
- const d={...data,sessions:[{...data.sessions[0],agents:new Map([['main',{id:'main',requests:[unknown]}]])}]};
29
- const x=createExplorer(d,{period:'all',settings:{}});
30
- assert.match(x.render(130,32),/N\/A/);
31
- });
32
- test('TUI footer and model panel remain at fixed rows as selection changes',()=>{
33
- const extra={...data.sessions[0],id:'s2',title:'Another session',cwd:'/tmp/other'};
34
- const x=createExplorer({...data,sessions:[...data.sessions,extra]},{period:'all',settings:{}});
35
- const a=x.render(120,32).split('\n');
36
- x.selection=1;
37
- const b=x.render(120,32).split('\n');
38
- for(const label of ['MODELS IN SELECTED ROW','Billing/settings','PgUp/PgDn scroll']){
39
- const index=lines=>lines.findIndex(line=>line.includes(label));
40
- assert.equal(index(a),index(b),label);
41
- }
42
- assert.equal(a.length,b.length);
43
- });
44
-
45
- test('billing periods appear first and require configuration before selection',()=>{
46
- const x=createExplorer(data,{period:'all',settings:{}});
47
- x.nav('periods');
48
- assert.equal(x.rows()[0].value,'cycle');
49
- assert.equal(x.rows()[1].value,'previous-cycle');
50
- assert.equal(x.open(),'billing-required:cycle');
51
- assert.equal(x.currentPeriod,'all');
52
- x.setBillingDay(5,'Europe/Athens',10,30);
53
- assert.equal(x.open(),null);
54
- assert.equal(x.currentPeriod,'cycle');
55
- });
56
- test('custom date selection requests input instead of navigating or quitting',()=>{
57
- const x=createExplorer(data,{period:'all',settings:{}});
58
- x.nav('periods');
59
- x.selection=x.rows().findIndex(r=>r.value==='custom');
60
- assert.equal(x.open(),'custom');
61
- assert.equal(x.view,'periods');
62
- x.setPeriod('2026-11-01:2026-11-05');
63
- assert.equal(x.currentPeriod,'2026-11-01:2026-11-05');
64
- });
65
-
66
- test('billing date choices and header show descriptive date ranges',()=>{
67
- const settings={billingDay:1,billingTimeZone:'UTC',billingHour:0,billingMinute:0};
68
- assert.equal(describePeriod('cycle',settings,Date.UTC(2026,10,5)),'Current billing cycle · Nov 1, 2026 – Nov 30, 2026');
69
- assert.equal(describePeriod('previous-cycle',settings,Date.UTC(2026,10,5)),'Previous billing cycle · Oct 1, 2026 – Oct 31, 2026');
70
- assert.match(describePeriod('cycle',{},Date.UTC(2026,10,5)),/Set renewal date/);
71
- const x=createExplorer(data,{settings,period:'cycle'});
72
- assert.match(x.render(130,32),/Current billing cycle/);
73
- x.nav('periods');
74
- assert.match(x.rows()[0].label,/Nov/);
75
- });
76
-
77
- test('live refresh preserves project, session, sorting, and period',()=>{
78
- const x=createExplorer(data,{cwd:'/tmp/demo',settings:{}});
79
- x.open();x.open();
80
- x.setSort('output');x.setPeriod('today');
81
- // Date changes intentionally reset the view; navigate back into the session.
82
- x.open();x.open();
83
- assert.equal(x.view,'agents');
84
- x.rescan([], {automatic:true});
85
- assert.equal(x.currentPeriod,'today');
86
- assert.equal(x.sort,'output');
87
- assert.equal(x.view,'projects'); // source was removed by rescan
88
- x.toggleLive();assert.equal(x.liveState.enabled,false);
89
- x.toggleLive();assert.equal(x.liveState.enabled,true);
90
- });
91
-
92
- test('file signature changes only when JSONL metadata changes', async()=>{
93
- const {historySignature}=await import('../src/tui.mjs');
94
- const fs=await import('node:fs');
95
- const os=await import('node:os');
96
- const path=await import('node:path');
97
- const root=fs.mkdtempSync(path.join(os.tmpdir(),'claude-live-'));
98
- try{
99
- const file=path.join(root,'a.jsonl');
100
- fs.writeFileSync(file,'a');const a=historySignature([root]);
101
- assert.equal(a,historySignature([root]));
102
- fs.appendFileSync(file,'bc');assert.notEqual(a,historySignature([root]));
103
- }finally{fs.rmSync(root,{recursive:true,force:true});}
104
- });