claude-usage-explorer 1.1.5 → 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 +27 -2
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +79 -77
- package/commands/tui.md +12 -0
- package/hooks/usage-explorer.mjs +10 -2
- package/package.json +33 -3
- package/src/core.mjs +5 -7
- package/src/plan-usage.mjs +39 -0
- package/src/snapshot.mjs +2 -1
- package/src/tui.mjs +9 -3
- package/claude-usage +0 -20
- package/tests/core.test.mjs +0 -30
- package/tests/launcher.test.mjs +0 -12
- package/tests/mod.test.mjs +0 -177
- package/tests/official-spend.test.mjs +0 -17
- package/tests/render.test.mjs +0 -23
- package/tests/snapshot.test.mjs +0 -15
- package/tests/statusline.test.mjs +0 -19
- package/tests/tui.test.mjs +0 -104
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-explorer-marketplace",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
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
|
+
]
|
|
5
30
|
}
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage",
|
|
3
|
-
"description": "
|
|
4
|
-
"version": "1.
|
|
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"
|
|
5
11
|
}
|
package/README.md
CHANGED
|
@@ -1,125 +1,127 @@
|
|
|
1
|
-
# Claude Usage Explorer
|
|
1
|
+
# Claude Usage Explorer
|
|
2
2
|
|
|
3
|
-
|
|
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
4
|
|
|
5
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/claude-usage-explorer)
|
|
6
|
+
[](LICENSE)
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
- Claude Code **2.1.287+** for native Mods (newer versions recommended).
|
|
9
|
-
- Access to the same machine/filesystem where Claude stores its JSONL transcripts.
|
|
8
|
+
## What's new in v1.1.6
|
|
10
9
|
|
|
11
|
-
|
|
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.
|
|
12
16
|
|
|
13
|
-
|
|
14
|
-
2. Run `./claude-usage tui` in that folder for the standalone full-screen explorer. On macOS, optionally run `./scripts/install-command.sh` once, then use `claude-usage tui` from any folder (ensure `~/.local/bin` is on your PATH).
|
|
15
|
-
3. Start Claude with `claude --plugin-dir "$HOME/tools/claude-usage-explorer"`.
|
|
16
|
-
4. In Claude Code type **`/usage-explorer`** to open the **native interactive sidebar**. In CLI, run `/tui fullscreen` first and use a wide terminal (100+ columns). Claude Desktop Code may dock the pane automatically if mods are supported locally.
|
|
17
|
-
5. Optional: **`/claude-usage:open`** launches the standalone TUI in a separate terminal. This skill requires one model invocation; the native mod `/usage-explorer` is registered directly and does not need one.
|
|
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
18
|
|
|
19
|
-
|
|
19
|
+
## Preview
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
### Native Claude Code sidebar
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
- Switch periods (including current/previous billing cycle), filter entries, sort by tokens/cost, refresh.
|
|
25
|
-
- Automatic refresh every five seconds while the pane is open.
|
|
26
|
-
- Shows model usage and API-equivalent estimated cost (not subscription billing).
|
|
27
|
-
- Billing renewal date/time/timezone and custom date ranges are configured in the standalone TUI (`B`); the sidebar reads those saved settings.
|
|
28
|
-
- Unknown model prices are displayed as N/A rather than $0.
|
|
23
|
+

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

