claude-usage-explorer 1.0.1 → 1.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +30 -30
- package/.claude-plugin/plugin.json +11 -11
- package/README.md +127 -116
- package/bin/claude-usage.mjs +4 -1
- package/hooks/usage-explorer.mjs +204 -59
- package/package.json +48 -48
- package/src/core.mjs +7 -5
- package/src/tui.mjs +41 -17
|
@@ -1,30 +1,30 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-usage-explorer-marketplace",
|
|
3
|
-
"description": "Local, read-only Claude Code token usage analytics and terminal tools",
|
|
4
|
-
"owner": {
|
|
5
|
-
"name": "IliasPol",
|
|
6
|
-
"url": "https://github.com/IliasPol"
|
|
7
|
-
},
|
|
8
|
-
"plugins": [
|
|
9
|
-
{
|
|
10
|
-
"name": "claude-usage",
|
|
11
|
-
"source": "./",
|
|
12
|
-
"description": "Explore Claude Code projects, sessions, token usage, model breakdowns and estimated costs locally",
|
|
13
|
-
"version": "1.
|
|
14
|
-
"author": {
|
|
15
|
-
"name": "IliasPol"
|
|
16
|
-
},
|
|
17
|
-
"homepage": "https://github.com/IliasPol/claude-usage",
|
|
18
|
-
"repository": "https://github.com/IliasPol/claude-usage",
|
|
19
|
-
"license": "MIT",
|
|
20
|
-
"category": "development",
|
|
21
|
-
"tags": [
|
|
22
|
-
"claude-code",
|
|
23
|
-
"usage",
|
|
24
|
-
"analytics",
|
|
25
|
-
"tokens",
|
|
26
|
-
"terminal"
|
|
27
|
-
]
|
|
28
|
-
}
|
|
29
|
-
]
|
|
30
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-usage-explorer-marketplace",
|
|
3
|
+
"description": "Local, read-only Claude Code token usage analytics and terminal tools",
|
|
4
|
+
"owner": {
|
|
5
|
+
"name": "IliasPol",
|
|
6
|
+
"url": "https://github.com/IliasPol"
|
|
7
|
+
},
|
|
8
|
+
"plugins": [
|
|
9
|
+
{
|
|
10
|
+
"name": "claude-usage",
|
|
11
|
+
"source": "./",
|
|
12
|
+
"description": "Explore Claude Code projects, sessions, token usage, model breakdowns and estimated costs locally",
|
|
13
|
+
"version": "1.1.6",
|
|
14
|
+
"author": {
|
|
15
|
+
"name": "IliasPol"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://github.com/IliasPol/claude-usage",
|
|
18
|
+
"repository": "https://github.com/IliasPol/claude-usage",
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"category": "development",
|
|
21
|
+
"tags": [
|
|
22
|
+
"claude-code",
|
|
23
|
+
"usage",
|
|
24
|
+
"analytics",
|
|
25
|
+
"tokens",
|
|
26
|
+
"terminal"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
]
|
|
30
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "claude-usage",
|
|
3
|
-
"description": "Local, read-only Claude Code usage analytics with a native sidebar and standalone terminal UI",
|
|
4
|
-
"version": "1.
|
|
5
|
-
"author": {
|
|
6
|
-
"name": "IliasPol"
|
|
7
|
-
},
|
|
8
|
-
"homepage": "https://github.com/IliasPol/claude-usage",
|
|
9
|
-
"repository": "https://github.com/IliasPol/claude-usage",
|
|
10
|
-
"license": "MIT"
|
|
11
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-usage",
|
|
3
|
+
"description": "Local, read-only Claude Code usage analytics with a native sidebar and standalone terminal UI",
|
|
4
|
+
"version": "1.1.6",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "IliasPol"
|
|
7
|
+
},
|
|
8
|
+
"homepage": "https://github.com/IliasPol/claude-usage",
|
|
9
|
+
"repository": "https://github.com/IliasPol/claude-usage",
|
|
10
|
+
"license": "MIT"
|
|
11
|
+
}
|
package/README.md
CHANGED
|
@@ -1,116 +1,127 @@
|
|
|
1
|
-
# Claude Usage Explorer
|
|
2
|
-
|
|
3
|
-
**Explore Claude Code token usage, estimated API-equivalent costs, projects, sessions, and agents — locally.** Use a native Claude Code sidebar or a standalone terminal dashboard. No uploads, telemetry, or external npm dependencies.
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/claude-usage-explorer)
|
|
6
|
-
[](LICENSE)
|
|
7
|
-
|
|
8
|
-
##
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
npm
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
1
|
+
# Claude Usage Explorer
|
|
2
|
+
|
|
3
|
+
**Explore Claude Code token usage, estimated API-equivalent costs, projects, sessions, and agents — locally.** Use a native Claude Code sidebar or a standalone terminal dashboard. No uploads, telemetry, or external npm dependencies.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/claude-usage-explorer)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## What's new in v1.1.6
|
|
9
|
+
|
|
10
|
+
- **Session actions:** open or copy session identifiers from supported views.
|
|
11
|
+
- **Account usage diagnostics:** optional official usage-limit and extra-spend information when accessible; not the same as estimated API-equivalent cost.
|
|
12
|
+
- **Cleaner agent navigation:** the main agent appears as **Agent (main)**; subagents use a recorded name when available, or **Subagent** when metadata is missing. Opaque agent IDs are shown only after opening an agent.
|
|
13
|
+
- **Native sidebar and standalone terminal explorer:** navigate projects, sessions, agents, and models with token and API-equivalent cost breakdowns.
|
|
14
|
+
- **Live refresh and flexible views:** filter and sort usage, switch reporting periods, and see per-model summaries.
|
|
15
|
+
- **Privacy-first:** transcript processing stays on your computer. Account plan-limit information, when available, is separate from local estimated API-equivalent cost.
|
|
16
|
+
|
|
17
|
+
**Important:** Subagent names depend on metadata in the local Claude transcript; names are not inferred when absent. Estimated API costs are not the amount billed for a Claude subscription.
|
|
18
|
+
|
|
19
|
+
## Preview
|
|
20
|
+
|
|
21
|
+
### Native Claude Code sidebar
|
|
22
|
+
|
|
23
|
+

