pi-extensions 0.1.24 β†’ 0.1.28

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.24",
3
+ "version": "0.1.28",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-04-17
4
+ - Add a one-line formula footer to the `/usage` dashboard (`Tokens = Input + Output + CacheWrite Β· ↑In = Input + CacheWrite`)
5
+ - README now calls out the 0.2.0 formula change explicitly under the columns table
6
+
7
+ ## 0.2.0 - 2026-04-17
8
+ - 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
9
+ - Keep `cacheRead` out of `Tokens` so repeated cache hits do not swamp the dashboard
10
+ - Keep the `Cache` column as combined cache read + write tokens for reference
11
+ - Minor semver bump: the numbers shown under `Tokens` and `↑In` are now higher for Anthropic usage. Cost, `↓Out`, and `Cache` are unchanged.
12
+
13
+ ## 0.1.7 - 2026-04-09
14
+ - Prevent `/usage` from crashing in narrow terminals by switching to a compact responsive table and truncating every rendered line to the terminal width
15
+ - Thanks @markokocic
16
+
17
+ ## 0.1.6 - 2026-04-09
18
+ - Add a "Last Week" time period tab
19
+ - Thanks @ttttmr
20
+
3
21
  ## 0.1.5 - 2026-04-09
4
22
  - Keep recursive subagent session scanning in `/usage`
5
23
  - 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-09
10
+ - **Last updated:** 2026-04-17 (0.2.1)
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,14 @@ 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** | Total tokens (input + output) |
81
- | **↑In** | Input tokens *(dimmed)* |
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
+ > **As of 0.2.0:** `Tokens = Input + Output + CacheWrite` and `↑In = Input + CacheWrite`. `CacheRead` stays out of `Tokens` so repeated cache hits don't swamp the dashboard. The dashboard itself shows a one-line footer reminder.
87
+
88
+ 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
89
 
85
90
  ### Navigation
86
91
 
@@ -109,6 +114,8 @@ Cache token support varies by provider:
109
114
 
110
115
  The "Cache" column combines both read and write tokens.
111
116
 
