claude-usage-explorer 1.0.0 → 1.1.5

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,5 @@
1
1
  {
2
2
  "name": "claude-usage",
3
3
  "description": "Interactive native Claude Code usage sidebar and standalone TUI",
4
- "version": "1.0.0"
4
+ "version": "1.0.11"
5
5
  }
package/README.md CHANGED
@@ -1,84 +1,125 @@
1
- # Claude Usage Explorer
2
-
3
- A local, read-only usage explorer for Claude Code. Browse projects, sessions, agents and models, inspect token usage and estimated API-equivalent costs, and optionally view cached Claude Desktop plan-limit percentages. No external npm dependencies or telemetry.
4
-
5
- > **Release candidate:** v1.0.0. The standalone terminal UI is automated-test verified. Native Claude Code mod integration and the external-terminal launcher require final host verification before a stable release.
6
-
7
- ## Requirements
8
-
9
- - Node.js 20+ (Node 22 recommended)
10
- - Claude Code with native Mods support for the sidebar
11
- - Local Claude Code JSONL transcript history
12
-
13
- ## Install the standalone CLI (npm)
14
-
15
- Once this package is published on npm, install it globally with Node.js 20+:
16
-
17
- ```sh
18
- npm install -g claude-usage-explorer
19
- claude-usage tui
20
- ```
21
-
22
- Or run it without a global installation:
23
-
24
- ```sh
25
- npx --yes --package claude-usage-explorer claude-usage tui
26
- ```
27
-
28
- **Note:** These commands will work only after the package is published to the npm registry. The GitHub repository alone does not publish an npm package. On Windows PowerShell, use `npm.cmd` and `claude-usage.cmd` if execution policy blocks the `.ps1` shims.
29
-
30
- To run directly from a local checkout, use:
31
-
32
- ```sh
33
- node ./bin/claude-usage.mjs tui
34
- ```
35
-
36
- 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.
37
-
38
- ## Run the native Claude Code sidebar
39
-
40
- Start Claude Code with the local plugin directory:
41
-
42
- ```sh
43
- claude --plugin-dir /absolute/path/to/claude-usage-explorer
44
- ```
45
-
46
- 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`.
47
-
48
- 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.
49
-
50
- ## Features
51
-
52
- - Drill down into projects, sessions, agents and models
53
- - Periods, sorting, filtering and auto-refresh
54
- - Token totals and estimated API-equivalent cost by model
55
- - Optional cached 5-hour and 7-day Claude Desktop plan-limit percentages (not live usage)
56
- - Read-only, local processing; no uploads or external analytics
57
-
58
- **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.
59
-
60
- ## Windows
61
-
62
- In PowerShell, from the extracted project directory (or use the npm installation above):
63
-
64
- ```powershell
65
- node .\bin\claude-usage.mjs tui
66
- claude --plugin-dir "$PWD"
67
- ```
68
-
69
- ## Development
70
-
71
- ```sh
72
- npm test
73
- npm run check
74
- ```
75
-
76
- The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
77
-
78
- ## Contributing and security
79
-
80
- See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). Licensed under [MIT](LICENSE).
81
-
82
- ## Support
83
-
84
- If this project is useful, you can star the repository. GitHub Sponsors may be enabled later; no donation or payment service is required to use the project.
1
+ # Claude Usage Explorer v1.0.4
2
+
3
+ Local, read-only analytics for Claude Code sessions. No external npm dependencies and no tmux requirement.
4
+
5
+ ## Requirements
6
+
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.
10
+
11
+ ## Install and run
12
+
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.
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.
20
+
21
+ ## Native sidebar
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.
29
+
30
+ ## Standalone TUI
31
+
32
+ `./claude-usage tui`
33
+
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.
35
+
36
+ ## Notes and limitations
37
+
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.
39
+
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.
41
+
42
+ ## Validation
43
+
44
+ Run `npm test` and `npm run check`. For native mod validation on a machine with Claude Code installed: `claude plugin validate .`.
45
+
46
+ ## v1.0.4 sidebar fix
47
+
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).
49
+
50
+ ## Fixing old asdf commands and terminal flashes (v1.0.4)
51
+
52
+ The installer now creates a small executable wrapper instead of a symlink. This avoids resolving `bin/claude-usage.mjs` relative to `~/.local/bin`.
53
+
54
+ ```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
60
+ ```
61
+
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.
63
+
64
+ ## Sidebar controls (v1.0.4)
65
+
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.
67
+
68
+ ### Native sidebar keyboard and exit (v1.0.6)
69
+
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.
76
+
77
+ ### Install both native sidebar and standalone CLI (v1.0.7)
78
+
79
+ Extract the release archive into `~/claude-usage-explorer-1.0.7`, then:
80
+
81
+ ```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
88
+ ```
89
+
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.
91
+
92
+ ### v1.0.8 display updates
93
+
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.
95
+
96
+ ### Experimental official usage diagnostic (v1.0.9)
97
+
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.
99
+
100
+ ### Official account usage (v1.0.10)
101
+
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.
103
+
104
+ ### Usage response schema diagnostic (v1.0.11)
105
+
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.
107
+
108
+ ## v1.1.0 changes
109
+
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.
116
+
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.
120
+
121
+ ### Native session actions (v1.1.2)
122
+
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.
124
+
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.
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
2
5
  import { main } from '../src/core.mjs';
3
6
  import { launchTui } from '../src/tui.mjs';
4
7
  import { snapshot } from '../src/snapshot.mjs';
5
8
  import {runStatusLine,installStatusLine} from '../src/statusline.mjs';
6
9
  import {launchSidecar} from '../src/launcher.mjs';
7
- (process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
10
+ (process.argv[2] === 'preference' ? Promise.resolve().then(()=>{const file=path.join(os.homedir(),'.claude-usage-explorer','settings.json');let obj={};try{obj=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}if(process.argv[3]==='period' && ['all','cycle','previous-cycle','today','yesterday','7d','30d','month','previous-month'].includes(process.argv[4])){obj.lastPeriod=process.argv[4];fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});fs.writeFileSync(file,JSON.stringify(obj,null,2)+'\n',{mode:0o600});}else throw Error('Invalid preference');}) : process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
package/claude-usage ADDED
@@ -0,0 +1,20 @@
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,23 +1,24 @@
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: no node: built-in imports.
4
- // Convert the file URL using web-standard URL APIs, preserving Windows drive paths.
5
- export function scriptPathFromModuleUrl(moduleUrl){
6
- const url=new URL('../bin/claude-usage.mjs',moduleUrl);
7
- let path=decodeURIComponent(url.pathname);
8
- if(/^\/[A-Za-z]:\//.test(path))path=path.slice(1);
9
- if(url.host&&url.host!=='localhost')path=`//${url.host}${path}`;
10
- return path;
11
- }
12
3
  const PANE='claude-usage-explorer';
