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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +125 -84
- package/bin/claude-usage.mjs +4 -1
- package/claude-usage +20 -0
- package/hooks/usage-explorer.mjs +200 -63
- package/package.json +18 -29
- 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/package.json
CHANGED
|
@@ -1,29 +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
|
-
}
|
|
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
|
}
|
package/src/tui.mjs
CHANGED
|
@@ -2,8 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import readline from 'node:readline';
|
|
5
|
+
import {spawnSync} from 'node:child_process';
|
|
5
6
|
import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
|
|
6
|
-
import {readPlanUsage} from './plan-usage.mjs';
|
|
7
7
|
|
|
8
8
|
const periods=[['Current billing cycle','cycle'],['Previous billing cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['Last 7 days','7d'],['Last 30 days','30d'],['This month','month'],['Previous month','previous-month'],['Custom dates','custom']];
|
|
9
9
|
export function describePeriod(period, settings={}, now=Date.now()){
|
|
@@ -25,10 +25,8 @@ function saveSettings(s){fs.mkdirSync(path.dirname(settingFile()),{recursive:tru
|
|
|
25
25
|
const sortColumns=[['cost','Est. USD'],['tokens','Tokens'],['requests','Requests'],['input','Input'],['output','Output'],['cacheWrite','Cache write'],['cacheRead','Cache read'],['name','Name']];
|
|
26
26
|
const value=(r,key)=>key==='tokens'?totalTokens(r.stats):key==='cost'?r.stats.unknownCostRequests?null:r.stats.knownCost:key==='name'?r.label.toLowerCase():r.stats[key];
|
|
27
27
|
const empty={requests:0,input:0,output:0,cacheWrite:0,cacheRead:0,knownCost:0,unknownCostRequests:0};
|
|
28
|
-
export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period='all'
|
|
28
|
+
export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all'}={}){
|
|
29
29
|
let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
|
|
30
|
-
let planUsage=null,planCheckedAt=0;
|
|
31
|
-
function refreshPlan(now=Date.now()){if(now-planCheckedAt<4000)return;planCheckedAt=now;try{planUsage=planReader({now})||null;}catch{planUsage=null;}}
|
|
32
30
|
let sourceData=data,view='projects',selection=0,offset=0,stack=[],sort='tokens',descending=true,filter='',message='',currentPeriod=period,project=null,session=null,agent=null,report=null;
|
|
33
31
|
const rates=()=>({...DEFAULT_RATES,...settings.rates});
|
|
34
32
|
function refresh(){try{report=summarize(sourceData,timeWindow(currentPeriod,Date.now(),settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0),rates());message=report.overall.unknownCostRequests?`${fmt(report.overall.unknownCostRequests)} requests have unknown model prices. Configure rates in Settings (B).`:'';}catch(e){message=e.message;currentPeriod='all';report=summarize(sourceData,undefined,rates());}}
|
|
@@ -36,7 +34,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
36
34
|
function rows(){let list=[];
|
|
37
35
|
if(view==='projects')list=report.projects.map(x=>({...x,label:path.basename(x.name)||x.name}));
|
|
38
36
|
if(view==='sessions')list=report.sessions.filter(x=>!project||x.project===project.name).map(x=>({...x,label:x.title}));
|
|
39
|
-
if(view==='agents')list=(session?.agents||[]).map(x=>({...x,label:x.id==='main'?'
|
|
37
|
+
if(view==='agents')list=(session?.agents||[]).map(x=>({...x,label:x.id==='main'?'Agent (main)':(x.name||x.type||'Subagent')}));
|
|
40
38
|
if(view==='models')list=(agent?.models||session?.models||project?.models||report.models).map(x=>({...x,label:x.name}));
|
|
41
39
|
if(view==='periods')list=periods.map(([,v])=>({label:describePeriod(v,settings),value:v,stats:empty}));
|
|
42
40
|
if(view==='settings')list=[{label:`Billing: day ${settings.billingDay||'not set'} at ${String(settings.billingHour||0).padStart(2,'0')}:${String(settings.billingMinute||0).padStart(2,'0')} ${settings.billingTimeZone||'UTC'}`,value:'billing',stats:empty},{label:`Custom model prices: ${Object.keys(settings.rates||{}).length} configured`,value:'rates',stats:empty},{label:'Rescan history (R)',value:'rescan',stats:empty}];
|
|
@@ -52,7 +50,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
52
50
|
if(view==='projects'){project=x;nav('sessions');}
|
|
53
51
|
else if(view==='sessions'){session=x;nav('agents');}
|
|
54
52
|
else if(view==='agents'){agent=x;nav('models');}
|
|
55
|
-
else if(view==='periods'){if(x.value==='custom')return 'custom';if(['cycle','previous-cycle'].includes(x.value)&&!(Number.isInteger(settings.billingDay)&&settings.billingDay>=1&&settings.billingDay<=31))return `billing-required:${x.value}`;currentPeriod=x.value;refresh();restore();}
|
|
53
|
+
else if(view==='periods'){if(x.value==='custom')return 'custom';if(['cycle','previous-cycle'].includes(x.value)&&!(Number.isInteger(settings.billingDay)&&settings.billingDay>=1&&settings.billingDay<=31))return `billing-required:${x.value}`;currentPeriod=x.value;settings.lastPeriod=currentPeriod;saveSettings(settings);refresh();restore();}
|
|
56
54
|
else if(view==='settings')return x.value;
|
|
57
55
|
else if(view==='sort'){setSort(x.value);restore();}
|
|
58
56
|
else if(view==='models')message=`${x.name} · ${fmt(totalTokens(x.stats))} tokens · ${money(x.stats)} estimated USD`;
|
|
@@ -75,13 +73,12 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
75
73
|
}
|
|
76
74
|
function setLiveState(update){liveState={...liveState,...update};}
|
|
77
75
|
function toggleLive(){liveState.enabled=!liveState.enabled;liveState.lastCheck=Date.now();}
|
|
78
|
-
function setPeriod(p){currentPeriod=p;refresh();switchView('projects');}
|
|
76
|
+
function setPeriod(p){currentPeriod=p;settings.lastPeriod=p;saveSettings(settings);refresh();switchView('projects');}
|
|
79
77
|
function setBillingDay(n,timeZone='UTC',hour=0,minute=0){settings.billingDay=n;settings.billingTimeZone=timeZone;settings.billingHour=hour;settings.billingMinute=minute;saveSettings(settings);refresh();message=`Billing saved: day ${n}, ${String(hour).padStart(2,'0')}:${String(minute).padStart(2,'0')} ${timeZone}.`;}
|
|
80
78
|
function setModelRate(model,values){settings.rates??={};settings.rates[model]=values;saveSettings(settings);refresh();message=`Saved custom USD/million-token rates for ${model}.`;}
|
|
81
79
|
function setFilter(s){filter=s;selection=0;offset=0;}
|
|
82
80
|
function setSort(key){if(sort===key)descending=!descending;else{sort=key;descending=key!=='name';}selection=0;offset=0;}
|
|
83
81
|
function render(width=process.stdout.columns||110,height=process.stdout.rows||30){
|
|
84
|
-
refreshPlan();
|
|
85
82
|
width=Math.max(20,width);height=Math.max(8,height);
|
|
86
83
|
const tty=process.stdout.isTTY && !process.env.NO_COLOR;
|
|
87
84
|
const c=(text,style)=>tty?`\x1b[${style}m${text}\x1b[0m`:text;
|
|
@@ -94,9 +91,8 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
94
91
|
rule();
|
|
95
92
|
const crumb=['Projects',project&&path.basename(project.name),session&&truncate(session.title,32),agent&&agent.label].filter(Boolean).join(' › ');
|
|
96
93
|
line(` ${c(crumb,'1')} ${c('PERIOD','90')} ${describePeriod(currentPeriod,settings)} ${c('SORT','90')} ${sort} ${descending?'↓':'↑'}`);
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
|
|
94
|
+
const scoped=view==='sessions'?project?.stats:view==='agents'?session?.stats:view==='models'?(agent?.stats||session?.stats||project?.stats||report.overall):report.overall;
|
|
95
|
+
line(` ${c('TOTAL','90')} ${fmt(totalTokens(scoped))} tokens ${fmt(scoped.requests)} requests ${c(money(scoped),'1;32')} API-equivalent`);
|
|
100
96
|
rule();
|
|
101
97
|
const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
|
|
102
98
|
const selected=available[selection];
|
|
@@ -104,21 +100,24 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
104
100
|
// Fixed-height table, model panel, and footer prevent vertical jumping on selection.
|
|
105
101
|
const modelPanelHeight=7; // heading + five model rows + divider
|
|
106
102
|
const footerHeight=5; // separator, two shortcuts, search, status
|
|
107
|
-
const headerHeight=
|
|
103
|
+
const headerHeight=6;
|
|
108
104
|
const visible=Math.max(0,height-headerHeight-modelPanelHeight-footerHeight-2);
|
|
109
105
|
if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
|
|
110
106
|
// Keep columns aligned by terminal width, shrinking the name first.
|
|
111
107
|
const columns=width>=135?[['REQ',7,'requests'],['INPUT',10,'input'],['OUTPUT',10,'output'],['C.WRITE',12,'cacheWrite'],['C.READ',13,'cacheRead'],['TOKENS',14,'tokens'],['USD',13,'cost']]:width>=105?[['REQ',7,'requests'],['OUTPUT',11,'output'],['C.WRITE',13,'cacheWrite'],['C.READ',14,'cacheRead'],['TOKENS',15,'tokens'],['USD',12,'cost']]:[['REQ',7,'requests'],['TOKENS',15,'tokens'],['USD',13,'cost']];
|
|
112
|
-
const
|
|
108
|
+
const rightMargin=4; // Always leave a few columns clear of the terminal's right edge.
|
|
109
|
+
const labelWidth=Math.max(17,width-5-rightMargin-7-columns.reduce((sum,[,n])=>sum+n+1,0));
|
|
113
110
|
if(!['periods','settings','sort'].includes(view)){
|
|
114
|
-
line(c(' '+pad('NAME',labelWidth)+' '+columns.map(([name,n,key])=>pad(name+(sort===key?(descending?'↓':'↑'):''),n,'right')).join(' '),'1;36'));
|
|
111
|
+
line(c(' '+pad('NAME',labelWidth)+' '+pad('SHARE',6,'right')+' '+columns.map(([name,n,key])=>pad(name+(sort===key?(descending?'↓':'↑'):''),n,'right')).join(' '),'1;36'));
|
|
115
112
|
}
|
|
116
113
|
for(let i=offset;i<Math.min(available.length,offset+visible);i++){
|
|
117
114
|
const x=available[i],active=i===selection;
|
|
118
115
|
let text;
|
|
119
116
|
if(['periods','settings','sort'].includes(view))text=` ${active?'❯':' '} ${x.label}${view==='periods'&&x.value===currentPeriod?' ✓':''}`;
|
|
120
117
|
else {const s=x.stats;const vals={requests:fmt(s.requests),input:fmt(s.input),output:fmt(s.output),cacheWrite:fmt(s.cacheWrite),cacheRead:fmt(s.cacheRead),tokens:fmt(totalTokens(s)),cost:money(s)};
|
|
121
|
-
|
|
118
|
+
const parentTotal=view==='projects'?totalTokens(report.overall):view==='sessions'?totalTokens(project?.stats):view==='agents'?totalTokens(session?.stats):totalTokens(agent?.stats||report.overall);
|
|
119
|
+
const pct=parentTotal>0?(totalTokens(s)>0&&100*totalTokens(s)/parentTotal<0.05?'<0.1%':(100*totalTokens(s)/parentTotal).toFixed(1)+'%'):'—';
|
|
120
|
+
text=` ${active?'❯':' '} ${pad(x.label,labelWidth)} ${pad(pct,6,'right')} `+columns.map(([,n,key])=>pad(vals[key],n,'right')).join(' ');
|
|
122
121
|
}
|
|
123
122
|
line(active?c(truncate(text,width),'7'):truncate(text,width));
|
|
124
123
|
}
|
|
@@ -128,7 +127,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
128
127
|
for(let i=renderedRows;i<visible;i++)line('');
|
|
129
128
|
line(c(` ${available.length?`${offset+1}–${Math.min(available.length,offset+visible)} of ${available.length}`:'0 rows'} • PgUp/PgDn scroll`,'90'));
|
|
130
129
|
rule();
|
|
131
|
-
line(c('
|
|
130
|
+
line(c(' MODELS IN SELECTED ROW','1;36')+c(' (M: full model table)','90'));
|
|
132
131
|
const models=breakdown?[...breakdown].sort((a,b)=>totalTokens(b.stats)-totalTokens(a.stats)).slice(0,5):[];
|
|
133
132
|
for(let i=0;i<5;i++){
|
|
134
133
|
const m=models[i];
|
|
@@ -136,12 +135,12 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
136
135
|
}
|
|
137
136
|
rule();
|
|
138
137
|
line(c(' ↑↓ Navigate ENTER Open BACKSPACE Back M Models D Dates O Sort / Search','1'));
|
|
139
|
-
line(c(' B
|
|
138
|
+
line(c(' B Settings S Reverse sort C Current project E Open session ? Help Q Quit','90'));
|
|
140
139
|
line(c(' '+(filter?'Search: '+truncate(filter,width-12):' '),'36'));
|
|
141
140
|
line(c(` ${liveState.enabled?'● LIVE':'Ⅱ PAUSED'} · Updated ${Math.max(0,Math.floor((Date.now()-liveState.lastUpdated)/1000))}s ago${liveState.error?' · '+liveState.error:''}`,'36')+(message?' '+c(truncate(message,Math.max(10,width-65)),'33'):''));
|
|
142
141
|
return out.slice(0,Math.max(1,height-1)).join('\n');
|
|
143
142
|
}
|
|
144
|
-
return {render,
|
|
143
|
+
return {render,rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get session(){return session;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
|
|
145
144
|
}
|
|
146
145
|
// Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
|
|
147
146
|
export function clipAnsi(value, maxWidth) {
|
|
@@ -185,8 +184,7 @@ export async function launchTui(argv=[]){
|
|
|
185
184
|
checking=true;
|
|
186
185
|
try{
|
|
187
186
|
const next=historySignature(roots);
|
|
188
|
-
if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;}
|
|
189
|
-
explorer.refreshPlan();draw();
|
|
187
|
+
if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;draw();}
|
|
190
188
|
explorer.setLiveState({lastCheck:Date.now()});
|
|
191
189
|
}catch(e){explorer.setLiveState({error:e.message});draw();}
|
|
192
190
|
finally{checking=false;}
|
|
@@ -229,11 +227,31 @@ export async function launchTui(argv=[]){
|
|
|
229
227
|
else if(str==='b')explorer.nav('settings');
|
|
230
228
|
else if(str==='o')explorer.nav('sort');
|
|
231
229
|
else if(str==='s')explorer.setSort(explorer.sort);
|
|
230
|
+
else if(str==='C'){
|
|
231
|
+
const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
|
|
232
|
+
if(selected?.id&&process.platform==='darwin'){
|
|
233
|
+
const result=spawnSync('pbcopy',[],{input:selected.id,encoding:'utf8'});
|
|
234
|
+
explorer.message=result.status===0?'Session ID copied.':'Unable to copy session ID.';
|
|
235
|
+
}else explorer.message='Select a session on macOS to copy its ID.';
|
|
236
|
+
}
|
|
237
|
+
else if(str==='e'){
|
|
238
|
+
const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
|
|
239
|
+
const directory=selected?.project||explorer.project?.name;
|
|
240
|
+
if(!selected?.id||!directory||process.platform!=='darwin')explorer.message='External resume requires a selected session on macOS.';
|
|
241
|
+
else if(!fs.existsSync(directory))explorer.message='Original project directory is missing.';
|
|
242
|
+
else {
|
|
243
|
+
const quote=x=>"'"+String(x).replaceAll("'","'\\''")+"'";
|
|
244
|
+
const command=`cd ${quote(directory)} && claude --resume ${quote(selected.id)}`;
|
|
245
|
+
const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
|
|
246
|
+
const result=spawnSync('osascript',['-e',script],{encoding:'utf8',timeout:10000});
|
|
247
|
+
explorer.message=result.status===0?'Opened in Terminal.app.':`Unable to open Terminal: ${(result.stderr||'').slice(0,80)}`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
232
250
|
else if(str==='c'){explorer.switchView('projects');const i=explorer.rows().findIndex(x=>path.resolve(x.name).toLowerCase()===path.resolve(explorer.cwd).toLowerCase());if(i>=0){explorer.selection=i;explorer.open();}else explorer.message='No exact match for current directory.';}
|
|
233
251
|
else if(str==='/')explorer.setFilter(await ask('Filter table (empty clears):'));
|
|
234
252
|
else if(str==='r'){explorer.rescan(roots);lastSignature=historySignature(roots);}
|
|
235
253
|
else if(str==='l')explorer.toggleLive();
|
|
236
|
-
else if(str==='?'){await ask('HELP\n↑↓ select rows; Enter drills down; Backspace goes back.\nM shows models at the current level.\nD selects date range; B configures billing and model prices.\nO chooses a sort column; S reverses direction.\n/ filters; C opens
|
|
254
|
+
else if(str==='?'){await ask('HELP\n↑↓ select rows; Enter drills down; Backspace goes back.\nM shows models at the current level.\nD selects date range; B configures billing and model prices.\nO chooses a sort column; S reverses direction.\n/ filters; C copies session ID; E opens selected session externally; R rescans; L toggles live updates; Q quits.\n\nPress Enter to return.');}
|
|
237
255
|
draw();
|
|
238
256
|
}
|
|
239
257
|
process.stdin.on('keypress',onKey);process.stdout.on('resize',resize);resize();
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';import os from 'node:os';import path from 'node:path';
|
|
4
|
+
import {parseFile,ingest,summarize,timeWindow,totalTokens,projectRoots} from '../src/core.mjs';
|
|
5
|
+
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'claude-usage-test-'));
|
|
6
|
+
const root=path.join(dir,'projects','-sample');fs.mkdirSync(root,{recursive:true});
|
|
7
|
+
const file=path.join(root,'session-123.jsonl');
|
|
8
|
+
const write=(f,rows)=>{fs.mkdirSync(path.dirname(f),{recursive:true});fs.writeFileSync(f,rows.map(x=>JSON.stringify(x)).join('\n')+'\n');};
|
|
9
|
+
const assistant=(id,output,ts='2026-10-12T12:00:00Z')=>({type:'assistant',timestamp:ts,cwd:'/sample',sessionId:'session-123',message:{id,model:'claude-sonnet-4-5-20250929',usage:{input_tokens:100,output_tokens:output,cache_creation_input_tokens:20,cache_read_input_tokens:500}}});
|
|
10
|
+
write(file,[{type:'user',timestamp:'2026-10-12T11:59:00Z',cwd:'/sample',sessionId:'session-123',message:{content:'Implement the API'}},assistant('msg1',3),assistant('msg1',10),assistant('msg2',5)]);
|
|
11
|
+
test('deduplicates repeated message IDs using max observed counters',()=>{const p=parseFile(file);assert.equal(p.requests.length,2);assert.equal(p.requests[0].output,10);assert.equal(p.title,'Implement the API');});
|
|
12
|
+
test('aggregates model usage without counting streaming duplicates',()=>{const d=ingest([path.join(dir,'projects')]);const s=summarize(d);assert.equal(s.overall.requests,2);assert.equal(s.overall.output,15);assert.equal(totalTokens(s.overall),100*2+15+20*2+500*2);assert.equal(s.projects.length,1);});
|
|
13
|
+
test('subagent file appears in same session',()=>{const child=path.join(root,'session-123','subagents','agent-abc.jsonl');write(child,[assistant('msg3',8)]);const d=ingest([path.join(dir,'projects')]);assert.equal(d.sessions.length,1);assert.equal(d.sessions[0].agents.size,2);const s=summarize(d);assert.equal(s.overall.requests,3);assert.equal(s.sessions[0].agents.length,2);});
|
|
14
|
+
test('date filtering attributes usage by request timestamp',()=>{const d=ingest([path.join(dir,'projects')]);assert.equal(summarize(d,timeWindow('2026-10-13:2026-10-14')).overall.requests,0);assert.equal(summarize(d,timeWindow('2026-10-12:2026-10-12')).overall.requests,3);});
|
|
15
|
+
test('billing period clamps month end',()=>{const now=Date.parse('2026-03-02T12:00:00Z');const [a,b]=timeWindow('cycle',now,31);assert.equal(new Date(a).toISOString(),'2026-02-28T00:00:00.000Z');assert.equal(new Date(b).toISOString(),'2026-03-31T00:00:00.000Z');});
|
|
16
|
+
test('config dir discovery honors custom root',()=>{const roots=projectRoots({CLAUDE_CONFIG_DIR:'/tmp/custom'},'/home/test');assert.ok(roots.includes(path.resolve('/tmp/custom/projects')));});
|
|
17
|
+
test('unknown pricing is reported instead of zero-cost certainty',()=>{const unknown=path.join(root,'unknown.jsonl');write(unknown,[{...assistant('unknown',2),sessionId:'unknown',message:{...assistant('unknown',2).message,model:'claude-future-unknown'}}]);const s=summarize(ingest([path.join(dir,'projects')]));assert.equal(s.overall.unknownCostRequests,1);});
|
|
18
|
+
|
|
19
|
+
test('Sonnet 5.5 official base prices yield nonzero API equivalent cost', async()=>{
|
|
20
|
+
const {costFor}=await import('../src/core.mjs');
|
|
21
|
+
const price=costFor('claude-sonnet-5-5',{input:1000000,output:1000000,cacheWrite:1000000,cacheRead:1000000});
|
|
22
|
+
assert.equal(price,14.6);
|
|
23
|
+
});
|
|
24
|
+
test('billing timezone and local time determine cycle boundaries',async()=>{
|
|
25
|
+
const {timeWindow}=await import('../src/core.mjs');
|
|
26
|
+
const now=Date.parse('2026-11-05T10:00:00Z');
|
|
27
|
+
const [start,end]=timeWindow('cycle',now,5,'Europe/Athens',9,0);
|
|
28
|
+
assert.equal(new Date(start).toISOString(),'2026-11-05T07:00:00.000Z');
|
|
29
|
+
assert.equal(new Date(end).toISOString(),'2026-12-05T07:00:00.000Z');
|
|
30
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {launchSidecar} from '../src/launcher.mjs';
|
|
4
|
+
test('macOS sidecar launches separate Terminal with current project',()=>{
|
|
5
|
+
let invocation;
|
|
6
|
+
const spawnProcess=(program,args,opts)=>{invocation={program,args,opts};return {on(){},unref(){}};};
|
|
7
|
+
launchSidecar({platform:'darwin',cwd:'/tmp/my project',spawnProcess});
|
|
8
|
+
assert.equal(invocation.program,'osascript');
|
|
9
|
+
assert.match(invocation.args.join(' '),/Terminal/);
|
|
10
|
+
assert.match(invocation.args.join(' '),/my project/);
|
|
11
|
+
assert.equal(invocation.opts.detached,true);
|
|
12
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import {drill,back,itemsFor,modelScope,register} from '../hooks/usage-explorer.mjs';
|
|
5
|
+
const stats={input:5,output:2,cacheWrite:0,cacheRead:10,knownCost:0.25,unknownCostRequests:0};
|
|
6
|
+
const agent={id:'main',stats,models:[{name:'sonnet',stats}]};
|
|
7
|
+
const session={id:'s1',title:'Build feature',project:'/repo',stats,agents:[agent],models:agent.models};
|
|
8
|
+
const report={overall:stats,projects:[{name:'/repo',stats}],sessions:[session],models:agent.models,files:1};
|
|
9
|
+
test('native mod drills projects, sessions, agents and models, and navigates back',()=>{
|
|
10
|
+
let state={view:'projects',stack:[],project:null,session:null,agent:null,report,sort:'tokens',query:''};
|
|
11
|
+
state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'sessions');
|
|
12
|
+
state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'agents');
|
|
13
|
+
state=drill(state,itemsFor(state)[0]);assert.equal(state.view,'models');
|
|
14
|
+
assert.equal(itemsFor(state)[0].label,'sonnet');
|
|
15
|
+
state=back(state);assert.equal(state.view,'agents');
|
|
16
|
+
state=back(state);assert.equal(state.view,'sessions');
|
|
17
|
+
state=back(state);assert.equal(state.view,'projects');
|
|
18
|
+
});
|
|
19
|
+
test('native mod registers a model-free immediate command',async()=>{
|
|
20
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
21
|
+
const start=hooks.find(x=>x.event==='session.start');
|
|
22
|
+
let command;
|
|
23
|
+
await start.fn({command:{register:async c=>{command=c;}}},{},async()=>({}));
|
|
24
|
+
assert.equal(command.name,'usage-explorer');assert.equal(command.immediate,true);
|
|
25
|
+
});
|
|
26
|
+
test('native sidebar render includes clickable entries and period controls',async()=>{
|
|
27
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,matcher,fn}));
|
|
28
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
29
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
30
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
31
|
+
assert.equal(element.type,'Box');
|
|
32
|
+
assert.ok(element.children.some(x=>x?.type==='Box'));
|
|
33
|
+
const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
|
|
34
|
+
const input=flat(element).find(x=>x?.type==='Input');
|
|
35
|
+
assert.equal(input?.key,'usage-filter','host requires a stable key on Input');
|
|
36
|
+
assert.equal(typeof input?.onSubmit,'function','Claude Code 2.1.311 requires onSubmit for Input');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('native sidebar collapses period choices and puts actions after entries',()=>{
|
|
40
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
41
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
42
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
43
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
44
|
+
const flat=(node)=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
|
|
45
|
+
const all=flat(element);
|
|
46
|
+
assert.ok(all.some(x=>x.type==='Button'&&x.label==='◷ Period'));
|
|
47
|
+
assert.ok(!all.some(x=>x.type==='Button'&&x.key==='period-cycle'));
|
|
48
|
+
assert.ok(all.some(x=>x.type==='Input'&&x.key==='usage-filter'));
|
|
49
|
+
const last=element.children[element.children.length-1];
|
|
50
|
+
assert.ok(flat(last).some(x=>x.type==='Button'&&x.label==='↻ Refresh'));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
test('model scope respects project, session and agent drilldown',()=>{
|
|
55
|
+
const other={name:'opus',stats:{...stats,input:30}};
|
|
56
|
+
const scoped={...report,models:[other],projects:[{name:'/repo',stats,models:[{name:'sonnet',stats}]}]};
|
|
57
|
+
const base={view:'projects',stack:[],project:null,session:null,agent:null,report:scoped,sort:'tokens',query:''};
|
|
58
|
+
assert.equal(modelScope(base)[0].name,'opus');
|
|
59
|
+
const projectState={...base,project:'/repo'};
|
|
60
|
+
assert.equal(modelScope(projectState)[0].name,'sonnet');
|
|
61
|
+
const sessionState={...projectState,session};
|
|
62
|
+
assert.equal(modelScope(sessionState)[0].name,'sonnet');
|
|
63
|
+
const agentState={...sessionState,agent};
|
|
64
|
+
assert.equal(modelScope(agentState)[0].name,'sonnet');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('models screen has a prominent back control and footer close-models action',()=>{
|
|
68
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
69
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
70
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
71
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
72
|
+
const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
|
|
73
|
+
assert.match(requireSource(),/MODEL USAGE/);
|
|
74
|
+
const modSource=requireSource();
|
|
75
|
+
assert.match(modSource,/Back to previous view/);
|
|
76
|
+
assert.match(modSource,/Close models/);
|
|
77
|
+
});
|
|
78
|
+
function requireSource(){return requireSource.text||(requireSource.text=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8'));}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
test('native sidebar uses host close-on-Escape and offers explicit exit action',async()=>{
|
|
82
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
83
|
+
const run=hooks.find(x=>x.event==='command.run').fn;
|
|
84
|
+
let opened;
|
|
85
|
+
const $={session:{surfaces:async()=>['desktop']},ui:{open:async p=>{opened=p},invalidate:async()=>{},close:async()=>{}},process:{run:async()=>({exitCode:1,stderr:'fixture'})},clock:{every:()=>({cancel(){}})}};
|
|
86
|
+
await run($,{presentation:{isFullscreen:true,columns:160}});
|
|
87
|
+
assert.equal(opened.closeOnEscape,true);
|
|
88
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
89
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
90
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
91
|
+
const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
|
|
92
|
+
const exit=flat(element).find(x=>x.type==='Button'&&x.label==='× Exit');
|
|
93
|
+
assert.equal(exit.hotkey,'x');assert.equal(typeof exit.onPress,'function');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('period choices stack vertically and footer is outside expanding content',()=>{
|
|
97
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
98
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
99
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
100
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
101
|
+
assert.equal(element.justifyContent,'space-between');
|
|
102
|
+
assert.equal(element.height,'100%');
|
|
103
|
+
assert.equal(element.children.length,2);
|
|
104
|
+
assert.equal(element.children[0].flexGrow,1);
|
|
105
|
+
assert.equal(element.children[1].flexShrink,0);
|
|
106
|
+
const source=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8');
|
|
107
|
+
assert.match(source,/flexDirection:'column',gap:0,children:periodButtons/);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('official usage bars clamp percentages and show enterprise extra usage',async()=>{
|
|
111
|
+
const {usageBar,officialLimitRows}=await import('../hooks/usage-explorer.mjs');
|
|
112
|
+
assert.match(usageBar(37.66,10),/37\.7%/);
|
|
113
|
+
assert.match(usageBar(130,10),/100\.0%/);
|
|
114
|
+
assert.match(usageBar(-1,10),/0\.0%/);
|
|
115
|
+
const rows=officialLimitRows({rateLimits:[],details:['extra_usage: utilization: 37.66 · is_enabled: true']});
|
|
116
|
+
assert.deepEqual(rows,[{label:'extra_usage',percent:37.66,resetsAt:''}]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('usage response diagnostic exposes field names and types only',async()=>{
|
|
120
|
+
const {usageSchema}=await import('../hooks/usage-explorer.mjs');
|
|
121
|
+
const fields=usageSchema({extra_usage:{utilization:37.66,resets_at:'secret-date'},billing:{cycle_start:'private',cap:200},some_id:'private-id'});
|
|
122
|
+
assert.ok(fields.includes('extra_usage.utilization: number'));
|
|
123
|
+
assert.ok(fields.includes('billing.cycle_start: string'));
|
|
124
|
+
assert.ok(!fields.join(' ').includes('private'));
|
|
125
|
+
assert.ok(!fields.join(' ').includes('37.66'));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
test('native mod uses only supported single-letter or digit hotkeys',()=>{
|
|
130
|
+
const hooks=[];register((event,matcher,fn)=>hooks.push({event,fn:fn||matcher}));
|
|
131
|
+
const render=hooks.find(x=>x.event==='ui.render').fn;
|
|
132
|
+
const ui={resolve:()=>Object.fromEntries(['Box','Text','Button','Input'].map(k=>[k,p=>({type:k,...p})]))};
|
|
133
|
+
const element=render({ui},{surface:'terminal'},()=>null);
|
|
134
|
+
const flat=node=>[node,...(Array.isArray(node?.children)?node.children:[]).flatMap(flat)];
|
|
135
|
+
const buttons=flat(element).filter(x=>x.type==='Button');
|
|
136
|
+
for(const b of buttons){if(b.hotkey!==undefined)assert.match(b.hotkey,/^[a-z0-9]$/,`invalid hotkey on ${b.label}`);}
|
|
137
|
+
assert.equal(buttons.some(b=>b.label==='Help'),false);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
test('native sidebar removes diagnostic and provides session open actions',async()=>{
|
|
142
|
+
const {sessionResumeCommand}=await import('../hooks/usage-explorer.mjs');
|
|
143
|
+
const command=sessionResumeCommand({id:'bf9de351-f0e6-48cc-a941-21e3705fe379',project:'/Users/example/my repo'});
|
|
144
|
+
assert.match(command,/claude --resume/);
|
|
145
|
+
assert.match(command,/my repo/);
|
|
146
|
+
assert.throws(()=>sessionResumeCommand({id:'bad',project:'/repo'}));
|
|
147
|
+
const source=readFileSync(new URL('../hooks/usage-explorer.mjs',import.meta.url),'utf8');
|
|
148
|
+
assert.doesNotMatch(source,/Show response fields|Hide response fields|USAGE RESPONSE FIELD TYPES/);
|
|
149
|
+
assert.doesNotMatch(source,/session-open-terminal|session-copy-id/);
|
|
150
|
+
assert.match(source,/action\('Open session','o'/);
|
|
151
|
+
assert.match(source,/action\('Copy ID','c'/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
import { readFileSync as readModSource } from "node:fs";
|
|
155
|
+
import { fileURLToPath as modFilePath } from "node:url";
|
|
156
|
+
import { dirname as modDirname, join as modJoin } from "node:path";
|
|
157
|
+
test("session actions use focused session from list and parent session deeper", async () => {
|
|
158
|
+
const {selectedSessionForAction}=await import('../hooks/usage-explorer.mjs');
|
|
159
|
+
const a={...session,id:'aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'};
|
|
160
|
+
const b={...session,id:'bbbbbbbb-bbbb-4bbb-bbbb-bbbbbbbbbbbb'};
|
|
161
|
+
const s={view:'sessions',project:'/repo',report:{...report,sessions:[a,b]}};
|
|
162
|
+
assert.equal(selectedSessionForAction(s,b.id),b);
|
|
163
|
+
assert.equal(selectedSessionForAction(s,null),null);
|
|
164
|
+
assert.equal(selectedSessionForAction({...s,view:'agents',session:a},b.id),a);
|
|
165
|
+
const source=requireSource();
|
|
166
|
+
assert.match(source,/onFocus:\(\)=>\{if\(state.view==='sessions'\)focusedSessionId=item.id/);
|
|
167
|
+
assert.doesNotMatch(source,/key:'session-open-terminal'|key:'session-copy-id'/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('agent rows hide IDs until drilldown and show main label',async()=>{
|
|
171
|
+
const {agentLabel}=await import('../hooks/usage-explorer.mjs');
|
|
172
|
+
assert.equal(agentLabel({id:'main'}),'Agent (main)');
|
|
173
|
+
assert.equal(agentLabel({id:'af22fd9030fbd751f'}),'Subagent');
|
|
174
|
+
assert.equal(agentLabel({id:'af22fd9030fbd751f',name:'code-reviewer'}),'code-reviewer');
|
|
175
|
+
const state={view:'agents',stack:[],project:'/repo',session:{...session,id:'different-session',agents:[{...agent,id:'af22fd9030fbd751f',name:'code-reviewer'}]},agent:null,report,sort:'tokens',query:''};
|
|
176
|
+
assert.equal(itemsFor(state)[0].label,'code-reviewer');
|
|
177
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {parseOfficialSpend,usageBar} from '../hooks/usage-explorer.mjs';
|
|
4
|
+
test('official spend normalizes minor currency units',()=>{
|
|
5
|
+
const value=parseOfficialSpend({spend:{used:{amount_minor:7532,currency:'USD',exponent:2},limit:{amount_minor:20000,currency:'USD',exponent:2},percent:37.66}});
|
|
6
|
+
assert.deepEqual(value,{used:75.32,limit:200,currency:'USD',percent:37.66,source:'spend'});
|
|
7
|
+
});
|
|
8
|
+
test('extra usage fallback and no double counting',()=>{
|
|
9
|
+
const value=parseOfficialSpend({extra_usage:{used_credits:75.32,monthly_limit:200,currency:'USD',utilization:37.66}});
|
|
10
|
+
assert.equal(value.used,75.32);assert.equal(value.limit,200);
|
|
11
|
+
});
|
|
12
|
+
test('mismatched currencies do not report official spend',()=>{
|
|
13
|
+
assert.equal(parseOfficialSpend({spend:{used:{amount_minor:500,currency:'EUR',exponent:2},limit:{amount_minor:1000,currency:'USD',exponent:2}}}),null);
|
|
14
|
+
});
|
|
15
|
+
test('bar displays filled and empty segments',()=>{
|
|
16
|
+
assert.match(usageBar(37.66,10),/^████░░░░░░ 37\.7%$/);
|
|
17
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {screenDiff,clipAnsi,createExplorer} from '../src/tui.mjs';
|
|
4
|
+
test('only updates changed lines and never clears the full screen',()=>{
|
|
5
|
+
const result=screenDiff('one\ntwo\nthree','one\nTWO\nthree');
|
|
6
|
+
assert.equal(result,'\x1b[2;1H\x1b[2KTWO');
|
|
7
|
+
assert.ok(!result.includes('\x1b[2J'));
|
|
8
|
+
});
|
|
9
|
+
test('clears removed lines and renders initial frame',()=>{
|
|
10
|
+
assert.equal(screenDiff('a\nb','a'),'\x1b[2;1H\x1b[2K');
|
|
11
|
+
assert.equal(screenDiff(null,'a\nb'),'\x1b[1;1H\x1b[2Ka\x1b[2;1H\x1b[2Kb');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('colored rows are clipped without breaking ANSI reset',()=>{
|
|
15
|
+
const text='\x1b[31m'+('x'.repeat(100))+'\x1b[0m';
|
|
16
|
+
assert.equal(clipAnsi(text,10),'\x1b[31mxxxxxxxxxx\x1b[0m');
|
|
17
|
+
});
|
|
18
|
+
test('short terminals never receive a frame filling the last row',()=>{
|
|
19
|
+
const explorer=createExplorer({sessions:[],files:0,malformed:0,roots:[]},{cwd:process.cwd()});
|
|
20
|
+
const lines=explorer.render(45,18).split('\n');
|
|
21
|
+
assert.ok(lines.length<=17);
|
|
22
|
+
assert.ok(lines.every(line=>line.replace(/\x1b\[[0-9;]*m/g,'').length<=44));
|
|
23
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {snapshot} from '../src/snapshot.mjs';
|
|
7
|
+
test('snapshot reads configured billing cycle and reports no external data',()=>{
|
|
8
|
+
const home=fs.mkdtempSync(path.join(os.tmpdir(),'usage-mod-'));
|
|
9
|
+
try {
|
|
10
|
+
fs.mkdirSync(path.join(home,'.claude-usage-explorer'));
|
|
11
|
+
fs.writeFileSync(path.join(home,'.claude-usage-explorer','settings.json'),JSON.stringify({billingDay:1,billingTimeZone:'UTC'}));
|
|
12
|
+
const data=snapshot(['--period','cycle'],{now:Date.UTC(2026,10,5),home,roots:[]});
|
|
13
|
+
assert.match(data.periodLabel,/Nov 1, 2026/);assert.equal(data.report.overall.requests,0);
|
|
14
|
+
}finally{fs.rmSync(home,{recursive:true,force:true});}
|
|
15
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {formatStatusLine,installStatusLine} from '../src/statusline.mjs';
|
|
7
|
+
test('statusline distinguishes live session cost and historical cycle estimate',()=>{
|
|
8
|
+
const result=formatStatusLine({model:{display_name:'Claude Sonnet'},cost:{total_cost_usd:1.234}}, {cycle:{input:1000,output:0,cacheWrite:0,cacheRead:0,knownCost:12.5,unknownCostRequests:0}});
|
|
9
|
+
assert.match(result,/Session \$1\.23/);assert.match(result,/Cycle \$12\.50/);
|
|
10
|
+
});
|
|
11
|
+
test('install preserves other settings, refuses overwrite, and backs up',()=>{
|
|
12
|
+
const dir=fs.mkdtempSync(path.join(os.tmpdir(),'usage-status-'));
|
|
13
|
+
try{const file=path.join(dir,'settings.json');fs.writeFileSync(file,JSON.stringify({theme:'dark',statusLine:{type:'command',command:'echo previous'}}));
|
|
14
|
+
assert.throws(()=>installStatusLine({settingsPath:file,scriptPath:'/tmp/usage.mjs'}),/already/);
|
|
15
|
+
installStatusLine({settingsPath:file,scriptPath:'/tmp/usage.mjs',force:true});
|
|
16
|
+
const settings=JSON.parse(fs.readFileSync(file));assert.equal(settings.theme,'dark');assert.match(settings.statusLine.command,/statusline/);
|
|
17
|
+
assert.ok(fs.readdirSync(dir).some(f=>f.includes('backup')));
|
|
18
|
+
}finally{fs.rmSync(dir,{recursive:true,force:true});}
|
|
19
|
+
});
|