@tmustier/pi-usage-extension 0.2.1 → 0.3.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 2026-04-19
4
+ - **Cost-only insights.** The Insights view now weights every insight by recorded USD cost, with no tokens fallback. The headline question is now "What's contributing to your cost?" and every bullet reads "X% of your cost …". Periods with no recorded cost show an explicit empty state instead of silently switching unit.
5
+ - **Long-running sessions use true lifetime.** The 8h+ insight now looks at each session's global lifetime across all session files, not just the span visible inside the selected period slice.
6
+ - **Exact ±2 min parallel window.** The "4+ sessions in parallel" insight now uses a precise ±120000 ms two-pointer sweep instead of rounded minute buckets. A message at second 1 of minute M and another at second 59 of minute M+2 are correctly treated as ~178 s apart (outside the window).
7
+ - **Empty states.** Insights view now distinguishes three cases: no usage recorded in the period, usage but no cost data, and usage with cost but no insights clearing the 1% threshold.
8
+ - **Narrow-terminal compact hint is hidden in Insights mode** (it only applied to the table layout).
9
+ - **"Cache miss" bullet relabelled** to "of your cost came from >100k-token uncached prompts" — same math, more accurate wording.
10
+ - **Messages with missing/invalid timestamps are excluded** from the parallel-sessions sweep so that older/incomplete logs don't inflate the insight by collapsing into a single synthetic instant.
11
+
12
+ ## 0.3.0 - 2026-04-19
13
+ - Add an **Insights** view to `/usage` (press `v` to toggle). Surfaces Claude-style narrative characteristics of your usage for the active time period:
14
+ - `X% of your usage was while 4+ sessions ran in parallel`
15
+ - `X% of your usage was at >150k context`
16
+ - `X% of your usage hit a >100k-token cache miss`
17
+ - `X% of your usage came from sessions active for 8+ hours`
18
+ - `X% of your usage came from your top 5 sessions`
19
+ - Insights are weighted by cost when cost data is recorded, otherwise by tokens, with a small footer noting which basis is in use.
20
+ - Insights are independent characteristics of usage (they overlap), not a breakdown — the view makes this explicit.
21
+
3
22
  ## 0.2.1 - 2026-04-17
4
23
  - Add a one-line formula footer to the `/usage` dashboard (`Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite`)
5
24
  - README now calls out the 0.2.0 formula change explicitly under the columns table
package/README.md CHANGED
@@ -2,12 +2,12 @@
2
2
 
3
3
  A Pi extension that displays aggregated usage statistics across all sessions.
4
4
 
5
- ![Usage Statistics Screenshot](screenshot.png)
5
+ ![Default table view of /usage](screenshot.png)
6
6
 
7
7
  ## Compatibility
8
8
 
9
9
  - **Pi version:** 0.42.4+
10
- - **Last updated:** 2026-04-17 (0.2.1)
10
+ - **Last updated:** 2026-04-19 (0.3.1)
11
11
 
12
12
  ## Installation
13
13
 
@@ -55,6 +55,27 @@ In Pi, run:
55
55
 
56
56
  ## Features
57
57
 
58
+ ### Views
59
+
60
+ `/usage` has two view modes, toggled with `v`:
61
+
62
+ - **Table** (default) — per-provider / per-model stats with cost and token breakdown (screenshot at the top of this page).
63
+ - **Insights** — narrative characteristics of your cost for the active time period, e.g. *"X% of your cost was at >150k context"*. Insights are **independent characteristics**, not a breakdown, so they overlap and can sum to more than 100%.
64
+
65
+ ![Insights view of /usage](insights-screenshot.png)
66
+
67
+ **Unit:** insights are always weighted by recorded API cost (USD). Periods with no recorded cost show an explicit empty state rather than silently switching to a different unit.
68
+
69
+ The insights currently shown:
70
+
71
+ | Insight | Threshold |
72
+ |---|---|
73
+ | Parallel sessions | ≥ 4 sessions active within an exact ±2 min window |
74
+ | Large context | `input + cacheRead + cacheWrite > 150k` |
75
+ | Large uncached prompt | `input + cacheWrite > 100k` |
76
+ | Long-running sessions | session lifetime ≥ 8 hours (global, not per-period slice) |
77
+ | Top-session concentration | top 5 sessions by cost |
78
+
58
79
  ### Time Periods