13
4
  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']];
14
- 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,planUsage:null};
5
+ 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};
6
+ let limitState={status:'not checked',source:'',rateLimits:[],details:[],checkedAt:0};
7
+ let limitInFlight=null;
8
+ let usageFieldSchema=[];
9
+
10
+ let officialSpend=null;
11
+ let sessionActionStatus='';
12
+ let focusedSessionId=null;
13
+ const LIMIT_REFRESH_MS=180000;
15
14
  let timer=null;
16
15
  let inFlight=null;
17
16
  const total=s=>(s?.input||0)+(s?.output||0)+(s?.cacheRead||0)+(s?.cacheWrite||0);
18
17
  const compact=n=>Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n||0);
18
+ const share=(part,whole)=>whole>0?(part>0&&100*part/whole<0.05?'<0.1%':`${(100*part/whole).toFixed(1)}%`):'—';
19
19
  const price=s=>s?.unknownCostRequests?'N/A':`$${(s?.knownCost||0).toFixed(2)}`;
20
20
  const name=p=>String(p||'').replace(/\\/g,'/').split('/').filter(Boolean).pop()||p;
21
+ export const agentLabel=a=>a?.id==='main'?'Agent (main)':(a?.name||a?.type||'Subagent');
21
22
  const change=($,patch)=>{state={...state,...patch};void $.ui.invalidate('ui.render');};
