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 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 |
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.23",
3
+ "version": "0.1.27",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -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-09
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** | Total tokens (input + output) |
90
- | **↑In** | Input tokens *(dimmed)* |
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
- In **Deduped** mode, assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens (matching the extension's previous behavior and keeping totals comparable with earlier releases).
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
 
@@ -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
- cache: number;
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
- const NAME_COL_WIDTH = 26;
75
-
76
- const DATA_COLUMNS: DataColumn[] = [
77
- {
78
- label: "Sessions",
79
- width: 9,
80
- getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
81
- },
82
- { label: "Msgs", width: 9, getValue: (s) => formatNumber(s.messages) },
83
- { label: "Cost", width: 9, getValue: (s) => formatCost(s.cost) },
84
- { label: "Tokens", width: 9, getValue: (s) => formatTokens(s.tokens.total) },
85
- { label: "↑In", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.input) },
86
- { label: "↓Out", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.output) },
87
- { label: "Cache", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.cache) },
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 TABLE_WIDTH = NAME_COL_WIDTH + DATA_COLUMNS.reduce((sum, col) => sum + col.width, 0);
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
- rawMessages: SessionMessage[];
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 rawMessages: SessionMessage[] = [];
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
- const sessionMessage: SessionMessage = {
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, rawMessages, dedupedMessages } : null;
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; cache: 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.cache += tokens.cache;
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, cache: 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) periods.push("thisWeek");
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
- // Total = input + output only. cacheRead/cacheWrite are tracked separately.
271
- // cacheRead tokens were already counted when first sent, so including them
272
- // would double-count and massively inflate totals (cache hits repeat every message).
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
- cache: msg.cacheRead + msg.cacheWrite,
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<UsageDataByMode | null> {
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
- const data: UsageDataByMode = {
324
- deduped: emptyUsageData(),
325
- raw: emptyUsageData(),
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.raw, parsed.sessionId, parsed.rawMessages, todayMs, weekStartMs);
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 activeMode: UsageCountMode = "deduped";
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: UsageDataByMode, requestRender: () => void, done: () => void) {
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.getActiveStats();
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(_width: number): string[] {
501
- return [
502
- ...this.renderTitle(),
503
- ...this.renderTabs(),
504
- ...this.renderModes(),
505
- ...this.renderHeader(),
506
- ...this.renderRows(),
507
- ...this.renderTotals(),
508
- ...this.renderHelp(),
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 tabs = TAB_ORDER.map((tab) => {
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
- private renderModes(): string[] {
527
- const th = this.theme;
528
- const modes = MODE_ORDER.map((mode) => {
529
- const label = MODE_LABELS[mode];
530
- return mode === this.activeMode ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
531
- }).join(" ");
532
- const note = this.activeMode === "deduped"
533
- ? "Dedupes copied branched-history messages. Recursive subagent sessions included."
534
- : "Counts raw message totals from all session files. Recursive subagent sessions included.";
535
- return [modes, th.fg("dim", note), ""];
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 = padRight("Provider / Model", NAME_COL_WIDTH);
542
- for (const col of DATA_COLUMNS) {
543
- const label = padLeft(col.label, col.width);
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(TABLE_WIDTH))];
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
- options: { indent?: number; selected?: boolean; dimAll?: boolean } = {}
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 indentStr = " ".repeat(indent);
559
- const nameWidth = NAME_COL_WIDTH - indent;
560
- const truncName = truncateToWidth(name, nameWidth - 1);
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 = indentStr + padRight(styledName, nameWidth);
653
+ let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
564
654
 
565
- for (const col of DATA_COLUMNS) {
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", padLeft(value, col.width)) : padLeft(value, col.width);
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.getActiveStats();
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 + " ") : th.fg("dim", arrow + " ");
593
- const dataRow = this.renderDataRow(providerName, providerStats, {
594
- indent: 2,
595
- selected: isSelected,
596
- });
597
- lines.push(prefix + dataRow.slice(2)); // Replace indent with arrow prefix
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.getActiveStats();
703
+ const stats = this.data[this.activeTab];
615
704
 
616
- let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
617
- for (const col of DATA_COLUMNS) {
618
- const value = col.getValue(stats.totals);
619
- totalRow += col.dimmed ? th.fg("dim", padLeft(value, col.width)) : padLeft(value, col.width);
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(TABLE_WIDTH)), totalRow, ""];
711
+ return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
623
712
  }
624
713
 
625
- private renderHelp(): string[] {
626
- return [this.theme.fg("dim", "[Tab/←→] period [m/d/r] count mode [↑↓] select [Enter] expand [q] close")];
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<UsageDataByMode | null>((tui, theme, _kb, done) => {
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: UsageDataByMode | null) => {
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),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",