claude-usage-explorer 1.0.1 → 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.
- package/.claude-plugin/marketplace.json +5 -30
- package/.claude-plugin/plugin.json +5 -11
- package/README.md +125 -116
- package/bin/claude-usage.mjs +4 -1
- package/claude-usage +20 -0
- package/hooks/usage-explorer.mjs +200 -63
- package/package.json +18 -48
- package/src/core.mjs +9 -5
- package/src/snapshot.mjs +1 -2
- package/src/tui.mjs +39 -21
- package/tests/core.test.mjs +30 -0
- package/tests/launcher.test.mjs +12 -0
- package/tests/mod.test.mjs +177 -0
- package/tests/official-spend.test.mjs +17 -0
- package/tests/render.test.mjs +23 -0
- package/tests/snapshot.test.mjs +15 -0
- package/tests/statusline.test.mjs +19 -0
- package/tests/tui.test.mjs +104 -0
- package/commands/tui.md +0 -12
- package/src/plan-usage.mjs +0 -39
package/hooks/usage-explorer.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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=
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
117
|
-
...scopeModels.slice(0,
|
|
118
|
-
...(scopeModels.length>
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
Box({flexDirection:'row',gap:
|
|
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,
|
|
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:
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...(
|
|
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
|
]});
|
package/package.json
CHANGED
|
@@ -1,48 +1,18 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-usage-explorer",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Local, read-only Claude Code usage analytics CLI and plugin",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"claude-usage": "./bin/claude-usage.mjs"
|
|
8
|
-
},
|
|
9
|
-
"scripts": {
|
|
10
|
-
"test": "node --test",
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
]
|
|
48
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-usage-explorer",
|
|
3
|
+
"version": "1.1.5",
|
|
4
|
+
"description": "Local, read-only Claude Code usage analytics CLI and plugin",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"claude-usage": "./bin/claude-usage.mjs"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test",
|
|
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"
|
|
13
|
+
},
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT"
|
|
18
|
+
}
|
package/src/core.mjs
CHANGED
|
@@ -85,11 +85,15 @@ export function costFor(model,m,rates=DEFAULT_RATES) {
|
|
|
85
85
|
return (m.input*rate[0]+m.output*rate[1]+writeCost+m.cacheRead*rate[3])/1e6;
|
|
86
86
|
}
|
|
87
87
|
export function parseFile(file, rates=DEFAULT_RATES) {
|
|
88
|
-
const id=sessionIdentity(file);const requests=new Map();let title=null,cwd=null,start=null,end=null,sessionId=null;
|
|
88
|
+
const id=sessionIdentity(file);const requests=new Map();let title=null,cwd=null,start=null,end=null,sessionId=null,agentName=null;
|
|
89
89
|
const bad=readLines(file,r=>{
|
|
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
97
|
const ts=Date.parse(r.timestamp);
|
|
94
98
|
if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
|
|
95
99
|
if(!title && r.type==='user') {
|
|
@@ -112,7 +116,7 @@ export function parseFile(file, rates=DEFAULT_RATES) {
|
|
|
112
116
|
if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
|
|
113
117
|
} else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
|
|
114
118
|
});
|
|
115
|
-
return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
119
|
+
return {file,sessionId:sessionId||id.id,agent:id.agent,agentName,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
// Cache derived metadata and token counters only. Never persist transcript text beyond
|
|
@@ -151,8 +155,8 @@ export function ingest(roots,rates=DEFAULT_RATES) {
|
|
|
151
155
|
if(parsed.start!==null) s.start=Math.min(s.start??parsed.start,parsed.start);
|
|
152
156
|
if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
|
|
153
157
|
let agent=s.agents.get(parsed.agent);
|
|
154
|
-
if(!agent) {agent={id:parsed.agent,requests:[]};s.agents.set(parsed.agent,agent);}
|
|
155
|
-
agent.requests.push(...parsed.requests);
|
|
158
|
+
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
160
|
}
|
|
157
161
|
return {sessions:[...sessions.values()],files:files.length,malformed,roots};
|
|
158
162
|
}
|
|
@@ -208,7 +212,7 @@ export function summarize(data,window=[-Infinity,Infinity],rates=DEFAULT_RATES)
|
|
|
208
212
|
if(!byModel.has(r.model))byModel.set(r.model,ZERO());add(byModel.get(r.model),one);
|
|
209
213
|
if(!models.has(r.model))models.set(r.model,ZERO());add(models.get(r.model),one);
|
|
210
214
|
}
|
|
211
|
-
if(am.requests){add(stat,am);agents.push({id:a.id,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
|
|
215
|
+
if(am.requests){add(stat,am);agents.push({id:a.id,name:a.name||null,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
|
|
212
216
|
}
|
|
213
217
|
if(!stat.requests)continue;
|
|
214
218
|
const project=s.cwd||'(unknown project)';
|
package/src/snapshot.mjs
CHANGED
|
@@ -3,7 +3,6 @@ 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';
|
|
7
6
|
|
|
8
7
|
export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projectRoots()}={}) {
|
|
9
8
|
const periodIndex=argv.indexOf('--period');
|
|
@@ -14,5 +13,5 @@ export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projec
|
|
|
14
13
|
const rates={...DEFAULT_RATES,...settings.rates};
|
|
15
14
|
const window=timeWindow(period,now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
|
|
16
15
|
const report=summarize(ingest(roots,rates),window,rates);
|
|
17
|
-
return {period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report
|
|
16
|
+
return {savedPeriod:settings.lastPeriod||'all',period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report};
|
|
18
17
|
}
|