22
23
  export function itemsFor(s=state){
23
24
  const r=s.report;
@@ -25,7 +26,7 @@ export function itemsFor(s=state){
25
26
  let items=[];
26
27
  if(s.view==='projects')items=r.projects.map(x=>({id:x.name,label:name(x.name),stats:x.stats,source:x}));
27
28
  if(s.view==='sessions')items=r.sessions.filter(x=>!s.project||x.project===s.project).map(x=>({id:x.id,label:x.title,stats:x.stats,source:x}));
28
- if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:x.id==='main'?'Main agent':x.id,stats:x.stats,source:x}));}
29
+ if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:agentLabel(x),stats:x.stats,source:x}));}
29
30
  if(s.view==='models'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=modelScope(s).map(x=>({id:x.name,label:x.name,stats:x.stats,source:x}));}
30
31
  if(s.query)items=items.filter(x=>x.label.toLowerCase().includes(s.query.toLowerCase()));
31
32
  return items.sort((a,b)=>s.sort==='cost'?(b.stats.knownCost-a.stats.knownCost):total(b.stats)-total(a.stats));
@@ -50,23 +51,153 @@ export function back(s){
50
51
  const last=s.stack[s.stack.length-1];
51
52
  return {...s,...last,stack:s.stack.slice(0,-1),query:''};
52
53
  }
