@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 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 plus this session's consumption:
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
- | `/usage` | Print the usage card 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. |
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
- | `/usage` | 向会话流打印用量卡片。重复执行即刷新:5 分钟 TTL 内秒回,过期则重新拉取(15 秒超时)。卡片留存在会话里,`/reload`、恢复旧会话时自动重放 |
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.0",
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": ["src", "README.md", "README.zh-CN.md", "LICENSE"],
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": ["./src/index.ts"]
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 matches = STATUS_MODES.filter((mode) => mode.startsWith(prefix.toLowerCase()));
106
- return matches.length > 0 ? matches.map((mode) => ({ value: mode, label: mode })) : null;
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 mode "${argument}" — use active, all, or off`, "warning");
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
  }