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/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',planReader=readPlanUsage}={}){
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'?'Main agent':`Subagent ${x.id}`}));
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
- line(` ${c('TOTAL','90')} ${fmt(totalTokens(report.overall))} tokens ${fmt(report.overall.requests)} requests ${c(money(report.overall),'1;32')} API-equivalent`);
98
- const planLabel=planUsage?`5h ${planUsage.fiveHour??'—'}% · 7d ${planUsage.sevenDay??'—'}% · ${Date.now()-planUsage.timestamp>15*60000?'Cached, may be outdated':'Cached, recent'} · ${new Date(planUsage.timestamp).toLocaleString()}`:'Claude Desktop plan usage unavailable';
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=7;
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 labelWidth=Math.max(17,width-5-columns.reduce((sum,[,n])=>sum+n+1,0));
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
- text=` ${active?'❯':' '} ${pad(x.label,labelWidth)} `+columns.map(([,n,key])=>pad(vals[key],n,'right')).join(' ');
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(' MODEL BREAKDOWN · SELECTED ROW','1;36')+c(' (M: full model table)','90'));
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 Billing/settings S Reverse sort C Current project R Refresh L Live ? Help Q Quit','90'));
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,refreshPlan,get planUsage(){return planUsage;},rows,open,restore,switchView,nav,refresh,rescan,setLiveState,toggleLive,get liveState(){return liveState;},setPeriod,setBillingDay,setModelRate,setFilter,setSort,get view(){return view;},get currentPeriod(){return currentPeriod;},get settings(){return settings;},get cwd(){return cwd;},get selection(){return selection;},set selection(v){selection=v;},get sort(){return sort;},set sort(v){setSort(v);},get project(){return project;},set project(v){project=v;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
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 current project; R rescans; L toggles live updates; Q quits.\n\nPress Enter to return.');}
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
+ });
@@ -0,0 +1,104 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import {createExplorer,describePeriod} from '../src/tui.mjs';
4
+ const z={input:3,output:2,cacheRead:5,cacheWrite:1,model:'claude-sonnet-4-5',timestamp:Date.now()};
5
+ const data={sessions:[{id:'s1',title:'Example session',cwd:'/tmp/demo',start:Date.now(),end:Date.now(),agents:new Map([['main',{id:'main',requests:[z]}]])}],files:1,malformed:0,roots:[]};
6
+ test('TUI drills from project to session to agent to model without LLM',()=>{
7
+ const x=createExplorer(data,{period:'all',cwd:'/tmp/demo',settings:{}});
8
+ assert.equal(x.rows().length,1);
9
+ assert.match(x.render(115,30),/CLAUDE USAGE EXPLORER/);
10
+ x.open();assert.equal(x.view,'sessions');assert.equal(x.rows()[0].title,'Example session');
11
+ x.open();assert.equal(x.view,'agents');assert.equal(x.rows()[0].id,'main');
12
+ x.open();assert.equal(x.view,'models');assert.equal(x.rows()[0].name,'claude-sonnet-4-5');
13
+ x.restore();assert.equal(x.view,'agents');
14
+ });
15
+ test('TUI switches dates and shows model columns',()=>{
16
+ const x=createExplorer(data,{period:'all',settings:{}});
17
+ x.nav('periods');const i=x.rows().findIndex(x=>x.value==='today');x.selection=i;x.open();
18
+ assert.equal(x.currentPeriod,'today');assert.match(x.render(120,30),/C.READ/);
19
+ });
20
+ test('TUI shows models for selected project without drilling into sessions',()=>{
21
+ const x=createExplorer(data,{period:'all',settings:{}});
22
+ assert.match(x.render(130,32),/MODELS IN SELECTED ROW/);
23
+ assert.match(x.render(130,32),/claude-sonnet-4-5/);
24
+ x.setSort('output');assert.equal(x.sort,'output');
25
+ });
26
+ test('TUI displays unknown model pricing as N/A, never a misleading zero',()=>{
27
+ const unknown={...z,model:'claude-unknown-9-9'};
28
+ const d={...data,sessions:[{...data.sessions[0],agents:new Map([['main',{id:'main',requests:[unknown]}]])}]};
29
+ const x=createExplorer(d,{period:'all',settings:{}});
30
+ assert.match(x.render(130,32),/N\/A/);
31
+ });
32
+ test('TUI footer and model panel remain at fixed rows as selection changes',()=>{
33
+ const extra={...data.sessions[0],id:'s2',title:'Another session',cwd:'/tmp/other'};
34
+ const x=createExplorer({...data,sessions:[...data.sessions,extra]},{period:'all',settings:{}});
35
+ const a=x.render(120,32).split('\n');
36
+ x.selection=1;
37
+ const b=x.render(120,32).split('\n');
38
+ for(const label of ['MODELS IN SELECTED ROW','Billing/settings','PgUp/PgDn scroll']){
39
+ const index=lines=>lines.findIndex(line=>line.includes(label));
40
+ assert.equal(index(a),index(b),label);
41
+ }
42
+ assert.equal(a.length,b.length);
43
+ });
44
+
45
+ test('billing periods appear first and require configuration before selection',()=>{
46
+ const x=createExplorer(data,{period:'all',settings:{}});
47
+ x.nav('periods');
48
+ assert.equal(x.rows()[0].value,'cycle');
49
+ assert.equal(x.rows()[1].value,'previous-cycle');
50
+ assert.equal(x.open(),'billing-required:cycle');
51
+ assert.equal(x.currentPeriod,'all');
52
+ x.setBillingDay(5,'Europe/Athens',10,30);
53
+ assert.equal(x.open(),null);
54
+ assert.equal(x.currentPeriod,'cycle');
55
+ });
56
+ test('custom date selection requests input instead of navigating or quitting',()=>{
57
+ const x=createExplorer(data,{period:'all',settings:{}});
58
+ x.nav('periods');
59
+ x.selection=x.rows().findIndex(r=>r.value==='custom');
60
+ assert.equal(x.open(),'custom');
61
+ assert.equal(x.view,'periods');
62
+ x.setPeriod('2026-11-01:2026-11-05');
63
+ assert.equal(x.currentPeriod,'2026-11-01:2026-11-05');
64
+ });
65
+
66
+ test('billing date choices and header show descriptive date ranges',()=>{
67
+ const settings={billingDay:1,billingTimeZone:'UTC',billingHour:0,billingMinute:0};
68
+ assert.equal(describePeriod('cycle',settings,Date.UTC(2026,10,5)),'Current billing cycle · Nov 1, 2026 – Nov 30, 2026');
69
+ assert.equal(describePeriod('previous-cycle',settings,Date.UTC(2026,10,5)),'Previous billing cycle · Oct 1, 2026 – Oct 31, 2026');
70
+ assert.match(describePeriod('cycle',{},Date.UTC(2026,10,5)),/Set renewal date/);
71
+ const x=createExplorer(data,{settings,period:'cycle'});
72
+ assert.match(x.render(130,32),/Current billing cycle/);
73
+ x.nav('periods');
74
+ assert.match(x.rows()[0].label,/Nov/);
75
+ });
76
+
77
+ test('live refresh preserves project, session, sorting, and period',()=>{
78
+ const x=createExplorer(data,{cwd:'/tmp/demo',settings:{}});
79
+ x.open();x.open();
80
+ x.setSort('output');x.setPeriod('today');
81
+ // Date changes intentionally reset the view; navigate back into the session.
82
+ x.open();x.open();
83
+ assert.equal(x.view,'agents');
84
+ x.rescan([], {automatic:true});
85
+ assert.equal(x.currentPeriod,'today');
86
+ assert.equal(x.sort,'output');
87
+ assert.equal(x.view,'projects'); // source was removed by rescan
88
+ x.toggleLive();assert.equal(x.liveState.enabled,false);
89
+ x.toggleLive();assert.equal(x.liveState.enabled,true);
90
+ });
91
+
92
+ test('file signature changes only when JSONL metadata changes', async()=>{
93
+ const {historySignature}=await import('../src/tui.mjs');
94
+ const fs=await import('node:fs');
95
+ const os=await import('node:os');
96
+ const path=await import('node:path');
97
+ const root=fs.mkdtempSync(path.join(os.tmpdir(),'claude-live-'));
98
+ try{
99
+ const file=path.join(root,'a.jsonl');
100
+ fs.writeFileSync(file,'a');const a=historySignature([root]);
101
+ assert.equal(a,historySignature([root]));
102
+ fs.appendFileSync(file,'bc');assert.notEqual(a,historySignature([root]));
103
+ }finally{fs.rmSync(root,{recursive:true,force:true});}
104
+ });
package/commands/tui.md DELETED
@@ -1,12 +0,0 @@
1
- ---
2
- description: Launch the interactive Claude Usage Explorer in a separate terminal window, without model calls for navigation
3
- allowed-tools: Bash
4
- ---
5
-
6
- Launch the local TUI sidecar once, without asking the user any questions. Use the following command via Bash, passing the current project directory as CLAUDE_PROJECT_DIR when available:
7
-
8
- ```sh
9
- node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" open
10
- ```
11
-
12
- Do not run the `tui` command directly in the Bash tool (it needs an interactive terminal). The `open` command starts a separate terminal process and exits immediately. Report only whether the launch succeeded. This slash command requires one Claude invocation; subsequent navigation and refreshes use zero model calls. Do not pretend it embeds inside the Claude Code conversation. If a terminal emulator is unsupported, advise running `node "${CLAUDE_PLUGIN_ROOT}/bin/claude-usage.mjs" tui` in a separate terminal.