54
+ 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{}
56
+ }
53
57
  async function refresh($){
54
58
  if(inFlight)return inFlight;
55
59
  change($,{loading:true,error:''});
56
60
  inFlight=(async()=>{
57
61
  try {
58
62
  // Module location resolves to the plugin's installed directory, independent of cwd.
59
- const script=scriptPathFromModuleUrl(import.meta.url);
63
+ const script=decodeURIComponent(new URL('../bin/claude-usage.mjs',import.meta.url).pathname);
60
64
  const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
61
65
  if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
62
66
  const snap=JSON.parse(result.stdout);
67
+ if(!state.preferencesLoaded){state.period=snap.savedPeriod||state.period;state.preferencesLoaded=true;if(state.period!==snap.period){inFlight=null;return refresh($);}}
63
68
  // Refresh preserves the active drilldown; a missing session is handled by an empty list.
64
- change($,{report:snap.report,planUsage:snap.planUsage||null,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
69
+ change($,{report:snap.report,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
65
70
  }catch(err){change($,{loading:false,error:String(err?.message||err).slice(0,260)});}
66
71
  })().finally(()=>{inFlight=null});
67
72
  return inFlight;
68
73
  }
69
- async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);}
74
+ // Opt-in diagnostic: never store or display the raw response, credentials or identifiers.
75
+ // The first-party OAuth usage route is undocumented; a failure is not fatal.
76
+ export function summarizeLimits(payload){
77
+ if(!payload||typeof payload!=='object')return [];
78
+ const lines=[];
79
+ for(const [key,val] of Object.entries(payload)){
80
+ if(!/^(five_hour|seven_day|seven_day_[a-z_]+|extra_usage|monthly|billing|usage|rate_limits)$/i.test(key))continue;
81
+ if(val&&typeof val==='object'&&!Array.isArray(val)){
82
+ const fields=[];
83
+ for(const field of ['utilization','percentUsed','limit','used','remaining','resets_at','resetsAt','is_enabled']){
84
+ const v=val[field];
85
+ if(typeof v==='number'&&Number.isFinite(v))fields.push(`${field}: ${v}`);
86
+ else if(typeof v==='boolean')fields.push(`${field}: ${v}`);
87
+ else if((field==='resets_at'||field==='resetsAt')&&typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v))fields.push(`${field}: ${v.slice(0,25)}`);
88
+ }
89
+ if(fields.length)lines.push(`${key}: ${fields.join(' · ')}`);
90
+ }
91
+ }
92
+ return lines.slice(0,12);
93
+ }
94
+ // Diagnostic exposes only structural metadata, never values, identifiers or credentials.
95
+ export function usageSchema(payload){
96
+ if(!payload||typeof payload!=='object'||Array.isArray(payload))return [];
97
+ const rows=[];
98
+ const walk=(obj,prefix,depth)=>{
99
+ for(const [key,value] of Object.entries(obj)){
100
+ if(rows.length>=90)return;
101
+ // Only keys with conventional field-name syntax; avoid leaking arbitrary map keys.
102
+ if(!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(key))continue;
103
+ const path=prefix?`${prefix}.${key}`:key;
104
+ const type=value===null?'null':Array.isArray(value)?'array':typeof value;
105
+ rows.push(`${path}: ${type}`);
106
+ if(depth<2&&type==='object')walk(value,path,depth+1);
107
+ }
108
+ };
109
+ walk(payload,'',0);
110
+ return rows;
111
+ }
112
+ // Normalize money using currency minor units. Never add spend and extra_usage together.
113
+ export function parseOfficialSpend(payload){
114
+ const spend=payload?.spend;
115
+ const money=(obj)=>{
116
+ if(!obj||!Number.isFinite(obj.amount_minor)||!Number.isInteger(obj.exponent)||obj.exponent<0||obj.exponent>8||typeof obj.currency!=='string')return null;
117
+ return {amount:obj.amount_minor/10**obj.exponent,currency:obj.currency.toUpperCase()};
118
+ };
119
+ const used=money(spend?.used),limit=money(spend?.limit);
120
+ if(used&&limit&&used.currency===limit.currency&&limit.amount>0){
121
+ return {used:used.amount,limit:limit.amount,currency:used.currency,percent:Number.isFinite(spend?.percent)?spend.percent:100*used.amount/limit.amount,source:'spend'};
122
+ }
123
+ const extra=payload?.extra_usage;
124
+ if(extra&&Number.isFinite(extra.used_credits)&&Number.isFinite(extra.monthly_limit)&&extra.monthly_limit>0&&typeof extra.currency==='string'){
125
+ return {used:extra.used_credits,limit:extra.monthly_limit,currency:extra.currency.toUpperCase(),percent:Number.isFinite(extra.utilization)?extra.utilization:100*extra.used_credits/extra.monthly_limit,source:'extra_usage'};
126
+ }
127
+ return null;
128
+ }
129
+ const currencyAmount=(amount,currency)=>{try{return new Intl.NumberFormat('en-US',{style:'currency',currency}).format(amount);}catch{return `${amount.toFixed(2)} ${currency}`;}};
130
+ async function checkLimits($,{force=false}={}){
131
+ if(limitInFlight)return limitInFlight;
132
+ if(!force&&limitState.checkedAt&&Date.now()-limitState.checkedAt<LIMIT_REFRESH_MS)return;
133
+ limitState={...limitState,status:'checking'};void $.ui.invalidate('ui.render');
134
+ limitInFlight=(async()=>{try{
135
+ const usage=await $.session.usage();
136
+ const rateLimits=Array.isArray(usage?.rateLimits)?usage.rateLimits:[];
137
+ limitState={status:rateLimits.length?'available':'no rate limits returned',source:'session.usage',rateLimits:rateLimits.map(x=>({kind:String(x.kind||'limit').slice(0,40),percentUsed:Number(x.percentUsed)||0,resetsAt:typeof x.resetsAt==='string'?x.resetsAt.slice(0,25):''})),details:[]};
138
+ const authorization=await $.session.authorize();
139
+ if(!authorization?.handle){limitState.status='No first-party authorization handle';return;}
140
+ const response=await $.http.fetch('https://api.anthropic.com/api/oauth/usage',{auth:authorization.handle});
141
+ if(!response.ok){limitState.status=`HTTP ${response.status}`;return;}
142
+ const payload=JSON.parse(response.text);
143
+ officialSpend=parseOfficialSpend(payload);
144
+ const details=summarizeLimits(payload);
145
+ limitState={...limitState,status:details.length?'available':'No recognized fields (Enterprise may differ)',source:'Claude session + OAuth usage',details};
146
+ }catch(err){limitState={...limitState,status:`Unavailable: ${String(err?.message||err).slice(0,100)}`};}
147
+ finally{limitState.checkedAt=Date.now();void $.ui.invalidate('ui.render');}
148
+ })().finally(()=>{limitInFlight=null});
149
+ return limitInFlight;
150
+ }
151
+ export function usageBar(percent,width=24){
152
+ const value=Math.max(0,Math.min(100,Number.isFinite(percent)?percent:0));
153
+ const count=Math.round(value/100*width);
154
+ return `${'█'.repeat(count)}${'░'.repeat(width-count)} ${value.toFixed(1)}%`;
155
+ }
156
+ export function officialLimitRows(limits){
157
+ const rows=[];
158
+ for(const limit of limits.rateLimits||[]){
159
+ if(Number.isFinite(limit.percentUsed))rows.push({label:limit.kind,percent:limit.percentUsed,resetsAt:limit.resetsAt||''});
160
+ }
161
+ for(const line of limits.details||[]){
162
+ const match=/^([^:]+):.*?utilization: ([0-9]+(?:\.[0-9]+)?)/.exec(line);
163
+ if(match){
164
+ const resets=/resets_(?:at|At): ([^·]+)/.exec(line);
165
+ rows.push({label:match[1],percent:Number(match[2]),resetsAt:resets?.[1]?.trim()||''});
166
+ }
167
+ }
168
+ return rows;
169
+ }
170
+ // Session resume opens a separate terminal; no supported in-place Mods session switch is assumed.
171
+ export function sessionResumeCommand(session){
172
+ if(!session?.id||!/^[a-f0-9-]{36}$/i.test(session.id))throw new Error('Invalid session ID');
173
+ if(!session.project||typeof session.project!=='string'||!session.project.startsWith('/'))throw new Error('Original project directory unavailable');
174
+ const quote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
175
+ return `cd ${quote(session.project)} && claude --resume ${quote(session.id)}`;
176
+ }
177
+ export function selectedSessionForAction(s,focusedId){
178
+ if(s.view==='sessions')return s.report?.sessions?.find(x=>x.id===focusedId&&(!s.project||x.project===s.project))||null;
179
+ return s.report?.sessions?.find(x=>x.id===s.session?.id)||s.session||null;
180
+ }
181
+ async function sessionAction($,mode){
182
+ const selected=selectedSessionForAction(state,focusedSessionId);
183
+ try{
184
+ if(!selected)throw new Error('Select a session first');
185
+ if(mode==='copy'){
186
+ const result=await $.process.run(['osascript','-e',`set the clipboard to ${JSON.stringify(selected.id)}`],{timeoutMs:5000});
187
+ if(result.exitCode!==0)throw new Error('Clipboard unavailable');
188
+ sessionActionStatus='Session ID copied';
189
+ }else{
190
+ const command=sessionResumeCommand(selected);
191
+ // osascript argument is encoded as an AppleScript string, never shell-interpolated.
192
+ const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
193
+ const result=await $.process.run(['osascript','-e',script],{timeoutMs:10000});
194
+ if(result.exitCode!==0)throw new Error((result.stderr||'Unable to open Terminal').slice(0,120));
195
+ sessionActionStatus='Opened session in Terminal.app';
196
+ }
197
+ }catch(error){sessionActionStatus=String(error?.message||error).slice(0,140);}
198
+ void $.ui.invalidate('ui.render');
199
+ }
200
+ async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);void checkLimits($);}
70
201
  export function register(on){
71
202
  on('session.start',async ($,e,next)=>{
72
203
  await $.command.register({name:'usage-explorer',description:'Open the native interactive Claude Usage Explorer sidebar',immediate:true});
@@ -77,7 +208,7 @@ export function register(on){
77
208
  if(surfaces.includes('terminal')&&!e.presentation.isFullscreen)return {text:'Use /tui fullscreen, then /usage-explorer. The native sidebar needs fullscreen layout.'};
78
209
  if(surfaces.includes('terminal')&&e.presentation.columns<100)return {text:'Widen the terminal to at least 100 columns, then /usage-explorer.'};
79
210
  await show($);
80
- timer?.cancel();timer=$.clock.every(5000,()=>void refresh($));
211
+ timer?.cancel();timer=$.clock.every(5000,()=>{void refresh($);void checkLimits($);});
81
212
  return {text:'Usage Explorer opened in the sidebar. Navigation and refresh do not call the model.'};
82
213
  });
83
214
  on('ui.close',async ($,e,next)=>{if(e.id===PANE){timer?.cancel();timer=null;}return next(e);});
@@ -86,71 +217,77 @@ export function register(on){
86
217
  if(e.surface!=='terminal'&&e.surface!=='desktop')return next(e);
87
218
  const {Box,Text,Button,Input}=$.ui.resolve(e);
88
219
  const rows=itemsFor();
89
- // Prefer the actual viewport row count when the host exposes it.
90
- // Percent heights are not consistently resolved in Claude terminal panes.
91
- const viewportRows=Number(e.presentation?.rows??e.viewport?.height??e.rows);
92
- const paneHeight=Number.isFinite(viewportRows)&&viewportRows>=16?Math.max(12,viewportRows-3):'100%';
93
- const press=item=>change($,drill(state,item));
220
+ const press=item=>{sessionActionStatus='';if(state.view==='sessions')focusedSessionId=item.id;change($,drill(state,item));};
94
221
  const models=()=>{if(state.view==='models'){change($,back(state));return;}change($,{...state,stack:[...state.stack,{view:state.view,project:state.project,session:state.session,agent:state.agent}],view:'models',query:''});};
95
- const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void refresh($);}}));
222
+ const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void persistPeriod($,id);void refresh($);}}));
96
223
  const action=(label,hotkey,fn)=>Button({key:`action-${label}`,plain:true,label,hotkey,onPress:fn});
