@tmustier/pi-usage-extension 0.7.0 → 0.7.2

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,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.7.2] - 2026-07-17
4
+
5
+ ### Changed
6
+ - **Insights view polish.** Section headers with rules (“Worth attention” in warning colour, “Where it went” in accent), alarm markers and stats in warning colour, the trailing “(x% of this period)” de-emphasised, content capped at 100 columns for readability on wide terminals, and an explicit all-clear line (✓) when no alarms fire. The stat column fits 6-character values (e.g. `$58.79`).
7
+ - **Plain-language copy.** Insight headlines now state what happened rather than the cache mechanism (“spent re-sending conversations after a break” instead of “re-warming caches that expired during idle gaps”), advice avoids jargon, and vendor-specific cache details are gone.
8
+ - The period · “independent lenses” note is folded into the subtitle: “Approximate, based on local sessions on this machine (these are independent and don't sum to 100%).”
9
+
10
+ ## [0.7.1] - 2026-07-17
11
+
12
+ ### Added
13
+ - Descriptive loading messages with a live file counter while `/usage` opens: “Building your usage history for the first time…” on a fresh install, “Rebuilding your usage history — the cache format changed…” after an upgrade that bumps the cache version, and “Updating your usage history since \<date>…” for routine incremental refreshes. Warm opens with nothing to parse keep the plain spinner.
14
+ - `collectUsageData` accepts an `onProgress` callback reporting mode (`first-run` / `rebuild` / `update`), files to parse, files parsed, and the newest already-ingested session activity timestamp.
15
+
3
16
  ## [0.7.0] - 2026-07-17
4
17
 
5
18
  ### Changed
package/README.md CHANGED
@@ -69,12 +69,12 @@ In Pi, run:
69
69
 
70
70
  The **Insights** view has two sections:
71
71
 
72
- **Worth attention** — alarms that only appear when a wasteful pattern is material for the period. A quiet panel means nothing notable was detected.
72
+ **Worth attention** — alarms that only appear when a wasteful pattern is material for the period. When nothing is flagged, the section shows an explicit all-clear.
73
73
 
74
74
  | Alarm | Fires when |
75
75
  |---|---|
76
- | TTL re-warm tax | ≥ 2% of the period's cost (and ≥ $1) went to messages that re-wrote a large context from scratch after a > 5 min idle gap — provider caches expire after a few minutes idle (Anthropic ~5 min) |
77
- | Prefix-change misses | ≥ 2% of cost (and ≥ $1) went to large-context cache misses with **no** idle gap — the request prefix changed mid-session. Messages right after a pi compaction are excluded, since compaction legitimately rewrites the prefix |
76
+ | Re-sends after a break (cache TTL) | ≥ 2% of the period's cost (and ≥ $1) went to messages that re-sent a large conversation from scratch after a > 5 min idle gap — provider caches expire after a few minutes idle |
77
+ | Mid-session re-sends (prefix change) | ≥ 2% of cost (and ≥ $1) went to large-context cache misses with **no** idle gap — the request prefix changed mid-session. Messages right after a pi compaction are excluded, since compaction legitimately rewrites the prefix |
78
78
  | Session concentration | the top 5 sessions account for ≥ 35% of the period's cost |
79
79
  | Upfront tax | ≥ 8% of cost was the first message of a session (session starts pay for their whole prompt uncached) |
80
80
  | Cache leverage floor | fewer than 5 cached tokens served per fresh token paid (shown only above $5 / 1M fresh tokens, to avoid noise) |
@@ -159,7 +159,7 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
159
159
  - **On-disk cache.** Per-file extraction results are cached in `<agentDir>/usage-extension-cache.json` (respects `PI_CODING_AGENT_DIR`), keyed by file size + mtime. Warm opens only re-parse session files that changed since the last run — on a 5.2 GB / 3,310-file corpus that takes the open from ~17 s to ~0.3 s.
160
160
  - **First open** after install (or after deleting the cache) does a one-off full build, showing the usual cancellable loader. Cancelling saves partial progress, so the next open resumes where it left off.
161
161
  - The cache is safe to delete at any time; it is rebuilt automatically. Corrupt or version-mismatched caches are ignored and rebuilt rather than trusted.
162
- - **0.7.0 bumps the cache format to v3** (adds session working directory and compaction markers; 0.6.0's v2 added thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild, then warm opens are fast again.
162
+ - **0.7.0 bumps the cache format to v3** (adds session working directory and compaction markers; 0.6.0's v2 added thinking level and reasoning tokens). The first open after upgrading does a one-off full rebuild (with a progress message and live file counter), then warm opens are fast again.
163
163
 
164
164
  ## Provider Notes
165
165
 
package/data.ts CHANGED
@@ -619,6 +619,7 @@ function getPeriodsForTimestamp(
619
619
  }
620
620
 
621
621
  const DAY_MS = 24 * HOUR_MS;
622
+ const PROGRESS_REPORT_EVERY = 100;
622
623
 
623
624
  function addMessagesToUsageData(
624
625
  data: UsageData,
@@ -727,8 +728,21 @@ const STAT_CONCURRENCY = 16;
727
728
  const DEFAULT_PARSE_CONCURRENCY = 4;
728
729
  const AGGREGATE_YIELD_EVERY_FILES = 200;
729
730
 
731
+ export interface CollectProgress {
732
+ /** Why this pass needs to parse files. */
733
+ mode: "first-run" | "rebuild" | "update";
734
+ /** Session files that need parsing this pass (0 = fully warm). */
735
+ filesToParse: number;
736
+ /** Files parsed so far; reported in coarse increments. */
737
+ filesParsed: number;
738
+ /** Newest session activity already ingested (ms since epoch); null when starting fresh. */
739
+ sinceMs: number | null;
740
+ }
741
+
730
742
  export interface CollectUsageOptions {
731
743
  signal?: AbortSignal;
744
+ /** Called once before parsing begins and periodically while files are parsed. */
745
+ onProgress?: (progress: CollectProgress) => void;
732
746
  /** Defaults to `<agentDir>/sessions`. */
733
747
  sessionsDir?: string;
734
748
  /** Defaults to `<agentDir>/usage-extension-cache.json`. Pass `null` to disable the on-disk cache. */
@@ -794,6 +808,15 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
794
808
  if (signal?.aborted) return null;
795
809
 
796
810
  // 3. Load the cache and decide which files actually need parsing.
811
+ let cacheFileExists = false;
812
+ if (cachePath) {
813
+ try {
814
+ await stat(cachePath);
815
+ cacheFileExists = true;
816
+ } catch {
817
+ // No cache file yet — first run.
818
+ }
819
+ }
797
820
  const previous = cachePath ? await loadUsageCache(cachePath) : new Map<string, CachedFileState>();
798
821
  if (signal?.aborted) return null;
799
822
  const current = new Map<string, CachedFileState>();
@@ -818,6 +841,22 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
818
841
  }
819
842
  }
820
843
 
844
+ // Progress reporting: distinguish a true first run from a format-change
845
+ // rebuild (cache file present but unusable) and a routine incremental update.
846
+ const progressMode: CollectProgress["mode"] =
847
+ previous.size > 0 ? "update" : cacheFileExists ? "rebuild" : "first-run";
848
+ let sinceMs: number | null = null;
849
+ if (progressMode === "update") {
850
+ for (const state of previous.values()) {
851
+ if (state.mtimeMs > (sinceMs ?? 0)) sinceMs = state.mtimeMs;
852
+ }
853
+ }
854
+ let filesParsed = 0;
855
+ const reportProgress = (): void => {
856
+ options.onProgress?.({ mode: progressMode, filesToParse: toParse.length, filesParsed, sinceMs });
857
+ };
858
+ reportProgress();
859
+
821
860
  // 4. Parse new/changed files with bounded concurrency.
822
861
  {
823
862
  let next = 0;
@@ -831,11 +870,16 @@ export async function collectUsageData(options: CollectUsageOptions = {}): Promi
831
870
  try {
832
871
  buffer = await readFile(filePath);
833
872
  } catch {
873
+ filesParsed++;
834
874
  continue; // File vanished — skip it.
835
875
  }
836
876
  const parsed = await parseSessionBuffer(buffer, signal);
837
877
  if (signal?.aborted) return; // Never cache a partial parse.
838
878
  current.set(filePath, { size: st.size, mtimeMs: st.mtimeMs, parsed });
879
+ filesParsed++;
880
+ if (filesParsed % PROGRESS_REPORT_EVERY === 0 || filesParsed === toParse.length) {
881
+ reportProgress();
882
+ }
839
883
  }
840
884
  })
841
885
  );
