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 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.27",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -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-09
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** | 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
+ 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.
@@ -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,19 @@ 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.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 tabs = TAB_ORDER.map((tab) => {
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
- return [tabs, th.fg("dim", "Dedupes copied branched-history messages. Recursive subagent sessions included."), ""];
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 = padRight("Provider / Model", NAME_COL_WIDTH);
485
- for (const col of DATA_COLUMNS) {
486
- 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");
487
631
  headerLine += col.dimmed ? th.fg("dim", label) : label;
488
632
  }
489
633
 
490
- return [th.fg("muted", headerLine), th.fg("border", "─".repeat(TABLE_WIDTH))];
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
- options: { indent?: number; selected?: boolean; dimAll?: boolean } = {}
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 indentStr = " ".repeat(indent);
502
- const nameWidth = NAME_COL_WIDTH - indent;
503
- 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) : "";
504
651
  const styledName = selected ? th.fg("accent", truncName) : dimAll ? th.fg("dim", truncName) : truncName;
505
652
 
506
- let row = indentStr + padRight(styledName, nameWidth);
653
+ let row = safePrefix + (innerNameWidth > 0 ? padRight(styledName, innerNameWidth) : "");
507
654
 
508
- for (const col of DATA_COLUMNS) {
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", padLeft(value, col.width)) : padLeft(value, col.width);
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 + " ") : 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
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 = 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);
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(TABLE_WIDTH)), totalRow, ""];
711
+ return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
566
712
  }
567
713
 
568
- private renderHelp(): string[] {
569
- return [this.theme.fg("dim", "[Tab/←→] period [↑↓] 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)];
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),
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",