|
|
35
30
|
|
|
36
|
-
|
|
31
|
+
Run `claude-usage tui` to explore projects, sessions, token usage, model breakdowns, and estimated costs.
|
|
37
32
|
|
|
38
|
-
|
|
33
|
+
## Quick start
|
|
39
34
|
|
|
40
|
-
|
|
35
|
+
**Standalone CLI (Node.js 20+):**
|
|
41
36
|
|
|
42
|
-
|
|
37
|
+
```sh
|
|
38
|
+
npm install -g claude-usage-explorer
|
|
39
|
+
claude-usage tui
|
|
40
|
+
```
|
|
43
41
|
|
|
44
|
-
|
|
42
|
+
**Claude Code community plugin marketplace:**
|
|
45
43
|
|
|
46
|
-
|
|
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
|
|
47
52
|
|
|
48
|
-
|
|
53
|
+
- Node.js 20+ (Node 22 recommended)
|
|
54
|
+
- Claude Code with native Mods support for the sidebar
|
|
55
|
+
- Local Claude Code JSONL transcript history
|
|
49
56
|
|
|
50
|
-
##
|
|
57
|
+
## Standalone CLI: other ways to run
|
|
51
58
|
|
|
52
|
-
|
|
59
|
+
Run without a global installation:
|
|
53
60
|
|
|
54
61
|
```sh
|
|
55
|
-
|
|
56
|
-
export PATH="$HOME/.local/bin:$PATH"
|
|
57
|
-
hash -r
|
|
58
|
-
type -a claude-usage
|
|
59
|
-
claude-usage tui
|
|
62
|
+
npx --yes --package claude-usage-explorer claude-usage tui
|
|
60
63
|
```
|
|
61
64
|
|
|
62
|
-
|
|
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
|
+
```
|
|
63
72
|
|
|
64
|
-
|
|
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.
|
|
65
74
|
|
|
66
|
-
|
|
75
|
+
## Claude Code plugin installation
|
|
67
76
|
|
|
68
|
-
|
|
77
|
+
Alternatively, from inside Claude Code use `/plugin marketplace add IliasPol/claude-usage`, followed by `/plugin install claude-usage@claude-usage-explorer-marketplace`.
|
|
69
78
|
|
|
70
|
-
|
|
71
|
-
- **X** or **× Exit** closes the pane; **H** or **← Back** navigates up one level.
|
|
72
|
-
- **Backspace cannot be registered as a custom pane shortcut** in the current Claude mod interface.
|
|
73
|
-
- **Ctrl+X, then X** is the host-level close shortcut.
|
|
74
|
-
- The action bar is placed after a flex-growing results area, to anchor it near the bottom where supported by the host layout. Terminal dimensions and host rendering may affect placement.
|
|
75
|
-
- Claude’s built-in **/usage** shows account plan usage limits; Usage Explorer shows local history and API-equivalent cost estimates.
|
|
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.
|
|
76
80
|
|
|
77
|
-
|
|
81
|
+
## Native sidebar: local development
|
|
78
82
|
|
|
79
|
-
|
|
83
|
+
Start Claude Code with the local plugin directory:
|
|
80
84
|
|
|
81
85
|
```sh
|
|
82
|
-
|
|
83
|
-
./scripts/install-command.sh
|
|
84
|
-
export PATH="$HOME/.local/bin:$PATH"
|
|
85
|
-
hash -r
|
|
86
|
-
type -a claude-usage
|
|
87
|
-
claude-usage tui
|
|
86
|
+
claude --plugin-dir /absolute/path/to/claude-usage-explorer
|
|
88
87
|
```
|
|
89
88
|
|
|
90
|
-
|
|
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`.
|
|
91
90
|
|
|
92
|
-
|
|
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.
|
|
93
92
|
|
|
94
|
-
|
|
93
|
+
## Features
|
|
95
94
|
|
|
96
|
-
|
|
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
|
|
97
100
|
|
|
98
|
-
|
|
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.
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
## Windows
|
|
101
104
|
|
|
102
|
-
|
|
105
|
+
In PowerShell, from the extracted project directory (or use the npm installation above):
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
```powershell
|
|
108
|
+
node .\bin\claude-usage.mjs tui
|
|
109
|
+
claude --plugin-dir "$PWD"
|
|
110
|
+
```
|
|
105
111
|
|
|
106
|
-
|
|
112
|
+
## Development
|
|
107
113
|
|
|
108
|
-
|
|
114
|
+
```sh
|
|
115
|
+
npm test
|
|
116
|
+
npm run check
|
|
117
|
+
```
|
|
109
118
|
|
|
110
|
-
|
|
111
|
-
- Native sidebar has a `? Help` control, and the summary follows the selected project/session scope.
|
|
112
|
-
- Standalone TUI summary follows the selected scope; tiny nonzero shares display `<0.1%`.
|
|
113
|
-
- In the standalone macOS TUI, select a session and press `e` to open it in Terminal.app; press uppercase `C` to copy its ID. These actions do not call a model. Terminal.app is used even if the current terminal is PortX.
|
|
114
|
-
- Native in-place session resume is **not implemented** because a supported session-switching Mods API has not been verified. Official billing-cycle reset timestamps also remain unverified; locally configured dates are not represented as official.
|
|
115
|
-
- `stats-cache.json` versus transcript token reconciliation remains open. Local transcript counts should not be treated as official account totals.
|
|
119
|
+
The GitHub Actions workflow tests on Windows, macOS and Ubuntu.
|
|
116
120
|
|
|
117
|
-
##
|
|
118
|
-
- Fix native sidebar render failure: Help uses `z` instead of unsupported `?` button hotkey. Standalone TUI still uses `?` for Help.
|
|
119
|
-
- Add a regression test for Mods UI hotkey restrictions.
|
|
121
|
+
## Contributing, security, and license
|
|
120
122
|
|
|
121
|
-
|
|
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).
|
|
122
124
|
|
|
123
|
-
|
|
125
|
+
## Support
|
|
124
126
|
|
|
125
|
-
|
|
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/commands/tui.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
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.
|
package/hooks/usage-explorer.mjs
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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; use web-standard URL APIs instead of node: imports.
|
|
4
|
+
export function scriptPathFromModuleUrl(moduleUrl){
|
|
5
|
+
const url=new URL('../bin/claude-usage.mjs',moduleUrl);
|
|
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;
|
|
10
|
+
}
|
|
3
11
|
const PANE='claude-usage-explorer';
|
|
4
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']];
|
|
5
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};
|
|
@@ -52,7 +60,7 @@ export function back(s){
|
|
|
52
60
|
return {...s,...last,stack:s.stack.slice(0,-1),query:''};
|
|
53
61
|
}
|
|
54
62
|
async function persistPeriod($,period){
|
|
55
|
-
try {const script=
|
|
63
|
+
try {const script=scriptPathFromModuleUrl(import.meta.url);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
|
|
56
64
|
}
|
|
57
65
|
async function refresh($){
|
|
58
66
|
if(inFlight)return inFlight;
|
|
@@ -60,7 +68,7 @@ async function refresh($){
|
|
|
60
68
|
inFlight=(async()=>{
|
|
61
69
|
try {
|
|
62
70
|
// Module location resolves to the plugin's installed directory, independent of cwd.
|
|
63
|
-
const script=
|
|
71
|
+
const script=scriptPathFromModuleUrl(import.meta.url);
|
|
64
72
|
const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
|
|
65
73
|
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
|
|
66
74
|
const snap=JSON.parse(result.stdout);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-usage-explorer",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.6",
|
|
4
4
|
"description": "Local, read-only Claude Code usage analytics CLI and plugin",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,10 +9,40 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "node --test",
|
|
11
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"
|
|
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"
|
|
13
14
|
},
|
|
14
15
|
"engines": {
|
|
15
16
|
"node": ">=20"
|
|
16
17
|
},
|
|
17
|
-
"license": "MIT"
|
|
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
|
+
]
|
|
18
48
|
}
|
package/src/core.mjs
CHANGED
|
@@ -90,10 +90,7 @@ export function parseFile(file, rates=DEFAULT_RATES) {
|
|
|
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 &&
|
|
94
|
-
const candidate=r.agentType||r.agentName||r.agent_type||r.agent_name;
|
|
95
|
-
if(typeof candidate==='string' && /^[a-zA-Z][a-zA-Z0-9_-]{0,60}$/.test(candidate))agentName=candidate;
|
|
96
|
-
}
|
|
93
|
+
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();}
|
|
97
94
|
const ts=Date.parse(r.timestamp);
|
|
98
95
|
if(Number.isFinite(ts)) {start=Math.min(start??ts,ts);end=Math.max(end??ts,ts);}
|
|
99
96
|
if(!title && r.type==='user') {
|
|
@@ -116,12 +113,12 @@ export function parseFile(file, rates=DEFAULT_RATES) {
|
|
|
116
113
|
if(Number.isFinite(ts)) prev.timestamp=Math.min(prev.timestamp??ts,ts);
|
|
117
114
|
} else requests.set(key,{...m,model,timestamp:Number.isFinite(ts)?ts:null});
|
|
118
115
|
});
|
|
119
|
-
return {file,sessionId:sessionId||id.id,agent:id.agent,
|
|
116
|
+
return {file,sessionId:sessionId||id.id,agent:id.agent,isSubagent:id.isSubagent,agentName,title,cwd,start,end,bad,requests:[...requests.values()]};
|
|
120
117
|
}
|
|
121
118
|
|
|
122
119
|
// Cache derived metadata and token counters only. Never persist transcript text beyond
|
|
123
120
|
// the short session title. Cache invalidates when file size/mtime changes.
|
|
124
|
-
const CACHE_VERSION=
|
|
121
|
+
const CACHE_VERSION=4;
|
|
125
122
|
const cacheDir=()=>path.join(os.homedir(),'.claude-usage-explorer','cache-v3');
|
|
126
123
|
function readCachedFile(file){
|
|
127
124
|
const st=fs.statSync(file);
|
|
@@ -156,7 +153,8 @@ export function ingest(roots,rates=DEFAULT_RATES) {
|
|
|
156
153
|
if(parsed.end!==null) s.end=Math.max(s.end??parsed.end,parsed.end);
|
|
157
154
|
let agent=s.agents.get(parsed.agent);
|
|
158
155
|
if(!agent) {agent={id:parsed.agent,name:parsed.agentName||null,requests:[]};s.agents.set(parsed.agent,agent);}
|
|
159
|
-
agent.name
|
|
156
|
+
if(!agent.name&&parsed.agentName)agent.name=parsed.agentName;
|
|
157
|
+
agent.requests.push(...parsed.requests);
|
|
160
158
|
}
|
|
161
159
|
return {sessions:[...sessions.values()],files:files.length,malformed,roots};
|
|
162
160
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Claude Desktop's plan-usage-history.json is undocumented, optional cached data.
|
|
5
|
+
// Read only known numeric utilization fields; never expose organization identifiers.
|
|
6
|
+
export function planUsageFromSamples(samples, now=Date.now()) {
|
|
7
|
+
if (!Array.isArray(samples)) return null;
|
|
8
|
+
const valid=samples.filter(s=>s && Number.isFinite(s.t) && s.u &&
|
|
9
|
+
(Number.isFinite(s.u.fh)||Number.isFinite(s.u.sd)))
|
|
10
|
+
.map(s=>({timestamp:s.t<1e12?s.t*1000:s.t,
|
|
11
|
+
fiveHour:Number.isFinite(s.u.fh)?s.u.fh:null,
|
|
12
|
+
sevenDay:Number.isFinite(s.u.sd)?s.u.sd:null}))
|
|
13
|
+
.filter(s=>s.timestamp>0 && s.timestamp<=now+86400000)
|
|
14
|
+
.sort((a,b)=>a.timestamp-b.timestamp);
|
|
15
|
+
if (!valid.length)return null;
|
|
16
|
+
const latest=valid.at(-1);
|
|
17
|
+
return { ...latest, stale:now-latest.timestamp>15*60*1000,
|
|
18
|
+
samples:valid.slice(-100),source:'Claude Desktop cached history'};
|
|
19
|
+
}
|
|
20
|
+
export function readPlanUsage({platform=process.platform,localAppData=process.env.LOCALAPPDATA,home=process.env.HOME,now=Date.now()}={}){
|
|
21
|
+
const roots=[];
|
|
22
|
+
if(platform==='win32'&&localAppData)roots.push(path.join(localAppData,'Claude-Data'));
|
|
23
|
+
if(platform==='darwin'&&home){roots.push(path.join(home,'Library','Application Support','Claude-Data'));roots.push(path.join(home,'Library','Application Support','Claude'));}
|
|
24
|
+
let best=null;
|
|
25
|
+
for(const root of roots){
|
|
26
|
+
let folders=[];
|
|
27
|
+
try{folders=fs.readdirSync(root,{withFileTypes:true}).filter(x=>x.isDirectory()&&x.name.startsWith('claude_')).map(x=>path.join(root,x.name));}catch{continue;}
|
|
28
|
+
for(const folder of folders){
|
|
29
|
+
try{
|
|
30
|
+
const file=path.join(folder,'plan-usage-history.json');
|
|
31
|
+
if(fs.statSync(file).size>2_000_000)continue;
|
|
32
|
+
const data=JSON.parse(fs.readFileSync(file,'utf8'));
|
|
33
|
+
const result=planUsageFromSamples(data.samples,now);
|
|
34
|
+
if(result&&(!best||result.timestamp>best.timestamp))best=result;
|
|
35
|
+
}catch{}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return best;
|
|
39
|
+
}
|
package/src/snapshot.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import {DEFAULT_RATES,ingest,projectRoots,summarize,timeWindow} from './core.mjs';
|
|
5
5
|
import {describePeriod} from './tui.mjs';
|
|
6
|
+
import {readPlanUsage} from './plan-usage.mjs';
|
|
6
7
|
|
|
7
8
|
export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projectRoots()}={}) {
|
|
8
9
|
const periodIndex=argv.indexOf('--period');
|
|
@@ -13,5 +14,5 @@ export function snapshot(argv=[], {now=Date.now(),home=os.homedir(),roots=projec
|
|
|
13
14
|
const rates={...DEFAULT_RATES,...settings.rates};
|
|
14
15
|
const window=timeWindow(period,now,settings.billingDay,settings.billingTimeZone||'UTC',settings.billingHour||0,settings.billingMinute||0);
|
|
15
16
|
const report=summarize(ingest(roots,rates),window,rates);
|
|
16
|
-
return {
|
|
17
|
+
return {period,periodLabel:describePeriod(period,settings,now),settings:{billingDay:settings.billingDay||null,billingTimeZone:settings.billingTimeZone||'UTC'},updatedAt:now,report,planUsage:readPlanUsage({now})};
|
|
17
18
|
}
|
package/src/tui.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import readline from 'node:readline';
|
|
5
5
|
import {spawnSync} from 'node:child_process';
|
|
6
|
+
import {readPlanUsage} from './plan-usage.mjs';
|
|
6
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']];
|
|
@@ -25,7 +26,9 @@ 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=settings.lastPeriod||'all'}={}){
|
|
29
|
+
export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),period=settings.lastPeriod||'all',planReader=readPlanUsage}={}){
|
|
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;}}
|
|
29
32
|
let liveState={enabled:true,lastUpdated:Date.now(),lastCheck:Date.now(),error:null};
|
|
30
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;
|
|
31
34
|
const rates=()=>({...DEFAULT_RATES,...settings.rates});
|
|
@@ -79,6 +82,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
79
82
|
function setFilter(s){filter=s;selection=0;offset=0;}
|
|
80
83
|
function setSort(key){if(sort===key)descending=!descending;else{sort=key;descending=key!=='name';}selection=0;offset=0;}
|
|
81
84
|
function render(width=process.stdout.columns||110,height=process.stdout.rows||30){
|
|
85
|
+
refreshPlan();
|
|
82
86
|
width=Math.max(20,width);height=Math.max(8,height);
|
|
83
87
|
const tty=process.stdout.isTTY && !process.env.NO_COLOR;
|
|
84
88
|
const c=(text,style)=>tty?`\x1b[${style}m${text}\x1b[0m`:text;
|
|
@@ -93,6 +97,8 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
93
97
|
line(` ${c(crumb,'1')} ${c('PERIOD','90')} ${describePeriod(currentPeriod,settings)} ${c('SORT','90')} ${sort} ${descending?'↓':'↑'}`);
|
|
94
98
|
const scoped=view==='sessions'?project?.stats:view==='agents'?session?.stats:view==='models'?(agent?.stats||session?.stats||project?.stats||report.overall):report.overall;
|
|
95
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';
|
|
101
|
+
line(` ${c('PLAN LIMITS','1;36')} ${c(planLabel,'90')}`);
|
|
96
102
|
rule();
|
|
97
103
|
const available=rows();selection=Math.max(0,Math.min(selection,Math.max(0,available.length-1)));
|
|
98
104
|
const selected=available[selection];
|
|
@@ -100,7 +106,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
100
106
|
// Fixed-height table, model panel, and footer prevent vertical jumping on selection.
|
|
101
107
|
const modelPanelHeight=7; // heading + five model rows + divider
|
|
102
108
|
const footerHeight=5; // separator, two shortcuts, search, status
|
|
103
|
-
const headerHeight=
|
|
109
|
+
const headerHeight=7;
|
|
104
110
|
const visible=Math.max(0,height-headerHeight-modelPanelHeight-footerHeight-2);
|
|
105
111
|
if(selection<offset)offset=selection;if(selection>=offset+visible)offset=selection-visible+1;
|
|
106
112
|
// Keep columns aligned by terminal width, shrinking the name first.
|
|
@@ -140,7 +146,7 @@ export function createExplorer(data,{cwd=process.cwd(),settings=readSettings(),p
|
|
|
140
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'):''));
|
|
141
147
|
return out.slice(0,Math.max(1,height-1)).join('\n');
|
|
142
148
|
}
|
|
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;}};
|
|
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;}};
|
|
144
150
|
}
|
|
145
151
|
// Clip colored output to the terminal viewport to prevent line wrapping and scroll flashes.
|
|
146
152
|
export function clipAnsi(value, maxWidth) {
|
package/claude-usage
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
#!/bin/sh
|
|
2
|
-
# Portable launcher: run directly from this folder, without npm global shims.
|
|
3
|
-
set -eu
|
|
4
|
-
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
|
5
|
-
if [ -n "${CLAUDE_USAGE_NODE:-}" ]; then
|
|
6
|
-
NODE="$CLAUDE_USAGE_NODE"
|
|
7
|
-
elif command -v node >/dev/null 2>&1 && node -e 'process.exit(Number(process.versions.node.split(".")[0]) < 20)' >/dev/null 2>&1; then
|
|
8
|
-
NODE=$(command -v node)
|
|
9
|
-
else
|
|
10
|
-
NODE=''
|
|
11
|
-
for candidate in "$HOME"/.asdf/installs/nodejs/22.22.2/bin/node "$HOME"/.asdf/installs/nodejs/22.22.1/bin/node "$HOME"/.asdf/installs/nodejs/24.18.0/bin/node "$HOME"/.asdf/installs/nodejs/20.19.5/bin/node; do
|
|
12
|
-
if [ -x "$candidate" ]; then NODE="$candidate"; break; fi
|
|
13
|
-
done
|
|
14
|
-
if [ -z "$NODE" ]; then
|
|
15
|
-
printf '%s\n' 'Claude Usage Explorer requires Node.js 20+. Set CLAUDE_USAGE_NODE to its executable path.' >&2
|
|
16
|
-
exit 1
|
|
17
|
-
fi
|
|
18
|
-
fi
|
|
19
|
-
if [ "$#" -eq 0 ]; then set -- tui; fi
|
|
20
|
-
exec "$NODE" "$ROOT/bin/claude-usage.mjs" "$@"
|
package/tests/core.test.mjs
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/launcher.test.mjs
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/mod.test.mjs
DELETED
|
@@ -1,177 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/render.test.mjs
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/snapshot.test.mjs
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,19 +0,0 @@
|
|
|
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
|
-
});
|
package/tests/tui.test.mjs
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
});
|