@aaroncarry/pi-usage 0.1.0 → 0.2.1
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/README.md +87 -6
- package/README.zh-CN.md +85 -5
- package/package.json +10 -3
- package/src/config.ts +3 -0
- package/src/index.ts +144 -7
- package/src/trends/aggregate.ts +822 -0
- package/src/trends/dashboard.ts +364 -0
- package/src/trends/insights.ts +152 -0
- package/src/trends/render.ts +393 -0
- package/src/ui/card.ts +39 -1
- package/src/ui/statusline.ts +5 -2
package/README.md
CHANGED
|
@@ -6,31 +6,111 @@ A [pi](https://github.com/earendil-works/pi) extension that shows the balances a
|
|
|
6
6
|
|
|
7
7
|
## What you get
|
|
8
8
|
|
|
9
|
-
The footer status line (default mode) follows the model you are using and shows its quota windows
|
|
9
|
+
The footer status line (default mode) follows the model you are using and shows its quota windows, this session's consumption, and a 7-day token sparkline:
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
Codex 5h 13% · weekly 2% · session 10.0k tok $0.020
|
|
12
|
+
Codex 5h 13% used · weekly 2% used · session 10.0k tok $0.020 · 7d ▁▁▁▁▁█▂ 66k
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
`/usage` prints a card into the conversation (a custom entry — no popup, no focus steal):
|
|
15
|
+
`/usage` prints a card into the conversation (a custom entry — no popup, no focus steal) with a 30-day usage summary at the bottom:
|
|
16
16
|
|
|
17
17
|
```
|
|
18
18
|
Usage · 02:15
|
|
19
19
|
|
|
20
20
|
● Codex (Plus)
|
|
21
|
-
5h ░░░░░░░░░░ 0% · resets in 4h 54m
|
|
22
|
-
weekly ░░░░░░░░░░ 2% · resets in 6d 17h
|
|
21
|
+
5h ░░░░░░░░░░ 0% used · resets in 4h 54m
|
|
22
|
+
weekly ░░░░░░░░░░ 2% used · resets in 6d 17h
|
|
23
23
|
○ GLM
|
|
24
24
|
Balance ¥21.46 recharged ¥118.00 · spent ¥96.54
|
|
25
25
|
○ DeepSeek
|
|
26
26
|
Balance ¥38.48
|
|
27
|
+
Last 30 days ────────────────────────────
|
|
28
|
+
tokens ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▂▆███ 66k
|
|
29
|
+
gpt-5.6-luna █████████████████░░░ 70% 34k
|
|
30
|
+
gpt-5.6-terra █████░░░░░░░░░░░░░░░ 21% 10k
|
|
27
31
|
```
|
|
28
32
|
|
|
33
|
+
The dashboard opens on the **Charts** view; `v` cycles Charts → Heatmap → Insights → Table. Four views, one example each:
|
|
34
|
+
|
|
35
|
+
## Trends dashboard
|
|
36
|
+
|
|
37
|
+
Keyboard: `v` cycle views · `←→` period (7d / 30d / 90d / all) · `m` tokens ↔ cost · `g` group by provider / project (table) · `↑↓` + `enter` expand a provider row (table) · `esc` close.
|
|
38
|
+
|
|
39
|
+
**Charts** — braille time series grouped by model, plus the model distribution. Same model ids served by different providers stay separate (`model (provider)`):
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
Usage trends [Charts] Heatmap Insights Table 7d [30d] 90d all
|
|
43
|
+
Total 66k tok · Cost $0.04 · Peak 39k (9/13) · Streak 2d
|
|
44
|
+
39k ┤ ⢸⡄
|
|
45
|
+
│ ⣿⠘⡄
|
|
46
|
+
│ ⡇⡇⢸
|
|
47
|
+
│ ⢸⠇⢣⡎
|
|
48
|
+
17k ┤ ⢸ ⢸⠃
|
|
49
|
+
│ ⡏ ⢸⡆
|
|
50
|
+
│ ⡇⢀⡇⢇
|
|
51
|
+
0 └⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣸⣔⣱⠑⢼
|
|
52
|
+
08-14 08-29 09-13
|
|
53
|
+
● Total ● gpt-5.6-luna ● gpt-5.6-terra ● deepseek-flash
|
|
54
|
+
Models · 30d
|
|
55
|
+
gpt-5.6-luna (openai-codex) █████████████████░░░ 52% 34k
|
|
56
|
+
gpt-5.6-terra (lingsuan) █████░░░░░░░░░░░░░░░ 24% 16k
|
|
57
|
+
deepseek-flash ██░░░░░░░░░░░░░░░░░░ 7% 4.5k
|
|
58
|
+
m metric · ←→ period · v view · g group · esc close
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Heatmap** — 12-week activity calendar with streaks:
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
Activity · 12 weeks Streak 2d
|
|
65
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
66
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
67
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
68
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
69
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ █ ░
|
|
70
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ █
|
|
71
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
72
|
+
░ none ▒ light ▓ mid █ heavy
|
|
73
|
+
Peak 39k tokens on 9/13 · Total 66k
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Table** — provider→model line items; `enter` expands a provider into its models:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
Provider / Model Sessions Msgs Cost Tokens ↑In ↓Out Cache
|
|
80
|
+
▾ openai-codex 2 16 $0.04 44k 39k 5.3k 150k
|
|
81
|
+
gpt-5.6-terra 1 2 $0.02 10k 10k 53 9.7k
|
|
82
|
+
gpt-5.6-luna 1 14 $0.01 34k 29k 5.2k 140k
|
|
83
|
+
▸ deepseek 1 8 $0.003 4.5k 3.2k 1.2k 16k
|
|
84
|
+
▸ lingsuan 1 4 - 17k 16k 1.1k 12k
|
|
85
|
+
─────────────────────────────────────────────────────────────────────
|
|
86
|
+
Total 4 28 $0.04 66k 58k 7.6k 177k
|
|
87
|
+
Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Insights** — where the spend went, and waste patterns worth attention:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
What's contributing to your cost? 30d
|
|
94
|
+
Where it went
|
|
95
|
+
$0.02 gpt-5.6-terra (openai-codex) drives 57% of your spend
|
|
96
|
+
~ routing some traffic to a cheaper model is the biggest cost lever
|
|
97
|
+
73% of processed tokens came from cache reads
|
|
98
|
+
48% of output is reasoning (thinking) tokens
|
|
99
|
+
~ lowering the thinking level on routine tasks cuts this hidden spend
|
|
100
|
+
Worth attention
|
|
101
|
+
$0.50 2 likely cache misses re-read the prompt at full price
|
|
102
|
+
~ pauses over 5 minutes and mid-session model switches invalidate the prompt cache
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Trends are aggregated from pi's session files (`<agentDir>/sessions/**/*.jsonl`) with an incremental disk cache; forked session copies are deduplicated. Token metric = input + output + cache write.
|
|
106
|
+
|
|
29
107
|
## Commands
|
|
30
108
|
|
|
31
109
|
| Command | Effect |
|
|
32
110
|
|---|---|
|
|
33
|
-
| `/
|
|
111
|
+
| `/trends` | Open the interactive trends dashboard (charts, heatmap, table, insights). Same as `/usage trends`. |
|
|
112
|
+
| `/usage` | Print the usage card (balances + 30-day summary) into the session. Re-running refreshes: served from cache within the 5-minute TTL, refetched afterwards (15 s timeout). Cards persist in the session and are re-rendered on `/reload` and session restore. |
|
|
113
|
+
| `/usage trends` | Open the interactive trends dashboard (Table / Charts / Heatmap / Insights; `m` metric, `←→` period, `v` view, `↑↓`+`enter` table expand). |
|
|
34
114
|
| `/usage active\|all\|off` | Switch the footer status line mode immediately and persist it to `usage.json` (tab-completed). |
|
|
35
115
|
| `pi --usage-status all` | Set the footer mode for this run only (overrides `usage.json`, not written back). |
|
|
36
116
|
|
|
@@ -126,6 +206,7 @@ Optional config file `<agentDir>/usage.json`:
|
|
|
126
206
|
|
|
127
207
|
- `intervalMinutes`: background refresh interval (default 5, minimum 1).
|
|
128
208
|
- `status`: `active` (default) | `all` | `off`.
|
|
209
|
+
- `sparkline`: set `false` to drop the 7-day sparkline from the footer status line.
|
|
129
210
|
- `autoDetect`: set `false` to disable endpoint auto-detection for unknown providers.
|
|
130
211
|
- `providers.<id>.enabled: false`: hide an account from the status line and card.
|
|
131
212
|
- `providers.<id>.label`: display name override.
|
package/README.zh-CN.md
CHANGED
|
@@ -8,10 +8,10 @@
|
|
|
8
8
|
|
|
9
9
|
## 效果
|
|
10
10
|
|
|
11
|
-
footer
|
|
11
|
+
footer 状态行(默认模式)跟随当前模型,显示其额度窗口、本次会话消耗和 7 天迷你趋势:
|
|
12
12
|
|
|
13
13
|
```
|
|
14
|
-
Codex 5h 13% · weekly 2% · session 10.0k tok $0.020
|
|
14
|
+
Codex 5h 13% used · weekly 2% used · session 10.0k tok $0.020 · 7d ▁▁▁▁▁█▂ 66k
|
|
15
15
|
```
|
|
16
16
|
|
|
17
17
|
`/usage` 向会话流打印一张卡片(自定义条目渲染——非弹窗、不抢焦点):
|
|
@@ -20,19 +20,98 @@ Codex 5h 13% · weekly 2% · session 10.0k tok $0.020
|
|
|
20
20
|
Usage · 02:15
|
|
21
21
|
|
|
22
22
|
● Codex (Plus)
|
|
23
|
-
5h ░░░░░░░░░░ 0% · resets in 4h 54m
|
|
24
|
-
weekly ░░░░░░░░░░ 2% · resets in 6d 17h
|
|
23
|
+
5h ░░░░░░░░░░ 0% used · resets in 4h 54m
|
|
24
|
+
weekly ░░░░░░░░░░ 2% used · resets in 6d 17h
|
|
25
25
|
○ GLM
|
|
26
26
|
Balance ¥21.46 recharged ¥118.00 · spent ¥96.54
|
|
27
27
|
○ DeepSeek
|
|
28
28
|
Balance ¥38.48
|
|
29
|
+
Last 30 days ────────────────────────────
|
|
30
|
+
tokens ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▂▆███ 66k
|
|
31
|
+
gpt-5.6-luna █████████████████░░░ 70% 34k
|
|
32
|
+
gpt-5.6-terra █████░░░░░░░░░░░░░░░ 21% 10k
|
|
29
33
|
```
|
|
30
34
|
|
|
35
|
+
仪表盘默认打开 **Charts** 视图(盲文时间序列);`v` 循环 Charts → Heatmap → Insights → Table。四个视图各举一例:
|
|
36
|
+
|
|
37
|
+
## 趋势仪表盘
|
|
38
|
+
|
|
39
|
+
按键:`v` 切视图 · `←→` 切周期(7d / 30d / 90d / all)· `m` 切 tokens ↔ cost · `g` 切换按厂商 / 按项目分组(明细表)· `↑↓` + `enter` 展开厂商行(明细表)· `esc` 关闭。
|
|
40
|
+
|
|
41
|
+
**Charts** —— 按模型分组的盲文时间序列 + 模型分布。同一模型 id 由不同厂商提供时保持独立并标注 `(厂商)`:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Usage trends [Charts] Heatmap Insights Table 7d [30d] 90d all
|
|
45
|
+
Total 66k tok · Cost $0.04 · Peak 39k (9/13) · Streak 2d
|
|
46
|
+
39k ┤ ⢸⡄
|
|
47
|
+
│ ⣿⠘⡄
|
|
48
|
+
│ ⡇⡇⢸
|
|
49
|
+
│ ⢸⠇⢣⡎
|
|
50
|
+
17k ┤ ⢸ ⢸⠃
|
|
51
|
+
│ ⡏ ⢸⡆
|
|
52
|
+
│ ⡇⢀⡇⢇
|
|
53
|
+
0 └⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣀⣸⣔⣱⠑⢼
|
|
54
|
+
08-14 08-29 09-13
|
|
55
|
+
● Total ● gpt-5.6-luna ● gpt-5.6-terra ● deepseek-flash
|
|
56
|
+
Models · 30d
|
|
57
|
+
gpt-5.6-luna (openai-codex) █████████████████░░░ 52% 34k
|
|
58
|
+
gpt-5.6-terra (lingsuan) █████░░░░░░░░░░░░░░░ 24% 16k
|
|
59
|
+
deepseek-flash ██░░░░░░░░░░░░░░░░░░ 7% 4.5k
|
|
60
|
+
m metric · ←→ period · v view · g group · esc close
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**Heatmap** —— 12 周活动日历 + 连续天数:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
Activity · 12 weeks Streak 2d
|
|
67
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
68
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
69
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
70
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
71
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ █ ░
|
|
72
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ▒ █
|
|
73
|
+
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
|
74
|
+
░ none ▒ light ▓ mid █ heavy
|
|
75
|
+
Peak 39k tokens on 9/13 · Total 66k
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Table** —— 厂商→模型明细条目;`enter` 展开某厂商的模型明细:
|
|
79
|
+
|
|
80
|
+
```
|
|
81
|
+
Provider / Model Sessions Msgs Cost Tokens ↑In ↓Out Cache
|
|
82
|
+
▾ openai-codex 2 16 $0.04 44k 39k 5.3k 150k
|
|
83
|
+
gpt-5.6-terra 1 2 $0.02 10k 10k 53 9.7k
|
|
84
|
+
gpt-5.6-luna 1 14 $0.01 34k 29k 5.2k 140k
|
|
85
|
+
▸ deepseek 1 8 $0.003 4.5k 3.2k 1.2k 16k
|
|
86
|
+
▸ lingsuan 1 4 - 17k 16k 1.1k 12k
|
|
87
|
+
─────────────────────────────────────────────────────────────────────
|
|
88
|
+
Total 4 28 $0.04 66k 58k 7.6k 177k
|
|
89
|
+
Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Insights** —— 花费去向 + 值得注意的浪费模式:
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
What's contributing to your cost? 30d
|
|
96
|
+
Where it went
|
|
97
|
+
$0.02 gpt-5.6-terra (openai-codex) drives 57% of your spend
|
|
98
|
+
~ routing some traffic to a cheaper model is the biggest cost lever
|
|
99
|
+
73% of processed tokens came from cache reads
|
|
100
|
+
48% of output is reasoning (thinking) tokens
|
|
101
|
+
~ lowering the thinking level on routine tasks cuts this hidden spend
|
|
102
|
+
Worth attention
|
|
103
|
+
$0.50 2 likely cache misses re-read the prompt at full price
|
|
104
|
+
~ pauses over 5 minutes and mid-session model switches invalidate the prompt cache
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
趋势数据聚合自 pi 的会话文件(`<agentDir>/sessions/**/*.jsonl`),带增量磁盘缓存;分叉会话副本自动去重。Token 口径 = input + output + cache 写入。
|
|
31
108
|
## 命令
|
|
32
109
|
|
|
33
110
|
| 命令 | 作用 |
|
|
34
111
|
|---|---|
|
|
35
|
-
| `/
|
|
112
|
+
| `/trends` | 打开交互式趋势仪表盘(图表/热力图/明细表/洞察)。等价于 `/usage trends` |
|
|
113
|
+
| `/usage` | 向会话流打印用量卡片(余额 + 30 天摘要)。重复执行即刷新:5 分钟 TTL 内秒回,过期则重新拉取(15 秒超时)。卡片留存在会话里,`/reload`、恢复旧会话时自动重放 |
|
|
114
|
+
| `/usage trends` | 打开交互式趋势仪表盘(Table / Charts / Heatmap / Insights;`m` 切指标,`←→` 切周期,`v` 切视图,`↑↓`+`enter` 展开表格) |
|
|
36
115
|
| `/usage active\|all\|off` | 立即切换 footer 状态行模式,并持久化到 `usage.json`(输入时有补全) |
|
|
37
116
|
| `pi --usage-status all` | 指定本次运行的 footer 模式(覆盖 `usage.json`,不写回文件) |
|
|
38
117
|
|
|
@@ -127,6 +206,7 @@ pi install npm:@aaroncarry/pi-usage
|
|
|
127
206
|
|
|
128
207
|
- `intervalMinutes`:后台刷新间隔(默认 5,最小 1)。
|
|
129
208
|
- `status`:`active`(默认)/ `all` / `off`。
|
|
209
|
+
- `sparkline`:设为 `false` 移除 footer 状态行的 7 天迷你趋势。
|
|
130
210
|
- `autoDetect`:设为 `false` 关闭对未知厂商的余额端点自动探测。
|
|
131
211
|
- `providers.<id>.enabled: false`:从状态行和卡片隐藏某账号。
|
|
132
212
|
- `providers.<id>.label`:显示名覆盖。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aaroncarry/pi-usage",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Usage and balance viewer for pi: subscription quota windows, prepaid balances, and session consumption in a footer status line and a /usage card",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
"url": "https://github.com/aaroncarry/pi-usage/issues"
|
|
13
13
|
},
|
|
14
14
|
"homepage": "https://github.com/aaroncarry/pi-usage#readme",
|
|
15
|
-
"files": [
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"README.md",
|
|
18
|
+
"README.zh-CN.md",
|
|
19
|
+
"LICENSE"
|
|
20
|
+
],
|
|
16
21
|
"keywords": [
|
|
17
22
|
"pi",
|
|
18
23
|
"pi-extension",
|
|
@@ -33,7 +38,9 @@
|
|
|
33
38
|
"access": "public"
|
|
34
39
|
},
|
|
35
40
|
"pi": {
|
|
36
|
-
"extensions": [
|
|
41
|
+
"extensions": [
|
|
42
|
+
"./src/index.ts"
|
|
43
|
+
]
|
|
37
44
|
},
|
|
38
45
|
"scripts": {
|
|
39
46
|
"typecheck": "tsc --noEmit",
|
package/src/config.ts
CHANGED
|
@@ -57,6 +57,8 @@ export interface BalanceConfig {
|
|
|
57
57
|
* (New API relays, Sub2API, MiniMax, Zhipu). Default true.
|
|
58
58
|
*/
|
|
59
59
|
autoDetect?: boolean;
|
|
60
|
+
/** 7-day token sparkline in the footer status line. Default true. */
|
|
61
|
+
sparkline?: boolean;
|
|
60
62
|
/** Per-provider options; entries with `custom` define generic adapters. */
|
|
61
63
|
providers?: Record<string, ProviderConfig>;
|
|
62
64
|
}
|
|
@@ -111,6 +113,7 @@ function sanitizeBalanceConfig(raw: Record<string, unknown>): BalanceConfig {
|
|
|
111
113
|
config.status = raw.status;
|
|
112
114
|
}
|
|
113
115
|
if (typeof raw.autoDetect === "boolean") config.autoDetect = raw.autoDetect;
|
|
116
|
+
if (typeof raw.sparkline === "boolean") config.sparkline = raw.sparkline;
|
|
114
117
|
if (isRecord(raw.providers)) {
|
|
115
118
|
const providers: Record<string, ProviderConfig> = {};
|
|
116
119
|
for (const [id, value] of Object.entries(raw.providers)) {
|
package/src/index.ts
CHANGED
|
@@ -10,15 +10,26 @@ import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
11
11
|
import { isRecord, loadBalanceConfig, saveStatusMode, type StatusMode } from "./config.ts";
|
|
12
12
|
import { getAgentDir, tokenFromRegistryAuth } from "./credentials.ts";
|
|
13
|
-
import { sumSessionUsage, type SessionUsageTotals } from "./session-usage.ts";
|
|
13
|
+
import { sumSessionUsage, formatTokens, type SessionUsageTotals } from "./session-usage.ts";
|
|
14
|
+
import {
|
|
15
|
+
annotateModelLabels,
|
|
16
|
+
collectTrends,
|
|
17
|
+
dailyTotals,
|
|
18
|
+
distributionRows,
|
|
19
|
+
type TrendsData,
|
|
20
|
+
} from "./trends/aggregate.ts";
|
|
21
|
+
import { TrendsDashboard } from "./trends/dashboard.ts";
|
|
22
|
+
import { sparklineString } from "./trends/render.ts";
|
|
14
23
|
import { BalanceService } from "./service.ts";
|
|
15
|
-
import { buildUsageCard, type UsageCardData } from "./ui/card.ts";
|
|
24
|
+
import { buildUsageCard, type CardTrendsSummary, type UsageCardData } from "./ui/card.ts";
|
|
16
25
|
import { formatStatusLine } from "./ui/statusline.ts";
|
|
17
26
|
import type { CredentialResolver } from "./types.ts";
|
|
18
27
|
|
|
19
28
|
const USAGE_ENTRY_TYPE = "usage-report";
|
|
20
29
|
/** Bound /usage fetches so a hanging provider API cannot stall the command. */
|
|
21
30
|
const USAGE_FETCH_TIMEOUT_MS = 15_000;
|
|
31
|
+
/** Trends scans are cached in memory briefly so card/dashboard/sparkline share one scan. */
|
|
32
|
+
const TRENDS_TTL_MS = 60_000;
|
|
22
33
|
|
|
23
34
|
const STATUS_MODES = ["active", "all", "off"] as const;
|
|
24
35
|
|
|
@@ -31,6 +42,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
31
42
|
let ui: ExtensionUIContext | undefined;
|
|
32
43
|
let activeProviderId: string | undefined;
|
|
33
44
|
let consumption: SessionUsageTotals | undefined;
|
|
45
|
+
let trendsCache: { data: TrendsData; at: number } | undefined;
|
|
46
|
+
let sparkline: string | undefined;
|
|
34
47
|
/** `/usage <mode>` override for this session; wins over flag and usage.json. */
|
|
35
48
|
let statusOverride: StatusMode | undefined;
|
|
36
49
|
/** `--usage-status` CLI flag; wins over usage.json. */
|
|
@@ -74,6 +87,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
74
87
|
activeProviderId,
|
|
75
88
|
theme: ui.theme,
|
|
76
89
|
consumption,
|
|
90
|
+
sparkline,
|
|
77
91
|
});
|
|
78
92
|
ui.setStatus("usage", text);
|
|
79
93
|
}
|
|
@@ -96,29 +110,139 @@ export default function (pi: ExtensionAPI) {
|
|
|
96
110
|
activeProviderId: typeof entry.data.activeProviderId === "string" ? entry.data.activeProviderId : undefined,
|
|
97
111
|
generatedAt: typeof entry.data.generatedAt === "number" ? entry.data.generatedAt : Date.now(),
|
|
98
112
|
};
|
|
113
|
+
if (isRecord(entry.data.trends)) {
|
|
114
|
+
const raw = entry.data.trends;
|
|
115
|
+
const rawDays = raw.days;
|
|
116
|
+
const rawModels = raw.models;
|
|
117
|
+
if (
|
|
118
|
+
Array.isArray(rawDays) &&
|
|
119
|
+
typeof raw.endsAt === "number" &&
|
|
120
|
+
typeof raw.total === "number" &&
|
|
121
|
+
typeof raw.cost === "number" &&
|
|
122
|
+
Array.isArray(rawModels)
|
|
123
|
+
) {
|
|
124
|
+
data.trends = {
|
|
125
|
+
days: rawDays.filter((value): value is number => typeof value === "number"),
|
|
126
|
+
endsAt: raw.endsAt,
|
|
127
|
+
total: raw.total,
|
|
128
|
+
cost: raw.cost,
|
|
129
|
+
models: rawModels.filter(
|
|
130
|
+
(model): model is { label: string; tokens: number } =>
|
|
131
|
+
isRecord(model) && typeof model.label === "string" && typeof model.tokens === "number",
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
99
136
|
return buildUsageCard(data, theme);
|
|
100
137
|
});
|
|
101
138
|
|
|
139
|
+
/** Session trends with a small TTL so card, dashboard and sparkline share one scan. */
|
|
140
|
+
async function getTrends(signal?: AbortSignal): Promise<TrendsData> {
|
|
141
|
+
if (trendsCache && Date.now() - trendsCache.at < TRENDS_TTL_MS) return trendsCache.data;
|
|
142
|
+
const data = await collectTrends(getAgentDir(), { signal });
|
|
143
|
+
trendsCache = { data, at: Date.now() };
|
|
144
|
+
return data;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function costTotal(data: TrendsData, fromMs?: number): number {
|
|
148
|
+
return distributionRows(data, fromMs).reduce((total, row) => total + row.cost, 0);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 30-day summary embedded in the /usage card. */
|
|
152
|
+
async function cardTrendsSummary(): Promise<CardTrendsSummary | undefined> {
|
|
153
|
+
try {
|
|
154
|
+
const data = await getTrends();
|
|
155
|
+
const now = Date.now();
|
|
156
|
+
const from = now - 30 * 86_400_000;
|
|
157
|
+
const days = dailyTotals(data, "tokens", from);
|
|
158
|
+
const todayStart = new Date(now).setHours(0, 0, 0, 0);
|
|
159
|
+
const byDay = new Map(days.map((entry) => [entry.dayStart, entry.value]));
|
|
160
|
+
const aligned: number[] = [];
|
|
161
|
+
for (let index = 29; index >= 0; index--) {
|
|
162
|
+
aligned.push(byDay.get(todayStart - index * 86_400_000) ?? 0);
|
|
163
|
+
}
|
|
164
|
+
const models = annotateModelLabels(distributionRows(data, from))
|
|
165
|
+
.filter((row) => row.model !== "summaries")
|
|
166
|
+
.slice(0, 3)
|
|
167
|
+
.map((row) => ({ label: row.label, tokens: row.tokens }));
|
|
168
|
+
return {
|
|
169
|
+
days: aligned,
|
|
170
|
+
endsAt: todayStart,
|
|
171
|
+
total: aligned.reduce((total, value) => total + value, 0),
|
|
172
|
+
cost: costTotal(data, from),
|
|
173
|
+
models,
|
|
174
|
+
};
|
|
175
|
+
} catch {
|
|
176
|
+
return undefined;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Refresh the 7-day status-line sparkline once trends data is available. */
|
|
181
|
+
function refreshSparkline(): void {
|
|
182
|
+
if (!service || service.config.sparkline === false) return;
|
|
183
|
+
void getTrends()
|
|
184
|
+
.then((data) => {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const days = dailyTotals(data, "tokens", now - 7 * 86_400_000);
|
|
187
|
+
const todayStart = new Date(now).setHours(0, 0, 0, 0);
|
|
188
|
+
const byDay = new Map(days.map((entry) => [entry.dayStart, entry.value]));
|
|
189
|
+
const aligned: number[] = [];
|
|
190
|
+
for (let index = 6; index >= 0; index--) {
|
|
191
|
+
aligned.push(byDay.get(todayStart - index * 86_400_000) ?? 0);
|
|
192
|
+
}
|
|
193
|
+
sparkline = sparklineString(aligned);
|
|
194
|
+
updateStatus();
|
|
195
|
+
})
|
|
196
|
+
.catch(() => undefined);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Open the interactive trends dashboard. */
|
|
200
|
+
async function openTrendsDashboard(ctx: ExtensionContext): Promise<void> {
|
|
201
|
+
if (ctx.mode !== "tui") {
|
|
202
|
+
ctx.ui.notify("/trends requires interactive mode", "warning");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
ensureService(ctx);
|
|
206
|
+
await ctx.ui.custom<undefined>((_tui, theme, _keybindings, done) => {
|
|
207
|
+
return new TrendsDashboard({ theme, done, data: getTrends() });
|
|
208
|
+
}, {
|
|
209
|
+
overlay: true,
|
|
210
|
+
overlayOptions: { width: "85%", maxHeight: "92%", anchor: "center" },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
102
214
|
pi.registerCommand("usage", {
|
|
103
|
-
description: "Show usage card, or set the footer status line mode",
|
|
215
|
+
description: "Show usage card, open the trends dashboard, or set the footer status line mode",
|
|
104
216
|
getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
|
|
105
|
-
const
|
|
106
|
-
|
|
217
|
+
const options: AutocompleteItem[] = [
|
|
218
|
+
{ value: "trends", label: "trends", description: "Open the interactive trends dashboard" },
|
|
219
|
+
{ value: "active", label: "active", description: "Footer: current account + session usage (default)" },
|
|
220
|
+
{ value: "all", label: "all", description: "Footer: all accounts on one line" },
|
|
221
|
+
{ value: "off", label: "off", description: "Footer: hide the status line" },
|
|
222
|
+
];
|
|
223
|
+
const matches = options.filter((option) => option.value.startsWith(prefix.toLowerCase()));
|
|
224
|
+
return matches.length > 0 ? matches : null;
|
|
107
225
|
},
|
|
108
226
|
handler: async (args, ctx) => {
|
|
109
227
|
const argument = args?.trim().toLowerCase();
|
|
110
228
|
if (!argument) {
|
|
111
229
|
const current = ensureService(ctx);
|
|
112
230
|
await current.refreshAll({ signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS) });
|
|
231
|
+
const trends = await cardTrendsSummary();
|
|
113
232
|
pi.appendEntry(USAGE_ENTRY_TYPE, {
|
|
114
233
|
balances: current.getAll(),
|
|
115
234
|
activeProviderId,
|
|
116
235
|
generatedAt: Date.now(),
|
|
236
|
+
trends,
|
|
117
237
|
});
|
|
118
238
|
return;
|
|
119
239
|
}
|
|
240
|
+
if (argument === "trends") {
|
|
241
|
+
await openTrendsDashboard(ctx);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
120
244
|
if (!isStatusMode(argument)) {
|
|
121
|
-
ctx.ui.notify(`/usage: unknown
|
|
245
|
+
ctx.ui.notify(`/usage: unknown argument "${argument}" — use trends, active, all, or off`, "warning");
|
|
122
246
|
return;
|
|
123
247
|
}
|
|
124
248
|
statusOverride = argument;
|
|
@@ -128,6 +252,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
128
252
|
},
|
|
129
253
|
});
|
|
130
254
|
|
|
255
|
+
// First-class command so new users discover the dashboard without knowing
|
|
256
|
+
// about the two-stage "/usage <space> trends" argument completion.
|
|
257
|
+
pi.registerCommand("trends", {
|
|
258
|
+
description: "Open the usage trends dashboard (charts, heatmap, table, insights)",
|
|
259
|
+
handler: async (_args, ctx) => {
|
|
260
|
+
await openTrendsDashboard(ctx);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
131
264
|
pi.on("session_start", async (_event, ctx) => {
|
|
132
265
|
// Session replacement tears the old service down via session_shutdown;
|
|
133
266
|
// build a fresh one bound to the new session context.
|
|
@@ -145,13 +278,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
145
278
|
consumption = sumSessionUsage(ctx.sessionManager.getEntries());
|
|
146
279
|
next.start();
|
|
147
280
|
updateStatus();
|
|
281
|
+
refreshSparkline();
|
|
148
282
|
});
|
|
149
283
|
|
|
150
284
|
// Session tokens/cost are local data: refresh immediately after every turn
|
|
151
|
-
// instead of waiting for the provider quota polling cycle.
|
|
285
|
+
// instead of waiting for the provider quota polling cycle. The sparkline's
|
|
286
|
+
// today-bucket moves too; getTrends' TTL keeps this cheap.
|
|
152
287
|
pi.on("turn_end", async (_event, ctx) => {
|
|
153
288
|
consumption = sumSessionUsage(ctx.sessionManager.getEntries());
|
|
154
289
|
updateStatus();
|
|
290
|
+
refreshSparkline();
|
|
155
291
|
});
|
|
156
292
|
|
|
157
293
|
pi.on("model_select", async (event) => {
|
|
@@ -164,5 +300,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
164
300
|
service = undefined;
|
|
165
301
|
ui = undefined;
|
|
166
302
|
consumption = undefined;
|
|
303
|
+
sparkline = undefined;
|
|
167
304
|
});
|
|
168
305
|
}
|