claude-usage-explorer 1.0.0 → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +125 -84
- package/bin/claude-usage.mjs +4 -1
- package/claude-usage +20 -0
- package/hooks/usage-explorer.mjs +200 -63
- package/package.json +18 -29
- package/src/core.mjs +9 -5
- package/src/snapshot.mjs +1 -2
- package/src/tui.mjs +39 -21
- package/tests/core.test.mjs +30 -0
- package/tests/launcher.test.mjs +12 -0
- package/tests/mod.test.mjs +177 -0
- package/tests/official-spend.test.mjs +17 -0
- package/tests/render.test.mjs +23 -0
- package/tests/snapshot.test.mjs +15 -0
- package/tests/statusline.test.mjs +19 -0
- package/tests/tui.test.mjs +104 -0
- package/commands/tui.md +0 -12
- package/src/plan-usage.mjs +0 -39
package/README.md
CHANGED
|
@@ -1,84 +1,125 @@
|
|
|
1
|
-
# Claude Usage Explorer
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
1
|
+
# Claude Usage Explorer v1.0.4
|
|
2
|
+
|
|
3
|
+
Local, read-only analytics for Claude Code sessions. No external npm dependencies and no tmux requirement.
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- Node.js 20 or newer (22 LTS recommended).
|
|
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.
|
|
10
|
+
|
|
11
|
+
## Install and run
|
|
12
|
+
|
|
13
|
+
1. Extract the ZIP into a permanent folder, e.g. `~/tools/claude-usage-explorer`.
|
|
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.
|
|
18
|
+
|
|
19
|
+
**asdf users:** switch to a compatible Node before starting Claude, e.g. `asdf shell nodejs 22.22.2`, or `export ASDF_NODEJS_VERSION=22.22.2` if your asdf lacks `shell`. The native mod starts `node` from Claude's environment.
|
|
20
|
+
|
|
21
|
+
## Native sidebar
|
|
22
|
+
|
|
23
|
+
- Click projects → sessions → agents → models, and Back to return.
|
|
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.
|
|
29
|
+
|
|
30
|
+
## Standalone TUI
|
|
31
|
+
|
|
32
|
+
`./claude-usage tui`
|
|
33
|
+
|
|
34
|
+
Supports all original navigation, custom dates, settings, live refresh and model breakdowns. `./claude-usage open` opens another terminal window when supported. `split` and its Claude skill were removed.
|
|
35
|
+
|
|
36
|
+
## Notes and limitations
|
|
37
|
+
|
|
38
|
+
The mod is implemented against Anthropic's current Mod API (session.start, command.run, ui.render, ui.open, ui.invalidate, process.run, clock.every). **It has not been verified in a live Claude Code or Claude Desktop installation**, so treat this as an integration candidate and report any host API errors. The standalone TUI is independently tested. The native sidebar uses clickable rows; arrow-key selection parity with the standalone TUI is not yet complete. Native mod availability depends on the Claude Code version and host.
|
|
39
|
+
|
|
40
|
+
The plugin reads local Claude transcript files and does not send them to an external service. Costs are estimates based on configured per-model pricing, not actual Claude subscription charges. Pricing defaults may need verification or overrides.
|
|
41
|
+
|
|
42
|
+
## Validation
|
|
43
|
+
|
|
44
|
+
Run `npm test` and `npm run check`. For native mod validation on a machine with Claude Code installed: `claude plugin validate .`.
|
|
45
|
+
|
|
46
|
+
## v1.0.4 sidebar fix
|
|
47
|
+
|
|
48
|
+
Adds the required `onSubmit` handler as well as a stable `key` for the filter `<Input>` component in Claude Code 2.1.311. If the sidebar remains blank after upgrading, restart Claude Code with the updated plugin folder. The native mod still invokes `node` from Claude’s own environment, so start Claude under Node 20+ (e.g. Node 22).
|
|
49
|
+
|
|
50
|
+
## Fixing old asdf commands and terminal flashes (v1.0.4)
|
|
51
|
+
|
|
52
|
+
The installer now creates a small executable wrapper instead of a symlink. This avoids resolving `bin/claude-usage.mjs` relative to `~/.local/bin`.
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
./scripts/install-command.sh
|
|
56
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
57
|
+
hash -r
|
|
58
|
+
type -a claude-usage
|
|
59
|
+
claude-usage tui
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
If an old asdf shim still appears first, ensure `~/.local/bin` is prepended *after* asdf initialization in your shell startup file. The standalone TUI now clips terminal output, reserves the last terminal row, and ignores duplicate resize events to reduce blank-screen flashes.
|
|
63
|
+
|
|
64
|
+
## Sidebar controls (v1.0.4)
|
|
65
|
+
|
|
66
|
+
The sidebar keeps its period picker collapsed by default, with the usage summary and drill-down list at the top and actions at the bottom. Click **◷ Period** (or use its host-supported `p` hotkey while the panel is focused) to choose a billing period. Other actions: Back (`h`), Refresh (`r`), Models (`m`), and Sort (`s`). Click a project or session to drill down. These shortcuts depend on Claude Code focus and hotkey handling.
|
|
67
|
+
|
|
68
|
+
### Native sidebar keyboard and exit (v1.0.6)
|
|
69
|
+
|
|
70
|
+
- **Esc** closes the pane and returns focus to Claude (native `closeOnEscape`).
|
|
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.
|
|
76
|
+
|
|
77
|
+
### Install both native sidebar and standalone CLI (v1.0.7)
|
|
78
|
+
|
|
79
|
+
Extract the release archive into `~/claude-usage-explorer-1.0.7`, then:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
cd ~/claude-usage-explorer-1.0.7
|
|
83
|
+
./scripts/install-command.sh
|
|
84
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
85
|
+
hash -r
|
|
86
|
+
type -a claude-usage
|
|
87
|
+
claude-usage tui
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Place `export PATH="$HOME/.local/bin:$PATH"` **after asdf initialization** in your shell startup file to keep the CLI command ahead of old asdf shims. Run `claude --plugin-dir "$HOME/claude-usage-explorer-1.0.7"`, enter `/tui fullscreen` if needed, then `/usage-explorer` to open the native sidebar.
|
|
91
|
+
|
|
92
|
+
### v1.0.8 display updates
|
|
93
|
+
|
|
94
|
+
The selected period persists across standalone TUI and native sidebar launches in `~/.claude-usage-explorer/settings.json` (`lastPeriod`). Each usage row shows a token percentage of its parent scope; the standalone TUI reserves four terminal columns on the right. Enterprise plan limits remain unavailable when no supported source provides them; no estimated spend is misrepresented as an official limit.
|
|
95
|
+
|
|
96
|
+
### Experimental official usage diagnostic (v1.0.9)
|
|
97
|
+
|
|
98
|
+
Inside `/usage-explorer`, select **Check official limits (opt-in)**. It first reads `$.session.usage()` and then, if Claude supplies an opaque first-party authorization handle, calls Anthropic's undocumented OAuth usage endpoint through `$.http.fetch`. The mod never reads or stores the token and only renders allowlisted numeric usage fields and reset timestamps; no raw response is logged or persisted. Enterprise monthly spending limits may not be included. This is an experimental diagnostic, not a stable public API integration. No automatic polling.
|
|
99
|
+
|
|
100
|
+
### Official account usage (v1.0.10)
|
|
101
|
+
|
|
102
|
+
Native sidebar automatically loads official utilization through Claude-hosted authorization when available, refreshes no more often than every 3 minutes, and supports manual refresh. It shows a percentage bar for recognized limits (including Enterprise `extra_usage`) but does not infer a dollar amount or billing cap. The endpoint is undocumented and may change; credentials and raw responses are never stored. Standalone TUI remains local-only.
|
|
103
|
+
|
|
104
|
+
### Usage response schema diagnostic (v1.0.11)
|
|
105
|
+
|
|
106
|
+
The native sidebar displays official account spending and available limit bars without the developer-only response field diagnostic. No automatic billing-period override is applied.
|
|
107
|
+
|
|
108
|
+
## v1.1.0 changes
|
|
109
|
+
|
|
110
|
+
- Native sidebar now reads official `spend.used` and `spend.limit` in minor currency units, with `extra_usage` as a fallback, and displays actual used/limit/remaining amounts with a higher-contrast utilization bar. This is separate from estimated local API cost.
|
|
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.
|
|
116
|
+
|
|
117
|
+
## v1.1.1 hotfix
|
|
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.
|
|
120
|
+
|
|
121
|
+
### Native session actions (v1.1.2)
|
|
122
|
+
|
|
123
|
+
Drill into a project and select a session. Use **Open session in Terminal** to resume in a new macOS Terminal window from the original project directory, or **Copy session ID** to copy the identifier. In-place switching of the active Claude Code session is not supported.
|
|
124
|
+
|
|
125
|
+
Agent rows display a readable agent type when available in local transcripts, otherwise “Subagent”; raw subagent IDs appear only after opening an agent. Session shortcuts O/C apply inside a session, not to highlighted Sessions-list entries.
|
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/claude-usage
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
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/hooks/usage-explorer.mjs
CHANGED
|
@@ -1,23 +1,24 @@
|
|
|
1
1
|
// Claude Code native mod. The UI uses host components; local analytics run in a child Node process.
|
|
2
2
|
// No model requests are made when opening, navigating or refreshing this pane.
|
|
3
|
-
// Native hooks run in a restricted loader: no node: built-in imports.
|
|
4
|
-
// Convert the file URL using web-standard URL APIs, preserving Windows drive paths.
|
|
5
|
-
export function scriptPathFromModuleUrl(moduleUrl){
|
|
6
|
-
const url=new URL('../bin/claude-usage.mjs',moduleUrl);
|
|
7
|
-
let path=decodeURIComponent(url.pathname);
|
|
8
|
-
if(/^\/[A-Za-z]:\//.test(path))path=path.slice(1);
|
|
9
|
-
if(url.host&&url.host!=='localhost')path=`//${url.host}${path}`;
|
|
10
|
-
return path;
|
|
11
|
-
}
|
|
12
3
|
const PANE='claude-usage-explorer';
|
|
13
4
|
const PERIODS=[['Billing cycle','cycle'],['Previous cycle','previous-cycle'],['All time','all'],['Today','today'],['Yesterday','yesterday'],['7 days','7d'],['30 days','30d'],['This month','month'],['Previous month','previous-month']];
|
|
14
|
-
let state={period:'all',view:'projects',stack:[],project:null,session:null,agent:null,report:null,periodLabel:'All time',loading:false,error:'',updatedAt:0,sort:'tokens',query:'',periodMenu:false
|
|
5
|
+
let state={period:'all',view:'projects',stack:[],project:null,session:null,agent:null,report:null,periodLabel:'All time',loading:false,error:'',updatedAt:0,sort:'tokens',query:'',periodMenu:false};
|
|
6
|
+
let limitState={status:'not checked',source:'',rateLimits:[],details:[],checkedAt:0};
|
|
7
|
+
let limitInFlight=null;
|
|
8
|
+
let usageFieldSchema=[];
|
|
9
|
+
|
|
10
|
+
let officialSpend=null;
|
|
11
|
+
let sessionActionStatus='';
|
|
12
|
+
let focusedSessionId=null;
|
|
13
|
+
const LIMIT_REFRESH_MS=180000;
|
|
15
14
|
let timer=null;
|
|
16
15
|
let inFlight=null;
|
|
17
16
|
const total=s=>(s?.input||0)+(s?.output||0)+(s?.cacheRead||0)+(s?.cacheWrite||0);
|
|
18
17
|
const compact=n=>Intl.NumberFormat('en-US',{notation:'compact',maximumFractionDigits:1}).format(n||0);
|
|
18
|
+
const share=(part,whole)=>whole>0?(part>0&&100*part/whole<0.05?'<0.1%':`${(100*part/whole).toFixed(1)}%`):'—';
|
|
19
19
|
const price=s=>s?.unknownCostRequests?'N/A':`$${(s?.knownCost||0).toFixed(2)}`;
|
|
20
20
|
const name=p=>String(p||'').replace(/\\/g,'/').split('/').filter(Boolean).pop()||p;
|
|
21
|
+
export const agentLabel=a=>a?.id==='main'?'Agent (main)':(a?.name||a?.type||'Subagent');
|
|
21
22
|
const change=($,patch)=>{state={...state,...patch};void $.ui.invalidate('ui.render');};
|
|
22
23
|
export function itemsFor(s=state){
|
|
23
24
|
const r=s.report;
|
|
@@ -25,7 +26,7 @@ export function itemsFor(s=state){
|
|
|
25
26
|
let items=[];
|
|
26
27
|
if(s.view==='projects')items=r.projects.map(x=>({id:x.name,label:name(x.name),stats:x.stats,source:x}));
|
|
27
28
|
if(s.view==='sessions')items=r.sessions.filter(x=>!s.project||x.project===s.project).map(x=>({id:x.id,label:x.title,stats:x.stats,source:x}));
|
|
28
|
-
if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:x
|
|
29
|
+
if(s.view==='agents'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=(current?.agents||[]).map(x=>({id:x.id,label:agentLabel(x),stats:x.stats,source:x}));}
|
|
29
30
|
if(s.view==='models'){const current=r.sessions.find(x=>x.id===s.session?.id)||s.session;items=modelScope(s).map(x=>({id:x.name,label:x.name,stats:x.stats,source:x}));}
|
|
30
31
|
if(s.query)items=items.filter(x=>x.label.toLowerCase().includes(s.query.toLowerCase()));
|
|
31
32
|
return items.sort((a,b)=>s.sort==='cost'?(b.stats.knownCost-a.stats.knownCost):total(b.stats)-total(a.stats));
|
|
@@ -50,23 +51,153 @@ export function back(s){
|
|
|
50
51
|
const last=s.stack[s.stack.length-1];
|
|
51
52
|
return {...s,...last,stack:s.stack.slice(0,-1),query:''};
|
|
52
53
|
}
|
|
54
|
+
async function persistPeriod($,period){
|
|
55
|
+
try {const script=decodeURIComponent(new URL('../bin/claude-usage.mjs',import.meta.url).pathname);await $.process.run(['node',script,'preference','period',period],{timeoutMs:5000});}catch{}
|
|
56
|
+
}
|
|
53
57
|
async function refresh($){
|
|
54
58
|
if(inFlight)return inFlight;
|
|
55
59
|
change($,{loading:true,error:''});
|
|
56
60
|
inFlight=(async()=>{
|
|
57
61
|
try {
|
|
58
62
|
// Module location resolves to the plugin's installed directory, independent of cwd.
|
|
59
|
-
const script=
|
|
63
|
+
const script=decodeURIComponent(new URL('../bin/claude-usage.mjs',import.meta.url).pathname);
|
|
60
64
|
const result=await $.process.run(['node',script,'snapshot','--period',state.period],{timeoutMs:30000});
|
|
61
65
|
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to read usage').trim().slice(0,240));
|
|
62
66
|
const snap=JSON.parse(result.stdout);
|
|
67
|
+
if(!state.preferencesLoaded){state.period=snap.savedPeriod||state.period;state.preferencesLoaded=true;if(state.period!==snap.period){inFlight=null;return refresh($);}}
|
|
63
68
|
// Refresh preserves the active drilldown; a missing session is handled by an empty list.
|
|
64
|
-
change($,{report:snap.report,
|
|
69
|
+
change($,{report:snap.report,periodLabel:snap.periodLabel,updatedAt:snap.updatedAt,loading:false,error:''});
|
|
65
70
|
}catch(err){change($,{loading:false,error:String(err?.message||err).slice(0,260)});}
|
|
66
71
|
})().finally(()=>{inFlight=null});
|
|
67
72
|
return inFlight;
|
|
68
73
|
}
|
|
69
|
-
|
|
74
|
+
// Opt-in diagnostic: never store or display the raw response, credentials or identifiers.
|
|
75
|
+
// The first-party OAuth usage route is undocumented; a failure is not fatal.
|
|
76
|
+
export function summarizeLimits(payload){
|
|
77
|
+
if(!payload||typeof payload!=='object')return [];
|
|
78
|
+
const lines=[];
|
|
79
|
+
for(const [key,val] of Object.entries(payload)){
|
|
80
|
+
if(!/^(five_hour|seven_day|seven_day_[a-z_]+|extra_usage|monthly|billing|usage|rate_limits)$/i.test(key))continue;
|
|
81
|
+
if(val&&typeof val==='object'&&!Array.isArray(val)){
|
|
82
|
+
const fields=[];
|
|
83
|
+
for(const field of ['utilization','percentUsed','limit','used','remaining','resets_at','resetsAt','is_enabled']){
|
|
84
|
+
const v=val[field];
|
|
85
|
+
if(typeof v==='number'&&Number.isFinite(v))fields.push(`${field}: ${v}`);
|
|
86
|
+
else if(typeof v==='boolean')fields.push(`${field}: ${v}`);
|
|
87
|
+
else if((field==='resets_at'||field==='resetsAt')&&typeof v==='string'&&/^\d{4}-\d{2}-\d{2}/.test(v))fields.push(`${field}: ${v.slice(0,25)}`);
|
|
88
|
+
}
|
|
89
|
+
if(fields.length)lines.push(`${key}: ${fields.join(' · ')}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return lines.slice(0,12);
|
|
93
|
+
}
|
|
94
|
+
// Diagnostic exposes only structural metadata, never values, identifiers or credentials.
|
|
95
|
+
export function usageSchema(payload){
|
|
96
|
+
if(!payload||typeof payload!=='object'||Array.isArray(payload))return [];
|
|
97
|
+
const rows=[];
|
|
98
|
+
const walk=(obj,prefix,depth)=>{
|
|
99
|
+
for(const [key,value] of Object.entries(obj)){
|
|
100
|
+
if(rows.length>=90)return;
|
|
101
|
+
// Only keys with conventional field-name syntax; avoid leaking arbitrary map keys.
|
|
102
|
+
if(!/^[a-zA-Z][a-zA-Z0-9_]{0,49}$/.test(key))continue;
|
|
103
|
+
const path=prefix?`${prefix}.${key}`:key;
|
|
104
|
+
const type=value===null?'null':Array.isArray(value)?'array':typeof value;
|
|
105
|
+
rows.push(`${path}: ${type}`);
|
|
106
|
+
if(depth<2&&type==='object')walk(value,path,depth+1);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
walk(payload,'',0);
|
|
110
|
+
return rows;
|
|
111
|
+
}
|
|
112
|
+
// Normalize money using currency minor units. Never add spend and extra_usage together.
|
|
113
|
+
export function parseOfficialSpend(payload){
|
|
114
|
+
const spend=payload?.spend;
|
|
115
|
+
const money=(obj)=>{
|
|
116
|
+
if(!obj||!Number.isFinite(obj.amount_minor)||!Number.isInteger(obj.exponent)||obj.exponent<0||obj.exponent>8||typeof obj.currency!=='string')return null;
|
|
117
|
+
return {amount:obj.amount_minor/10**obj.exponent,currency:obj.currency.toUpperCase()};
|
|
118
|
+
};
|
|
119
|
+
const used=money(spend?.used),limit=money(spend?.limit);
|
|
120
|
+
if(used&&limit&&used.currency===limit.currency&&limit.amount>0){
|
|
121
|
+
return {used:used.amount,limit:limit.amount,currency:used.currency,percent:Number.isFinite(spend?.percent)?spend.percent:100*used.amount/limit.amount,source:'spend'};
|
|
122
|
+
}
|
|
123
|
+
const extra=payload?.extra_usage;
|
|
124
|
+
if(extra&&Number.isFinite(extra.used_credits)&&Number.isFinite(extra.monthly_limit)&&extra.monthly_limit>0&&typeof extra.currency==='string'){
|
|
125
|
+
return {used:extra.used_credits,limit:extra.monthly_limit,currency:extra.currency.toUpperCase(),percent:Number.isFinite(extra.utilization)?extra.utilization:100*extra.used_credits/extra.monthly_limit,source:'extra_usage'};
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
const currencyAmount=(amount,currency)=>{try{return new Intl.NumberFormat('en-US',{style:'currency',currency}).format(amount);}catch{return `${amount.toFixed(2)} ${currency}`;}};
|
|
130
|
+
async function checkLimits($,{force=false}={}){
|
|
131
|
+
if(limitInFlight)return limitInFlight;
|
|
132
|
+
if(!force&&limitState.checkedAt&&Date.now()-limitState.checkedAt<LIMIT_REFRESH_MS)return;
|
|
133
|
+
limitState={...limitState,status:'checking'};void $.ui.invalidate('ui.render');
|
|
134
|
+
limitInFlight=(async()=>{try{
|
|
135
|
+
const usage=await $.session.usage();
|
|
136
|
+
const rateLimits=Array.isArray(usage?.rateLimits)?usage.rateLimits:[];
|
|
137
|
+
limitState={status:rateLimits.length?'available':'no rate limits returned',source:'session.usage',rateLimits:rateLimits.map(x=>({kind:String(x.kind||'limit').slice(0,40),percentUsed:Number(x.percentUsed)||0,resetsAt:typeof x.resetsAt==='string'?x.resetsAt.slice(0,25):''})),details:[]};
|
|
138
|
+
const authorization=await $.session.authorize();
|
|
139
|
+
if(!authorization?.handle){limitState.status='No first-party authorization handle';return;}
|
|
140
|
+
const response=await $.http.fetch('https://api.anthropic.com/api/oauth/usage',{auth:authorization.handle});
|
|
141
|
+
if(!response.ok){limitState.status=`HTTP ${response.status}`;return;}
|
|
142
|
+
const payload=JSON.parse(response.text);
|
|
143
|
+
officialSpend=parseOfficialSpend(payload);
|
|
144
|
+
const details=summarizeLimits(payload);
|
|
145
|
+
limitState={...limitState,status:details.length?'available':'No recognized fields (Enterprise may differ)',source:'Claude session + OAuth usage',details};
|
|
146
|
+
}catch(err){limitState={...limitState,status:`Unavailable: ${String(err?.message||err).slice(0,100)}`};}
|
|
147
|
+
finally{limitState.checkedAt=Date.now();void $.ui.invalidate('ui.render');}
|
|
148
|
+
})().finally(()=>{limitInFlight=null});
|
|
149
|
+
return limitInFlight;
|
|
150
|
+
}
|
|
151
|
+
export function usageBar(percent,width=24){
|
|
152
|
+
const value=Math.max(0,Math.min(100,Number.isFinite(percent)?percent:0));
|
|
153
|
+
const count=Math.round(value/100*width);
|
|
154
|
+
return `${'█'.repeat(count)}${'░'.repeat(width-count)} ${value.toFixed(1)}%`;
|
|
155
|
+
}
|
|
156
|
+
export function officialLimitRows(limits){
|
|
157
|
+
const rows=[];
|
|
158
|
+
for(const limit of limits.rateLimits||[]){
|
|
159
|
+
if(Number.isFinite(limit.percentUsed))rows.push({label:limit.kind,percent:limit.percentUsed,resetsAt:limit.resetsAt||''});
|
|
160
|
+
}
|
|
161
|
+
for(const line of limits.details||[]){
|
|
162
|
+
const match=/^([^:]+):.*?utilization: ([0-9]+(?:\.[0-9]+)?)/.exec(line);
|
|
163
|
+
if(match){
|
|
164
|
+
const resets=/resets_(?:at|At): ([^·]+)/.exec(line);
|
|
165
|
+
rows.push({label:match[1],percent:Number(match[2]),resetsAt:resets?.[1]?.trim()||''});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return rows;
|
|
169
|
+
}
|
|
170
|
+
// Session resume opens a separate terminal; no supported in-place Mods session switch is assumed.
|
|
171
|
+
export function sessionResumeCommand(session){
|
|
172
|
+
if(!session?.id||!/^[a-f0-9-]{36}$/i.test(session.id))throw new Error('Invalid session ID');
|
|
173
|
+
if(!session.project||typeof session.project!=='string'||!session.project.startsWith('/'))throw new Error('Original project directory unavailable');
|
|
174
|
+
const quote=value=>"'"+String(value).replaceAll("'","'\\''")+"'";
|
|
175
|
+
return `cd ${quote(session.project)} && claude --resume ${quote(session.id)}`;
|
|
176
|
+
}
|
|
177
|
+
export function selectedSessionForAction(s,focusedId){
|
|
178
|
+
if(s.view==='sessions')return s.report?.sessions?.find(x=>x.id===focusedId&&(!s.project||x.project===s.project))||null;
|
|
179
|
+
return s.report?.sessions?.find(x=>x.id===s.session?.id)||s.session||null;
|
|
180
|
+
}
|
|
181
|
+
async function sessionAction($,mode){
|
|
182
|
+
const selected=selectedSessionForAction(state,focusedSessionId);
|
|
183
|
+
try{
|
|
184
|
+
if(!selected)throw new Error('Select a session first');
|
|
185
|
+
if(mode==='copy'){
|
|
186
|
+
const result=await $.process.run(['osascript','-e',`set the clipboard to ${JSON.stringify(selected.id)}`],{timeoutMs:5000});
|
|
187
|
+
if(result.exitCode!==0)throw new Error('Clipboard unavailable');
|
|
188
|
+
sessionActionStatus='Session ID copied';
|
|
189
|
+
}else{
|
|
190
|
+
const command=sessionResumeCommand(selected);
|
|
191
|
+
// osascript argument is encoded as an AppleScript string, never shell-interpolated.
|
|
192
|
+
const script=`tell application "Terminal" to do script ${JSON.stringify(command)}`;
|
|
193
|
+
const result=await $.process.run(['osascript','-e',script],{timeoutMs:10000});
|
|
194
|
+
if(result.exitCode!==0)throw new Error((result.stderr||'Unable to open Terminal').slice(0,120));
|
|
195
|
+
sessionActionStatus='Opened session in Terminal.app';
|
|
196
|
+
}
|
|
197
|
+
}catch(error){sessionActionStatus=String(error?.message||error).slice(0,140);}
|
|
198
|
+
void $.ui.invalidate('ui.render');
|
|
199
|
+
}
|
|
200
|
+
async function show($){await $.ui.open({id:PANE,title:'Claude Usage Explorer',focus:true,closeOnEscape:true});void refresh($);void checkLimits($);}
|
|
70
201
|
export function register(on){
|
|
71
202
|
on('session.start',async ($,e,next)=>{
|
|
72
203
|
await $.command.register({name:'usage-explorer',description:'Open the native interactive Claude Usage Explorer sidebar',immediate:true});
|
|
@@ -77,7 +208,7 @@ export function register(on){
|
|
|
77
208
|
if(surfaces.includes('terminal')&&!e.presentation.isFullscreen)return {text:'Use /tui fullscreen, then /usage-explorer. The native sidebar needs fullscreen layout.'};
|
|
78
209
|
if(surfaces.includes('terminal')&&e.presentation.columns<100)return {text:'Widen the terminal to at least 100 columns, then /usage-explorer.'};
|
|
79
210
|
await show($);
|
|
80
|
-
timer?.cancel();timer=$.clock.every(5000,()=>void refresh($));
|
|
211
|
+
timer?.cancel();timer=$.clock.every(5000,()=>{void refresh($);void checkLimits($);});
|
|
81
212
|
return {text:'Usage Explorer opened in the sidebar. Navigation and refresh do not call the model.'};
|
|
82
213
|
});
|
|
83
214
|
on('ui.close',async ($,e,next)=>{if(e.id===PANE){timer?.cancel();timer=null;}return next(e);});
|
|
@@ -86,71 +217,77 @@ export function register(on){
|
|
|
86
217
|
if(e.surface!=='terminal'&&e.surface!=='desktop')return next(e);
|
|
87
218
|
const {Box,Text,Button,Input}=$.ui.resolve(e);
|
|
88
219
|
const rows=itemsFor();
|
|
89
|
-
|
|
90
|
-
// Percent heights are not consistently resolved in Claude terminal panes.
|
|
91
|
-
const viewportRows=Number(e.presentation?.rows??e.viewport?.height??e.rows);
|
|
92
|
-
const paneHeight=Number.isFinite(viewportRows)&&viewportRows>=16?Math.max(12,viewportRows-3):'100%';
|
|
93
|
-
const press=item=>change($,drill(state,item));
|
|
220
|
+
const press=item=>{sessionActionStatus='';if(state.view==='sessions')focusedSessionId=item.id;change($,drill(state,item));};
|
|
94
221
|
const models=()=>{if(state.view==='models'){change($,back(state));return;}change($,{...state,stack:[...state.stack,{view:state.view,project:state.project,session:state.session,agent:state.agent}],view:'models',query:''});};
|
|
95
|
-
const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void refresh($);}}));
|
|
222
|
+
const periodButtons=PERIODS.map(([label,id])=>Button({key:`period-${id}`,plain:true,label:id===state.period?`● ${label}`:` ${label}`,onPress:()=>{change($,{period:id,periodMenu:false});void persistPeriod($,id);void refresh($);}}));
|
|
96
223
|
const action=(label,hotkey,fn)=>Button({key:`action-${label}`,plain:true,label,hotkey,onPress:fn});
|
|
97
|
-
const path=[state.project&&name(state.project),state.session?.title,state.agent
|
|
224
|
+
const path=[state.project&&name(state.project),state.session?.title,state.agent?agentLabel(state.agent):null].filter(Boolean);
|
|
98
225
|
const breadcrumb=`${state.view.toUpperCase()}${path.length?' / '+path.join(' / '):''}`;
|
|
99
|
-
const stats=state.report?.overall;
|
|
100
|
-
const
|
|
226
|
+
const stats=state.view==='projects'?state.report?.overall:state.view==='sessions'?state.report?.projects?.find(x=>x.name===state.project)?.stats:state.view==='agents'?state.session?.stats:state.view==='models'?(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||state.report?.overall):state.report?.overall;
|
|
227
|
+
const scopeTotal=state.view==='projects'?total(stats):state.view==='sessions'?total(state.report?.projects?.find(x=>x.name===state.project)?.stats):state.view==='agents'?total(state.session?.stats):state.view==='models'?total(state.agent?.stats||state.session?.stats||state.report?.projects?.find(x=>x.name===state.project)?.stats||stats):total(stats);
|
|
101
228
|
const scopeModels=modelScope(state).slice().sort((a,b)=>total(b.stats)-total(a.stats));
|
|
102
229
|
const showModelSummary=state.view!=='models'&&scopeModels.length>0;
|
|
103
|
-
const divider=()=>Text({dimColor:true,children:'────────────────────────────────'});
|
|
104
|
-
const header=Box({flexDirection:'column',gap:0,children:[
|
|
105
|
-
Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'Updating…':'● Auto · 5s'})]}),
|
|
106
|
-
Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
|
|
107
|
-
stats?Text({bold:true,children:`${compact(total(stats))} tokens · ${price(stats)} est. API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
|
|
108
|
-
stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} files`}):null,
|
|
109
|
-
].filter(Boolean)});
|
|
110
|
-
const planSection=Box({flexDirection:'column',gap:0,children:[
|
|
111
|
-
Text({bold:true,children:'PLAN LIMITS'}),
|
|
112
|
-
plan?Text({children:`5-hour ${plan.fiveHour??'—'}% · 7-day ${plan.sevenDay??'—'}%`}):Text({dimColor:true,children:'Desktop usage cache unavailable'}),
|
|
113
|
-
plan?Text({dimColor:true,wrap:'truncate-end',children:`${plan.stale?'Cached · May be outdated':'Cached · Recently updated'} · ${new Date(plan.timestamp).toLocaleString()}`}):null,
|
|
114
|
-
].filter(Boolean)});
|
|
115
230
|
const modelSummary=showModelSummary?Box({flexDirection:'column',children:[
|
|
116
|
-
Text({bold:true,children:'MODEL
|
|
117
|
-
...scopeModels.slice(0,
|
|
118
|
-
...(scopeModels.length>
|
|
231
|
+
Text({bold:true,children:'MODEL USAGE'}),
|
|
232
|
+
...scopeModels.slice(0,4).map((model,i)=>Text({key:`model-summary-${i}`,dimColor:true,wrap:'truncate-end',children:`${model.name} · ${compact(total(model.stats))} tokens · ${price(model.stats)} · ${share(total(model.stats),scopeTotal)}`})),
|
|
233
|
+
...(scopeModels.length>4?[Text({dimColor:true,children:`+${scopeModels.length-4} more · Select Models to view all`})]:[]),
|
|
119
234
|
]}):null;
|
|
120
|
-
|
|
121
|
-
|
|
235
|
+
const header=Box({flexDirection:'column',children:[
|
|
236
|
+
Box({flexDirection:'row',gap:2,children:[Text({bold:true,children:'USAGE EXPLORER'}),Text({dimColor:true,children:state.loading?'⟳ Updating':'● Live · 5s'})]}),
|
|
237
|
+
Text({dimColor:true,wrap:'truncate-end',children:state.periodLabel}),
|
|
238
|
+
stats?Text({bold:true,children:`${compact(total(stats))} tokens ${price(stats)} estimated API cost`}):Text({dimColor:true,children:'Loading local usage…'}),
|
|
239
|
+
stats?Text({dimColor:true,children:`${stats.requests} requests · ${state.report.files} transcript files`}):null,
|
|
240
|
+
Text({bold:true,children:'OFFICIAL ACCOUNT USAGE'}),
|
|
241
|
+
...(officialSpend?[
|
|
242
|
+
Text({key:'official-spend-heading',bold:true,children:`${currencyAmount(officialSpend.used,officialSpend.currency)} / ${currencyAmount(officialSpend.limit,officialSpend.currency)} · ${officialSpend.percent.toFixed(1)}%`}),
|
|
243
|
+
Text({key:'official-spend-bar',children:usageBar(officialSpend.percent,30)}),
|
|
244
|
+
Text({key:'official-spend-left',dimColor:true,children:`${currencyAmount(Math.max(0,officialSpend.limit-officialSpend.used),officialSpend.currency)} remaining · official ${officialSpend.source}`}),
|
|
245
|
+
]:[]),
|
|
246
|
+
...officialLimitRows(limitState).filter(l=>!(officialSpend&&l.label==='extra_usage')).flatMap((l,i)=>[
|
|
247
|
+
Text({key:`limit-name-${i}`,dimColor:true,children:`${l.label.replaceAll('_',' ')}${l.resetsAt?' · resets '+l.resetsAt:''}`}),
|
|
248
|
+
Text({key:`limit-bar-${i}`,children:usageBar(l.percent)}),
|
|
249
|
+
]),
|
|
250
|
+
...(officialLimitRows(limitState).length===0?[Text({dimColor:true,children:`${limitState.status} · No supported limit values`})]:[]),
|
|
251
|
+
Text({dimColor:true,children:'Official account spend and local API-equivalent estimates are separate.'}),
|
|
252
|
+
Button({key:'check-official-limits',plain:true,label:'↻ Refresh official usage',onPress:()=>void checkLimits($,{force:true})}),
|
|
253
|
+
].filter(Boolean)});
|
|
122
254
|
const footer=Box({flexDirection:'column',flexShrink:0,children:[
|
|
123
|
-
|
|
124
|
-
Box({flexDirection:'row',gap:
|
|
255
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
256
|
+
Box({flexDirection:'row',gap:2,flexWrap:'wrap',children:[
|
|
125
257
|
action('← Back','h',()=>change($,back(state))),
|
|
126
258
|
action('↻ Refresh','r',()=>void refresh($)),
|
|
127
259
|
action('◷ Period','p',()=>change($,{periodMenu:!state.periodMenu})),
|
|
128
260
|
action(state.view==='models'?'← Close models':'▤ Models','m',models),
|
|
129
261
|
action(state.sort==='tokens'?'↓ Tokens':'↓ Cost','s',()=>change($,{sort:state.sort==='tokens'?'cost':'tokens'})),
|
|
262
|
+
...(state.session?[
|
|
263
|
+
action('Open session','o',()=>void sessionAction($,'open')),
|
|
264
|
+
action('Copy ID','c',()=>void sessionAction($,'copy')),
|
|
265
|
+
]:[]),
|
|
130
266
|
action('× Exit','x',()=>void $.ui.close({id:PANE})),
|
|
131
267
|
]}),
|
|
132
|
-
Text({dimColor:true,
|
|
268
|
+
Text({dimColor:true,children:'Esc: close · H: back · O: open current session · C: copy ID · X: exit'}),
|
|
133
269
|
]});
|
|
134
|
-
return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:
|
|
270
|
+
return Box({flexDirection:'column',paddingX:1,flexGrow:1,minHeight:0,height:'100%',justifyContent:'space-between',children:[
|
|
135
271
|
Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...(
|
|
272
|
+
header,
|
|
273
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
274
|
+
Text({bold:true,wrap:'truncate-end',children:breadcrumb}),
|
|
275
|
+
Input({key:'usage-filter',placeholder:'Filter entries…',value:state.query,onInput:v=>change($,{query:v}),onSubmit:v=>change($,{query:typeof v==='string'?v:state.query})}),
|
|
276
|
+
...(state.periodMenu?[
|
|
277
|
+
Text({bold:true,children:'SELECT PERIOD'}),
|
|
278
|
+
Box({flexDirection:'column',gap:0,children:periodButtons}),
|
|
279
|
+
Text({dimColor:true,children:'────────────────────────────────────────'}),
|
|
280
|
+
]:[]),
|
|
281
|
+
...(state.error?[Text({color:'red',wrap:'wrap',children:state.error})]:[]),
|
|
282
|
+
...(state.view==='models'?[action('← Back to previous view','h',()=>change($,back(state)))]:[]),
|
|
283
|
+
...(state.session?[Text({dimColor:true,children:`Session ID: ${state.session.id}`})]:[]),
|
|
284
|
+
...(state.agent&&state.agent.id!=='main'?[Text({dimColor:true,children:`Agent ID: ${state.agent.id}`})]:[]),
|
|
285
|
+
...(sessionActionStatus?[Text({dimColor:true,children:sessionActionStatus})]:[]),
|
|
286
|
+
...(modelSummary?[modelSummary]:[]),
|
|
287
|
+
Box({flexDirection:'column',flexGrow:1,minHeight:0,children:[
|
|
288
|
+
...rows.slice(0,80).map((item,i)=>Button({key:`entry-${i}-${item.id}`,plain:true,onFocus:()=>{if(state.view==='sessions')focusedSessionId=item.id;},onPress:()=>press(item),label:`${item.label.slice(0,40)} · ${compact(total(item.stats))} · ${price(item.stats)} · ${share(total(item.stats),scopeTotal)}`})),
|
|
289
|
+
...(rows.length===0?[Text({dimColor:true,children:state.loading?'Loading entries…':'No matching entries.'})]:[]),
|
|
290
|
+
]}),
|
|
154
291
|
].filter(Boolean)}),
|
|
155
292
|
footer,
|
|
156
293
|
]});
|