@@ -1001,9 +1045,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1001
1045
  insights.push({
1002
1046
  kind: "alarm",
1003
1047
  stat: fmtMoney(raw.ttlMissCost),
1004
- headline: `spent re-warming caches that expired during idle gaps (${fmtPercent(ttlPct)} of this period)`,
1048
+ headline: `spent re-sending conversations after a break (${fmtPercent(ttlPct)} of this period)`,
1005
1049
  advice:
1006
- "Provider caches expire after a few minutes idle (Anthropic ~5min); the next message re-writes the whole context at premium rates. Batching replies instead of trickling them keeps caches warm.",
1050
+ "Sent context is only reusable for a few minutes. After a longer pause, the next message pays to send the whole conversation again. Replying while a session is fresh avoids this.",
1007
1051
  });
1008
1052
  }
1009
1053
 
@@ -1012,9 +1056,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1012
1056
  insights.push({
1013
1057
  kind: "alarm",
1014
1058
  stat: fmtMoney(raw.prefixMissCost),
1015
- headline: `spent on cache misses with no idle gap (${fmtPercent(prefixPct)} of this period)`,
1059
+ headline: `spent re-sending conversations mid-session (${fmtPercent(prefixPct)} of this period)`,
1016
1060
  advice:
1017
- "The request prefix changed mid-session (compaction excluded), so cached context was re-sent at full price. If this stays high, something in the workflow is defeating provider caching.",
1061
+ "These messages paid full price for context that had already been sent, without a pause to explain it. Usually a tool or workflow is restarting or rewriting conversations. Worth a look if it stays high.",
1018
1062
  });
