pi-extensions 0.1.24 β 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 +14 -0
- package/usage-extension/README.md +9 -4
- package/usage-extension/index.ts +234 -81
- 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,19 @@
|
|
|
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
|
+
|
|
3
17
|
## 0.1.5 - 2026-04-09
|
|
4
18
|
- Keep recursive subagent session scanning in `/usage`
|
|
5
19
|
- Remove the deduped/raw mode toggle and keep the deduped view as the default behavior
|
|
@@ -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,6 +61,7 @@ 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.
|
|
@@ -77,10 +78,12 @@ Time periods are calculated in the local timezone where Pi runs. If you want to
|
|
|
77
78
|
| **Sessions** | Number of unique sessions |
|
|
78
79
|
| **Msgs** | Number of assistant messages |
|
|
79
80
|
| **Cost** | Total cost in USD (from API response) |
|
|
80
|
-
| **Tokens** |
|
|
81
|
-
| **βIn** |
|
|
81
|
+
| **Tokens** | Fresh tokens for the turn: input + output + cache write |
|
|
82
|
+
| **βIn** | Fresh input tokens: input + cache write *(dimmed)* |
|
|
82
83
|
| **βOut** | Output tokens *(dimmed)* |
|
|
83
|
-
| **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.
|
|
84
87
|
|
|
85
88
|
### Navigation
|
|
86
89
|
|
|
@@ -109,6 +112,8 @@ Cache token support varies by provider:
|
|
|
109
112
|
|
|
110
113
|
The "Cache" column combines both read and write tokens.
|
|
111
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
|
+
|
|
112
117
|
## Data Source
|
|
113
118
|
|
|
114
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.
|
package/usage-extension/index.ts
CHANGED
|
@@ -2,14 +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
|
|
5
|
+
* - Tab cycles: Today β This Week β Last Week β All Time
|
|
6
6
|
* - Arrow keys navigate providers
|
|
7
7
|
* - Enter expands/collapses to show models
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { ExtensionAPI, ExtensionCommandContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
11
11
|
import { DynamicBorder } from "@mariozechner/pi-coding-agent";
|
|
12
|
-
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
|
|
12
|
+
import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
|
|
13
13
|
import { readdir, readFile } from "node:fs/promises";
|
|
14
14
|
import { join } from "node:path";
|
|
15
15
|
import { homedir } from "node:os";
|
|
@@ -22,7 +22,8 @@ interface TokenStats {
|
|
|
22
22
|
total: number;
|
|
23
23
|
input: number;
|
|
24
24
|
output: number;
|
|
25
|
-
|
|
25
|
+
cacheRead: number;
|
|
26
|
+
cacheWrite: number;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
interface BaseStats {
|
|
@@ -52,10 +53,11 @@ interface TimeFilteredStats {
|
|
|
52
53
|
interface UsageData {
|
|
53
54
|
today: TimeFilteredStats;
|
|
54
55
|
thisWeek: TimeFilteredStats;
|
|
56
|
+
lastWeek: TimeFilteredStats;
|
|
55
57
|
allTime: TimeFilteredStats;
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
type TabName = "today" | "thisWeek" | "allTime";
|
|
60
|
+
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
|
|
59
61
|
|
|
60
62
|
// =============================================================================
|
|
61
63
|
// Column Configuration
|
|
@@ -68,23 +70,86 @@ interface DataColumn {
|
|
|
68
70
|
getValue: (stats: BaseStats & { sessions: Set<string> | number }) => string;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
85
144
|
];
|
|
86
145
|
|
|
87
|
-
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
|
+
];
|
|
88
153
|
|
|
89
154
|
// =============================================================================
|
|
90
155
|
// Data Collection
|
|
@@ -204,18 +269,19 @@ async function parseSessionFile(
|
|
|
204
269
|
function accumulateStats(
|
|
205
270
|
target: BaseStats,
|
|
206
271
|
cost: number,
|
|
207
|
-
tokens: { total: number; input: number; output: number;
|
|
272
|
+
tokens: { total: number; input: number; output: number; cacheRead: number; cacheWrite: number }
|
|
208
273
|
): void {
|
|
209
274
|
target.messages++;
|
|
210
275
|
target.cost += cost;
|
|
211
276
|
target.tokens.total += tokens.total;
|
|
212
277
|
target.tokens.input += tokens.input;
|
|
213
278
|
target.tokens.output += tokens.output;
|
|
214
|
-
target.tokens.
|
|
279
|
+
target.tokens.cacheRead += tokens.cacheRead;
|
|
280
|
+
target.tokens.cacheWrite += tokens.cacheWrite;
|
|
215
281
|
}
|
|
216
282
|
|
|
217
283
|
function emptyTokens(): TokenStats {
|
|
218
|
-
return { total: 0, input: 0, output: 0,
|
|
284
|
+
return { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
219
285
|
}
|
|
220
286
|
|
|
221
287
|
function emptyModelStats(): ModelStats {
|
|
@@ -237,14 +303,19 @@ function emptyUsageData(): UsageData {
|
|
|
237
303
|
return {
|
|
238
304
|
today: emptyTimeFilteredStats(),
|
|
239
305
|
thisWeek: emptyTimeFilteredStats(),
|
|
306
|
+
lastWeek: emptyTimeFilteredStats(),
|
|
240
307
|
allTime: emptyTimeFilteredStats(),
|
|
241
308
|
};
|
|
242
309
|
}
|
|
243
310
|
|
|
244
|
-
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
|
|
311
|
+
function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number, lastWeekStartMs: number): TabName[] {
|
|
245
312
|
const periods: TabName[] = ["allTime"];
|
|
246
313
|
if (timestamp >= todayMs) periods.push("today");
|
|
247
|
-
if (timestamp >= weekStartMs)
|
|
314
|
+
if (timestamp >= weekStartMs) {
|
|
315
|
+
periods.push("thisWeek");
|
|
316
|
+
} else if (timestamp >= lastWeekStartMs) {
|
|
317
|
+
periods.push("lastWeek");
|
|
318
|
+
}
|
|
248
319
|
return periods;
|
|
249
320
|
}
|
|
250
321
|
|
|
@@ -253,20 +324,22 @@ function addMessagesToUsageData(
|
|
|
253
324
|
sessionId: string,
|
|
254
325
|
messages: SessionMessage[],
|
|
255
326
|
todayMs: number,
|
|
256
|
-
weekStartMs: number
|
|
327
|
+
weekStartMs: number,
|
|
328
|
+
lastWeekStartMs: number
|
|
257
329
|
): void {
|
|
258
|
-
const sessionContributed = { today: false, thisWeek: false, allTime: false };
|
|
330
|
+
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
259
331
|
|
|
260
332
|
for (const msg of messages) {
|
|
261
|
-
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
|
|
333
|
+
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
262
334
|
const tokens = {
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
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,
|
|
267
339
|
input: msg.input,
|
|
268
340
|
output: msg.output,
|
|
269
|
-
|
|
341
|
+
cacheRead: msg.cacheRead,
|
|
342
|
+
cacheWrite: msg.cacheWrite,
|
|
270
343
|
};
|
|
271
344
|
|
|
272
345
|
for (const period of periods) {
|
|
@@ -297,6 +370,7 @@ function addMessagesToUsageData(
|
|
|
297
370
|
|
|
298
371
|
if (sessionContributed.today) data.today.totals.sessions++;
|
|
299
372
|
if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
|
|
373
|
+
if (sessionContributed.lastWeek) data.lastWeek.totals.sessions++;
|
|
300
374
|
if (sessionContributed.allTime) data.allTime.totals.sessions++;
|
|
301
375
|
}
|
|
302
376
|
|
|
@@ -313,6 +387,11 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
313
387
|
startOfWeek.setHours(0, 0, 0, 0);
|
|
314
388
|
const weekStartMs = startOfWeek.getTime();
|
|
315
389
|
|
|
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
|
+
|
|
316
395
|
const data = emptyUsageData();
|
|
317
396
|
|
|
318
397
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
@@ -325,7 +404,7 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
325
404
|
if (signal?.aborted) return null;
|
|
326
405
|
if (!parsed) continue;
|
|
327
406
|
|
|
328
|
-
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs);
|
|
407
|
+
addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs, lastWeekStartMs);
|
|
329
408
|
|
|
330
409
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
331
410
|
}
|
|
@@ -372,6 +451,54 @@ function padRight(s: string, len: number): string {
|
|
|
372
451
|
return s + " ".repeat(len - vis);
|
|
373
452
|
}
|
|
374
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
|
+
|
|
375
502
|
// =============================================================================
|
|
376
503
|
// Component
|
|
377
504
|
// =============================================================================
|
|
@@ -379,10 +506,11 @@ function padRight(s: string, len: number): string {
|
|
|
379
506
|
const TAB_LABELS: Record<TabName, string> = {
|
|
380
507
|
today: "Today",
|
|
381
508
|
thisWeek: "This Week",
|
|
509
|
+
lastWeek: "Last Week",
|
|
382
510
|
allTime: "All Time",
|
|
383
511
|
};
|
|
384
512
|
|
|
385
|
-
const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
|
|
513
|
+
const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
|
|
386
514
|
|
|
387
515
|
class UsageComponent {
|
|
388
516
|
private activeTab: TabName = "allTime";
|
|
@@ -453,15 +581,19 @@ class UsageComponent {
|
|
|
453
581
|
// Render Methods
|
|
454
582
|
// -------------------------------------------------------------------------
|
|
455
583
|
|
|
456
|
-
render(
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
+
);
|
|
465
597
|
}
|
|
466
598
|
|
|
467
599
|
private renderTitle(): string[] {
|
|
@@ -469,52 +601,67 @@ class UsageComponent {
|
|
|
469
601
|
return [th.fg("accent", th.bold("Usage Statistics")), ""];
|
|
470
602
|
}
|
|
471
603
|
|
|
472
|
-
private renderTabs(): string[] {
|
|
604
|
+
private renderTabs(width: number, layout: TableLayout): string[] {
|
|
473
605
|
const th = this.theme;
|
|
474
|
-
const
|
|
606
|
+
const fullTabs = TAB_ORDER.map((tab) => {
|
|
475
607
|
const label = TAB_LABELS[tab];
|
|
476
608
|
return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
|
|
477
609
|
}).join(" ");
|
|
478
|
-
|
|
610
|
+
|
|
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, ""];
|
|
479
623
|
}
|
|
480
624
|
|
|
481
|
-
private renderHeader(): string[] {
|
|
625
|
+
private renderHeader(layout: TableLayout): string[] {
|
|
482
626
|
const th = this.theme;
|
|
483
627
|
|
|
484
|
-
let headerLine =
|
|
485
|
-
for (const col of
|
|
486
|
-
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");
|
|
487
631
|
headerLine += col.dimmed ? th.fg("dim", label) : label;
|
|
488
632
|
}
|
|
489
633
|
|
|
490
|
-
return [th.fg("muted", headerLine), th.fg("border", "β".repeat(
|
|
634
|
+
return [th.fg("muted", headerLine), th.fg("border", "β".repeat(layout.tableWidth))];
|
|
491
635
|
}
|
|
492
636
|
|
|
493
637
|
private renderDataRow(
|
|
494
638
|
name: string,
|
|
495
639
|
stats: BaseStats & { sessions: Set<string> | number },
|
|
496
|
-
|
|
640
|
+
layout: TableLayout,
|
|
641
|
+
options: { indent?: number; selected?: boolean; dimAll?: boolean; prefix?: string } = {}
|
|
497
642
|
): string {
|
|
498
643
|
const th = this.theme;
|
|
499
|
-
const { indent = 0, selected = false, dimAll = false } = options;
|
|
644
|
+
const { indent = 0, selected = false, dimAll = false, prefix } = options;
|
|
500
645
|
|
|
501
|
-
const
|
|
502
|
-
const
|
|
503
|
-
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) : "";
|
|
504
651
|
const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
|
|
505
652
|
|
|
506
|
-
let row =
|
|
653
|
+
let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
|
|
507
654
|
|
|
508
|
-
for (const col of
|
|
509
|
-
const value = col.getValue(stats);
|
|
655
|
+
for (const col of layout.columns) {
|
|
656
|
+
const value = fitCell(col.getValue(stats), col.width, "right");
|
|
510
657
|
const shouldDim = col.dimmed || dimAll;
|
|
511
|
-
row += shouldDim ? th.fg("dim",
|
|
658
|
+
row += shouldDim ? th.fg("dim", value) : value;
|
|
512
659
|
}
|
|
513
660
|
|
|
514
661
|
return row;
|
|
515
662
|
}
|
|
516
663
|
|
|
517
|
-
private renderRows(): string[] {
|
|
664
|
+
private renderRows(layout: TableLayout): string[] {
|
|
518
665
|
const th = this.theme;
|
|
519
666
|
const stats = this.data[this.activeTab];
|
|
520
667
|
const lines: string[] = [];
|
|
@@ -529,22 +676,21 @@ class UsageComponent {
|
|
|
529
676
|
const providerStats = stats.providers.get(providerName)!;
|
|
530
677
|
const isSelected = i === this.selectedIndex;
|
|
531
678
|
const isExpanded = this.expanded.has(providerName);
|
|
532
|
-
|
|
533
|
-
// Provider row with expand/collapse arrow
|
|
534
679
|
const arrow = isExpanded ? "βΎ" : "βΈ";
|
|
535
|
-
const prefix = isSelected ? th.fg("accent", arrow
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
+
);
|
|
541
688
|
|
|
542
|
-
// Model rows (if expanded)
|
|
543
689
|
if (isExpanded) {
|
|
544
690
|
const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
|
|
545
691
|
|
|
546
692
|
for (const [modelName, modelStats] of models) {
|
|
547
|
-
lines.push(this.renderDataRow(modelName, modelStats, { indent: 4, dimAll: true }));
|
|
693
|
+
lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
|
|
548
694
|
}
|
|
549
695
|
}
|
|
550
696
|
}
|
|
@@ -552,21 +698,28 @@ class UsageComponent {
|
|
|
552
698
|
return lines;
|
|
553
699
|
}
|
|
554
700
|
|
|
555
|
-
private renderTotals(): string[] {
|
|
701
|
+
private renderTotals(layout: TableLayout): string[] {
|
|
556
702
|
const th = this.theme;
|
|
557
703
|
const stats = this.data[this.activeTab];
|
|
558
704
|
|
|
559
|
-
let totalRow =
|
|
560
|
-
for (const col of
|
|
561
|
-
const value = col.getValue(stats.totals);
|
|
562
|
-
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;
|
|
563
709
|
}
|
|
564
710
|
|
|
565
|
-
return [th.fg("border", "β".repeat(
|
|
711
|
+
return [th.fg("border", "β".repeat(layout.tableWidth)), totalRow, ""];
|
|
566
712
|
}
|
|
567
713
|
|
|
568
|
-
private renderHelp(): string[] {
|
|
569
|
-
|
|
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)];
|
|
570
723
|
}
|
|
571
724
|
|
|
572
725
|
invalidate(): void {}
|
|
@@ -625,10 +778,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
625
778
|
|
|
626
779
|
return {
|
|
627
780
|
render: (w: number) => {
|
|
628
|
-
const borderLines = container.render(w);
|
|
781
|
+
const borderLines = clampLines(container.render(w), w);
|
|
629
782
|
const usageLines = usage.render(w);
|
|
630
783
|
const bottomBorder = theme.fg("border", "β".repeat(w));
|
|
631
|
-
return [...borderLines, ...usageLines, "", bottomBorder];
|
|
784
|
+
return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
|
|
632
785
|
},
|
|
633
786
|
invalidate: () => container.invalidate(),
|
|
634
787
|
handleInput: (input: string) => usage.handleInput(input),
|