97
- const path=[state.project&&name(state.project),state.session?.title,state.agent?.id].filter(Boolean);
224
+ const path=[state.project&&name(state.project),state.session?.title,state.agent?agentLabel(state.agent):null].filter(Boolean);
98
225
  const breadcrumb=`${state.view.toUpperCase()}${path.length?' / '+path.join(' / '):''}`;
99
- const stats=state.report?.overall;
100
- const plan=state.planUsage;
226
+ const stats=state.view==='projects'?state.report?.overall:state.view==='sessions'?state.report?.projects?.find(x=>x.name===state.project)?.stats:state.view==='agents'?state.session?.stats:state.view==='models'?(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||state.report?.overall):state.report?.overall;
227
+ const scopeTotal=state.view==='projects'?total(stats):state.view==='sessions'?total(state.report?.projects?.find(x=>x.name===state.project)?.stats):state.view==='agents'?total(state.session?.stats):state.view==='models'?total(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||stats):total(stats);
101
228
  const scopeModels=modelScope(state).slice().sort((a,b)=>total(b.stats)-total(a.stats));
102
229
  const showModelSummary=state.view!=='models'&&scopeModels.length>0;
103
- const divider=()=>Text({dimColor:true,children:'────────────────────────────────'});
104
- const header=Box({flexDirection:'column',gap:0,children:[
105
- Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'Updating…':'● Auto · 5s'})]}),
106
- Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
107
- stats?Text({bold:true,children:`${compact(total(stats))} tokens · ${price(stats)} est. API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
108
- stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} files`}):null,
109
- ].filter(Boolean)});
110
- const planSection=Box({flexDirection:'column',gap:0,children:[
111
- Text({bold:true,children:'PLAN LIMITS'}),
112
- plan?Text({children:`5-hour ${plan.fiveHour??'—'}% · 7-day ${plan.sevenDay??'—'}%`}):Text({dimColor:true,children:'Desktop usage cache unavailable'}),
113
- plan?Text({dimColor:true,wrap:'truncate-end',children:`${plan.stale?'Cached · May be outdated':'Cached · Recently updated'} · ${new Date(plan.timestamp).toLocaleString()}`}):null,
114
- ].filter(Boolean)});
115
230
  const modelSummary=showModelSummary?Box({flexDirection:'column',children:[
116
- Text({bold:true,children:'MODEL BREAKDOWN'}),
117
- ...scopeModels.slice(0,3).map((model,i)=>Text({key:`model-summary-${i}`,dimColor:true,wrap:'truncate-end',children:`${model.name} · ${compact(total(model.stats))} · ${price(model.stats)}`})),
118
- ...(scopeModels.length>3?[Text({dimColor:true,children:`+${scopeModels.length-3} more (M to view)`})]:[]),
231
+ Text({bold:true,children:'MODEL USAGE'}),
232
+ ...scopeModels.slice(0,4).map((model,i)=>Text({key:`model-summary-${i}`,dimColor:true,wrap:'truncate-end',children:`${model.name} · ${compact(total(model.stats))} tokens · ${price(model.stats)} · ${share(total(model.stats),scopeTotal)}`})),
233
+ ...(scopeModels.length>4?[Text({dimColor:true,children:`+${scopeModels.length-4} more · Select Models to view all`})]:[]),
119
234
  ]}):null;
