pi-extensions 0.1.23 β 0.1.27
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 +1 -1
- package/files-widget/index.ts +2 -2
- package/package.json +1 -1
- package/usage-extension/CHANGELOG.md +18 -0
- package/usage-extension/README.md +10 -19
- package/usage-extension/index.ts +254 -158
- package/usage-extension/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ Personal extensions for the [Pi coding agent](https://github.com/badlogic/pi-mon
|
|
|
10
10
|
| [tab-status](tab-status/) | Manage as many parallel sessions as your mind can handle. Terminal tab indicators for <br>β
done / π§ stuck / π timed out |
|
|
11
11
|
| [ralph-wiggum](ralph-wiggum/) | Run arbitrarily-long tasks without diluting model attention. Flat version without subagents like [ralph-loop](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/ralph-loop) |
|
|
12
12
|
| [agent-guidance](agent-guidance/) | Switch between Claude/Codex/Gemini with model-specific guidance (CLAUDE.md, CODEX.md, GEMINI.md) |
|
|
13
|
-
| [/usage](usage-extension/) | π Usage statistics dashboard. See cost, tokens, and messages by provider/model across Today, This Week, All Time |
|
|
13
|
+
| [/usage](usage-extension/) | π Usage statistics dashboard. See cost, tokens, and messages by provider/model across Today, This Week, Last Week, and All Time β with a compact view for narrow terminals |
|
|
14
14
|
| [/paste](raw-paste/) | Paste editable text, not [paste #1 +21 lines]. Running `/paste` with optional keybinding |
|
|
15
15
|
| [/code](code-actions/) | Pick code blocks or inline snippets from assistant messages to copy, insert, or run with `/code` |
|
|
16
16
|
| [arcade](arcade/) | Play minigames while your tests run: πΎ sPIce-invaders, π» picman, π ping, π§© tetris, π mario-not |
|
package/files-widget/index.ts
CHANGED
|
@@ -66,7 +66,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
66
66
|
},
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
pi.registerCommand("review", {
|
|
69
|
+
pi.registerCommand("readfiles-review", {
|
|
70
70
|
description: "Open tuicr to review changes and send feedback to agent",
|
|
71
71
|
handler: async (_args, ctx) => {
|
|
72
72
|
if (!hasCommand("tuicr")) {
|
|
@@ -101,7 +101,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
101
101
|
},
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
pi.registerCommand("diff", {
|
|
104
|
+
pi.registerCommand("readfiles-diff", {
|
|
105
105
|
description: "Open critique to view diffs",
|
|
106
106
|
handler: async (args, ctx) => {
|
|
107
107
|
if (!hasCommand("bun")) {
|
package/package.json
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-04-17
|
|
4
|
+
- Include `cacheWrite` in the main `Tokens` total and in the `βIn` column so providers like Anthropic that report fresh prompt work under `cacheWrite` are no longer undercounted
|
|
5
|
+
- Keep `cacheRead` out of `Tokens` so repeated cache hits do not swamp the dashboard
|
|
6
|
+
- Keep the `Cache` column as combined cache read + write tokens for reference
|
|
7
|
+
- Minor semver bump: the numbers shown under `Tokens` and `βIn` are now higher for Anthropic usage. Cost, `βOut`, and `Cache` are unchanged.
|
|
8
|
+
|
|
9
|
+
## 0.1.7 - 2026-04-09
|
|
10
|
+
- Prevent `/usage` from crashing in narrow terminals by switching to a compact responsive table and truncating every rendered line to the terminal width
|
|
11
|
+
- Thanks @markokocic
|
|
12
|
+
|
|
13
|
+
## 0.1.6 - 2026-04-09
|
|
14
|
+
- Add a "Last Week" time period tab
|
|
15
|
+
- Thanks @ttttmr
|
|
16
|
+
|
|
17
|
+
## 0.1.5 - 2026-04-09
|
|
18
|
+
- Keep recursive subagent session scanning in `/usage`
|
|
19
|
+
- Remove the deduped/raw mode toggle and keep the deduped view as the default behavior
|
|
20
|
+
|
|
3
21
|
## 0.1.4 - 2026-04-09
|
|
4
22
|
- Scan session files recursively so nested subagent runs are included in `/usage`
|
|
5
23
|
- Add deduped vs raw counting modes to compare copied branch history against raw file totals
|
|
@@ -7,7 +7,7 @@ A Pi extension that displays aggregated usage statistics across all sessions.
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-04-
|
|
10
|
+
- **Last updated:** 2026-04-17
|
|
11
11
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
@@ -61,19 +61,11 @@ In Pi, run:
|
|
|
61
61
|
|--------|------------|
|
|
62
62
|
| **Today** | From midnight (00:00) today |
|
|
63
63
|
| **This Week** | From Monday 00:00 of the current week |
|
|
64
|
+
| **Last Week** | Previous week (Monday 00:00 β this Monday 00:00) |
|
|
64
65
|
| **All Time** | All recorded sessions |
|
|
65
66
|
|
|
66
67
|
Use `Tab` or `β`/`β` to switch between periods.
|
|
67
68
|
|
|
68
|
-
### Count Modes
|
|
69
|
-
|
|
70
|
-
| Mode | Definition |
|
|
71
|
-
|------|------------|
|
|
72
|
-
| **Deduped** | Default. Deduplicates copied assistant history across branched session files |
|
|
73
|
-
| **Raw** | Counts every assistant message found in every session file |
|
|
74
|
-
|
|
75
|
-
Both modes scan nested session files recursively, so subagent runs are included.
|
|
76
|
-
|
|
77
69
|
### Timezone
|
|
78
70
|
|
|
79
71
|
Time periods are calculated in the local timezone where Pi runs. If you want to override it, set the `TZ` environment variable (IANA timezone, e.g. `TZ=UTC` or `TZ=America/New_York`) before launching Pi.
|
|
@@ -86,19 +78,18 @@ Time periods are calculated in the local timezone where Pi runs. If you want to
|
|
|
86
78
|
| **Sessions** | Number of unique sessions |
|
|
87
79
|
| **Msgs** | Number of assistant messages |
|
|
88
80
|
| **Cost** | Total cost in USD (from API response) |
|
|
89
|
-
| **Tokens** |
|
|
90
|
-
| **βIn** |
|
|
81
|
+
| **Tokens** | Fresh tokens for the turn: input + output + cache write |
|
|
82
|
+
| **βIn** | Fresh input tokens: input + cache write *(dimmed)* |
|
|
91
83
|
| **βOut** | Output tokens *(dimmed)* |
|
|
92
|
-
| **Cache** | Cache read + write tokens *(dimmed)* |
|
|
84
|
+
| **Cache** | Cache read + write tokens *(dimmed; informational)* |
|
|
85
|
+
|
|
86
|
+
On narrow terminals, `/usage` automatically switches to a compact table instead of overflowing the terminal. Hidden columns reappear as soon as you widen the terminal.
|
|
93
87
|
|
|
94
88
|
### Navigation
|
|
95
89
|
|
|
96
90
|
| Key | Action |
|
|
97
91
|
|-----|--------|
|
|
98
92
|
| `Tab` / `β` `β` | Switch time period |
|
|
99
|
-
| `m` | Cycle count mode |
|
|
100
|
-
| `d` | Switch to deduped mode |
|
|
101
|
-
| `r` | Switch to raw mode |
|
|
102
93
|
| `β` `β` | Select provider |
|
|
103
94
|
| `Enter` / `Space` | Expand/collapse provider to show models |
|
|
104
95
|
| `q` / `Esc` | Close |
|
|
@@ -121,13 +112,13 @@ Cache token support varies by provider:
|
|
|
121
112
|
|
|
122
113
|
The "Cache" column combines both read and write tokens.
|
|
123
114
|
|
|
115
|
+
`Tokens` and `βIn` include cache writes but intentionally exclude cache reads. That keeps totals aligned with fresh/billed prompt work without letting repeated cache hits swamp the dashboard.
|
|
116
|
+
|
|
124
117
|
## Data Source
|
|
125
118
|
|
|
126
119
|
Statistics are parsed recursively from session files in `~/.pi/agent/sessions/`, including nested subagent runs such as `run-0/` directories. Each session is a JSONL file containing message entries with usage data.
|
|
127
120
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
In **Raw** mode, every assistant message found in every session file is counted.
|
|
121
|
+
Assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens, matching the extension's previous behavior while still including recursive subagent sessions.
|
|
131
122
|
|
|
132
123
|
Respects the `PI_CODING_AGENT_DIR` environment variable if set.
|
|
133
124
|
|
package/usage-extension/index.ts
CHANGED
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
* /usage - Usage statistics dashboard
|
|
3
3
|
*
|
|
4
4
|
* Shows an inline view with usage stats grouped by provider.
|
|
5
|
-
* - Tab cycles: Today β This Week β All Time
|
|
6
|
-
* - D toggles deduped view, R toggles raw view, M cycles both
|
|
5
|
+
* - Tab cycles: Today β This Week β Last Week β All Time
|
|
7
6
|
* - Arrow keys navigate providers
|
|
8
7
|
* - Enter expands/collapses to show models
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
12
11
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
13
|
-
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
12
|
+
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
14
13
|
import { readdir, readFile } from "node:fs/promises";
|
|
15
14
|
import { join } from "node:path";
|
|
16
15
|
import { homedir } from "node:os";
|
|
@@ -23,7 +22,8 @@ interface TokenStats {
|
|
|
23
22
|
total: number;
|
|
24
23
|
input: number;
|
|
25
24
|
output: number;
|
|
26
|
-
|
|
25
|
+
cacheRead: number;
|
|
26
|
+
cacheWrite: number;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
interface BaseStats {
|
|
@@ -53,12 +53,11 @@ interface TimeFilteredStats {
|
|
|
53
53
|
interface UsageData {
|
|
54
54
|
today: TimeFilteredStats;
|
|
55
55
|
thisWeek: TimeFilteredStats;
|
|
56
|
+
lastWeek: TimeFilteredStats;
|
|
56
57
|
allTime: TimeFilteredStats;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
type TabName = "today" | "thisWeek" | "allTime";
|
|
60
|
-
type UsageCountMode = "deduped" | "raw";
|
|
61
|
-
type UsageDataByMode = Record<UsageCountMode, UsageData>;
|
|
60
|
+
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
|
|
62
61
|
|
|
63
62
|
// =============================================================================
|
|
64
63
|
// Column Configuration
|
|
@@ -71,23 +70,86 @@ interface DataColumn {
|
|
|
71
70
|
getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
73
|
+
interface TableLayoutCandidate {
|
|
74
|
+
columns: DataColumn[];
|
|
75
|
+
minNameWidth: number;
|
|
76
|
+
compact?: boolean;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
interface TableLayout {
|
|
80
|
+
columns: DataColumn[];
|
|
81
|
+
nameWidth: number;
|
|
82
|
+
tableWidth: number;
|
|
83
|
+
compact: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const MAX_NAME_COL_WIDTH = 26;
|
|
87
|
+
|
|
88
|
+
const SESSIONS_COLUMN: DataColumn = {
|
|
89
|
+
label: "Sessions",
|
|
90
|
+
width: 9,
|
|
91
|
+
getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const MSGS_COLUMN: DataColumn = {
|
|
95
|
+
label: "Msgs",
|
|
96
|
+
width: 9,
|
|
97
|
+
getValue: (s) => formatNumber(s.messages),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const COST_COLUMN: DataColumn = {
|
|
101
|
+
label: "Cost",
|
|
102
|
+
width: 9,
|
|
103
|
+
getValue: (s) => formatCost(s.cost),
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const TOKENS_COLUMN: DataColumn = {
|
|
107
|
+
label: "Tokens",
|
|
108
|
+
width: 9,
|
|
109
|
+
getValue: (s) => formatTokens(s.tokens.total),
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const INPUT_COLUMN: DataColumn = {
|
|
113
|
+
label: "βIn",
|
|
114
|
+
width: 8,
|
|
115
|
+
dimmed: true,
|
|
116
|
+
// Include cacheWrite so this reflects fresh input tokens sent this turn,
|
|
117
|
+
// even for providers like Anthropic that split cached prompt creation out
|
|
118
|
+
// from the regular input token count.
|
|
119
|
+
getValue: (s) => formatTokens(s.tokens.input + s.tokens.cacheWrite),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const OUTPUT_COLUMN: DataColumn = {
|
|
123
|
+
label: "βOut",
|
|
124
|
+
width: 8,
|
|
125
|
+
dimmed: true,
|
|
126
|
+
getValue: (s) => formatTokens(s.tokens.output),
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const CACHE_COLUMN: DataColumn = {
|
|
130
|
+
label: "Cache",
|
|
131
|
+
width: 8,
|
|
132
|
+
dimmed: true,
|
|
133
|
+
getValue: (s) => formatTokens(s.tokens.cacheRead + s.tokens.cacheWrite),
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const FULL_DATA_COLUMNS: DataColumn[] = [
|
|
137
|
+
SESSIONS_COLUMN,
|
|
138
|
+
MSGS_COLUMN,
|
|
139
|
+
COST_COLUMN,
|
|
140
|
+
TOKENS_COLUMN,
|
|
141
|
+
INPUT_COLUMN,
|
|
142
|
+
OUTPUT_COLUMN,
|
|
143
|
+
CACHE_COLUMN,
|
|
88
144
|
];
|
|
89
145
|
|
|
90
|
-
const
|
|
146
|
+
const TABLE_LAYOUTS: TableLayoutCandidate[] = [
|
|
147
|
+
{ columns: FULL_DATA_COLUMNS, minNameWidth: MAX_NAME_COL_WIDTH },
|
|
148
|
+
{ columns: [SESSIONS_COLUMN, MSGS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 14, compact: true },
|
|
149
|
+
{ columns: [SESSIONS_COLUMN, COST_COLUMN, TOKENS_COLUMN], minNameWidth: 12, compact: true },
|
|
150
|
+
{ columns: [COST_COLUMN, TOKENS_COLUMN], minNameWidth: 10, compact: true },
|
|
151
|
+
{ columns: [COST_COLUMN], minNameWidth: 8, compact: true },
|
|
152
|
+
];
|
|
91
153
|
|
|
92
154
|
// =============================================================================
|
|
93
155
|
// Data Collection
|
|
@@ -136,8 +198,7 @@ interface SessionMessage {
|
|
|
136
198
|
|
|
137
199
|
interface ParsedSessionFile {
|
|
138
200
|
sessionId: string;
|
|
139
|
-
|
|
140
|
-
dedupedMessages: SessionMessage[];
|
|
201
|
+
messages: SessionMessage[];
|
|
141
202
|
}
|
|
142
203
|
|
|
143
204
|
async function parseSessionFile(
|
|
@@ -149,8 +210,7 @@ async function parseSessionFile(
|
|
|
149
210
|
const content = await readFile(filePath, "utf8");
|
|
150
211
|
if (signal?.aborted) return null;
|
|
151
212
|
const lines = content.trim().split("\n");
|
|
152
|
-
const
|
|
153
|
-
const dedupedMessages: SessionMessage[] = [];
|
|
213
|
+
const messages: SessionMessage[] = [];
|
|
154
214
|
let sessionId = "";
|
|
155
215
|
|
|
156
216
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -175,7 +235,14 @@ async function parseSessionFile(
|
|
|
175
235
|
const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
|
|
176
236
|
const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
|
|
177
237
|
|
|
178
|
-
|
|
238
|
+
// Deduplicate copied history across branched session files.
|
|
239
|
+
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
240
|
+
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
241
|
+
const hash = `${timestamp}:${totalTokens}`;
|
|
242
|
+
if (seenHashes.has(hash)) continue;
|
|
243
|
+
seenHashes.add(hash);
|
|
244
|
+
|
|
245
|
+
messages.push({
|
|
179
246
|
provider: msg.provider,
|
|
180
247
|
model: msg.model,
|
|
181
248
|
cost: msg.usage.cost?.total || 0,
|
|
@@ -184,16 +251,7 @@ async function parseSessionFile(
|
|
|
184
251
|
cacheRead,
|
|
185
252
|
cacheWrite,
|
|
186
253
|
timestamp,
|
|
187
|
-
};
|
|
188
|
-
rawMessages.push(sessionMessage);
|
|
189
|
-
|
|
190
|
-
// Deduplicate copied history across branched session files.
|
|
191
|
-
// Keep the existing ccusage-style hash so current totals remain comparable.
|
|
192
|
-
const totalTokens = input + output + cacheRead + cacheWrite;
|
|
193
|
-
const hash = `${timestamp}:${totalTokens}`;
|
|
194
|
-
if (seenHashes.has(hash)) continue;
|
|
195
|
-
seenHashes.add(hash);
|
|
196
|
-
dedupedMessages.push(sessionMessage);
|
|
254
|
+
});
|
|
197
255
|
}
|
|
198
256
|
}
|
|
199
257
|
} catch {
|
|
@@ -201,7 +259,7 @@ async function parseSessionFile(
|
|
|
201
259
|
}
|
|
202
260
|
}
|
|
203
261
|
|
|
204
|
-
return sessionId ? { sessionId,
|
|
262
|
+
return sessionId ? { sessionId, messages } : null;
|
|
205
263
|
} catch {
|
|
206
264
|
return null;
|
|
207
265
|
}
|
|
@@ -211,18 +269,19 @@ async function parseSessionFile(
|
|
|
211
269
|
function accumulateStats(
|
|
212
270
|
target: BaseStats,
|
|
213
271
|
cost: number,
|
|
214
|
-
tokens: { total: number; input: number; output: number;
|
|
272
|
+
tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
215
273
|
): void {
|
|
216
274
|
target.messages++;
|
|
217
275
|
target.cost += cost;
|
|
218
276
|
target.tokens.total += tokens.total;
|
|
219
277
|
target.tokens.input += tokens.input;
|
|
220
278
|
target.tokens.output += tokens.output;
|
|
221
|
-
target.tokens.
|
|
279
|
+
target.tokens.cacheRead += tokens.cacheRead;
|
|
280
|
+
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
222
281
|
}
|
|
223
282
|
|
|
224
283
|
function emptyTokens(): TokenStats {
|
|
225
|
-
return { total: 0, input: 0, output: 0,
|
|
284
|
+
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
226
285
|
}
|
|
227
286
|
|
|
228
287
|
function emptyModelStats(): ModelStats {
|
|
@@ -244,14 +303,19 @@ function emptyUsageData(): UsageData {
|
|
|
244
303
|
return {
|
|
245
304
|
today: emptyTimeFilteredStats(),
|
|
246
305
|
thisWeek: emptyTimeFilteredStats(),
|
|
306
|
+
lastWeek: emptyTimeFilteredStats(),
|
|
247
307
|
allTime: emptyTimeFilteredStats(),
|
|
248
308
|
};
|
|
249
309
|
}
|
|
250
310
|
|
|
251
|
-
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
|
|
311
|
+
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
|
|
252
312
|
const periods: TabName[] = ["allTime"];
|
|
253
313
|
if (timestamp >= todayMs) periods.push("today");
|
|
254
|
-
if (timestamp >= weekStartMs)
|
|
314
|
+
if (timestamp >= weekStartMs) {
|
|
315
|
+
periods.push("thisWeek");
|
|
316
|
+
} else if (timestamp >= lastWeekStartMs) {
|
|
317
|
+
periods.push("lastWeek");
|
|
318
|
+
}
|
|
255
319
|
return periods;
|
|
256
320
|
}
|
|
257
321
|
|
|
@@ -260,20 +324,22 @@ function addMessagesToUsageData(
|
|
|
260
324
|
sessionId: string,
|
|
261
325
|
messages: SessionMessage[],
|
|
262
326
|
todayMs: number,
|
|
263
|
-
weekStartMs: number
|
|
327
|
+
weekStartMs: number,
|
|
328
|
+
lastWeekStartMs: number
|
|
264
329
|
): void {
|
|
265
|
-
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
330
|
+
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
266
331
|
|
|
267
332
|
for (const msg of messages) {
|
|
268
|
-
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
|
|
333
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
269
334
|
const tokens = {
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
total: msg.input + msg.output,
|
|
335
|
+
// Count fresh tokens processed this turn.
|
|
336
|
+
// Include cacheWrite because those prompt tokens were newly written and billed.
|
|
337
|
+
// Exclude cacheRead because repeated cache hits would otherwise dominate totals.
|
|
338
|
+
total: msg.input + msg.output + msg.cacheWrite,
|
|
274
339
|
input: msg.input,
|
|
275
340
|
output: msg.output,
|
|
276
|
-
|
|
341
|
+
cacheRead: msg.cacheRead,
|
|
342
|
+
cacheWrite: msg.cacheWrite,
|
|
277
343
|
};
|
|
278
344
|
|
|
279
345
|
for (const period of periods) {
|
|
@@ -304,10 +370,11 @@ function addMessagesToUsageData(
|
|
|
304
370
|
|
|
305
371
|
if (sessionContributed.today) data.today.totals.sessions++;
|
|
306
372
|
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
373
|
+
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
307
374
|
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
308
375
|
}
|
|
309
376
|
|
|
310
|
-
async function collectUsageData(signal?: AbortSignal): Promise<
|
|
377
|
+
async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
|
|
311
378
|
const startOfToday = new Date();
|
|
312
379
|
startOfToday.setHours(0, 0, 0, 0);
|
|
313
380
|
const todayMs = startOfToday.getTime();
|
|
@@ -320,10 +387,12 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageDataByMode |
|
|
|
320
387
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
321
388
|
const weekStartMs = startOfWeek.getTime();
|
|
322
389
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
390
|
+
// Start of last week (previous Monday 00:00)
|
|
391
|
+
const startOfLastWeek = new Date(startOfWeek);
|
|
392
|
+
startOfLastWeek.setDate(startOfLastWeek.getDate() - 7);
|
|
393
|
+
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
394
|
+
|
|
395
|
+
const data = emptyUsageData();
|
|
327
396
|
|
|
328
397
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
329
398
|
if (signal?.aborted) return null;
|
|
@@ -335,8 +404,7 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageDataByMode |
|
|
|
335
404
|
if (signal?.aborted) return null;
|
|
336
405
|
if (!parsed) continue;
|
|
337
406
|
|
|
338
|
-
addMessagesToUsageData(data
|
|
339
|
-
addMessagesToUsageData(data.deduped, parsed.sessionId, parsed.dedupedMessages, todayMs, weekStartMs);
|
|
407
|
+
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs, lastWeekStartMs);
|
|
340
408
|
|
|
341
409
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
342
410
|
}
|
|
@@ -383,6 +451,54 @@ function padRight(s: string, len: number): string {
|
|
|
383
451
|
return s + " ".repeat(len - vis);
|
|
384
452
|
}
|
|
385
453
|
|
|
454
|
+
function sumColumnWidths(columns: DataColumn[]): number {
|
|
455
|
+
return columns.reduce((sum, col) => sum + col.width, 0);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function fitCell(s: string, len: number, align: "left" | "right" = "left"): string {
|
|
459
|
+
if (len <= 0) return "";
|
|
460
|
+
const truncated = truncateToWidth(s, len);
|
|
461
|
+
return align === "right" ? padLeft(truncated, len) : padRight(truncated, len);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function clampLines(lines: string[], width: number): string[] {
|
|
465
|
+
return lines.map((line) => truncateToWidth(line, Math.max(width, 0)));
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function pickFittingText(width: number, variants: string[]): string {
|
|
469
|
+
for (const variant of variants) {
|
|
470
|
+
if (visibleWidth(variant) <= width) return variant;
|
|
471
|
+
}
|
|
472
|
+
return variants[variants.length - 1] || "";
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function getTableLayout(width: number): TableLayout {
|
|
476
|
+
const safeWidth = Math.max(width, 0);
|
|
477
|
+
|
|
478
|
+
for (const candidate of TABLE_LAYOUTS) {
|
|
479
|
+
const columnsWidth = sumColumnWidths(candidate.columns);
|
|
480
|
+
const nameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - columnsWidth, 0));
|
|
481
|
+
if (nameWidth >= candidate.minNameWidth) {
|
|
482
|
+
return {
|
|
483
|
+
columns: candidate.columns,
|
|
484
|
+
nameWidth,
|
|
485
|
+
tableWidth: nameWidth + columnsWidth,
|
|
486
|
+
compact: candidate.compact ?? false,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
const fallback = TABLE_LAYOUTS[TABLE_LAYOUTS.length - 1]!;
|
|
492
|
+
const fallbackColumnsWidth = sumColumnWidths(fallback.columns);
|
|
493
|
+
const fallbackNameWidth = Math.min(MAX_NAME_COL_WIDTH, Math.max(safeWidth - fallbackColumnsWidth, 0));
|
|
494
|
+
return {
|
|
495
|
+
columns: fallback.columns,
|
|
496
|
+
nameWidth: fallbackNameWidth,
|
|
497
|
+
tableWidth: fallbackNameWidth + fallbackColumnsWidth,
|
|
498
|
+
compact: fallback.compact ?? false,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
386
502
|
// =============================================================================
|
|
387
503
|
// Component
|
|
388
504
|
// =============================================================================
|
|
@@ -390,22 +506,15 @@ function padRight(s: string, len: number): string {
|
|
|
390
506
|
const TAB_LABELS: Record<TabName, string> = {
|
|
391
507
|
today: "Today",
|
|
392
508
|
thisWeek: "This Week",
|
|
509
|
+
lastWeek: "Last Week",
|
|
393
510
|
allTime: "All Time",
|
|
394
511
|
};
|
|
395
512
|
|
|
396
|
-
const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
|
|
397
|
-
|
|
398
|
-
const MODE_LABELS: Record<UsageCountMode, string> = {
|
|
399
|
-
deduped: "Deduped",
|
|
400
|
-
raw: "Raw",
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
const MODE_ORDER: UsageCountMode[] = ["deduped", "raw"];
|
|
513
|
+
const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
|
|
404
514
|
|
|
405
515
|
class UsageComponent {
|
|
406
516
|
private activeTab: TabName = "allTime";
|
|
407
|
-
private
|
|
408
|
-
private data: UsageDataByMode;
|
|
517
|
+
private data: UsageData;
|
|
409
518
|
private selectedIndex = 0;
|
|
410
519
|
private expanded = new Set<string>();
|
|
411
520
|
private providerOrder: string[] = [];
|
|
@@ -413,7 +522,7 @@ class UsageComponent {
|
|
|
413
522
|
private requestRender: () => void;
|
|
414
523
|
private done: () => void;
|
|
415
524
|
|
|
416
|
-
constructor(theme: Theme, data:
|
|
525
|
+
constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
|
|
417
526
|
this.theme = theme;
|
|
418
527
|
this.requestRender = requestRender;
|
|
419
528
|
this.done = done;
|
|
@@ -421,25 +530,14 @@ class UsageComponent {
|
|
|
421
530
|
this.updateProviderOrder();
|
|
422
531
|
}
|
|
423
532
|
|
|
424
|
-
private getActiveStats(): TimeFilteredStats {
|
|
425
|
-
return this.data[this.activeMode][this.activeTab];
|
|
426
|
-
}
|
|
427
|
-
|
|
428
533
|
private updateProviderOrder(): void {
|
|
429
|
-
const stats = this.
|
|
534
|
+
const stats = this.data[this.activeTab];
|
|
430
535
|
this.providerOrder = Array.from(stats.providers.entries())
|
|
431
536
|
.sort((a, b) => b[1].cost - a[1].cost)
|
|
432
537
|
.map(([name]) => name);
|
|
433
538
|
this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
|
|
434
539
|
}
|
|
435
540
|
|
|
436
|
-
private cycleMode(step: 1 | -1): void {
|
|
437
|
-
const idx = MODE_ORDER.indexOf(this.activeMode);
|
|
438
|
-
this.activeMode = MODE_ORDER[(idx + step + MODE_ORDER.length) % MODE_ORDER.length]!;
|
|
439
|
-
this.updateProviderOrder();
|
|
440
|
-
this.requestRender();
|
|
441
|
-
}
|
|
442
|
-
|
|
443
541
|
handleInput(data: string): void {
|
|
444
542
|
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
445
543
|
this.done();
|
|
@@ -456,20 +554,6 @@ class UsageComponent {
|
|
|
456
554
|
this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
|
|
457
555
|
this.updateProviderOrder();
|
|
458
556
|
this.requestRender();
|
|
459
|
-
} else if (matchesKey(data, "m")) {
|
|
460
|
-
this.cycleMode(1);
|
|
461
|
-
} else if (matchesKey(data, "d")) {
|
|
462
|
-
if (this.activeMode !== "deduped") {
|
|
463
|
-
this.activeMode = "deduped";
|
|
464
|
-
this.updateProviderOrder();
|
|
465
|
-
this.requestRender();
|
|
466
|
-
}
|
|
467
|
-
} else if (matchesKey(data, "r")) {
|
|
468
|
-
if (this.activeMode !== "raw") {
|
|
469
|
-
this.activeMode = "raw";
|
|
470
|
-
this.updateProviderOrder();
|
|
471
|
-
this.requestRender();
|
|
472
|
-
}
|
|
473
557
|
} else if (matchesKey(data, "up")) {
|
|
474
558
|
if (this.selectedIndex > 0) {
|
|
475
559
|
this.selectedIndex--;
|
|
@@ -497,16 +581,19 @@ class UsageComponent {
|
|
|
497
581
|
// Render Methods
|
|
498
582
|
// -------------------------------------------------------------------------
|
|
499
583
|
|
|
500
|
-
render(
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
584
|
+
render(width: number): string[] {
|
|
585
|
+
const layout = getTableLayout(width);
|
|
586
|
+
return clampLines(
|
|
587
|
+
[
|
|
588
|
+
...this.renderTitle(),
|
|
589
|
+
...this.renderTabs(width, layout),
|
|
590
|
+
...this.renderHeader(layout),
|
|
591
|
+
...this.renderRows(layout),
|
|
592
|
+
...this.renderTotals(layout),
|
|
593
|
+
...this.renderHelp(width),
|
|
594
|
+
],
|
|
595
|
+
width
|
|
596
|
+
);
|
|
510
597
|
}
|
|
511
598
|
|
|
512
599
|
private renderTitle(): string[] {
|
|
@@ -514,66 +601,69 @@ class UsageComponent {
|
|
|
514
601
|
return [th.fg("accent", th.bold("Usage Statistics")), ""];
|
|
515
602
|
}
|
|
516
603
|
|
|
517
|
-
private renderTabs(): string[] {
|
|
604
|
+
private renderTabs(width: number, layout: TableLayout): string[] {
|
|
518
605
|
const th = this.theme;
|
|
519
|
-
const
|
|
606
|
+
const fullTabs = TAB_ORDER.map((tab) => {
|
|
520
607
|
const label = TAB_LABELS[tab];
|
|
521
608
|
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
522
609
|
}).join(" ");
|
|
523
|
-
return [tabs];
|
|
524
|
-
}
|
|
525
610
|
|
|
526
|
-
|
|
527
|
-
const
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
611
|
+
const activeTabOnly = th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`);
|
|
612
|
+
const tabLine = pickFittingText(width, [
|
|
613
|
+
fullTabs,
|
|
614
|
+
`${activeTabOnly} ${th.fg("dim", "[Tab/ββ]")}`,
|
|
615
|
+
activeTabOnly,
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
const infoLines = layout.compact
|
|
619
|
+
? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
|
|
620
|
+
: [];
|
|
621
|
+
|
|
622
|
+
return [tabLine, ...infoLines, ""];
|
|
536
623
|
}
|
|
537
624
|
|
|
538
|
-
private renderHeader(): string[] {
|
|
625
|
+
private renderHeader(layout: TableLayout): string[] {
|
|
539
626
|
const th = this.theme;
|
|
540
627
|
|
|
541
|
-
let headerLine =
|
|
542
|
-
for (const col of
|
|
543
|
-
const label =
|
|
628
|
+
let headerLine = fitCell("Provider / Model", layout.nameWidth);
|
|
629
|
+
for (const col of layout.columns) {
|
|
630
|
+
const label = fitCell(col.label, col.width, "right");
|
|
544
631
|
headerLine += col.dimmed ? th.fg("dim", label) : label;
|
|
545
632
|
}
|
|
546
633
|
|
|
547
|
-
return [th.fg("muted", headerLine), th.fg("border", "β".repeat(
|
|
634
|
+
return [th.fg("muted", headerLine), th.fg("border", "β".repeat(layout.tableWidth))];
|
|
548
635
|
}
|
|
549
636
|
|
|
550
637
|
private renderDataRow(
|
|
551
638
|
name: string,
|
|
552
639
|
stats: BaseStats & { sessions: Set<string> | number },
|
|
553
|
-
|
|
640
|
+
layout: TableLayout,
|
|
641
|
+
options: { indent?: number; selected?: boolean; dimAll?: boolean; prefix?: string } = {}
|
|
554
642
|
): string {
|
|
555
643
|
const th = this.theme;
|
|
556
|
-
const { indent = 0, selected = false, dimAll = false } = options;
|
|
644
|
+
const { indent = 0, selected = false, dimAll = false, prefix } = options;
|
|
557
645
|
|
|
558
|
-
const
|
|
559
|
-
const
|
|
560
|
-
const
|
|
646
|
+
const rawPrefix = prefix ?? " ".repeat(indent);
|
|
647
|
+
const safePrefix = layout.nameWidth > 0 ? truncateToWidth(rawPrefix, layout.nameWidth, "") : "";
|
|
648
|
+
const prefixWidth = visibleWidth(safePrefix);
|
|
649
|
+
const innerNameWidth = Math.max(layout.nameWidth - prefixWidth, 0);
|
|
650
|
+
const truncName = innerNameWidth > 0 ? truncateToWidth(name, innerNameWidth) : "";
|
|
561
651
|
const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
|
|
562
652
|
|
|
563
|
-
let row =
|
|
653
|
+
let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
|
|
564
654
|
|
|
565
|
-
for (const col of
|
|
566
|
-
const value = col.getValue(stats);
|
|
655
|
+
for (const col of layout.columns) {
|
|
656
|
+
const value = fitCell(col.getValue(stats), col.width, "right");
|
|
567
657
|
const shouldDim = col.dimmed || dimAll;
|
|
568
|
-
row += shouldDim ? th.fg("dim",
|
|
658
|
+
row += shouldDim ? th.fg("dim", value) : value;
|
|
569
659
|
}
|
|
570
660
|
|
|
571
661
|
return row;
|
|
572
662
|
}
|
|
573
663
|
|
|
574
|
-
private renderRows(): string[] {
|
|
664
|
+
private renderRows(layout: TableLayout): string[] {
|
|
575
665
|
const th = this.theme;
|
|
576
|
-
const stats = this.
|
|
666
|
+
const stats = this.data[this.activeTab];
|
|
577
667
|
const lines: string[] = [];
|
|
578
668
|
|
|
579
669
|
if (this.providerOrder.length === 0) {
|
|
@@ -586,22 +676,21 @@ class UsageComponent {
|
|
|
586
676
|
const providerStats = stats.providers.get(providerName)!;
|
|
587
677
|
const isSelected = i === this.selectedIndex;
|
|
588
678
|
const isExpanded = this.expanded.has(providerName);
|
|
589
|
-
|
|
590
|
-
// Provider row with expand/collapse arrow
|
|
591
679
|
const arrow = isExpanded ? "βΎ" : "βΈ";
|
|
592
|
-
const prefix = isSelected ? th.fg("accent", arrow
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
680
|
+
const prefix = isSelected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
|
|
681
|
+
|
|
682
|
+
lines.push(
|
|
683
|
+
this.renderDataRow(providerName, providerStats, layout, {
|
|
684
|
+
selected: isSelected,
|
|
685
|
+
prefix,
|
|
686
|
+
})
|
|
687
|
+
);
|
|
598
688
|
|
|
599
|
-
// Model rows (if expanded)
|
|
600
689
|
if (isExpanded) {
|
|
601
690
|
const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
602
691
|
|
|
603
692
|
for (const [modelName, modelStats] of models) {
|
|
604
|
-
lines.push(this.renderDataRow(modelName, modelStats, { indent: 4, dimAll: true }));
|
|
693
|
+
lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
|
|
605
694
|
}
|
|
606
695
|
}
|
|
607
696
|
}
|
|
@@ -609,21 +698,28 @@ class UsageComponent {
|
|
|
609
698
|
return lines;
|
|
610
699
|
}
|
|
611
700
|
|
|
612
|
-
private renderTotals(): string[] {
|
|
701
|
+
private renderTotals(layout: TableLayout): string[] {
|
|
613
702
|
const th = this.theme;
|
|
614
|
-
const stats = this.
|
|
703
|
+
const stats = this.data[this.activeTab];
|
|
615
704
|
|
|
616
|
-
let totalRow =
|
|
617
|
-
for (const col of
|
|
618
|
-
const value = col.getValue(stats.totals);
|
|
619
|
-
totalRow += col.dimmed ? th.fg("dim",
|
|
705
|
+
let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
|
|
706
|
+
for (const col of layout.columns) {
|
|
707
|
+
const value = fitCell(col.getValue(stats.totals), col.width, "right");
|
|
708
|
+
totalRow += col.dimmed ? th.fg("dim", value) : value;
|
|
620
709
|
}
|
|
621
710
|
|
|
622
|
-
return [th.fg("border", "β".repeat(
|
|
711
|
+
return [th.fg("border", "β".repeat(layout.tableWidth)), totalRow, ""];
|
|
623
712
|
}
|
|
624
713
|
|
|
625
|
-
private renderHelp(): string[] {
|
|
626
|
-
|
|
714
|
+
private renderHelp(width: number): string[] {
|
|
715
|
+
const line = pickFittingText(width, [
|
|
716
|
+
"[Tab/ββ] period [ββ] select [Enter] expand [q] close",
|
|
717
|
+
"[Tab] period [ββ] select [Enter] expand [q] close",
|
|
718
|
+
"[ββ] select [Enter] expand [q] close",
|
|
719
|
+
"[ββ] select [q] close",
|
|
720
|
+
"[q] close",
|
|
721
|
+
]);
|
|
722
|
+
return [this.theme.fg("dim", line)];
|
|
627
723
|
}
|
|
628
724
|
|
|
629
725
|
invalidate(): void {}
|
|
@@ -642,7 +738,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
642
738
|
return;
|
|
643
739
|
}
|
|
644
740
|
|
|
645
|
-
const data = await ctx.ui.custom<
|
|
741
|
+
const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
|
|
646
742
|
const loader = new CancellableLoader(
|
|
647
743
|
tui,
|
|
648
744
|
(s: string) => theme.fg("accent", s),
|
|
@@ -650,7 +746,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
650
746
|
"Loading Usage..."
|
|
651
747
|
);
|
|
652
748
|
let finished = false;
|
|
653
|
-
const finish = (value:
|
|
749
|
+
const finish = (value: UsageData | null) => {
|
|
654
750
|
if (finished) return;
|
|
655
751
|
finished = true;
|
|
656
752
|
loader.dispose();
|
|
@@ -682,10 +778,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
682
778
|
|
|
683
779
|
return {
|
|
684
780
|
render: (w: number) => {
|
|
685
|
-
const borderLines = container.render(w);
|
|
781
|
+
const borderLines = clampLines(container.render(w), w);
|
|
686
782
|
const usageLines = usage.render(w);
|
|
687
783
|
const bottomBorder = theme.fg("border", "β".repeat(w));
|
|
688
|
-
return [...borderLines, ...usageLines, "", bottomBorder];
|
|
784
|
+
return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
|
|
689
785
|
},
|
|
690
786
|
invalidate: () => container.invalidate(),
|
|
691
787
|
handleInput: (input: string) => usage.handleInput(input),
|