@fyeeme/pi-statusline 1.0.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [1.0.0] - 2025-05-31
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - Two-line footer: cwd + git branch (left) / model + thinking level (right), and token stats line
16
+ - Token usage: input, output, cache read/write, total per session
17
+ - Cost display with auto currency detection (¥ for DeepSeek, $ otherwise)
18
+ - DeepSeek account balance fetched on startup, cached 5 minutes
19
+ - Context window usage percentage and size
20
+ - Elapsed time and tokens/sec for last response
21
+ - MCP server connection count and tool count via `mcp:status` / `mcp:disconnect` events
22
+ - `/balance` command to force-refresh DeepSeek balance
23
+ - `/currency [auto|¥|$]` command to toggle cost currency
24
+ - `/status-debug` command to dump session stats to log file
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # pi-statusline
2
+
3
+ A rich custom status bar for [pi](https://pi.dev) that replaces the default footer.
4
+
5
+ ## Features
6
+
7
+ - **Token usage**: input, output, cache read/write, total per session
8
+ - **Cost**: cumulative cost with currency auto-detection (¥ for DeepSeek, $ otherwise)
9
+ - **DeepSeek balance**: live account balance fetched on startup and cached for 5 minutes
10
+ - **Context window**: usage percentage and size
11
+ - **Timing**: elapsed time + tokens/sec for last response
12
+ - **MCP status**: connected server count and total tool count
13
+ - **Git branch**: current branch shown in cwd display
14
+
15
+ ## Install
16
+
17
+ Requires the [pi](https://pi.dev) CLI.
18
+
19
+ ### From npm (recommended)
20
+
21
+ ```bash
22
+ # Global (user) install — available in every project
23
+ pi install npm:@fyeeme/pi-statusline
24
+
25
+ # Project-local — written to .pi/settings.json, shareable with your team
26
+ pi install -l npm:@fyeeme/pi-statusline
27
+
28
+ # Pinned version — skipped by `pi update`
29
+ pi install npm:@fyeeme/pi-statusline@1.0.0
30
+
31
+ # Try it once without saving (current run only)
32
+ pi -e npm:@fyeeme/pi-statusline
33
+ ```
34
+
35
+ ### From GitHub
36
+
37
+ Source: [`fyeeme/pi-packages`](https://github.com/fyeeme/pi-packages).
38
+
39
+ ```bash
40
+ # HTTPS shorthand
41
+ pi install git:github.com/fyeeme/pi-packages
42
+ # Pin to a tag or commit (skipped by `pi update`)
43
+ pi install git:github.com/fyeeme/pi-packages@v1.0.0
44
+ # Raw URL form
45
+ pi install https://github.com/fyeeme/pi-packages
46
+ ```
47
+
48
+ See the Pi Packages guide on [pi.dev](https://pi.dev) for the full list of source types, scopes, and `pi update` behavior.
49
+
50
+ ## Commands
51
+
52
+ | Command | Description |
53
+ |---|---|
54
+ | `/balance` | Force refresh DeepSeek account balance |
55
+ | `/currency [auto\|¥\|$]` | Toggle cost currency display |
56
+ | `/status-debug` | Dump session stats to `/tmp/pi-status-debug.log` |
57
+
58
+ ## Status Bar Layout
59
+
60
+ ```
61
+ ~/projects/my-repo (main) deepseek-v3 · xhigh
62
+ in 12k, out 8k, cache 45k, total 65k · ¥0.12/50.00 · 12.3%/64k · 45s 38.2 tok/s · MCP:2(15)
63
+ ```
64
+
65
+ Line 1: cwd + git branch (left) | model + thinking level (right)
66
+ Line 2: token stats · cost/balance · context · timing · MCP
67
+
68
+ ## MCP Integration
69
+
70
+ When used with [pi-mcp-adapter](https://www.npmjs.com/package/pi-mcp-adapter), the status bar shows live MCP connection status via `mcp:status` and `mcp:disconnect` events.
package/cache.ts ADDED
@@ -0,0 +1,63 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ProviderUsageResult } from "./types.ts";
3
+ import type { UsageProvider } from "./providers/types.ts";
4
+
5
+ export type ProviderRegistry = Record<string, UsageProvider>;
6
+
7
+ interface CacheEntry {
8
+ result: ProviderUsageResult;
9
+ fetchedAt: number;
10
+ }
11
+
12
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
13
+
14
+ let usageCache: CacheEntry | null = null;
15
+
16
+ export function resetUsageCache(): void {
17
+ usageCache = null;
18
+ }
19
+
20
+ export function getUsageCache(): CacheEntry | null {
21
+ return usageCache;
22
+ }
23
+
24
+ export async function refreshUsage(
25
+ providers: ProviderRegistry,
26
+ modelRegistry: ExtensionContext["modelRegistry"],
27
+ model: ExtensionContext["model"],
28
+ ): Promise<ProviderUsageResult> {
29
+ const providerName = model?.provider ?? "";
30
+ const provider = providers[providerName];
31
+ if (!provider) {
32
+ usageCache = null;
33
+ return null;
34
+ }
35
+
36
+ const result = await provider.fetchUsage(modelRegistry, model);
37
+ if (result) {
38
+ usageCache = { result, fetchedAt: Date.now() };
39
+ } else {
40
+ usageCache = null;
41
+ }
42
+ return result;
43
+ }
44
+
45
+ export function getUsageCacheAge(): number | null {
46
+ if (!usageCache) return null;
47
+ return Date.now() - usageCache.fetchedAt;
48
+ }
49
+
50
+ export function getCachedUsage(
51
+ providers: ProviderRegistry,
52
+ lastCtx: ExtensionContext | null,
53
+ lastModel: ExtensionContext["model"] | undefined,
54
+ ): ProviderUsageResult {
55
+ if (!usageCache) return null;
56
+ if (Date.now() - usageCache.fetchedAt > CACHE_TTL_MS) {
57
+ // TTL expired: return stale data but trigger background refresh
58
+ if (lastCtx && lastModel) {
59
+ void refreshUsage(providers, lastCtx.modelRegistry, lastModel);
60
+ }
61
+ }
62
+ return usageCache.result;
63
+ }
package/footer.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { isAbsolute, relative, resolve, sep } from "node:path";
2
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { ContextUsage } from "@earendil-works/pi-coding-agent";
4
+ import type { ProviderUsageResult } from "./types.ts";
5
+ import type { TokenUsage } from "./types.ts";
6
+ import type { UsageProvider } from "./providers/types.ts";
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Formatting helpers
10
+ // ---------------------------------------------------------------------------
11
+
12
+ export function fmt(n: number): string {
13
+ if (n < 1000) return n.toString();
14
+ if (n < 10000) return `${(n / 1000).toFixed(1)}k`;
15
+ if (n < 1000000) return `${Math.round(n / 1000)}k`;
16
+ if (n < 10000000) return `${(n / 1000000).toFixed(1)}M`;
17
+ return `${Math.round(n / 1000000)}M`;
18
+ }
19
+
20
+ export function formatDuration(sec: number): string {
21
+ if (sec < 60) return `${Math.floor(sec)}s`;
22
+ const min = Math.floor(sec / 60);
23
+ const s = Math.floor(sec % 60);
24
+ return `${min}m${s}s`;
25
+ }
26
+
27
+ /** Format countdown from epoch ms to XhYm string. */
28
+ export function formatCountdown(resetAt: number): string {
29
+ if (!resetAt) return "?";
30
+ const remainingMs = resetAt - Date.now();
31
+ if (remainingMs <= 0) return "0m";
32
+ const totalMin = Math.floor(remainingMs / 60000);
33
+ const h = Math.floor(totalMin / 60);
34
+ const m = totalMin % 60;
35
+ if (h > 0) return `${h}h${m}m`;
36
+ return `${m}m`;
37
+ }
38
+
39
+ /** Format countdown with day precision for weekly cycles. */
40
+ export function formatWeeklyCountdown(resetAt: number): string {
41
+ if (!resetAt) return "?";
42
+ const remainingMs = resetAt - Date.now();
43
+ if (remainingMs <= 0) return "0h";
44
+ const totalMin = Math.floor(remainingMs / 60000);
45
+ const d = Math.floor(totalMin / 1440);
46
+ const h = Math.floor((totalMin % 1440) / 60);
47
+ if (d > 0) return `${d}d${h}h`;
48
+ const m = totalMin % 60;
49
+ if (h > 0) return `${h}h${m}m`;
50
+ return `${m}m`;
51
+ }
52
+
53
+ export function formatCwd(cwd: string): string {
54
+ const home = process.env.HOME || process.env.USERPROFILE;
55
+ if (!home) return cwd;
56
+ const resolvedCwd = resolve(cwd);
57
+ const resolvedHome = resolve(home);
58
+ const rel = relative(resolvedHome, resolvedCwd);
59
+ const insideHome =
60
+ rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
61
+ if (!insideHome) return cwd;
62
+ return rel === "" ? "~" : `~${sep}${rel}`;
63
+ }
64
+
65
+ export { truncateToWidth, visibleWidth };
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Footer line builders
69
+ // ---------------------------------------------------------------------------
70
+
71
+ export function buildStatLine(
72
+ stats: TokenUsage & { cost: number; currency: "¥" | "$" },
73
+ contextUsage: ContextUsage | undefined,
74
+ providerResult: ProviderUsageResult,
75
+ providers: Record<string, UsageProvider>,
76
+ getElapsedSec: () => number,
77
+ lastTps: number,
78
+ mcpStatuses: Map<string, { connected: boolean; toolCount: number }>,
79
+ ): string {
80
+ const mods: string[] = [];
81
+
82
+ // tokens 566k(in 29k, out 22k, cache 515k,45.2%)
83
+ {
84
+ const tok: string[] = [];
85
+ if (stats.input) tok.push(`in ${fmt(stats.input)}`);
86
+ if (stats.output) tok.push(`out ${fmt(stats.output)}`);
87
+ const cache = stats.cacheRead + stats.cacheWrite;
88
+ if (cache) {
89
+ const cacheStr = stats.hitRate > 0
90
+ ? `cache ${fmt(cache)},${(stats.hitRate * 100).toFixed(1)}%`
91
+ : `cache ${fmt(cache)}`;
92
+ tok.push(cacheStr);
93
+ }
94
+ if (tok.length > 0 && stats.total) {
95
+ mods.push(`tokens ${fmt(stats.total)}(${tok.join(", ")})`);
96
+ } else if (tok.length > 0) {
97
+ mods.push(tok.join(", "));
98
+ }
99
+ }
100
+
101
+ // Provider-specific cost/usage
102
+ if (providerResult) {
103
+ const providerName = providerResult.provider;
104
+ const provider = providers[providerName];
105
+ if (provider) {
106
+ const formatted = provider.formatForFooter(providerResult, stats.cost, stats.currency);
107
+ if (formatted) mods.push(formatted);
108
+ }
109
+ } else {
110
+ if (stats.cost > 0) {
111
+ mods.push(`${stats.currency}${stats.cost.toFixed(2)}`);
112
+ }
113
+ }
114
+
115
+ // context: 45.2%/16k or ?/16k
116
+ if (contextUsage) {
117
+ const w = fmt(contextUsage.contextWindow);
118
+ mods.push(
119
+ contextUsage.percent !== null
120
+ ? `${contextUsage.percent.toFixed(1)}%/${w}`
121
+ : `?/${w}`,
122
+ );
123
+ }
124
+
125
+ // timing: 2m30s 39.5 tok/s
126
+ {
127
+ const t: string[] = [];
128
+ const elapsed = getElapsedSec();
129
+ if (elapsed > 0) t.push(formatDuration(elapsed));
130
+ if (lastTps > 0) t.push(`${lastTps.toFixed(1)} tok/s`);
131
+ if (t.length) mods.push(t.join(" "));
132
+ }
133
+
134
+ // MCP: MCP:2(15) or MCP:0
135
+ {
136
+ const entries = Array.from(mcpStatuses.entries());
137
+ if (entries.length) {
138
+ const connected = entries.filter(([, s]) => s.connected).length;
139
+ const tools = entries.reduce((sum, [, s]) => sum + s.toolCount, 0);
140
+ mods.push(connected > 0 ? `MCP:${connected}(${tools})` : "MCP:0");
141
+ }
142
+ }
143
+
144
+ return mods.join(" · ");
145
+ }
146
+
147
+ export function buildInfoLine(modelId: string | undefined, thinkingLevel: string): string {
148
+ const parts: string[] = [];
149
+ if (modelId) parts.push(modelId);
150
+ if (thinkingLevel && thinkingLevel !== "off") parts.push(thinkingLevel);
151
+ return parts.join(" · ");
152
+ }
package/index.ts ADDED
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Status Line Extension v3
3
+ *
4
+ * Replaces the default footer with a custom status line.
5
+ * Uses a strategy pattern to show provider-aware usage data:
6
+ * - DeepSeek: balance + weekly tokens (from session files)
7
+ * - ZAI/GLM: rolling quota (5h + MCP) + weekly tokens (from API)
8
+ * - Others: session-scoped cost only
9
+ *
10
+ * Commands:
11
+ * /status-debug - dump session stats to /tmp/pi-status-debug.log
12
+ * /currency - toggle ¥ / $ / auto
13
+ */
14
+
15
+ import { appendFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
20
+
21
+ import { DeepSeekUsageProvider } from "./providers/deepseek.ts";
22
+ import { ZaiUsageProvider } from "./providers/zai.ts";
23
+ import type { UsageProvider } from "./providers/types.ts";
24
+ import { SessionTokenUsageCalculator } from "./token-usage.ts";
25
+ import { refreshUsage, getCachedUsage, getUsageCacheAge } from "./cache.ts";
26
+ import { formatCwd, buildStatLine, buildInfoLine } from "./footer.ts";
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Provider registry
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const providers: Record<string, UsageProvider> = {
33
+ deepseek: new DeepSeekUsageProvider(),
34
+ zai: new ZaiUsageProvider(),
35
+ "zai-coding-cn": new ZaiUsageProvider(),
36
+ };
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Runtime state
40
+ // ---------------------------------------------------------------------------
41
+
42
+ let agentStartMs: number | null = null;
43
+ let lastCtx: ExtensionContext | null = null;
44
+ let lastModel: ExtensionContext["model"] = undefined;
45
+ let agentRunning = false;
46
+ let lastElapsedSec = 0;
47
+ let lastTps = 0;
48
+ let currencyOverride: "¥" | "$" | undefined = undefined;
49
+
50
+ const mcpStatuses = new Map<string, { connected: boolean; toolCount: number }>();
51
+
52
+ const tokenCalculator = new SessionTokenUsageCalculator(() => currencyOverride);
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Helpers
56
+ // ---------------------------------------------------------------------------
57
+
58
+ function getElapsedSec(): number {
59
+ if (agentRunning && agentStartMs !== null) return (Date.now() - agentStartMs) / 1000;
60
+ return lastElapsedSec;
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Extension
65
+ // ---------------------------------------------------------------------------
66
+
67
+ export default function (pi: ExtensionAPI) {
68
+ pi.on("agent_start", () => {
69
+ agentStartMs = Date.now();
70
+ agentRunning = true;
71
+ });
72
+
73
+ pi.on("agent_end", (event) => {
74
+ agentRunning = false;
75
+ if (agentStartMs === null) return;
76
+ const elapsedMs = Date.now() - agentStartMs;
77
+ if (elapsedMs <= 0) return;
78
+ lastElapsedSec += elapsedMs / 1000;
79
+
80
+ // Compute cumulative output tokens from all assistant messages in the session
81
+ let cumulativeOutput = 0;
82
+ if (lastCtx) {
83
+ for (const entry of lastCtx.sessionManager.getEntries()) {
84
+ if (entry.type === "message" && entry.message.role === "assistant") {
85
+ cumulativeOutput += (entry.message as AssistantMessage).usage.output;
86
+ }
87
+ }
88
+ } else {
89
+ // fallback: use current turn's messages when no session context
90
+ for (const m of event.messages) {
91
+ if (m.role === "assistant") cumulativeOutput += (m as AssistantMessage).usage.output;
92
+ }
93
+ }
94
+ lastTps = cumulativeOutput > 0 ? cumulativeOutput / lastElapsedSec : 0;
95
+
96
+ // Schedule background refresh to pick up new usage data
97
+ if (lastCtx && lastModel) {
98
+ void refreshUsage(providers, lastCtx.modelRegistry, lastModel);
99
+ }
100
+ });
101
+
102
+ pi.events.on("mcp:status", (raw) => {
103
+ const data = raw as { id: string; connected: boolean; toolCount: number };
104
+ mcpStatuses.set(data.id, { connected: data.connected, toolCount: data.toolCount });
105
+ });
106
+ pi.events.on("mcp:disconnect", (raw) => {
107
+ mcpStatuses.delete((raw as { id: string }).id);
108
+ });
109
+
110
+ // ---- /status-debug ----
111
+ pi.registerCommand("status-debug", {
112
+ description: "Dump session stats to /tmp/pi-status-debug.log + console.error",
113
+ handler: async (_args, ctx) => {
114
+ const logPath = `${tmpdir()}/pi-status-debug.log`;
115
+ const w = (s: string) => {
116
+ console.error(s);
117
+ try { appendFileSync(logPath, s + "\n"); } catch { /* ok */ }
118
+ };
119
+
120
+ w("=== STATUS-LINE DEBUG ===");
121
+ w(`cwd: ${ctx.cwd}`);
122
+ w(`model: ${ctx.model?.provider}/${ctx.model?.id}`);
123
+ w(`thinking: ${pi.getThinkingLevel()}`);
124
+
125
+ const cu = ctx.getContextUsage();
126
+ w(`contextUsage: tokens=${cu?.tokens ?? "?"} window=${cu?.contextWindow ?? "?"} percent=${cu?.percent ?? "?"}`);
127
+
128
+ // Usage cache dump
129
+ w(`--- provider usage ---`);
130
+ const cached = getCachedUsage(providers, lastCtx, lastModel);
131
+ if (cached) {
132
+ w(` provider: ${cached.provider}`);
133
+ const dbgProvider = providers[cached.provider];
134
+ if (dbgProvider) dbgProvider.debugDump(cached, w);
135
+ } else {
136
+ w(` (no cached usage data)`);
137
+ }
138
+ const cacheAge = getUsageCacheAge();
139
+ w(` cacheAge: ${cacheAge !== null ? `${Math.round(cacheAge / 1000)}s` : "N/A"}`);
140
+
141
+ let idx = 0;
142
+ for (const entry of ctx.sessionManager.getEntries()) {
143
+ if (entry.type === "message" && entry.message.role === "assistant") {
144
+ const m = entry.message as AssistantMessage;
145
+ w(`--- msg[${idx}] ---`);
146
+ w(` usage: ${JSON.stringify(m.usage)}`);
147
+ w(` model: ${m.model}`);
148
+ w(` cost: ${JSON.stringify(m.usage.cost)}`);
149
+ idx++;
150
+ }
151
+ }
152
+
153
+ const stats = tokenCalculator.compute(ctx);
154
+ w(`--- computed ---`);
155
+ w(` input=${stats.input} output=${stats.output}`);
156
+ w(` cacheRead=${stats.cacheRead} cacheWrite=${stats.cacheWrite}`);
157
+ w(` total=${stats.total} cost=${stats.cost} currency=${stats.currency}`);
158
+ w(` hitRate=${(stats.hitRate * 100).toFixed(1)}%`);
159
+ w("=========================");
160
+
161
+ ctx.ui.notify(`Debug written to ${logPath} (${idx} msgs)`, "info");
162
+ },
163
+ });
164
+
165
+ // ---- /currency ----
166
+ pi.registerCommand("currency", {
167
+ description: "Toggle cost currency (auto / ¥ / $)",
168
+ handler: async (args, ctx) => {
169
+ const a = args.trim().toLowerCase();
170
+ if (a === "auto" || a === "") {
171
+ currencyOverride = undefined;
172
+ ctx.ui.notify("Currency: auto (deepseek→¥)", "info");
173
+ } else if (a === "¥" || a === "rmb" || a === "cny") {
174
+ currencyOverride = "¥";
175
+ ctx.ui.notify("Currency: ¥", "info");
176
+ } else if (a === "$" || a === "usd") {
177
+ currencyOverride = "$";
178
+ ctx.ui.notify("Currency: $", "info");
179
+ } else {
180
+ ctx.ui.notify("Usage: /currency [auto|¥|$]", "warning");
181
+ }
182
+ },
183
+ });
184
+
185
+ // ---- Model switch: refresh usage when provider changes ----
186
+ pi.on("model_select", (event) => {
187
+ if (lastCtx) {
188
+ lastModel = event.model;
189
+ void refreshUsage(providers, lastCtx.modelRegistry, event.model);
190
+ }
191
+ });
192
+
193
+ // ---- Footer ----
194
+ pi.on("session_start", (_event, ctx) => {
195
+ if (!ctx.hasUI) return;
196
+
197
+ // Reset cumulative timing for new session
198
+ lastElapsedSec = 0;
199
+ lastTps = 0;
200
+
201
+ // Prime usage cache on startup (non-blocking)
202
+ lastCtx = ctx;
203
+ lastModel = ctx.model;
204
+ void refreshUsage(providers, ctx.modelRegistry, ctx.model);
205
+
206
+ ctx.ui.setFooter((tui, theme, footerData) => {
207
+ const unsub = footerData.onBranchChange(() => tui.requestRender());
208
+
209
+ return {
210
+ dispose: unsub,
211
+ invalidate() {},
212
+ render(width: number): string[] {
213
+ const stats = tokenCalculator.compute(ctx);
214
+ const cu = ctx.getContextUsage();
215
+ const model = ctx.model;
216
+ const level = pi.getThinkingLevel();
217
+ const providerResult = getCachedUsage(providers, lastCtx, lastModel);
218
+ const dim = (s: string) => theme.fg("dim", s);
219
+ const lines: string[] = [];
220
+
221
+ // Line 0: cwd + git branch (left) | model · thinking (right)
222
+ let pwd = formatCwd(ctx.cwd);
223
+ const branch = footerData.getGitBranch();
224
+ if (branch) pwd = `${pwd} (${branch})`;
225
+ const left = dim(pwd);
226
+ const right = dim(buildInfoLine(model?.id, level));
227
+ const leftW = visibleWidth(left);
228
+ const rightW = visibleWidth(right);
229
+ if (leftW + rightW + 2 <= width) {
230
+ const pad = " ".repeat(width - leftW - rightW);
231
+ lines.push(left + pad + right);
232
+ } else {
233
+ lines.push(truncateToWidth(left + " " + right, width, dim("...")));
234
+ }
235
+
236
+ // Line 1: tokens + cost + provider usage + context + elapsed + tps + mcp
237
+ lines.push(truncateToWidth(
238
+ dim(buildStatLine(stats, cu, providerResult, providers, getElapsedSec, lastTps, mcpStatuses)),
239
+ width,
240
+ dim("..."),
241
+ ));
242
+
243
+ // Line 2: extension statuses
244
+ const extStatuses = footerData.getExtensionStatuses();
245
+ if (extStatuses.size > 0) {
246
+ const text = Array.from(extStatuses.entries())
247
+ .sort(([a], [b]) => a.localeCompare(b))
248
+ .map(([, t]) => t.replace(/[\r\n\t]/g, " ").replace(/ +/g, " ").trim())
249
+ .join(" ");
250
+ lines.push(truncateToWidth(text, width, dim("...")));
251
+ }
252
+
253
+ return lines;
254
+ },
255
+ };
256
+ });
257
+ });
258
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@fyeeme/pi-statusline",
3
+ "version": "1.0.0",
4
+ "description": "Rich custom status bar for pi with token usage, cost, DeepSeek balance, context window, timing, and MCP connection status.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "fyeeme",
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/fyeeme/pi-packages"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/fyeeme/pi-packages/issues"
17
+ },
18
+ "homepage": "https://github.com/fyeeme/pi-packages#readme",
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "keywords": [
23
+ "pi-package",
24
+ "pi",
25
+ "status-line",
26
+ "statusline",
27
+ "deepseek",
28
+ "tokens",
29
+ "cost",
30
+ "mcp",
31
+ "balance",
32
+ "footer"
33
+ ],
34
+ "files": [
35
+ "*.ts",
36
+ "providers/*.ts",
37
+ "quota/*.ts",
38
+ "README.md",
39
+ "LICENSE",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "pi": {
43
+ "extensions": [
44
+ "./index.ts"
45
+ ]
46
+ },
47
+ "scripts": {
48
+ "test": "vitest --run",
49
+ "typecheck": "tsc"
50
+ },
51
+ "peerDependencies": {
52
+ "@earendil-works/pi-coding-agent": ">=0.77.0",
53
+ "@earendil-works/pi-ai": ">=0.77.0",
54
+ "@earendil-works/pi-tui": ">=0.77.0"
55
+ },
56
+ "devDependencies": {
57
+ "@earendil-works/pi-coding-agent": "0.77.0",
58
+ "@earendil-works/pi-ai": "0.77.0",
59
+ "@earendil-works/pi-tui": "0.77.0",
60
+ "@types/node": "22.19.19",
61
+ "typescript": "5.9.3"
62
+ }
63
+ }
@@ -0,0 +1,81 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { DeepSeekResult, ProviderUsageResult } from "../types.ts";
3
+ import type { UsageProvider } from "./types.ts";
4
+ import type { QuotaCalculator } from "../quota/types.ts";
5
+ import { DeepSeekQuotaCalculator } from "../quota/deepseek.ts";
6
+ import { scanWeeklyTokens } from "../session-scanner.ts";
7
+ import { fmt } from "../footer.ts";
8
+
9
+ export class DeepSeekUsageProvider implements UsageProvider {
10
+ quotaCalculator: QuotaCalculator = new DeepSeekQuotaCalculator();
11
+
12
+ async fetchUsage(
13
+ modelRegistry: ExtensionContext["modelRegistry"],
14
+ model: ExtensionContext["model"],
15
+ ): Promise<ProviderUsageResult> {
16
+ if (!model || model.provider !== "deepseek") return null;
17
+
18
+ const apiKey = await modelRegistry.getApiKeyForProvider("deepseek");
19
+ if (!apiKey) return null;
20
+
21
+ let origin = "https://api.deepseek.com";
22
+ try {
23
+ const u = new URL(model.baseUrl ?? "https://api.deepseek.com");
24
+ origin = u.origin;
25
+ } catch { /* use default */ }
26
+
27
+ try {
28
+ const res = await fetch(`${origin}/user/balance`, {
29
+ headers: { Authorization: `Bearer ${apiKey}` },
30
+ signal: AbortSignal.timeout(5000),
31
+ });
32
+ if (!res.ok) return null;
33
+
34
+ const data = (await res.json()) as {
35
+ is_available?: boolean;
36
+ balance_infos?: Array<{
37
+ currency?: string;
38
+ total_balance?: string;
39
+ }>;
40
+ };
41
+
42
+ const info = data.balance_infos?.[0];
43
+ if (!info) return null;
44
+
45
+ const weeklyTokens = scanWeeklyTokens("deepseek");
46
+
47
+ return {
48
+ provider: "deepseek",
49
+ totalBalance: info.total_balance ?? "?",
50
+ currency: info.currency ?? "CNY",
51
+ weeklyTokens,
52
+ };
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string {
59
+ if (result.provider !== "deepseek") return "";
60
+ const ds = result as DeepSeekResult;
61
+ const balance = `${ds.currency === "CNY" ? "¥" : "$"}${ds.totalBalance}`;
62
+ const weekly = ds.weeklyTokens > 0 ? `7d:${fmt(ds.weeklyTokens)}` : "";
63
+ const parts: string[] = [];
64
+
65
+ if (sessionCost > 0) {
66
+ parts.push(`${currency}${sessionCost.toFixed(2)}/${balance}`);
67
+ } else {
68
+ parts.push(balance);
69
+ }
70
+ if (weekly) parts.push(weekly);
71
+
72
+ return parts.join(" · ");
73
+ }
74
+
75
+ debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void {
76
+ if (result.provider !== "deepseek") return;
77
+ const ds = result as DeepSeekResult;
78
+ w(` balance: ${ds.currency} ${ds.totalBalance}`);
79
+ w(` weeklyTokens: ${ds.weeklyTokens}`);
80
+ }
81
+ }
@@ -0,0 +1,13 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ProviderUsageResult } from "../types.ts";
3
+ import type { QuotaCalculator } from "../quota/types.ts";
4
+
5
+ export interface UsageProvider {
6
+ fetchUsage(
7
+ modelRegistry: ExtensionContext["modelRegistry"],
8
+ model: ExtensionContext["model"],
9
+ ): Promise<ProviderUsageResult>;
10
+ formatForFooter(result: NonNullable<ProviderUsageResult>, sessionCost: number, currency: string): string;
11
+ debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void;
12
+ quotaCalculator: QuotaCalculator;
13
+ }
@@ -0,0 +1,178 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { ZaiResult, ProviderUsageResult } from "../types.ts";
3
+ import type { UsageProvider } from "./types.ts";
4
+ import type { QuotaCalculator } from "../quota/types.ts";
5
+ import { ZaiQuotaCalculator } from "../quota/zai.ts";
6
+ import { fmt, formatCountdown, formatWeeklyCountdown } from "../footer.ts";
7
+ import { scanWeeklyTokens } from "../session-scanner.ts";
8
+
9
+ /** Check if the provider name is a ZAI/GLM variant. */
10
+ function isZaiProvider(provider: string): boolean {
11
+ return provider === "zai" || provider === "zai-coding-cn";
12
+ }
13
+
14
+ export class ZaiUsageProvider implements UsageProvider {
15
+ quotaCalculator: QuotaCalculator = new ZaiQuotaCalculator();
16
+
17
+ async fetchUsage(
18
+ modelRegistry: ExtensionContext["modelRegistry"],
19
+ model: ExtensionContext["model"],
20
+ ): Promise<ProviderUsageResult> {
21
+ if (!model || !isZaiProvider(model.provider)) return null;
22
+
23
+ const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
24
+ if (!apiKey) return null;
25
+
26
+ const defaultOrigin = model.provider === "zai-coding-cn"
27
+ ? "https://open.bigmodel.cn"
28
+ : "https://api.z.ai";
29
+ let origin = defaultOrigin;
30
+ try {
31
+ origin = new URL(model.baseUrl ?? defaultOrigin).origin;
32
+ } catch { /* use default */ }
33
+
34
+ const headers: Record<string, string> = {
35
+ Authorization: apiKey,
36
+ "Accept-Language": "en-US,en",
37
+ "Content-Type": "application/json",
38
+ };
39
+
40
+ try {
41
+ // Fetch quota limits
42
+ const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
43
+ headers,
44
+ signal: AbortSignal.timeout(5000),
45
+ });
46
+ if (!quotaRes.ok) return null;
47
+
48
+ const quotaData = (await quotaRes.json()) as {
49
+ data?: {
50
+ limits?: Array<{
51
+ type: string;
52
+ unit?: number;
53
+ percentage?: number;
54
+ nextResetTime?: number;
55
+ }>;
56
+ level?: string;
57
+ };
58
+ };
59
+
60
+ const limits = quotaData.data?.limits ?? [];
61
+ const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
62
+ const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
63
+ const level = quotaData.data?.level ?? "";
64
+
65
+ const now = Date.now();
66
+ let weeklyTokens = 0;
67
+ let weeklyResetAt = 0;
68
+ let weeklyPct = 0;
69
+ let isNaturalWeek = false;
70
+
71
+ if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
72
+ // unit:6 TOKENS_LIMIT exists — use API percentage + model-usage tokens
73
+ weeklyPct = weeklyLimit.percentage ?? 0;
74
+ weeklyResetAt = weeklyLimit.nextResetTime;
75
+ const cycleStart = weeklyResetAt - 7 * 24 * 60 * 60 * 1000;
76
+ weeklyTokens = await this.fetchCycleUsage(origin, headers, cycleStart, now);
77
+ } else {
78
+ // No unit:6 — natural week fallback, same as DeepSeek
79
+ isNaturalWeek = true;
80
+ weeklyTokens = scanWeeklyTokens(model.provider);
81
+
82
+ const d = new Date(now);
83
+ const dayOfWeek = d.getUTCDay();
84
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
85
+ weeklyResetAt = Date.UTC(
86
+ d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate() + daysUntilMonday, 0, 0, 0, 0,
87
+ );
88
+ }
89
+
90
+ return {
91
+ provider: "zai",
92
+ tokensLimitPct: fiveHourLimit?.percentage ?? 0,
93
+ tokensResetAt: fiveHourLimit?.nextResetTime ?? 0,
94
+ level,
95
+ weeklyTokens,
96
+ weeklyResetAt,
97
+ weeklyPct,
98
+ isNaturalWeek,
99
+ };
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
105
+ formatForFooter(result: NonNullable<ProviderUsageResult>, _sessionCost: number, _currency: string): string {
106
+ if (result.provider !== "zai") return "";
107
+ const zai = result as ZaiResult;
108
+ const parts: string[] = [];
109
+
110
+ // 5-hour rolling quota
111
+ const countdown = formatCountdown(zai.tokensResetAt);
112
+ parts.push(`Usage ${zai.tokensLimitPct}%(${countdown})`);
113
+
114
+ // Weekly quota
115
+ if (zai.isNaturalWeek) {
116
+ if (zai.weeklyTokens > 0) {
117
+ parts.push(`7d:${fmt(zai.weeklyTokens)}`);
118
+ }
119
+ } else if (zai.weeklyTokens > 0 || zai.weeklyPct > 0) {
120
+ const weeklyCountdown = formatWeeklyCountdown(zai.weeklyResetAt);
121
+ parts.push(`W:${zai.weeklyPct}%(${fmt(zai.weeklyTokens)},${weeklyCountdown})`);
122
+ }
123
+
124
+ return parts.join(" · ");
125
+ }
126
+
127
+ debugDump(result: NonNullable<ProviderUsageResult>, w: (s: string) => void): void {
128
+ if (result.provider !== "zai") return;
129
+ const zai = result as ZaiResult;
130
+ w(` tokensLimitPct: ${zai.tokensLimitPct}%`);
131
+ w(` tokensResetAt: ${new Date(zai.tokensResetAt).toISOString()}`);
132
+ w(` level: ${zai.level}`);
133
+ w(` weeklyTokens: ${zai.weeklyTokens}`);
134
+ w(` weeklyPct: ${zai.weeklyPct}%`);
135
+ w(` isNaturalWeek: ${zai.isNaturalWeek}`);
136
+ w(` weeklyResetAt: ${zai.weeklyResetAt ? new Date(zai.weeklyResetAt).toISOString() : "?"}`);
137
+ }
138
+
139
+ /** Fetch model-usage for a time range. Returns total tokens. */
140
+ private async fetchCycleUsage(
141
+ origin: string,
142
+ headers: Record<string, string>,
143
+ cycleStartMs: number,
144
+ nowMs: number,
145
+ ): Promise<number> {
146
+ try {
147
+ const fmtDate = (d: Date) =>
148
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")} ${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
149
+ const start = fmtDate(new Date(cycleStartMs));
150
+ const end = fmtDate(new Date(nowMs));
151
+ const url = `${origin}/api/monitor/usage/model-usage?startTime=${encodeURIComponent(start)}&endTime=${encodeURIComponent(end)}`;
152
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(5000) });
153
+ if (!res.ok) return 0;
154
+
155
+ const usageData = (await res.json()) as {
156
+ data?: {
157
+ totalUsage?: { totalTokensUsage?: number };
158
+ modelSummaryList?: Array<{ modelName: string; totalTokens: number }>;
159
+ } | Array<{ totalTokens?: number }>;
160
+ };
161
+
162
+ const data = usageData.data;
163
+ if (!data) return 0;
164
+ if (Array.isArray(data)) {
165
+ return data.reduce((sum, e) => sum + (e.totalTokens ?? 0), 0);
166
+ }
167
+ if (data.totalUsage?.totalTokensUsage != null) {
168
+ return data.totalUsage.totalTokensUsage;
169
+ }
170
+ if (Array.isArray(data.modelSummaryList)) {
171
+ return data.modelSummaryList.reduce((sum, e) => sum + (e.totalTokens ?? 0), 0);
172
+ }
173
+ return 0;
174
+ } catch {
175
+ return 0;
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,31 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
3
+ import { scanWeeklyTokens } from "../session-scanner.ts";
4
+
5
+ export class DeepSeekQuotaCalculator implements QuotaCalculator {
6
+ async calculate(
7
+ _modelRegistry: ExtensionContext["modelRegistry"],
8
+ _model: ExtensionContext["model"],
9
+ ): Promise<QuotaInfo> {
10
+ const weeklyTokens = scanWeeklyTokens("deepseek");
11
+
12
+ // Next Monday 00:00 UTC
13
+ const now = new Date();
14
+ const dayOfWeek = now.getUTCDay();
15
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
16
+ const nextMonday = new Date(
17
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
18
+ );
19
+
20
+ const weekly: WeeklyQuota = {
21
+ usedTokens: weeklyTokens,
22
+ resetAt: nextMonday.getTime(),
23
+ crossesNaturalWeek: false,
24
+ };
25
+
26
+ return {
27
+ rolling: null,
28
+ weekly,
29
+ };
30
+ }
31
+ }
package/quota/types.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** Rolling quota window (e.g. ZAI 5-hour token quota) */
4
+ export interface RollingQuota {
5
+ /** Usage percentage 0–100 */
6
+ usedPct: number;
7
+ /** Epoch ms when the window resets */
8
+ resetAt: number;
9
+ }
10
+
11
+ /** Weekly subscription quota */
12
+ export interface WeeklyQuota {
13
+ /** Total tokens used in the subscription week */
14
+ usedTokens: number;
15
+ /** Epoch ms when the subscription week resets */
16
+ resetAt: number;
17
+ /** Whether resetAt crosses a natural week boundary */
18
+ crossesNaturalWeek: boolean;
19
+ /** Usage percentage from unit:6 TOKENS_LIMIT (0 if natural week) */
20
+ weeklyPct?: number;
21
+ }
22
+
23
+ /** Provider-specific quota. Fields are null when the provider doesn't support them. */
24
+ export interface QuotaInfo {
25
+ rolling: RollingQuota | null;
26
+ weekly: WeeklyQuota | null;
27
+ }
28
+
29
+ export interface QuotaCalculator {
30
+ calculate(
31
+ modelRegistry: ExtensionContext["modelRegistry"],
32
+ model: ExtensionContext["model"],
33
+ ): Promise<QuotaInfo>;
34
+ }
package/quota/zai.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { QuotaCalculator, QuotaInfo, WeeklyQuota } from "./types.ts";
3
+ import { scanWeeklyTokens } from "../session-scanner.ts";
4
+
5
+ export class ZaiQuotaCalculator implements QuotaCalculator {
6
+ async calculate(
7
+ modelRegistry: ExtensionContext["modelRegistry"],
8
+ model: ExtensionContext["model"],
9
+ ): Promise<QuotaInfo> {
10
+ if (!model || (model.provider !== "zai" && model.provider !== "zai-coding-cn")) {
11
+ return { rolling: null, weekly: null };
12
+ }
13
+
14
+ const apiKey = await modelRegistry.getApiKeyForProvider(model.provider);
15
+ if (!apiKey) return { rolling: null, weekly: null };
16
+
17
+ const defaultOrigin = model.provider === "zai-coding-cn"
18
+ ? "https://open.bigmodel.cn"
19
+ : "https://api.z.ai";
20
+ let origin = defaultOrigin;
21
+ try { origin = new URL(model.baseUrl ?? defaultOrigin).origin; } catch { /* use default */ }
22
+
23
+ const headers: Record<string, string> = {
24
+ Authorization: apiKey,
25
+ "Accept-Language": "en-US,en",
26
+ "Content-Type": "application/json",
27
+ };
28
+
29
+ let rolling: QuotaInfo["rolling"] = null;
30
+ let weekly: QuotaInfo["weekly"] = null;
31
+
32
+ try {
33
+ const quotaRes = await fetch(`${origin}/api/monitor/usage/quota/limit`, {
34
+ headers,
35
+ signal: AbortSignal.timeout(5000),
36
+ });
37
+ if (quotaRes.ok) {
38
+ const quotaData = (await quotaRes.json()) as {
39
+ data?: {
40
+ limits?: Array<{
41
+ type: string;
42
+ unit?: number;
43
+ percentage?: number;
44
+ nextResetTime?: number;
45
+ }>;
46
+ };
47
+ };
48
+
49
+ const limits = quotaData.data?.limits ?? [];
50
+ const fiveHourLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 3);
51
+ const weeklyLimit = limits.find((l) => l.type === "TOKENS_LIMIT" && l.unit === 6);
52
+
53
+ if (fiveHourLimit) {
54
+ rolling = {
55
+ usedPct: fiveHourLimit.percentage ?? 0,
56
+ resetAt: fiveHourLimit.nextResetTime ?? 0,
57
+ };
58
+ }
59
+
60
+ if (weeklyLimit && weeklyLimit.nextResetTime && weeklyLimit.nextResetTime > 0) {
61
+ // unit:6 — weekly quota from API
62
+ weekly = {
63
+ usedTokens: 0, // actual token count fetched separately by provider
64
+ resetAt: weeklyLimit.nextResetTime ?? 0,
65
+ crossesNaturalWeek: true,
66
+ weeklyPct: weeklyLimit.percentage ?? 0,
67
+ };
68
+ } else {
69
+ // No unit:6 — natural week fallback
70
+ const weeklyTokens = scanWeeklyTokens(model.provider);
71
+ const now = new Date();
72
+ const dayOfWeek = now.getUTCDay();
73
+ const daysUntilMonday = dayOfWeek === 0 ? 1 : 8 - dayOfWeek;
74
+ const nextMonday = new Date(
75
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + daysUntilMonday, 0, 0, 0, 0),
76
+ );
77
+ weekly = {
78
+ usedTokens: weeklyTokens,
79
+ resetAt: nextMonday.getTime(),
80
+ crossesNaturalWeek: false,
81
+ };
82
+ }
83
+ }
84
+ } catch {
85
+ // quota unavailable
86
+ }
87
+
88
+ return { rolling, weekly };
89
+ }
90
+ }
@@ -0,0 +1,64 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+
5
+ export function scanWeeklyTokens(providerName: string): number {
6
+ const sessionsDir = join(getAgentDir(), "sessions");
7
+ const now = new Date();
8
+ // Natural week: Monday 00:00 UTC
9
+ const dayOfWeek = now.getUTCDay();
10
+ const mondayOffset = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
11
+ const weekStart = new Date(
12
+ Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - mondayOffset, 0, 0, 0, 0),
13
+ );
14
+
15
+ let total = 0;
16
+
17
+ try {
18
+ const dirs = readdirSync(sessionsDir, { withFileTypes: true });
19
+ for (const dir of dirs) {
20
+ if (!dir.isDirectory()) continue;
21
+ const dirPath = join(sessionsDir, dir.name);
22
+ let files: string[];
23
+ try {
24
+ files = readdirSync(dirPath);
25
+ } catch {
26
+ continue;
27
+ }
28
+ for (const fname of files) {
29
+ if (!fname.endsWith(".jsonl")) continue;
30
+ try {
31
+ const fileDate = new Date(fname.slice(0, 10) + "T00:00:00Z");
32
+ if (fileDate < weekStart) continue;
33
+ } catch {
34
+ continue;
35
+ }
36
+
37
+ try {
38
+ const content = readFileSync(join(dirPath, fname), "utf-8");
39
+ for (const line of content.split("\n")) {
40
+ if (!line.trim()) continue;
41
+ try {
42
+ const d = JSON.parse(line);
43
+ if (
44
+ d.type === "message" &&
45
+ d.message?.role === "assistant" &&
46
+ d.message?.provider === providerName
47
+ ) {
48
+ total += d.message.usage?.totalTokens ?? 0;
49
+ }
50
+ } catch {
51
+ // skip malformed lines
52
+ }
53
+ }
54
+ } catch {
55
+ // skip unreadable files
56
+ }
57
+ }
58
+ }
59
+ } catch {
60
+ // sessions dir doesn't exist or is unreadable
61
+ }
62
+
63
+ return total;
64
+ }
package/token-usage.ts ADDED
@@ -0,0 +1,45 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import type { TokenUsage, TokenUsageCalculator } from "./types.ts";
4
+
5
+ export class SessionTokenUsageCalculator implements TokenUsageCalculator {
6
+ private getCurrencyOverride: () => "\u00a5" | "$" | undefined;
7
+
8
+ constructor(getCurrencyOverride: () => "\u00a5" | "$" | undefined) {
9
+ this.getCurrencyOverride = getCurrencyOverride;
10
+ }
11
+
12
+ compute(ctx: ExtensionContext): TokenUsage & { cost: number; currency: "\u00a5" | "$" } {
13
+ let input = 0;
14
+ let output = 0;
15
+ let cacheRead = 0;
16
+ let cacheWrite = 0;
17
+ let total = 0;
18
+ let cost = 0;
19
+
20
+ for (const entry of ctx.sessionManager.getEntries()) {
21
+ if (entry.type === "message" && entry.message.role === "assistant") {
22
+ const m = entry.message as AssistantMessage;
23
+ input += m.usage.input;
24
+ output += m.usage.output;
25
+ cacheRead += m.usage.cacheRead;
26
+ cacheWrite += m.usage.cacheWrite;
27
+ total += m.usage.totalTokens;
28
+ cost += m.usage.cost.total;
29
+ }
30
+ }
31
+
32
+ const hitRate = input + cacheRead > 0 ? cacheRead / (input + cacheRead) : 0;
33
+
34
+ let currency: "\u00a5" | "$" = "$";
35
+ const override = this.getCurrencyOverride();
36
+ if (override) {
37
+ currency = override;
38
+ } else {
39
+ const p = ctx.model?.provider ?? "";
40
+ if (p.toLowerCase().includes("deepseek")) currency = "\u00a5";
41
+ }
42
+
43
+ return { input, output, cacheRead, cacheWrite, total, hitRate, cost, currency };
44
+ }
45
+ }
package/types.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Token usage
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface TokenUsage {
8
+ input: number;
9
+ output: number;
10
+ cacheRead: number;
11
+ cacheWrite: number;
12
+ total: number;
13
+ /** cache hit ratio: cacheRead / (input + cacheRead), 0 when denominator is 0 */
14
+ hitRate: number;
15
+ }
16
+
17
+ export interface TokenUsageCalculator {
18
+ compute(ctx: ExtensionContext): TokenUsage & { cost: number; currency: "¥" | "$" };
19
+ }
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Provider result types
23
+ // ---------------------------------------------------------------------------
24
+
25
+ export type DeepSeekResult = {
26
+ provider: "deepseek";
27
+ totalBalance: string;
28
+ currency: string;
29
+ weeklyTokens: number;
30
+ };
31
+
32
+ export type ZaiResult = {
33
+ provider: "zai";
34
+ tokensLimitPct: number;
35
+ tokensResetAt: number;
36
+ level: string;
37
+ weeklyTokens: number;
38
+ weeklyResetAt: number;
39
+ weeklyPct: number;
40
+ isNaturalWeek: boolean;
41
+ };
42
+
43
+ export type ProviderUsageResult = DeepSeekResult | ZaiResult | null;