59
80
 
60
81
  | Period | Definition |
@@ -92,8 +113,9 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
92
113
  | Key | Action |
93
114
  |-----|--------|
94
115
  | `Tab` / `←` `→` | Switch time period |
95
- | `↑` `↓` | Select provider |
96
- | `Enter` / `Space` | Expand/collapse provider to show models |
116
+ | `↑` `↓` | Select provider *(table view)* |
117
+ | `Enter` / `Space` | Expand/collapse provider to show models *(table view)* |
118
+ | `v` | Toggle between Table and Insights view |
97
119
  | `q` / `Esc` | Close |
98
120
 
99
121
  ## Provider Notes
package/index.ts CHANGED
@@ -45,9 +45,39 @@ interface TotalStats extends BaseStats {
45
45
  sessions: number;
46
46
  }
47
47
 
48
+ interface Insight {
49
+ percent: number; // 0-100
50
+ headline: string;
51
+ advice: string;
52
+ }
53
+
54
+ interface PeriodInsights {
55
+ insights: Insight[];
56
+ }
57
+
58
+ interface RawMessage {
59
+ sessionId: string;
60
+ timestamp: number;
61
+ cost: number;
62
+ input: number;
63
+ cacheRead: number;
64
+ cacheWrite: number;
65
+ }
66
+
67
+ interface PeriodRawData {
68
+ messages: RawMessage[];
69
+ sessionCosts: Map<string, number>;
70
+ }
71
+
72
+ interface GlobalSessionSpan {
73
+ startMs: number;
74
+ endMs: number;
75
+ }
76
+
48
77
  interface TimeFilteredStats {
49
78
  providers: Map<string, ProviderStats>;
50
79
  totals: TotalStats;
80
+ insights: PeriodInsights;
51
81
  }
52
82
 
53
83
  interface UsageData {
@@ -58,6 +88,7 @@ interface UsageData {
58
88
  }
59
89
 
60
90
  type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
91
+ type ViewMode = "table" | "insights";
61
92
 
62
93
  // =============================================================================
63
94
  // Column Configuration
@@ -296,9 +327,14 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
296
327
  return {
297
328
  providers: new Map(),
298
329
  totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
330
+ insights: { insights: [] },
299
331
  };
300
332
  }
301
333
 