117
+ `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.
118
+
112
119
  ## Data Source
113
120
 
114
121
  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.
@@ -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
- cache: number;
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
- const NAME_COL_WIDTH = 26;
72
-
73
- const DATA_COLUMNS: DataColumn[] = [
74
- {
75
- label: "Sessions",
76
- width: 9,
77
- getValue: (s) => formatNumber(typeof s.sessions === "number" ? s.sessions : s.sessions.size),
78
- },
79
- { label: "Msgs", width: 9, getValue: (s) => formatNumber(s.messages) },
80
- { label: "Cost", width: 9, getValue: (s) => formatCost(s.cost) },
81
- { label: "Tokens", width: 9, getValue: (s) => formatTokens(s.tokens.total) },
82
- { label: "↑In", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.input) },
83
- { label: "↓Out", width: 8, dimmed: true, getValue: (s) => formatTokens(s.tokens.output) },
84
- { 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,
85
144
  ];
86
145
 
87
- 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
+ ];
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; cache: 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.cache += tokens.cache;
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, cache: 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) periods.push("thisWeek");
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
- // Total = input + output only. cacheRead/cacheWrite are tracked separately.
264
- // cacheRead tokens were already counted when first sent, so including them
265
- // would double-count and massively inflate totals (cache hits repeat every message).
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
- cache: msg.cacheRead + msg.cacheWrite,
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,20 @@ class UsageComponent {
453
581
  // Render Methods
454
582
  // -------------------------------------------------------------------------
455
583
 
456
- render(_width: number): string[] {
457
- return [
458
- ...this.renderTitle(),
459
- ...this.renderTabs(),
460
- ...this.renderHeader(),
461
- ...this.renderRows(),
462
- ...this.renderTotals(),
463
- ...this.renderHelp(),
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.renderFormulaNote(width),
594
+ ...this.renderHelp(width),
595
+ ],
596
+ width
597
+ );
465
598
  }
466
599
 
467
600
  private renderTitle(): string[] {
@@ -469,52 +602,67 @@ class UsageComponent {
469
602
  return [th.fg("accent", th.bold("Usage Statistics")), ""];
470
603
  }
471
604
 
472
- private renderTabs(): string[] {
605
+ private renderTabs(width: number, layout: TableLayout): string[] {
473
606
  const th = this.theme;
474
- const tabs = TAB_ORDER.map((tab) => {
607
+ const fullTabs = TAB_ORDER.map((tab) => {
475
608
  const label = TAB_LABELS[tab];
476
609
  return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
477
610
  }).join(" ");
478
- return [tabs, th.fg("dim", "Dedupes copied branched-history messages. Recursive subagent sessions included."), ""];
611
+
612
+ const activeTabOnly = th.fg("accent", `[${TAB_LABELS[this.activeTab]}]`);
613
+ const tabLine = pickFittingText(width, [
614
+ fullTabs,
615
+ `${activeTabOnly} ${th.fg("dim", "[Tab/←→]")}`,
616
+ activeTabOnly,
617
+ ]);
618
+
619
+ const infoLines = layout.compact
620
+ ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
621
+ : [];
622
+
623
+ return [tabLine, ...infoLines, ""];
479
624
  }
480
625
 
481
- private renderHeader(): string[] {
626
+ private renderHeader(layout: TableLayout): string[] {
482
627
  const th = this.theme;
483
628
 
484
- let headerLine = padRight("Provider / Model", NAME_COL_WIDTH);
485
- for (const col of DATA_COLUMNS) {
486
- const label = padLeft(col.label, col.width);
629
+ let headerLine = fitCell("Provider / Model", layout.nameWidth);
630
+ for (const col of layout.columns) {
631
+ const label = fitCell(col.label, col.width, "right");
487
632
  headerLine += col.dimmed ? th.fg("dim", label) : label;
488
633
  }
489
634
 
490
- return [th.fg("muted", headerLine), th.fg("border", "─".repeat(TABLE_WIDTH))];
635
+ return [th.fg("muted", headerLine), th.fg("border", "─".repeat(layout.tableWidth))];
491
636
  }
492
637
 
493
638
  private renderDataRow(
494
639
  name: string,
495
640
  stats: BaseStats & { sessions: Set<string> | number },
496
- options: { indent?: number; selected?: boolean; dimAll?: boolean } = {}
641
+ layout: TableLayout,
642
+ options: { indent?: number; selected?: boolean; dimAll?: boolean; prefix?: string } = {}
497
643
  ): string {
498
644
  const th = this.theme;
499
- const { indent = 0, selected = false, dimAll = false } = options;
645
+ const { indent = 0, selected = false, dimAll = false, prefix } = options;
500
646
 
501
- const indentStr = " ".repeat(indent);
502
- const nameWidth = NAME_COL_WIDTH - indent;
503
- const truncName = truncateToWidth(name, nameWidth - 1);
647
+ const rawPrefix = prefix ?? " ".repeat(indent);
648
+ const safePrefix = layout.nameWidth > 0 ? truncateToWidth(rawPrefix, layout.nameWidth, "") : "";
649
+ const prefixWidth = visibleWidth(safePrefix);
650
+ const innerNameWidth = Math.max(layout.nameWidth - prefixWidth, 0);
651
+ const truncName = innerNameWidth > 0 ? truncateToWidth(name, innerNameWidth) : "";
504
652
  const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
505
653
 
506
- let row = indentStr + padRight(styledName, nameWidth);
654
+ let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
507
655
 
508
- for (const col of DATA_COLUMNS) {
509
- const value = col.getValue(stats);
656
+ for (const col of layout.columns) {
657
+ const value = fitCell(col.getValue(stats), col.width, "right");
510
658
  const shouldDim = col.dimmed || dimAll;
511
- row += shouldDim ? th.fg("dim", padLeft(value, col.width)) : padLeft(value, col.width);
659
+ row += shouldDim ? th.fg("dim", value) : value;
512
660
  }
513
661
 
514
662
  return row;
515
663
  }
516
664
 
517
- private renderRows(): string[] {
665
+ private renderRows(layout: TableLayout): string[] {
518
666
  const th = this.theme;
519
667
  const stats = this.data[this.activeTab];
520
668
  const lines: string[] = [];
@@ -529,22 +677,21 @@ class UsageComponent {
529
677
  const providerStats = stats.providers.get(providerName)!;
530
678
  const isSelected = i === this.selectedIndex;
531
679
  const isExpanded = this.expanded.has(providerName);
532
-
533
- // Provider row with expand/collapse arrow
534
680
  const arrow = isExpanded ? "β–Ύ" : "β–Έ";
535
- const prefix = isSelected ? th.fg("accent", arrow + " ") : th.fg("dim", arrow + " ");
536
- const dataRow = this.renderDataRow(providerName, providerStats, {
537
- indent: 2,
538
- selected: isSelected,
539
- });
540
- lines.push(prefix + dataRow.slice(2)); // Replace indent with arrow prefix
681
+ const prefix = isSelected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
682
+
683
+ lines.push(
684
+ this.renderDataRow(providerName, providerStats, layout, {
685
+ selected: isSelected,
686
+ prefix,
687
+ })
688
+ );
541
689
 
542
- // Model rows (if expanded)
543
690
  if (isExpanded) {
544
691
  const models = Array.from(providerStats.models.entries()).sort((a, b) => b[1].cost - a[1].cost);
545
692
 
546
693
  for (const [modelName, modelStats] of models) {
547
- lines.push(this.renderDataRow(modelName, modelStats, { indent: 4, dimAll: true }));
694
+ lines.push(this.renderDataRow(modelName, modelStats, layout, { indent: 4, dimAll: true }));
548
695
  }
549
696
  }
550
697
  }
@@ -552,21 +699,38 @@ class UsageComponent {
552
699
  return lines;
553
700
  }
554
701
 
555
- private renderTotals(): string[] {
702
+ private renderTotals(layout: TableLayout): string[] {
556
703
  const th = this.theme;
557
704
  const stats = this.data[this.activeTab];
558
705
 
559
- let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
560
- for (const col of DATA_COLUMNS) {
561
- const value = col.getValue(stats.totals);
562
- totalRow += col.dimmed ? th.fg("dim", padLeft(value, col.width)) : padLeft(value, col.width);
706
+ let totalRow = fitCell(th.bold("Total"), layout.nameWidth);
707
+ for (const col of layout.columns) {
708
+ const value = fitCell(col.getValue(stats.totals), col.width, "right");
709
+ totalRow += col.dimmed ? th.fg("dim", value) : value;
563
710
  }
564
711
 
565
- return [th.fg("border", "─".repeat(TABLE_WIDTH)), totalRow, ""];
712
+ return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
713
+ }
714
+
715
+ private renderFormulaNote(width: number): string[] {
716
+ const line = pickFittingText(width, [
717
+ "Tokens = Input + Output + CacheWrite Β· ↑In = Input + CacheWrite (as of 0.2.0)",
718
+ "Tokens = In + Out + CacheWrite Β· ↑In = In + CacheWrite (v0.2.0+)",
719
+ "Tokens & ↑In include CacheWrite (v0.2.0+)",
720
+ "Incl. CacheWrite (v0.2.0+)",
721
+ ]);
722
+ return [this.theme.fg("dim", line), ""];
566
723
  }
567
724
 
568
- private renderHelp(): string[] {
569
- return [this.theme.fg("dim", "[Tab/←→] period [↑↓] select [Enter] expand [q] close")];
725
+ private renderHelp(width: number): string[] {
726
+ const line = pickFittingText(width, [
727
+ "[Tab/←→] period [↑↓] select [Enter] expand [q] close",
728
+ "[Tab] period [↑↓] select [Enter] expand [q] close",
729
+ "[↑↓] select [Enter] expand [q] close",
730
+ "[↑↓] select [q] close",
731
+ "[q] close",
732
+ ]);
733
+ return [this.theme.fg("dim", line)];
570
734
  }
571
735
 
572
736
  invalidate(): void {}
@@ -625,10 +789,10 @@ export default function (pi: ExtensionAPI) {
625
789
 
626
790
  return {
627
791
  render: (w: number) => {
628
- const borderLines = container.render(w);
792
+ const borderLines = clampLines(container.render(w), w);
629
793
  const usageLines = usage.render(w);
630
794
  const bottomBorder = theme.fg("border", "─".repeat(w));
631
- return [...borderLines, ...usageLines, "", bottomBorder];
795
+ return clampLines([...borderLines, ...usageLines, "", bottomBorder], w);
632
796
  },
633
797
  invalidate: () => container.invalidate(),
634
798
  handleInput: (input: string) => usage.handleInput(input),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",