1019
1063
  }
1020
1064
 
@@ -1027,7 +1071,7 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1027
1071
  kind: "alarm",
1028
1072
  stat: fmtPercent(topPct),
1029
1073
  headline: `of this period's cost came from just ${TOP_SESSION_COUNT} sessions (of ${raw.sessionCosts.size})`,
1030
- advice: "Spend is unusually concentrated. The graph view's filters can pinpoint what those sessions were doing.",
1074
+ advice: "A handful of sessions drove most of the spend. The graph view can show what they were doing.",
1031
1075
  });
1032
1076
  }
1033
1077
  }
@@ -1037,9 +1081,8 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1037
1081
  insights.push({
1038
1082
  kind: "alarm",
1039
1083
  stat: fmtPercent(upfrontPct),
1040
- headline: "of cost was the first message of a session",
1041
- advice:
1042
- "Session starts pay for their whole prompt uncached. Fewer, longer-lived sessions amortize that setup cost.",
1084
+ headline: "of cost went on the opening message of new sessions",
1085
+ advice: "A session's first message sends everything from scratch. Fewer, longer sessions cut this overhead.",
1043
1086
  });
1044
1087
  }
1045
1088
 
@@ -1049,9 +1092,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1049
1092
  insights.push({
1050
1093
  kind: "alarm",
1051
1094
  stat: `${leverage.toFixed(1)}×`,
1052
- headline: "cache leverage — cached tokens served per fresh token paid",
1095
+ headline: "tokens reused from history for every token paid at full price",
1053
1096
  advice:
1054
- "Healthy interactive use typically sees 10×+. Low leverage means context is being re-sent uncached — look for workflows that restart conversations.",
1097
+ "Typical interactive use reuses 10× or more. A low number means conversations keep being sent from scratch — look for workflows that restart sessions.",
1055
1098
  });
1056
1099
  }
1057
1100
  }
@@ -1065,13 +1108,13 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1065
1108
  const avgLow = raw.ctxLow.messages > 0 ? raw.ctxLow.cost / raw.ctxLow.messages : 0;
1066
1109
  const cmp =
1067
1110
  avgLow > 0