|
|
24
|
+
|
|
25
|
+
Use `/usage-explorer` to inspect local usage without leaving your Claude Code session.
|
|
26
|
+
|
|
27
|
+
### Standalone terminal dashboard
|
|
28
|
+
|
|
29
|
+

|
|
30
|
+
|
|
31
|
+
Run `claude-usage tui` to explore projects, sessions, token usage, model breakdowns, and estimated costs.
|
|
32
|
+
|
|
33
|
+
## Quick start
|
|
34
|
+
|
|
35
|
+
**Standalone CLI (Node.js 20+):**
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
npm install -g claude-usage-explorer
|
|
39
|
+
claude-usage tui
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
**Claude Code community plugin marketplace:**
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
claude plugin marketplace add IliasPol/claude-usage
|
|
46
|
+
claude plugin install claude-usage@claude-usage-explorer-marketplace
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then run `/usage-explorer` in a compatible Claude Code session.
|
|
50
|
+
|
|
51
|
+
## Requirements
|
|
52
|
+
|
|
53
|
+
- Node.js 20+ (Node 22 recommended)
|
|
54
|
+
- Claude Code with native Mods support for the sidebar
|
|
55
|
+
- Local Claude Code JSONL transcript history
|
|
56
|
+
|
|
57
|
+
## Standalone CLI: other ways to run
|
|
58
|
+
|
|
59
|
+
Run without a global installation:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
npx --yes --package claude-usage-explorer claude-usage tui
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**Note:** On Windows PowerShell, use `npm.cmd` and `claude-usage.cmd` if execution policy blocks the `.ps1` shims.
|
|
66
|
+
|
|
67
|
+
To run directly from a local checkout, use:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
node ./bin/claude-usage.mjs tui
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Run the TUI in an **interactive terminal**, not inside a noninteractive Claude tool shell. The optional macOS/Linux installer `./scripts/install-command.sh` is still available for source checkouts but is not needed for npm installations.
|
|
74
|
+
|
|
75
|
+
## Claude Code plugin installation
|
|
76
|
+
|
|
77
|
+
Alternatively, from inside Claude Code use `/plugin marketplace add IliasPol/claude-usage`, followed by `/plugin install claude-usage@claude-usage-explorer-marketplace`.
|
|
78
|
+
|
|
79
|
+
This repository hosts a **community marketplace**, not an Anthropic-curated listing. After installation, use `/usage-explorer` in compatible Claude Code environments. Native mod/sidebar support may depend on the installed Claude Code version; if it is unavailable, the standalone `claude-usage tui` remains an option.
|
|
80
|
+
|
|
81
|
+
## Native sidebar: local development
|
|
82
|
+
|
|
83
|
+
Start Claude Code with the local plugin directory:
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
claude --plugin-dir /absolute/path/to/claude-usage-explorer
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Then use `/usage-explorer`. In Claude Code CLI, `/tui fullscreen` may be required for the sidebar to appear. The native command is **`/usage-explorer`**, not `/claude-usage:open`.
|
|
90
|
+
|
|
91
|
+
The optional `/claude-usage:tui` skill attempts to open a **separate** terminal. This requires one model invocation and depends on your terminal environment. If no terminal appears, launch `node ./bin/claude-usage.mjs tui` directly in PowerShell or Terminal.
|
|
92
|
+
|
|
93
|
+
## Features
|
|
94
|
+
|
|
95
|
+
- Drill down into projects, sessions, agents and models
|
|
96
|
+
- Periods, sorting, filtering and auto-refresh
|
|
97
|
+
- Token totals and estimated API-equivalent cost by model
|
|
98
|
+
- Optional cached 5-hour and 7-day Claude Desktop plan-limit percentages (not live usage)
|
|
99
|
+
- Read-only, local processing; no uploads or external analytics
|
|
100
|
+
|
|
101
|
+
**Important:** Estimated API-equivalent cost is not what your Claude subscription was charged. Cached plan percentages can be stale. Claude Desktop Chat history is not included in Claude Code's local transcript analytics.
|
|
102
|
+
|
|
103
|
+
## Windows
|
|
104
|
+
|
|
105
|
+
In PowerShell, from the extracted project directory (or use the npm installation above):
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
node .\bin\claude-usage.mjs tui
|
|
109
|
+
claude --plugin-dir "$PWD"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
npm test
|
|
116
|
+
npm run check
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
|
|
120
|
+
|
|
121
|
+
## Contributing, security, and license
|
|
122
|
+
|
|
123
|
+
Contributions are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md). Please report vulnerabilities following [SECURITY.md](SECURITY.md). This project is licensed under the [MIT License](LICENSE).
|
|
124
|
+
|
|
125
|
+
## Support
|
|
126
|
+
|
|
127
|
+
If Claude Usage Explorer is useful, consider [starring the repository](https://github.com/IliasPol/claude-usage). The tool is free and open source.
|
package/bin/claude-usage.mjs
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
2
5
|
import { main } from '../src/core.mjs';
|
|
3
6
|
import { launchTui } from '../src/tui.mjs';
|
|
4
7
|
import { snapshot } from '../src/snapshot.mjs';
|
|
5
8
|
import {runStatusLine,installStatusLine} from '../src/statusline.mjs';
|
|
6
9
|
import {launchSidecar} from '../src/launcher.mjs';
|
|
7
|
-
(process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
|
|
10
|
+
(process.argv[2] === 'preference' ? Promise.resolve().then(()=>{const file=path.join(os.homedir(),'.claude-usage-explorer','settings.json');let obj={};try{obj=JSON.parse(fs.readFileSync(file,'utf8'));}catch{}if(process.argv[3]==='period' && ['all','cycle','previous-cycle','today','yesterday','7d','30d','month','previous-month'].includes(process.argv[4])){obj.lastPeriod=process.argv[4];fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});fs.writeFileSync(file,JSON.stringify(obj,null,2)+'\n',{mode:0o600});}else throw Error('Invalid preference');}) : process.argv[2] === 'open' ? Promise.resolve(launchSidecar({cwd:process.env.CLAUDE_PROJECT_DIR||process.cwd()})).then(()=>console.log('Opened Claude Usage Explorer in a separate terminal.')) : process.argv[2] === 'snapshot' ? Promise.resolve(snapshot(process.argv.slice(3))).then(data=>console.log(JSON.stringify(data))) : process.argv[2] === 'tui' ? launchTui(process.argv.slice(3)) : process.argv[2] === 'statusline' ? runStatusLine() : process.argv[2] === 'statusline-install' ? Promise.resolve(console.log('Installed status line in',installStatusLine({force:process.argv.includes('--force')}))) : main(process.argv.slice(2))).catch(err => { console.error('Error:', err.message); process.exitCode = 1; });
|
package/hooks/usage-explorer.mjs
CHANGED
|
@@ -1,23 +1,32 @@
|
|
|
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
|
|
4
|
-
// Convert the file URL using web-standard URL APIs, preserving Windows drive paths.
|
|
3
|
+
// Native hooks run in a restricted loader; use web-standard URL APIs instead of node: imports.
|
|
5
4
|
export function scriptPathFromModuleUrl(moduleUrl){
|
|
6
5
|
const url=new URL('../bin/claude-usage.mjs',moduleUrl);
|
|
7
|
-
let
|
|
8
|
-
if(/^\/[A-Za-z]:\//.test(
|
|
9
|
-
if(url.host&&url.host!=='localhost')
|
|
10
|
-
return
|
|
6
|
+
let p=decodeURIComponent(url.pathname);
|
|
7
|
+
if(/^\/[A-Za-z]:\//.test(p))p=p.slice(1);
|
|
8
|
+
if(url.host&&url.host!=='localhost')p=`//${url.host}${p}`;
|
|
9
|
+
return p;
|
|
11
10
|
}
|
|
12
11
|
const PANE='claude-usage-explorer';
|
|
13
12
|
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
|
|
13
|
+
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};
|
|
14
|
+
let limitState={status:'not checked',source:'',rateLimits:[],details:[],checkedAt:0};
|
|
15
|
+
let limitInFlight=null;
|
|
16
|
+
let usageFieldSchema=[];
|
|
17
|
+
|
|
18
|
+
let officialSpend=null;
|
|
19
|
+
let sessionActionStatus='';
|
|
20
|
+
let focusedSessionId=null;
|
|
21
|
+
const LIMIT_REFRESH_MS=180000;
|
|
15
22
|
let timer=null;
|
|
16
23
|
let inFlight=null;
|
|
17
24
|
const total=s=>(s?.input||0)+(s?.output||0)+(s?.cacheRead||0)+(s?.cacheWrite||0);
|
|
18
25
|
const compact=n=>Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n||0);
|
|
26
|
+
const share=(part,whole)=>whole>0?(part>0&&100*part/whole<0.05?'<0.1%':`${(100*part/whole).toFixed(1)}%`):'—';
|
|
19
27
|
const price=s=>s?.unknownCostRequests?'N/A':`$${(s?.knownCost||0).toFixed(2)}`;
|
|
20
28
|
const name=p=>String(p||'').replace(/\\/g,'/').split('/').filter(Boolean).pop()||p;
|
|
29
|
+
export const agentLabel=a=>a?.id==='main'?'Agent (main)':(a?.name||a?.type||'Subagent');
|
|
21
30
|
const change=($,patch)=>{state={...state,...patch};void $.ui.invalidate('ui.render');};
|
|
22
31
|
export function itemsFor(s=state){
|
|
23
32
|
const r=s.report;
|
|
@@ -25,7 +34,7 @@ export function itemsFor(s=state){
|
|
|
25
34
|
let items=[];
|
|
26
35
|
if(s.view==='projects')items=r.projects.map(x=>({id:x.name,label:name(x.name),stats:x.stats,source:x}));
|
|
27
36
|
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
|
|
37
|
+
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
38
|
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
39
|
if(s.query)items=items.filter(x=>x.label.toLowerCase().includes(s.query.toLowerCase()));
|
|
31
40
|
return items.sort((a,b)=>s.sort==='cost'?(b.stats.knownCost-a.stats.knownCost):total(b.stats)-total(a.stats));
|
|
@@ -50,6 +59,9 @@ export function back(s){
|
|
|
50
59
|
const last=s.stack[s.stack.length-1];
|
|
51
60
|
return {...s,...last,stack:s.stack.slice(0,-1),query:''};
|
|
52
61
|
}
|
|
62
|
+
async function persistPeriod($,period){
|
|
63
|
+
try {const script=scriptPathFromModuleUrl(import.meta.url);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
|
|
64
|
+
}
|
|
53
65
|
async function refresh($){
|
|
54
66
|
if(inFlight)return inFlight;
|
|
55
67
|
change($,{loading:true,error:''});
|
|
@@ -60,13 +72,140 @@ async function refresh($){
|
|
|
60
72
|
const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
|
|
61
73
|
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
|
|
62
74
|
const snap=JSON.parse(result.stdout);
|
|
75
|
+
if(!state.preferencesLoaded){state.period=snap.savedPeriod||state.period;state.preferencesLoaded=true;if(state.period!==snap.period){inFlight=null;return refresh($);}}
|
|
63
76
|
// Refresh preserves the active drilldown; a missing session is handled by an empty list.
|
|
64
|
-
change($,{report:snap.report,
|
|
77
|
+
change($,{report:snap.report,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
|
|
65
78
|
}catch(err){change($,{loading:false,error:String(err?.message||err).slice(0,260)});}
|
|
66
79
|
})().finally(()=>{inFlight=null});
|
|
67
80
|
return inFlight;
|
|
68
81
|
}
|
|
69
|
-
|
|
82
|
+
// Opt-in diagnostic: never store or display the raw response, credentials or identifiers.
|
|
83
|
+
// The first-party OAuth usage route is undocumented; a failure is not fatal.
|
|
84
|
+
export function summarizeLimits(payload){
|
|
85
|
+
if(!payload||typeof payload!=='object')return [];
|
|
86
|
+
const lines=[];
|
|
87
|
+
for(const [key,val] of Object.entries(payload)){
|
|
88
|
+
if(!/^(five_hour|seven_day|seven_day_[a-z_]+|extra_usage|monthly|billing|usage|rate_limits)$/i.test(key))continue;
|
|
89
|
+
if(val&&typeof val==='object'&&!Array.isArray(val)){
|
|
90
|
+
const fields=[];
|
|
91
|
+
for(const field of ['utilization','percentUsed','limit','used','remaining','resets_at','resetsAt','is_enabled']){
|
|
92
|
+
const v=val[field];
|
|
93
|
+
if(typeof v==='number'&&Number.isFinite(v))fields.push(`${field}: ${v}`);
|
|
94
|
+
else if(typeof v==='boolean')fields.push(`${field}: ${v}`);
|
|
95
|
+
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)}`);
|
|
96
|
+
}
|
|
97
|
+
if(fields.length)lines.push(`${key}: ${fields.join(' · ')}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return lines.slice(0,12);
|
|
101
|
+
}
|
|
102
|
+
// Diagnostic exposes only structural metadata, never values, identifiers or credentials.
|
|
103
|
+
export function usageSchema(payload){
|
|
104
|
+
if(!payload||typeof payload!=='object'||Array.isArray(payload))return [];
|
|
105
|
+
const rows=[];
|
|
106
|
+
const walk=(obj,prefix,depth)=>{
|
|
107
|
+
for(const [key,value] of Object.entries(obj)){
|
|
108
|
+
if(rows.length>=90)return;
|
|
109
|
+
// Only keys with conventional field-name syntax; avoid leaking arbitrary map keys.
|
|
110
|
+
if(!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(key))continue;
|
|
111
|
+
const path=prefix?`${prefix}.${key}`:key;
|
|
112
|
+
const type=value===null?'null':Array.isArray(value)?'array':typeof value;
|
|
113
|
+
rows.push(`${path}: ${type}`);
|
|
114
|
+
if(depth<2&&type==='object')walk(value,path,depth+1);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
walk(payload,'',0);
|
|
118
|
+
return rows;
|
|
119
|
+
}
|
|
120
|
+
// Normalize money using currency minor units. Never add spend and extra_usage together.
|
|
121
|
+
export function parseOfficialSpend(payload){
|
|
122
|
+
const spend=payload?.spend;
|
|
123
|
+
const money=(obj)=>{
|
|
124
|
+
if(!obj||!Number.isFinite(obj.amount_minor)||!Number.isInteger(obj.exponent)||obj.exponent<0||obj.exponent>8||typeof obj.currency!=='string')return null;
|
|
125
|
+
return {amount:obj.amount_minor/10**obj.exponent,currency:obj.currency.toUpperCase()};
|
|
126
|
+
};
|
|
127
|
+
const used=money(spend?.used),limit=money(spend?.limit);
|
|
128
|
+
if(used&&limit&&used.currency===limit.currency&&limit.amount>0){
|
|
129
|
+
return {used:used.amount,limit:limit.amount,currency:used.currency,percent:Number.isFinite(spend?.percent)?spend.percent:100*used.amount/limit.amount,source:'spend'};
|
|
130
|
+
}
|
|
131
|
+
const extra=payload?.extra_usage;
|
|
132
|
+
if(extra&&Number.isFinite(extra.used_credits)&&Number.isFinite(extra.monthly_limit)&&extra.monthly_limit>0&&typeof extra.currency==='string'){
|
|
133
|
+
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'};
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
const currencyAmount=(amount,currency)=>{try{return new Intl.NumberFormat('en-US',{style:'currency',currency}).format(amount);}catch{return `${amount.toFixed(2)} ${currency}`;}};
|
|
138
|
+
async function checkLimits($,{force=false}={}){
|
|
139
|
+
if(limitInFlight)return limitInFlight;
|
|
140
|
+
if(!force&&limitState.checkedAt&&Date.now()-limitState.checkedAt<LIMIT_REFRESH_MS)return;
|
|
141
|
+
limitState={...limitState,status:'checking'};void $.ui.invalidate('ui.render');
|
|
142
|
+
limitInFlight=(async()=>{try{
|
|
143
|
+
const usage=await $.session.usage();
|
|
144
|
+
const rateLimits=Array.isArray(usage?.rateLimits)?usage.rateLimits:[];
|
|
145
|
+
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:[]};
|
|
146
|
+
const authorization=await $.session.authorize();
|
|
147
|
+
if(!authorization?.handle){limitState.status='No first-party authorization handle';return;}
|
|
148
|
+
const response=await $.http.fetch('https://api.anthropic.com/api/oauth/usage',{auth:authorization.handle});
|
|
149
|
+
if(!response.ok){limitState.status=`HTTP ${response.status}`;return;}
|
|
150
|
+
const payload=JSON.parse(response.text);
|
|
151
|
+
officialSpend=parseOfficialSpend(payload);
|
|
152
|
+
const details=summarizeLimits(payload);
|
|
153
|
+
limitState={...limitState,status:details.length?'available':'No recognized fields (Enterprise may differ)',source:'Claude session + OAuth usage',details};
|
|
154
|
+
}catch(err){limitState={...limitState,status:`Unavailable: ${String(err?.message||err).slice(0,100)}`};}
|
|
155
|
+
finally{limitState.checkedAt=Date.now();void $.ui.invalidate('ui.render');}
|
|
156
|
+
})().finally(()=>{limitInFlight=null});
|
|
157
|
+
return limitInFlight;
|
|
158
|
+
}
|
|
159
|
+
export function usageBar(percent,width=24){
|
|
160
|
+
const value=Math.max(0,Math.min(100,Number.isFinite(percent)?percent:0));
|
|
161
|
+
const count=Math.round(value/100*width);
|
|
162
|
+
return `${'█'.repeat(count)}${'░'.repeat(width-count)} ${value.toFixed(1)}%`;
|
|
163
|
+
}
|
|
164
|
+
export function officialLimitRows(limits){
|
|
165
|
+
const rows=[];
|
|
166
|
+
for(const limit of limits.rateLimits||[]){
|
|
167
|
+
if(Number.isFinite(limit.percentUsed))rows.push({label:limit.kind,percent:limit.percentUsed,resetsAt:limit.resetsAt||''});
|
|
168
|
+
}
|
|
169
|
+
for(const line of limits.details||[]){
|
|
170
|
+
const match=/^([^:]+):.*?utilization: ([0-9]+(?:\.[0-9]+)?)/.exec(line);
|
|
171
|
+
if(match){
|
|
172
|
+
const resets=/resets_(?:at|At): ([^·]+)/.exec(line);
|
|
173
|
+
rows.push({label:match[1],percent:Number(match[2]),resetsAt:resets?.[1]?.trim()||''});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return rows;
|
|
177
|
+
}
|
|
178
|
+
// Session resume opens a separate terminal; no supported in-place Mods session switch is assumed.
|
|
179
|
+
export function sessionResumeCommand(session){
|
|
180
|
+
if(!session?.id||!/^[a-f0-9-]{36}$/i.test(session.id))throw new Error('Invalid session ID');
|
|
181
|
+
if(!session.project||typeof session.project!=='string'||!session.project.startsWith('/'))throw new Error('Original project directory unavailable');
|
|
182
|
+
const quote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
|
|
183
|
+
return `cd ${quote(session.project)} && claude --resume ${quote(session.id)}`;
|
|
184
|
+
}
|
|
185
|
+
export function selectedSessionForAction(s,focusedId){
|
|
186
|
+
if(s.view==='sessions')return s.report?.sessions?.find(x=>x.id===focusedId&&(!s.project||x.project===s.project))||null;
|
|
187
|
+
return s.report?.sessions?.find(x=>x.id===s.session?.id)||s.session||null;
|
|
188
|
+
}
|
|
189
|
+
async function sessionAction($,mode){
|
|
190
|
+
const selected=selectedSessionForAction(state,focusedSessionId);
|
|
191
|
+
try{
|
|
192
|
+
if(!selected)throw new Error('Select a session first');
|
|
193
|
+
if(mode==='copy'){
|
|
194
|
+
const result=await $.process.run(['osascript','-e',`set the clipboard to ${JSON.stringify(selected.id)}`],{timeoutMs:5000});
|
|
195
|
+
if(result.exitCode!==0)throw new Error('Clipboard unavailable');
|
|
196
|
+
sessionActionStatus='Session ID copied';
|
|
197
|
+
}else{
|
|
198
|
+
const command=sessionResumeCommand(selected);
|
|
199
|
+
// osascript argument is encoded as an AppleScript string, never shell-interpolated.
|
|
200
|
+
const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
|
|
201
|
+
const result=await $.process.run(['osascript','-e',script],{timeoutMs:10000});
|
|
202
|
+
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to open Terminal').slice(0,120));
|
|
203
|
+
sessionActionStatus='Opened session in Terminal.app';
|
|
204
|
+
}
|
|
205
|
+
}catch(error){sessionActionStatus=String(error?.message||error).slice(0,140);}
|
|
206
|
+
void $.ui.invalidate('ui.render');
|
|
207
|
+
}
|
|
208
|
+
async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);void checkLimits($);}
|
|
70
209
|
export function register(on){
|
|
71
210
|
on('session.start',async ($,e,next)=>{
|
|
72
211
|
await $.command.register({name:'usage-explorer',description:'Open the native interactive Claude Usage Explorer sidebar',immediate:true});
|
|
@@ -77,7 +216,7 @@ export function register(on){
|
|
|
77
216
|
if(surfaces.includes('terminal')&&!e.presentation.isFullscreen)return {text:'Use /tui fullscreen, then /usage-explorer. The native sidebar needs fullscreen layout.'};
|
|
78
217
|
if(surfaces.includes('terminal')&&e.presentation.columns<100)return {text:'Widen the terminal to at least 100 columns, then /usage-explorer.'};
|
|
79
218
|
await show($);
|
|
80
|
-
timer?.cancel();timer=$.clock.every(5000,()=>void refresh($));
|
|
219
|
+
timer?.cancel();timer=$.clock.every(5000,()=>{void refresh($);void checkLimits($);});
|
|
81
220
|
return {text:'Usage Explorer opened in the sidebar. Navigation and refresh do not call the model.'};
|
|
82
221
|
});
|
|
83
222
|
on('ui.close',async ($,e,next)=>{if(e.id===PANE){timer?.cancel();timer=null;}return next(e);});
|
|
@@ -86,71 +225,77 @@ export function register(on){
|
|
|
86
225
|
if(e.surface!=='terminal'&&e.surface!=='desktop')return next(e);
|
|
87
226
|
const {Box,Text,Button,Input}=$.ui.resolve(e);
|
|
88
227
|
const rows=itemsFor();
|
|
89
|
-
|
|
90
|
-
// Percent heights are not consistently resolved in Claude terminal panes.
|
|
91
|
-
const viewportRows=Number(e.presentation?.rows??e.viewport?.height??e.rows);
|
|
92
|
-
const paneHeight=Number.isFinite(viewportRows)&&viewportRows>=16?Math.max(12,viewportRows-3):'100%';
|
|
93
|
-
const press=item=>change($,drill(state,item));
|
|
228
|
+
const press=item=>{sessionActionStatus='';if(state.view==='sessions')focusedSessionId=item.id;change($,drill(state,item));};
|
|
94
229
|
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($);}}));
|
|
230
|
+
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
231
|
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
|
|
232
|
+
const path=[state.project&&name(state.project),state.session?.title,state.agent?agentLabel(state.agent):null].filter(Boolean);
|
|
98
233
|
const breadcrumb=`${state.view.toUpperCase()}${path.length?' / '+path.join(' / '):''}`;
|
|
99
|
-
const stats=state.report?.overall;
|
|
100
|
-
const
|
|
234
|
+
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;
|
|
235
|
+
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
236
|
const scopeModels=modelScope(state).slice().sort((a,b)=>total(b.stats)-total(a.stats));
|
|
102
237
|
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
238
|
const modelSummary=showModelSummary?Box({flexDirection:'column',children:[
|
|
116
|
-
Text({bold:true,children:'MODEL
|
|
117
|
-
...scopeModels.slice(0,
|
|
118
|
-
...(scopeModels.length>
|
|
239
|
+
Text({bold:true,children:'MODEL USAGE'}),
|
|
240
|
+
...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)}`})),
|
|
241
|
+
...(scopeModels.length>4?[Text({dimColor:true,children:`+${scopeModels.length-4} more · Select Models to view all`})]:[]),
|
|
119
242
|
]}):null;
|
|
120
|
-
|
|
121
|
-
|
|
243
|
+
const header=Box({flexDirection:'column',children:[
|
|
244
|
+
Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'⟳ Updating':'● Live · 5s'})]}),
|
|
245
|
+
Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
|
|
246
|
+
stats?Text({bold:true,children:`${compact(total(stats))} tokens ${price(stats)} estimated API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
|
|
247
|
+
stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} transcript files`}):null,
|
|
248
|
+
Text({bold:true,children:'OFFICIAL ACCOUNT USAGE'}),
|
|
249
|
+
...(officialSpend?[
|
|
250
|
+
Text({key:'official-spend-heading',bold:true,children:`${currencyAmount(officialSpend.used,officialSpend.currency)} / ${currencyAmount(officialSpend.limit,officialSpend.currency)} · ${officialSpend.percent.toFixed(1)}%`}),
|
|
251
|
+
Text({key:'official-spend-bar',children:usageBar(officialSpend.percent,30)}),
|
|
252
|
+
Text({key:'official-spend-left',dimColor:true,children:`${currencyAmount(Math.max(0,officialSpend.limit-officialSpend.used),officialSpend.currency)} remaining · official ${officialSpend.source}`}),
|
|
253
|
+
]:[]),
|
|
254
|
+
...officialLimitRows(limitState).filter(l=>!(officialSpend&&l.label==='extra_usage')).flatMap((l,i)=>[
|
|
255
|
+
Text({key:`limit-name-${i}`,dimColor:true,children:`${l.label.replaceAll('_',' ')}${l.resetsAt?' · resets '+l.resetsAt:''}`}),
|
|
256
|
+
Text({key:`limit-bar-${i}`,children:usageBar(l.percent)}),
|
|
257
|
+
]),
|
|
258
|
+
...(officialLimitRows(limitState).length===0?[Text({dimColor:true,children:`${limitState.status} · No supported limit values`})]:[]),
|
|
259
|
+
Text({dimColor:true,children:'Official account spend and local API-equivalent estimates are separate.'}),
|
|
260
|
+
Button({key:'check-official-limits',plain:true,label:'↻ Refresh official usage',onPress:()=>void checkLimits($,{force:true})}),
|
|
261
|
+
].filter(Boolean)});
|
|
122
262
|
const footer=Box({flexDirection:'column',flexShrink:0,children:[
|
|
123
|
-
|
|
124
|
-
Box({flexDirection:'row',gap:
|
|
263
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
264
|
+
Box({flexDirection:'row',gap:2,flexWrap:'wrap',children:[
|
|
125
265
|
action('← Back','h',()=>change($,back(state))),
|
|
126
266
|
action('↻ Refresh','r',()=>void refresh($)),
|
|
127
267
|
action('◷ Period','p',()=>change($,{periodMenu:!state.periodMenu})),
|
|
128
268
|
action(state.view==='models'?'← Close models':'▤ Models','m',models),
|
|
129
269
|
action(state.sort==='tokens'?'↓ Tokens':'↓ Cost','s',()=>change($,{sort:state.sort==='tokens'?'cost':'tokens'})),
|
|
270
|
+
...(state.session?[
|
|
271
|
+
action('Open session','o',()=>void sessionAction($,'open')),
|
|
272
|
+
action('Copy ID','c',()=>void sessionAction($,'copy')),
|
|
273
|
+
]:[]),
|
|
130
274
|
action('× Exit','x',()=>void $.ui.close({id:PANE})),
|
|
131
275
|
]}),
|
|
132
|
-
Text({dimColor:true,
|
|
276
|
+
Text({dimColor:true,children:'Esc: close · H: back · O: open current session · C: copy ID · X: exit'}),
|
|
133
277
|
]});
|
|
134
|
-
return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:
|
|
278
|
+
return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:'100%',justifyContent:'space-between',children:[
|
|
135
279
|
Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...(
|
|
280
|
+
header,
|
|
281
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
282
|
+
Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
|
|
283
|
+
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})}),
|
|
284
|
+
...(state.periodMenu?[
|
|
285
|
+
Text({bold:true,children:'SELECT PERIOD'}),
|
|
286
|
+
Box({flexDirection:'column',gap:0,children:periodButtons}),
|
|
287
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
288
|
+
]:[]),
|
|
289
|
+
...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
|
|
290
|
+
...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
|
|
291
|
+
...(state.session?[Text({dimColor:true,children:`Session ID: ${state.session.id}`})]:[]),
|
|
292
|
+
...(state.agent&&state.agent.id!=='main'?[Text({dimColor:true,children:`Agent ID: ${state.agent.id}`})]:[]),
|
|
293
|
+
...(sessionActionStatus?[Text({dimColor:true,children:sessionActionStatus})]:[]),
|
|
294
|
+
...(modelSummary?[modelSummary]:[]),
|
|
295
|
+
Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
|
|
296
|
+
...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)}`})),
|
|
297
|
+
...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
|
|
298
|
+
]}),
|
|
154
299
|
].filter(Boolean)}),
|
|
155
300
|
footer,
|
|
156
301
|
]});
|
package/package.json
CHANGED
|
@@ -1,48 +1,48 @@
|
|
|
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 && 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.6",
|
|
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
|
+
}
|
package/src/core.mjs
CHANGED
|
@@ -85,11 +85,12 @@ 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){const candidate=r.agentType||r.agentName||r.agent_type||r.agent_name;if(typeof candidate==='string'&&candidate.trim()&&candidate.length<=100)agentName=candidate.trim();}
|
|
93
94
|
const ts=Date.parse(r.timestamp);
|
|
94
95
|
if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
|
|
95
96
|
if(!title && r.type==='user') {
|
|
@@ -112,12 +113,12 @@ export function parseFile(file, rates=DEFAULT_RATES) {
|
|
|
112
113
|
if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
|
|
113
114
|
} else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
|
|
114
115
|
});
|
|
115
|
-
return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
116
|
+
return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,agentName,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
116
117
|
}
|
|
117
118
|
|
|
118
119
|
// Cache derived metadata and token counters only. Never persist transcript text beyond
|
|
119
120
|
// the short session title. Cache invalidates when file size/mtime changes.
|
|
120
|
-
const CACHE_VERSION=
|
|
121
|
+
const CACHE_VERSION=4;
|
|
121
122
|
const cacheDir=()=>path.join(os.homedir(),'.claude-usage-explorer','cache-v3');
|
|
122
123
|
function readCachedFile(file){
|
|
123
124
|
const st=fs.statSync(file);
|
|
@@ -151,7 +152,8 @@ export function ingest(roots,rates=DEFAULT_RATES) {
|
|
|
151
152
|
if(parsed.start!==null) s.start=Math.min(s.start??parsed.start,parsed.start);
|
|
152
153
|
if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
|
|
153
154
|
let agent=s.agents.get(parsed.agent);
|
|
154
|
-
if(!agent) {agent={id:parsed.agent,requests:[]};s.agents.set(parsed.agent,agent);}
|
|
155
|
+
if(!agent) {agent={id:parsed.agent,name:parsed.agentName||null,requests:[]};s.agents.set(parsed.agent,agent);}
|
|
156
|
+
if(!agent.name&&parsed.agentName)agent.name=parsed.agentName;
|
|
155
157
|
agent.requests.push(...parsed.requests);
|
|
156
158
|
}
|
|
157
159
|
return {sessions:[...sessions.values()],files:files.length,malformed,roots};
|
|
@@ -208,7 +210,7 @@ export function summarize(data,window=[-Infinity,Infinity],rates=DEFAULT_RATES)
|
|
|
208
210
|
if(!byModel.has(r.model))byModel.set(r.model,ZERO());add(byModel.get(r.model),one);
|
|
209
211
|
if(!models.has(r.model))models.set(r.model,ZERO());add(models.get(r.model),one);
|
|
210
212
|
}
|
|
211
|
-
if(am.requests){add(stat,am);agents.push({id:a.id,stats:am,models:[...byModel].map(([name,stats])=>({name,stats}))});}
|
|
213
|
+
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
214
|
}
|
|
213
215
|
if(!stat.requests)continue;
|
|
214
216
|
const project=s.cwd||'(unknown project)';
|
package/src/tui.mjs
CHANGED
|
@@ -2,8 +2,9 @@ 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 {
|
|
5
|
+
import {spawnSync} from 'node:child_process';
|
|
6
6
|
import {readPlanUsage} from './plan-usage.mjs';
|
|
7
|
+
import {ingest,projectRoots,summarize,timeWindow,totalTokens,number,DEFAULT_RATES} from './core.mjs';
|
|
7
8
|
|
|
8
9
|
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
10
|
export function describePeriod(period, settings={}, now=Date.now()){
|
|
@@ -25,10 +26,10 @@ function saveSettings(s){fs.mkdirSync(path.dirname(settingFile()),{recursive:tru
|
|
|
25
26
|
const sortColumns=[['cost','Est. USD'],['tokens','Tokens'],['requests','Requests'],['input','Input'],['output','Output'],['cacheWrite','Cache write'],['cacheRead','Cache read'],['name','Name']];
|
|
26
27
|
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
28
|
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}={}){
|
|
29
|
-
let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
|
|
29
|
+
export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all',planReader=readPlanUsage}={}){
|
|
30
30
|
let planUsage=null,planCheckedAt=0;
|
|
31
31
|
function refreshPlan(now=Date.now()){if(now-planCheckedAt<4000)return;planCheckedAt=now;try{planUsage=planReader({now})||null;}catch{planUsage=null;}}
|
|
32
|
+
let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
|
|
32
33
|
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
34
|
const rates=()=>({...DEFAULT_RATES,...settings.rates});
|
|
34
35
|
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 +37,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
36
37
|
function rows(){let list=[];
|
|
37
38
|
if(view==='projects')list=report.projects.map(x=>({...x,label:path.basename(x.name)||x.name}));
|
|
38
39
|
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'?'
|
|
40
|
+
if(view==='agents')list=(session?.agents||[]).map(x=>({...x,label:x.id==='main'?'Agent (main)':(x.name||x.type||'Subagent')}));
|
|
40
41
|
if(view==='models')list=(agent?.models||session?.models||project?.models||report.models).map(x=>({...x,label:x.name}));
|
|
41
42
|
if(view==='periods')list=periods.map(([,v])=>({label:describePeriod(v,settings),value:v,stats:empty}));
|
|
42
43
|
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 +53,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
52
53
|
if(view==='projects'){project=x;nav('sessions');}
|
|
53
54
|
else if(view==='sessions'){session=x;nav('agents');}
|
|
54
55
|
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();}
|
|
56
|
+
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
57
|
else if(view==='settings')return x.value;
|
|
57
58
|
else if(view==='sort'){setSort(x.value);restore();}
|
|
58
59
|
else if(view==='models')message=`${x.name} · ${fmt(totalTokens(x.stats))} tokens · ${money(x.stats)} estimated USD`;
|
|
@@ -75,7 +76,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
75
76
|
}
|
|
76
77
|
function setLiveState(update){liveState={...liveState,...update};}
|
|
77
78
|
function toggleLive(){liveState.enabled=!liveState.enabled;liveState.lastCheck=Date.now();}
|
|
78
|
-
function setPeriod(p){currentPeriod=p;refresh();switchView('projects');}
|
|
79
|
+
function setPeriod(p){currentPeriod=p;settings.lastPeriod=p;saveSettings(settings);refresh();switchView('projects');}
|
|
79
80
|
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
81
|
function setModelRate(model,values){settings.rates??={};settings.rates[model]=values;saveSettings(settings);refresh();message=`Saved custom USD/million-token rates for ${model}.`;}
|
|
81
82
|
function setFilter(s){filter=s;selection=0;offset=0;}
|
|
@@ -94,8 +95,9 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
94
95
|
rule();
|
|
95
96
|
const crumb=['Projects',project&&path.basename(project.name),session&&truncate(session.title,32),agent&&agent.label].filter(Boolean).join(' › ');
|
|
96
97
|
line(` ${c(crumb,'1')} ${c('PERIOD','90')} ${describePeriod(currentPeriod,settings)} ${c('SORT','90')} ${sort} ${descending?'↓':'↑'}`);
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const scoped=view==='sessions'?project?.stats:view==='agents'?session?.stats:view==='models'?(agent?.stats||session?.stats||project?.stats||report.overall):report.overall;
|
|
99
|
+
line(` ${c('TOTAL','90')} ${fmt(totalTokens(scoped))} tokens ${fmt(scoped.requests)} requests ${c(money(scoped),'1;32')} API-equivalent`);
|
|
100
|
+
const planLabel=planUsage?`5h ${planUsage.fiveHour??'—'}% · 7d ${planUsage.sevenDay??'—'}% · ${planUsage.stale?'Cached, may be outdated':'Cached, recent'}`:'Claude Desktop plan usage unavailable';
|
|
99
101
|
line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
|
|
100
102
|
rule();
|
|
101
103
|
const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
|
|
@@ -109,16 +111,19 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
109
111
|
if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
|
|
110
112
|
// Keep columns aligned by terminal width, shrinking the name first.
|
|
111
113
|
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
|
|
114
|
+
const rightMargin=4; // Always leave a few columns clear of the terminal's right edge.
|
|
115
|
+
const labelWidth=Math.max(17,width-5-rightMargin-7-columns.reduce((sum,[,n])=>sum+n+1,0));
|
|
113
116
|
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'));
|
|
117
|
+
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
118
|
}
|
|
116
119
|
for(let i=offset;i<Math.min(available.length,offset+visible);i++){
|
|
117
120
|
const x=available[i],active=i===selection;
|
|
118
121
|
let text;
|
|
119
122
|
if(['periods','settings','sort'].includes(view))text=` ${active?'❯':' '} ${x.label}${view==='periods'&&x.value===currentPeriod?' ✓':''}`;
|
|
120
123
|
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
|
-
|
|
124
|
+
const parentTotal=view==='projects'?totalTokens(report.overall):view==='sessions'?totalTokens(project?.stats):view==='agents'?totalTokens(session?.stats):totalTokens(agent?.stats||report.overall);
|
|
125
|
+
const pct=parentTotal>0?(totalTokens(s)>0&&100*totalTokens(s)/parentTotal<0.05?'<0.1%':(100*totalTokens(s)/parentTotal).toFixed(1)+'%'):'—';
|
|
126
|
+
text=` ${active?'❯':' '} ${pad(x.label,labelWidth)} ${pad(pct,6,'right')} `+columns.map(([,n,key])=>pad(vals[key],n,'right')).join(' ');
|
|
122
127
|
}
|
|
123
128
|
line(active?c(truncate(text,width),'7'):truncate(text,width));
|
|
124
129
|
}
|
|
@@ -128,7 +133,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
128
133
|
for(let i=renderedRows;i<visible;i++)line('');
|
|
129
134
|
line(c(` ${available.length?`${offset+1}–${Math.min(available.length,offset+visible)} of ${available.length}`:'0 rows'} • PgUp/PgDn scroll`,'90'));
|
|
130
135
|
rule();
|
|
131
|
-
line(c('
|
|
136
|
+
line(c(' MODELS IN SELECTED ROW','1;36')+c(' (M: full model table)','90'));
|
|
132
137
|
const models=breakdown?[...breakdown].sort((a,b)=>totalTokens(b.stats)-totalTokens(a.stats)).slice(0,5):[];
|
|
133
138
|
for(let i=0;i<5;i++){
|
|
134
139
|
const m=models[i];
|
|
@@ -136,12 +141,12 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
136
141
|
}
|
|
137
142
|
rule();
|
|
138
143
|
line(c(' ↑↓ Navigate ENTER Open BACKSPACE Back M Models D Dates O Sort / Search','1'));
|
|
139
|
-
line(c(' B
|
|
144
|
+
line(c(' B Settings S Reverse sort C Current project E Open session ? Help Q Quit','90'));
|
|
140
145
|
line(c(' '+(filter?'Search: '+truncate(filter,width-12):' '),'36'));
|
|
141
146
|
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
147
|
return out.slice(0,Math.max(1,height-1)).join('\n');
|
|
143
148
|
}
|
|
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;}};
|
|
149
|
+
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 session(){return session;},get report(){return report;},get message(){return message;},set message(v){message=v;}};
|
|
145
150
|
}
|
|
146
151
|
// Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
|
|
147
152
|
export function clipAnsi(value, maxWidth) {
|
|
@@ -185,8 +190,7 @@ export async function launchTui(argv=[]){
|
|
|
185
190
|
checking=true;
|
|
186
191
|
try{
|
|
187
192
|
const next=historySignature(roots);
|
|
188
|
-
if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;}
|
|
189
|
-
explorer.refreshPlan();draw();
|
|
193
|
+
if(next!==lastSignature){explorer.rescan(roots,{automatic:true});lastSignature=next;draw();}
|
|
190
194
|
explorer.setLiveState({lastCheck:Date.now()});
|
|
191
195
|
}catch(e){explorer.setLiveState({error:e.message});draw();}
|
|
192
196
|
finally{checking=false;}
|
|
@@ -229,11 +233,31 @@ export async function launchTui(argv=[]){
|
|
|
229
233
|
else if(str==='b')explorer.nav('settings');
|
|
230
234
|
else if(str==='o')explorer.nav('sort');
|
|
231
235
|
else if(str==='s')explorer.setSort(explorer.sort);
|
|
236
|
+
else if(str==='C'){
|
|
237
|
+
const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
|
|
238
|
+
if(selected?.id&&process.platform==='darwin'){
|
|
239
|
+
const result=spawnSync('pbcopy',[],{input:selected.id,encoding:'utf8'});
|
|
240
|
+
explorer.message=result.status===0?'Session ID copied.':'Unable to copy session ID.';
|
|
241
|
+
}else explorer.message='Select a session on macOS to copy its ID.';
|
|
242
|
+
}
|
|
243
|
+
else if(str==='e'){
|
|
244
|
+
const selected=explorer.view==='sessions'?explorer.rows()[explorer.selection]:explorer.session;
|
|
245
|
+
const directory=selected?.project||explorer.project?.name;
|
|
246
|
+
if(!selected?.id||!directory||process.platform!=='darwin')explorer.message='External resume requires a selected session on macOS.';
|
|
247
|
+
else if(!fs.existsSync(directory))explorer.message='Original project directory is missing.';
|
|
248
|
+
else {
|
|
249
|
+
const quote=x=>"'"+String(x).replaceAll("'","'\\''")+"'";
|
|
250
|
+
const command=`cd ${quote(directory)} && claude --resume ${quote(selected.id)}`;
|
|
251
|
+
const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
|
|
252
|
+
const result=spawnSync('osascript',['-e',script],{encoding:'utf8',timeout:10000});
|
|
253
|
+
explorer.message=result.status===0?'Opened in Terminal.app.':`Unable to open Terminal: ${(result.stderr||'').slice(0,80)}`;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
232
256
|
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
257
|
else if(str==='/')explorer.setFilter(await ask('Filter table (empty clears):'));
|
|
234
258
|
else if(str==='r'){explorer.rescan(roots);lastSignature=historySignature(roots);}
|
|
235
259
|
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
|
|
260
|
+
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
261
|
draw();
|
|
238
262
|
}
|
|
239
263
|
process.stdin.on('keypress',onKey);process.stdout.on('resize',resize);resize();
|