334
+ function emptyPeriodRawData(): PeriodRawData {
335
+ return { messages: [], sessionCosts: new Map() };
336
+ }
337
+
302
338
  function emptyUsageData(): UsageData {
303
339
  return {
304
340
  today: emptyTimeFilteredStats(),
@@ -325,11 +361,25 @@ function addMessagesToUsageData(
325
361
  messages: SessionMessage[],
326
362
  todayMs: number,
327
363
  weekStartMs: number,
328
- lastWeekStartMs: number
364
+ lastWeekStartMs: number,
365
+ rawByPeriod: Record<TabName, PeriodRawData>,
366
+ globalSessionSpans: Map<string, GlobalSessionSpan>
329
367
  ): void {
330
368
  const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
331
369
 
332
370
  for (const msg of messages) {
371
+ // Track real per-session lifetime across every message we see, regardless of
372
+ // which period the message falls into. Used later for the "8h+ session" insight.
373
+ if (msg.timestamp > 0) {
374
+ const span = globalSessionSpans.get(sessionId);
375
+ if (!span) {
376
+ globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
377
+ } else {
378
+ if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
379
+ if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
380
+ }
381
+ }
382
+
333
383
  const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
334
384
  const tokens = {
335
385
  // Count fresh tokens processed this turn.
@@ -365,6 +415,17 @@ function addMessagesToUsageData(
365
415
 
366
416
  accumulateStats(stats.totals, msg.cost, tokens);
367
417
  sessionContributed[period] = true;
418
+
419
+ const raw = rawByPeriod[period];
420
+ raw.messages.push({
421
+ sessionId,
422
+ timestamp: msg.timestamp,
423
+ cost: msg.cost,
424
+ input: msg.input,
425
+ cacheRead: msg.cacheRead,
426
+ cacheWrite: msg.cacheWrite,
427
+ });
428
+ raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
368
429
  }
369
430
  }
370
431
 
@@ -393,6 +454,13 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
393
454
  const lastWeekStartMs = startOfLastWeek.getTime();
394
455
 
395
456
  const data = emptyUsageData();
457
+ const rawByPeriod: Record<TabName, PeriodRawData> = {
458
+ today: emptyPeriodRawData(),
459
+ thisWeek: emptyPeriodRawData(),
460
+ lastWeek: emptyPeriodRawData(),
461
+ allTime: emptyPeriodRawData(),
462
+ };
463
+ const globalSessionSpans = new Map<string, GlobalSessionSpan>();
396
464
 
397
465
  const sessionFiles = await getAllSessionFiles(signal);
398
466
  if (signal?.aborted) return null;
@@ -404,14 +472,192 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
404
472
  if (signal?.aborted) return null;
405
473
  if (!parsed) continue;
406
474
 
407
- addMessagesToUsageData(data, parsed.sessionId, parsed.messages, todayMs, weekStartMs, lastWeekStartMs);
475
+ addMessagesToUsageData(
476
+ data,
477
+ parsed.sessionId,
478
+ parsed.messages,
479
+ todayMs,
480
+ weekStartMs,
481
+ lastWeekStartMs,
482
+ rawByPeriod,
483
+ globalSessionSpans
484
+ );
408
485
 
409
486
  await new Promise<void>((resolve) => setImmediate(resolve));
410
487
  }
411
488
 
489
+ // Classify sessions that are globally long-running once, then reuse across periods.
490
+ const longSessionIds = new Set<string>();
491
+ for (const [id, span] of globalSessionSpans) {
492
+ if (span.endMs - span.startMs >= LONG_SESSION_MS) longSessionIds.add(id);
493
+ }
494
+
495
+ for (const period of TAB_ORDER) {
496
+ data[period].insights = computeInsights(rawByPeriod[period], longSessionIds);
497
+ }
498
+
412
499
  return data;
413
500
  }
414
501
 
502
+ // =============================================================================
503
+ // Insights
504
+ // =============================================================================
505
+
506
+ const PARALLEL_WINDOW_MS = 2 * 60_000; // exact ±N milliseconds around each message
507
+ const PARALLEL_SESSION_THRESHOLD = 4;
508
+ const LARGE_CONTEXT_THRESHOLD = 150_000;
509
+ const LARGE_CACHE_MISS_THRESHOLD = 100_000;
510
+ const LONG_SESSION_MS = 8 * 60 * 60 * 1000;
511
+ const TOP_SESSION_COUNT = 5;
512
+ const MIN_MESSAGES_FOR_PARALLEL_INSIGHT = 10;
513
+ const MIN_PERCENT_TO_SHOW = 1;
514
+
515
+ /**
516
+ * Insights are weighted by recorded API cost. Periods with zero total cost produce
517
+ * an empty `insights` list — the UI renders a distinct empty-state for that case.
518
+ * Long-running-session classification is passed in from a global pass so that a
519
+ * session's real lifetime is used rather than the slice visible inside this period.
520
+ */
521
+ function computeInsights(raw: PeriodRawData, longSessionIds: Set<string>): PeriodInsights {
522
+ if (raw.messages.length === 0) {
523
+ return { insights: [] };
524
+ }
525
+
526
+ const total = raw.messages.reduce((sum, m) => sum + m.cost, 0);
527
+ if (total <= 0) {
528
+ return { insights: [] };
529
+ }
530
+
531
+ const candidates: Insight[] = [];
532
+
533
+ // 1. Parallel sessions — ≥ N unique sessions active within an exact ±W ms window.
534
+ const parallelWeight = computeParallelCostWeight(raw.messages);
535
+ if (parallelWeight !== null) {
536
+ candidates.push({
537
+ percent: (parallelWeight / total) * 100,
538
+ headline: `of your cost was while ${PARALLEL_SESSION_THRESHOLD}+ sessions ran in parallel`,
539
+ advice:
540
+ "All sessions share one rate limit. If you don't need them all at once, queueing uses capacity more evenly.",
541
+ });
542
+ }
543
+
544
+ // 2. Large context — input + cacheRead + cacheWrite > threshold.
545
+ const largeContextWeight = raw.messages
546
+ .filter((m) => m.input + m.cacheRead + m.cacheWrite > LARGE_CONTEXT_THRESHOLD)
547
+ .reduce((sum, m) => sum + m.cost, 0);
548
+ candidates.push({
549
+ percent: (largeContextWeight / total) * 100,
550
+ headline: `of your cost was at >${formatThresholdTokens(LARGE_CONTEXT_THRESHOLD)} context`,
551
+ advice:
552
+ "Longer sessions are more expensive even when cached. /compact mid-task, /clear when switching to new tasks.",
553
+ });
554
+
555
+ // 3. Large uncached prompt — fresh (non-cached) input > threshold, per the v0.2.0 formula.
556
+ const uncachedWeight = raw.messages
557
+ .filter((m) => m.input + m.cacheWrite > LARGE_CACHE_MISS_THRESHOLD)
558
+ .reduce((sum, m) => sum + m.cost, 0);
559
+ candidates.push({
560
+ percent: (uncachedWeight / total) * 100,
561
+ headline: `of your cost came from >${formatThresholdTokens(LARGE_CACHE_MISS_THRESHOLD)}-token uncached prompts`,
562
+ advice:
563
+ "Uncached input is expensive, and often happens when sending a message to a session that has gone idle. /compact before stepping away keeps the cold-start small.",
564
+ });
565
+
566
+ // 4. Long-running sessions — classification comes from the global pass so we use
567
+ // true session lifetime, not just the span visible inside this period slice.
568
+ const longWeight = raw.messages
569
+ .filter((m) => longSessionIds.has(m.sessionId))
570
+ .reduce((sum, m) => sum + m.cost, 0);
571
+ if (longWeight > 0) {
572
+ candidates.push({
573
+ percent: (longWeight / total) * 100,
574
+ headline: `of your cost came from sessions active for ${LONG_SESSION_MS / 3_600_000}+ hours`,
575
+ advice:
576
+ "These are often background/loop sessions. Continuous usage can add up quickly so make sure it is intentional.",
577
+ });
578
+ }
579
+
580
+ // 5. Top-N session concentration.
581
+ if (raw.sessionCosts.size > TOP_SESSION_COUNT) {
582
+ const sortedSessions = Array.from(raw.sessionCosts.values()).sort((a, b) => b - a);
583
+ const topN = Math.min(TOP_SESSION_COUNT, sortedSessions.length);
584
+ const topWeight = sortedSessions.slice(0, topN).reduce((sum, c) => sum + c, 0);
585
+ candidates.push({
586
+ percent: (topWeight / total) * 100,
587
+ headline: `of your cost came from your top ${topN} session${topN === 1 ? "" : "s"}`,
588
+ advice:
589
+ "A small number of sessions drives most of your spend. The table view can help pinpoint which ones.",
590
+ });
591
+ }
592
+
593
+ const insights = candidates.filter((i) => i.percent >= MIN_PERCENT_TO_SHOW).sort((a, b) => b.percent - a.percent);
594
+ return { insights };
595
+ }
596
+
597
+ /**
598
+ * Two-pointer sweep of messages sorted by timestamp. For each message, count the
599
+ * number of distinct session IDs whose messages fall within an exact ± window.
600
+ * Returns the total cost attributable to moments when ≥ threshold sessions were
601
+ * active, or null if the period has too few sessions/messages to call it.
602
+ *
603
+ * Messages with missing/invalid timestamps (parsed as 0) are filtered out first —
604
+ * otherwise they would collapse into a single synthetic instant and inflate the
605
+ * parallel count on older or incomplete logs.
606
+ */
607
+ function computeParallelCostWeight(messages: RawMessage[]): number | null {
608
+ const timed = messages.filter((m) => m.timestamp > 0);
609
+ if (timed.length < MIN_MESSAGES_FOR_PARALLEL_INSIGHT) return null;
610
+ const distinctSessions = new Set(timed.map((m) => m.sessionId));
611
+ if (distinctSessions.size < PARALLEL_SESSION_THRESHOLD) return null;
612
+
613
+ const sorted = timed.slice().sort((a, b) => a.timestamp - b.timestamp);
614
+ const sidCount = new Map<string, number>();
615
+ let uniqueCount = 0;
616
+ let left = 0;
617
+ let right = 0;
618
+ let parallelCost = 0;
619
+
620
+ for (let i = 0; i < sorted.length; i++) {
621
+ const current = sorted[i]!;
622
+ const high = current.timestamp + PARALLEL_WINDOW_MS;
623
+ const low = current.timestamp - PARALLEL_WINDOW_MS;
624
+
625
+ while (right < sorted.length && sorted[right]!.timestamp <= high) {
626
+ const sid = sorted[right]!.sessionId;
627
+ const next = (sidCount.get(sid) ?? 0) + 1;
628
+ sidCount.set(sid, next);
629
+ if (next === 1) uniqueCount++;
630
+ right++;
631
+ }
632
+ while (left < right && sorted[left]!.timestamp < low) {
633
+ const sid = sorted[left]!.sessionId;
634
+ const next = (sidCount.get(sid) ?? 0) - 1;
635
+ if (next === 0) {
636
+ sidCount.delete(sid);
637
+ uniqueCount--;
638
+ } else {
639
+ sidCount.set(sid, next);
640
+ }
641
+ left++;
642
+ }
643
+
644
+ if (uniqueCount >= PARALLEL_SESSION_THRESHOLD) parallelCost += current.cost;
645
+ }
646
+
647
+ return parallelCost;
648
+ }
649
+
650
+ function formatThresholdTokens(n: number): string {
651
+ if (n >= 1_000_000) return `${n / 1_000_000}M`;
652
+ if (n >= 1_000) return `${n / 1_000}k`;
653
+ return String(n);
654
+ }
655
+
656
+ function formatInsightPercent(p: number): string {
657
+ if (p >= 10) return `${Math.round(p)}%`;
658
+ return `${Math.round(p * 10) / 10}%`;
659
+ }
660
+
415
661
  // =============================================================================
416
662
  // Formatting Helpers
417
663
  // =============================================================================
@@ -514,6 +760,7 @@ const TAB_ORDER: TabName[] = ["today", "thisWeek", "lastWeek", "allTime"];
514
760
 
515
761
  class UsageComponent {
516
762
  private activeTab: TabName = "allTime";
763
+ private viewMode: ViewMode = "table";
517
764
  private data: UsageData;
518
765
  private selectedIndex = 0;
519
766
  private expanded = new Set<string>();
@@ -544,6 +791,12 @@ class UsageComponent {
544
791
  return;
545
792
  }
546
793
 
794
+ if (matchesKey(data, "v")) {
795
+ this.viewMode = this.viewMode === "table" ? "insights" : "table";
796
+ this.requestRender();
797
+ return;
798
+ }
799
+
547
800
  if (matchesKey(data, "tab") || matchesKey(data, "right")) {
548
801
  const idx = TAB_ORDER.indexOf(this.activeTab);
549
802
  this.activeTab = TAB_ORDER[(idx + 1) % TAB_ORDER.length]!;
@@ -554,17 +807,17 @@ class UsageComponent {
554
807
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
555
808
  this.updateProviderOrder();
556
809
  this.requestRender();
557
- } else if (matchesKey(data, "up")) {
810
+ } else if (this.viewMode === "table" && matchesKey(data, "up")) {
558
811
  if (this.selectedIndex > 0) {
559
812
  this.selectedIndex--;
560
813
  this.requestRender();
561
814
  }
562
- } else if (matchesKey(data, "down")) {
815
+ } else if (this.viewMode === "table" && matchesKey(data, "down")) {
563
816
  if (this.selectedIndex < this.providerOrder.length - 1) {
564
817
  this.selectedIndex++;
565
818
  this.requestRender();
566
819
  }
567
- } else if (matchesKey(data, "enter") || matchesKey(data, "space")) {
820
+ } else if (this.viewMode === "table" && (matchesKey(data, "enter") || matchesKey(data, "space"))) {
568
821
  const provider = this.providerOrder[this.selectedIndex];
569
822
  if (provider) {
570
823
  if (this.expanded.has(provider)) {
@@ -582,6 +835,18 @@ class UsageComponent {
582
835
  // -------------------------------------------------------------------------
583
836
 
584
837
  render(width: number): string[] {
838
+ if (this.viewMode === "insights") {
839
+ return clampLines(
840
+ [
841
+ ...this.renderTitle(),
842
+ ...this.renderTabs(width, getTableLayout(width)),
843
+ ...this.renderInsights(width),
844
+ ...this.renderHelp(width),
845
+ ],
846
+ width
847
+ );
848
+ }
849
+
585
850
  const layout = getTableLayout(width);
586
851
  return clampLines(
587
852
  [
@@ -599,7 +864,54 @@ class UsageComponent {
599
864
 
600
865
  private renderTitle(): string[] {
601
866
  const th = this.theme;
602
- return [th.fg("accent", th.bold("Usage Statistics")), ""];
867
+ const label = this.viewMode === "insights" ? "Usage Insights" : "Usage Statistics";
868
+ return [th.fg("accent", th.bold(label)), ""];
869
+ }
870
+
871
+ private renderInsights(width: number): string[] {
872
+ const th = this.theme;
873
+ const stats = this.data[this.activeTab];
874
+ const { insights } = stats.insights;
875
+ const hasMessages = stats.totals.messages > 0;
876
+ const hasCost = stats.totals.cost > 0;
877
+ const lines: string[] = [];
878
+
879
+ lines.push("What's contributing to your cost?");
880
+ lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
881
+ lines.push("");
882
+ const note = `${TAB_LABELS[this.activeTab]} · weighted by cost (USD) · these overlap and can sum to >100%`;
883
+ lines.push(th.fg("dim", note));
884
+ lines.push("");
885
+
886
+ if (!hasMessages) {
887
+ lines.push(th.fg("dim", " No usage recorded for this period."));
888
+ lines.push("");
889
+ return lines;
890
+ }
891
+ if (!hasCost) {
892
+ lines.push(th.fg("dim", " No cost data recorded for this period."));
893
+ lines.push("");
894
+ return lines;
895
+ }
896
+ if (insights.length === 0) {
897
+ lines.push(th.fg("dim", " No insights above 1% for this period."));
898
+ lines.push("");
899
+ return lines;
900
+ }
901
+
902
+ const indent = " ";
903
+ const adviceWidth = Math.max(width - indent.length, 30);
904
+
905
+ for (const insight of insights) {
906
+ const pct = th.fg("accent", th.bold(formatInsightPercent(insight.percent)));
907
+ lines.push(`${pct} ${insight.headline}`);
908
+ for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
909
+ lines.push(`${indent}${th.fg("dim", wrapped)}`);
910
+ }
911
+ lines.push("");
912
+ }
913
+
914
+ return lines;
603
915
  }
604
916
 
605
917
  private renderTabs(width: number, layout: TableLayout): string[] {
@@ -616,9 +928,11 @@ class UsageComponent {
616
928
  activeTabOnly,
617
929
  ]);
618
930
 
619
- const infoLines = layout.compact
620
- ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
621
- : [];
931
+ // Compact-note only applies to the table view — it's meaningless for insights.
932
+ const infoLines =
933
+ this.viewMode === "table" && layout.compact
934
+ ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
935
+ : [];
622
936
 
623
937
  return [tabLine, ...infoLines, ""];
624
938
  }
@@ -723,13 +1037,23 @@ class UsageComponent {
723
1037
  }
724
1038
 
725
1039
  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
- ]);
1040
+ const variants =
1041
+ this.viewMode === "insights"
1042
+ ? [
1043
+ "[Tab/←→] period [v] table view [q] close",
1044
+ "[Tab] period [v] table [q] close",
1045
+ "[v] table [q] close",
1046
+ "[q] close",
1047
+ ]
1048
+ : [
1049
+ "[Tab/←→] period [↑↓] select [Enter] expand [v] insights [q] close",
1050
+ "[Tab] period [↑↓] select [Enter] expand [v] insights [q] close",
1051
+ "[↑↓] select [Enter] expand [v] insights [q] close",
1052
+ "[↑↓] select [v] insights [q] close",
1053
+ "[↑↓] select [q] close",
1054
+ "[q] close",
1055
+ ];
1056
+ const line = pickFittingText(width, variants);
733
1057
  return [this.theme.fg("dim", line)];
734
1058
  }
735
1059
 
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",