1068
- ? ` — averaging ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1111
+ ? ` — ${fmtMoney(avgHigh)}/msg vs ${fmtMoney(avgLow)} under ${formatThresholdTokens(CTX_LOW_THRESHOLD)}`
1069
1112
  : "";
1070
1113
  insights.push({
1071
1114
  kind: "structure",
1072
1115
  stat: fmtPercent(pct),
1073
- headline: `of your cost was at ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} context${cmp}`,
1074
- advice: "Context size is the main cost driver. /compact mid-task and /clear between tasks to reset it.",
1116
+ headline: `of your cost came from messages with ≥${formatThresholdTokens(CTX_TAX_THRESHOLD)} tokens loaded${cmp}`,
1117
+ advice: "Long conversations cost more per message. /compact mid-task and /clear between tasks keep them lean.",
1075
1118
  });
1076
1119
  }
1077
1120
  }
@@ -1099,9 +1142,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1099
1142
  insights.push({
1100
1143
  kind: "structure",
1101
1144
  stat: fmtPercent(reasoningPct),
1102
- headline: "of your output tokens were invisible reasoning",
1145
+ headline: "of your output tokens were hidden reasoning",
1103
1146
  advice:
1104
- "Reasoning is billed as output but never displayed. Recorded by pi 0.80.3+ (June 2026) only, so older periods understate it.",
1147
+ "Models charge for their behind-the-scenes thinking as output tokens. pi records this only from 0.80.3 (June 2026), so older periods understate it.",
1105
1148
  });
1106
1149
  }
1107
1150
  }
@@ -1110,9 +1153,9 @@ function computeInsights(raw: PeriodRawData, trend: TrendInfo | null): PeriodIns
1110
1153
  const ratio = trend.last7Cost / trend.priorWeeklyPace;
1111
1154
  const advice =
1112
1155
  ratio >= TREND_HIGH_RATIO
1113
- ? "Spend is accelerating vs your own baseline — the graph view shows what changed."
1156
+ ? "Spending is up against your own baseline — the graph view shows what changed."
1114
1157
  : ratio <= TREND_LOW_RATIO
1115
- ? "Spend is well below your recent baseline."
1158
+ ? "Spending is well below your recent baseline."
1116
1159
  : "";
1117
1160
  insights.push({
1118
1161
  kind: "structure",
package/index.ts CHANGED
@@ -14,6 +14,7 @@ import { DynamicBorder } from "@earendil-works/pi-coding-agent";
14
14
  import { CancellableLoader, Container, Spacer, matchesKey, visibleWidth, truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
15
15
 
16
16
  import { collectUsageData, TAB_ORDER } from "./data";
17
+ import type { CollectProgress } from "./data";
17
18
  import type { BaseStats, TabName, UsageData } from "./data";
18
19
  import {
19
20
  buildGraphModel,
@@ -175,6 +176,18 @@ function seriesColor(index: number): string {
175
176
  return SERIES_COLORS[index % SERIES_COLORS.length]!;
176
177
  }
177
178
 
179
+ /** "14:32" if the timestamp is today, otherwise "16 Jul" (with year if not this year). */
180
+ function formatSinceDate(ms: number): string {
181
+ const d = new Date(ms);
182
+ const now = new Date();
183
+ if (d.toDateString() === now.toDateString()) {
184
+ return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
185
+ }
186
+ const opts: Intl.DateTimeFormatOptions = { day: "numeric", month: "short" };
187
+ if (d.getFullYear() !== now.getFullYear()) opts.year = "numeric";
188
+ return d.toLocaleDateString(undefined, opts);
189
+ }
190
+
178
191
  function padLeft(s: string, len: number): string {
179
192
  const vis = visibleWidth(s);
180
193
  if (vis >= len) return s;
@@ -486,11 +499,14 @@ class UsageComponent {
486
499
  const hasCost = stats.totals.cost > 0;
487
500
  const lines: string[] = [];
488
501
 
489
- lines.push("What's contributing to your cost?");
490
- lines.push(th.fg("dim", "Approximate, based on local sessions on this machine."));
491
- lines.push("");
492
- const note = `${TAB_LABELS[this.activeTab]} · independent lenses — they overlap and don't sum to 100%`;
493
- lines.push(th.fg("dim", note));
502
+ // Cap the content column so advice stays readable on very wide terminals.
503
+ const contentWidth = Math.max(Math.min(width, 100), 40);
504
+
505
+ lines.push(th.bold("What's contributing to your cost?"));
506
+ const subtitle = "Approximate, based on local sessions on this machine (these are independent and don't sum to 100%).";
507
+ for (const wrapped of wrapTextWithAnsi(subtitle, contentWidth)) {
508
+ lines.push(th.fg("dim", wrapped));
509
+ }
494
510
  lines.push("");