120
- // Keep the action strip separate from the growing list. Host layout decides
121
- // whether a 100%-height pane is available; do not claim fixed positioning.
235
+ const header=Box({flexDirection:'column',children:[
236
+ Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'⟳ Updating':'● Live · 5s'})]}),
237
+ Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
238
+ stats?Text({bold:true,children:`${compact(total(stats))} tokens ${price(stats)} estimated API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
239
+ stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} transcript files`}):null,
240
+ Text({bold:true,children:'OFFICIAL ACCOUNT USAGE'}),
241
+ ...(officialSpend?[
242
+ Text({key:'official-spend-heading',bold:true,children:`${currencyAmount(officialSpend.used,officialSpend.currency)} / ${currencyAmount(officialSpend.limit,officialSpend.currency)} · ${officialSpend.percent.toFixed(1)}%`}),
243
+ Text({key:'official-spend-bar',children:usageBar(officialSpend.percent,30)}),
244
+ Text({key:'official-spend-left',dimColor:true,children:`${currencyAmount(Math.max(0,officialSpend.limit-officialSpend.used),officialSpend.currency)} remaining · official ${officialSpend.source}`}),
245
+ ]:[]),
246
+ ...officialLimitRows(limitState).filter(l=>!(officialSpend&&l.label==='extra_usage')).flatMap((l,i)=>[
247
+ Text({key:`limit-name-${i}`,dimColor:true,children:`${l.label.replaceAll('_',' ')}${l.resetsAt?' · resets '+l.resetsAt:''}`}),
248
+ Text({key:`limit-bar-${i}`,children:usageBar(l.percent)}),
249
+ ]),
250
+ ...(officialLimitRows(limitState).length===0?[Text({dimColor:true,children:`${limitState.status} · No supported limit values`})]:[]),
251
+ Text({dimColor:true,children:'Official account spend and local API-equivalent estimates are separate.'}),
252
+ Button({key:'check-official-limits',plain:true,label:'↻ Refresh official usage',onPress:()=>void checkLimits($,{force:true})}),
253
+ ].filter(Boolean)});
122
254
  const footer=Box({flexDirection:'column',flexShrink:0,children:[
123
- divider(),
124
- Box({flexDirection:'row',gap:1,flexWrap:'wrap',children:[
255
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
256
+ Box({flexDirection:'row',gap:2,flexWrap:'wrap',children:[
125
257
  action('← Back','h',()=>change($,back(state))),
126
258
  action('↻ Refresh','r',()=>void refresh($)),
127
259
  action('◷ Period','p',()=>change($,{periodMenu:!state.periodMenu})),
128
260
  action(state.view==='models'?'← Close models':'▤ Models','m',models),
129
261
  action(state.sort==='tokens'?'↓ Tokens':'↓ Cost','s',()=>change($,{sort:state.sort==='tokens'?'cost':'tokens'})),
262
+ ...(state.session?[
263
+ action('Open session','o',()=>void sessionAction($,'open')),
264
+ action('Copy ID','c',()=>void sessionAction($,'copy')),
265
+ ]:[]),
130
266
  action('× Exit','x',()=>void $.ui.close({id:PANE})),
131
267
  ]}),
132
- Text({dimColor:true,wrap:'truncate-end',children:'Esc close · H back · R refresh · P period · M models · S sort'}),
268
+ Text({dimColor:true,children:'Esc: close · H: back · O: open current session · C: copy ID · X: exit'}),
133
269
  ]});
134
- return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:paneHeight,justifyContent:'space-between',children:[
270
+ return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:'100%',justifyContent:'space-between',children:[
135
271
  Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
136
- header,
137
- divider(),
138
- planSection,
139
- divider(),
140
- Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
141
- Input({key:'usage-filter',placeholder:'Search projects / sessions…',value:state.query,onInput:v=>change($,{query:v}),onSubmit:v=>change($,{query:typeof v==='string'?v:state.query})}),
142
- ...(state.periodMenu?[
143
- Text({bold:true,children:'CHOOSE PERIOD'}),
144
- Box({flexDirection:'column',gap:0,children:periodButtons}),
145
- divider(),
146
- ]:[]),
147
- ...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
148
- ...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
149
- Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
150
- ...rows.slice(0,80).map((item,i)=>Button({key:`entry-${i}-${item.id}`,plain:true,onPress:()=>press(item),label:`${item.label.slice(0,36)} · ${compact(total(item.stats))} · ${price(item.stats)}`})),
151
- ...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
152
- ]}),
153
- ...(modelSummary?[divider(),modelSummary]:[]),
272
+ header,
273
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
274
+ Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
275
+ Input({key:'usage-filter',placeholder:'Filter entries…',value:state.query,onInput:v=>change($,{query:v}),onSubmit:v=>change($,{query:typeof v==='string'?v:state.query})}),
276
+ ...(state.periodMenu?[
277
+ Text({bold:true,children:'SELECT PERIOD'}),
278
+ Box({flexDirection:'column',gap:0,children:periodButtons}),
279
+ Text({dimColor:true,children:'────────────────────────────────────────'}),
280
+ ]:[]),
281
+ ...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
282
+ ...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
283
+ ...(state.session?[Text({dimColor:true,children:`Session ID: ${state.session.id}`})]:[]),
284
+ ...(state.agent&&state.agent.id!=='main'?[Text({dimColor:true,children:`Agent ID: ${state.agent.id}`})]:[]),
285
+ ...(sessionActionStatus?[Text({dimColor:true,children:sessionActionStatus})]:[]),
286
+ ...(modelSummary?[modelSummary]:[]),
287
+ Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
288
+ ...rows.slice(0,80).map((item,i)=>Button({key:`entry-${i}-${item.id}`,plain:true,onFocus:()=>{if(state.view==='sessions')focusedSessionId=item.id;},onPress:()=>press(item),label:`${item.label.slice(0,40)} · ${compact(total(item.stats))} · ${price(item.stats)} · ${share(total(item.stats),scopeTotal)}`})),
289
+ ...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
290
+ ]}),
154
291
  ].filter(Boolean)}),
155
292
  footer,
156
293
  ]});