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.
@@ -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
  ]});
package/package.json CHANGED
@@ -1,48 +1,18 @@
1
- {
2
- "name": "claude-usage-explorer",
3
- "version": "1.0.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 && node --check ./src/plan-usage.mjs",
13
- "prepack": "npm run check && npm test"
14
- },
15
- "engines": {
16
- "node": ">=20"
17
- },
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
- ]
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,planUsage:readPlanUsage({now})};
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
  }