495
511
 
496
512
  if (!hasMessages) {
@@ -509,13 +525,24 @@ class UsageComponent {
509
525
  return lines;
510
526
  }
511
527
 
512
- const indent = " ";
513
- const adviceWidth = Math.max(width - indent.length, 30);
528
+ // Columns: marker(2) + stat(6) + gap(1); advice aligns under the headline.
529
+ const indent = " ";
530
+ const adviceWidth = Math.max(contentWidth - indent.length, 30);
531
+
532
+ const sectionHeader = (label: string, color: "warning" | "accent"): string => {
533
+ const rule = "─".repeat(Math.max(contentWidth - label.length - 1, 4));
534
+ return `${th.fg(color, th.bold(label))} ${th.fg("border", rule)}`;
535
+ };
514
536
 
515
537
  const renderOne = (insight: (typeof insights)[number]): void => {
516
- const marker = insight.kind === "alarm" ? "⚠ " : " ";
517
- const stat = th.fg("accent", th.bold(padLeft(insight.stat, 5)));
518
- lines.push(`${marker}${stat} ${insight.headline}`);
538
+ const isAlarm = insight.kind === "alarm";
539
+ const marker = isAlarm ? th.fg("warning", "⚠ ") : " ";
540
+ const statText = padLeft(insight.stat, 6);
541
+ const stat = isAlarm ? th.fg("warning", th.bold(statText)) : th.fg("accent", th.bold(statText));
542
+ // De-emphasise the trailing period-share parenthetical on alarm headlines.
543
+ const match = insight.headline.match(/^(.*?)\s*(\(\d[\d.,]*% of this period\))$/);
544
+ const headline = match ? `${match[1]} ${th.fg("dim", match[2]!)}` : insight.headline;
545
+ lines.push(`${marker}${stat} ${headline}`);
519
546
  if (insight.advice) {
520
547
  for (const wrapped of wrapTextWithAnsi(insight.advice, adviceWidth)) {
521
548
  lines.push(`${indent}${th.fg("dim", wrapped)}`);
@@ -526,12 +553,15 @@ class UsageComponent {
526
553
 
527
554
  const alarms = insights.filter((i) => i.kind === "alarm");
528
555
  const structure = insights.filter((i) => i.kind === "structure");
556
+ lines.push(sectionHeader("Worth attention", "warning"));
529
557
  if (alarms.length > 0) {
530
- lines.push(th.fg("accent", "Worth attention"));
531
558
  for (const insight of alarms) renderOne(insight);
559
+ } else {
560
+ lines.push(` ${th.fg("success", padLeft("✓", 6))} ${th.fg("dim", "no waste patterns flagged for this period")}`);
561
+ lines.push("");
532
562
  }
533
563
  if (structure.length > 0) {
534
- if (alarms.length > 0) lines.push(th.fg("dim", "Where it went"));
564
+ lines.push(sectionHeader("Where it went", "accent"));
535
565
  for (const insight of structure) renderOne(insight);
536
566
  }
537
567
 
@@ -722,7 +752,20 @@ export default function (pi: ExtensionAPI) {
722
752
 
723
753
  loader.onAbort = () => finish(null);
724
754
 
725
- collectUsageData({ signal: loader.signal })
755
+ const onProgress = (p: CollectProgress): void => {
756
+ if (finished || p.filesToParse === 0) return;
757
+ const files = `${p.filesParsed.toLocaleString()}/${p.filesToParse.toLocaleString()} files`;
758
+ if (p.mode === "update") {
759
+ const since = p.sinceMs !== null ? ` since ${formatSinceDate(p.sinceMs)}` : "";
760
+ loader.setMessage(`Updating your usage history${since}… (${files})`);
761
+ } else if (p.mode === "rebuild") {
762
+ loader.setMessage(`Rebuilding your usage history — the cache format changed… (${files})`);
763
+ } else {
764
+ loader.setMessage(`Building your usage history for the first time… (${files})`);
765
+ }
766
+ };
767
+
768
+ collectUsageData({ signal: loader.signal, onProgress })
726
769
  .then(finish)
727
770
  .catch(() => finish(null));
728
771
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",