pi-mono-all 1.2.0 → 1.2.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,11 @@
1
1
  # pi-mono-all
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Bundle `pi-mono-usage@0.1.1` with the updated Tools view and usage dashboard refinements.
8
+
3
9
  ## 1.2.0
4
10
 
5
11
  ### Minor Changes
@@ -1,5 +1,13 @@
1
1
  # pi-mono-usage
2
2
 
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Add a Tools view grouped by currently registered extension ownership, with expandable per-tool breakdowns sorted by usage.
8
+ - Replace Last Week with This Month and show the active period date range in the header.
9
+ - Tighten sustainability copy, show a single random equivalence, and simplify the grid/profile label.
10
+
3
11
  ## 0.1.0
4
12
 
5
13
  ### Initial release
@@ -33,8 +33,8 @@ import { estimateAiImpact, type AiEstimateResult } from "impact-equivalences";
33
33
  // Types
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
- type Period = "day" | "week" | "lastWeek" | "all";
37
- type View = "summary" | "providers" | "patterns";
36
+ type Period = "day" | "week" | "month" | "all";
37
+ type View = "summary" | "providers" | "patterns" | "tools";
38
38
 
39
39
  interface TokenBucket {
40
40
  input: number;
@@ -56,6 +56,18 @@ interface ProviderBucket extends Aggregate {
56
56
  models: Map<string, ModelBucket>;
57
57
  }
58
58
 
59
+ interface ToolBucket {
60
+ calls: number;
61
+ resultTokens: number;
62
+ sessions: Set<string>;
63
+ }
64
+
65
+ interface ToolGroupBucket extends ToolBucket {
66
+ tools: Map<string, ToolBucket>;
67
+ }
68
+
69
+ type ToolRegistry = Map<string, string>;
70
+
59
71
  interface RawTurn {
60
72
  sessionId: string;
61
73
  provider: string;
@@ -68,6 +80,13 @@ interface RawTurn {
68
80
  ts: number;
69
81
  }
70
82
 
83
+ interface RawToolUse {
84
+ sessionId: string;
85
+ name: string;
86
+ resultTokens: number;
87
+ ts: number;
88
+ }
89
+
71
90
  interface InsightRow {
72
91
  weight: number;
73
92
  headline: string;
@@ -76,6 +95,7 @@ interface InsightRow {
76
95
 
77
96
  interface PeriodReport {
78
97
  providers: Map<string, ProviderBucket>;
98
+ toolGroups: Map<string, ToolGroupBucket>;
79
99
  totals: Aggregate;
80
100
  turns: RawTurn[];
81
101
  insights: InsightRow[];
@@ -89,7 +109,7 @@ interface SessionLifespan {
89
109
  interface UsageReport {
90
110
  day: PeriodReport;
91
111
  week: PeriodReport;
92
- lastWeek: PeriodReport;
112
+ month: PeriodReport;
93
113
  all: PeriodReport;
94
114
  lifespans: Map<string, SessionLifespan>;
95
115
  }
@@ -97,25 +117,26 @@ interface UsageReport {
97
117
  interface SessionRecord {
98
118
  sessionId: string;
99
119
  turns: RawTurn[];
120
+ tools: RawToolUse[];
100
121
  }
101
122
 
102
123
  interface PeriodBoundaries {
103
124
  dayStart: number;
104
125
  weekStart: number;
105
- lastWeekStart: number;
126
+ monthStart: number;
106
127
  }
107
128
 
108
129
  // ---------------------------------------------------------------------------
109
130
  // Constants
110
131
  // ---------------------------------------------------------------------------
111
132
 
112
- const PERIOD_ORDER: readonly Period[] = ["day", "week", "lastWeek", "all"];
113
- const VIEW_ORDER: readonly View[] = ["summary", "providers", "patterns"];
133
+ const PERIOD_ORDER: readonly Period[] = ["day", "week", "month", "all"];
134
+ const VIEW_ORDER: readonly View[] = ["summary", "providers", "patterns", "tools"];
114
135
 
115
136
  const PERIOD_LABELS: Record<Period, string> = {
116
137
  day: "Today",
117
138
  week: "This Week",
118
- lastWeek: "Last Week",
139
+ month: "This Month",
119
140
  all: "All Time",
120
141
  };
121
142
 
@@ -123,6 +144,7 @@ const VIEW_LABELS: Record<View, string> = {
123
144
  summary: "Summary",
124
145
  providers: "Providers",
125
146
  patterns: "Patterns",
147
+ tools: "Tools",
126
148
  };
127
149
 
128
150
  const NAME_COL_MAX = 28;
@@ -141,6 +163,12 @@ const SUMMARY_TOP_PROVIDERS = 3;
141
163
  const BAR_WIDTH = 24;
142
164
  const BAR_FILLED = "█";
143
165
  const BAR_EMPTY = "░";
166
+ const BUILT_IN_TOOLS = new Set([
167
+ "bash",
168
+ "edit",
169
+ "read",
170
+ "write",
171
+ ]);
144
172
 
145
173
  // ---------------------------------------------------------------------------
146
174
  // Path helpers
@@ -175,6 +203,85 @@ async function listSessionFiles(root: string, signal?: AbortSignal): Promise<str
175
203
  return out.sort();
176
204
  }
177
205
 
206
+ async function listFiles(root: string, predicate: (name: string) => boolean, signal?: AbortSignal): Promise<string[]> {
207
+ const queue: string[] = [root];
208
+ const out: string[] = [];
209
+
210
+ while (queue.length > 0) {
211
+ if (signal?.aborted) return [];
212
+ const dir = queue.shift()!;
213
+ let entries: import("node:fs").Dirent[];
214
+ try {
215
+ entries = (await readdir(dir, { withFileTypes: true })) as unknown as import("node:fs").Dirent[];
216
+ } catch {
217
+ continue;
218
+ }
219
+ for (const entry of entries) {
220
+ const name = entry.name;
221
+ if (name === "node_modules" || name === "dist" || name === "__tests__") continue;
222
+ const full = join(dir, name);
223
+ if (entry.isDirectory()) queue.push(full);
224
+ else if (entry.isFile() && predicate(name)) out.push(full);
225
+ }
226
+ }
227
+
228
+ return out.sort();
229
+ }
230
+
231
+ async function buildToolRegistry(signal?: AbortSignal): Promise<ToolRegistry> {
232
+ const registry: ToolRegistry = new Map();
233
+ const extensionsDir = join(process.cwd(), "extensions");
234
+ let entries: import("node:fs").Dirent[];
235
+ try {
236
+ entries = (await readdir(extensionsDir, { withFileTypes: true })) as unknown as import("node:fs").Dirent[];
237
+ } catch {
238
+ return registry;
239
+ }
240
+
241
+ for (const entry of entries) {
242
+ if (signal?.aborted) return registry;
243
+ if (!entry.isDirectory()) continue;
244
+ const extensionDir = join(extensionsDir, entry.name);
245
+ const group = await extensionGroupName(extensionDir, entry.name);
246
+ const files = await listFiles(extensionDir, (name) => name.endsWith(".ts"), signal);
247
+ for (const file of files) {
248
+ if (signal?.aborted) return registry;
249
+ let source = "";
250
+ try {
251
+ source = await readFile(file, "utf8");
252
+ } catch {
253
+ continue;
254
+ }
255
+ for (const toolName of extractRegisteredToolNames(source)) registry.set(toolName, group);
256
+ }
257
+ }
258
+
259
+ return registry;
260
+ }
261
+
262
+ async function extensionGroupName(extensionDir: string, fallback: string): Promise<string> {
263
+ try {
264
+ const pkg = JSON.parse(await readFile(join(extensionDir, "package.json"), "utf8"));
265
+ const name = typeof pkg.name === "string" ? pkg.name : fallback;
266
+ return titleCaseWords(name.replace(/^pi-mono-/, ""));
267
+ } catch {
268
+ return titleCaseWords(fallback);
269
+ }
270
+ }
271
+
272
+ function extractRegisteredToolNames(source: string): string[] {
273
+ const names = new Set<string>();
274
+ const patterns = [
275
+ /registerTool\s*\(\s*{[\s\S]*?name:\s*["']([^"']+)["']/g,
276
+ /toolName:\s*["']([^"']+)["']/g,
277
+ ];
278
+ for (const pattern of patterns) {
279
+ let match: RegExpExecArray | null;
280
+ while ((match = pattern.exec(source))) names.add(match[1]!);
281
+ }
282
+ return [...names];
283
+ }
284
+
178
285
  // ---------------------------------------------------------------------------
179
286
  // Parsing
180
287
  // ---------------------------------------------------------------------------
@@ -198,6 +305,7 @@ async function parseSessionFile(
198
305
  if (signal?.aborted) return null;
199
306
 
200
307
  const turns: RawTurn[] = [];
308
+ const tools: RawToolUse[] = [];
201
309
  let sessionId = "";
202
310
  const lines = raw.trim().split("\n");
203
311
 
@@ -222,6 +330,16 @@ async function parseSessionFile(
222
330
 
223
331
  if (entry.type !== "message") continue;
224
332
  const msg = entry.message;
333
+ if (msg?.role === "toolResult" && typeof msg.toolName === "string") {
334
+ const ts = timestampForMessage(entry, msg);
335
+ tools.push({
336
+ sessionId: "", // filled later once header parsed
337
+ name: msg.toolName,
338
+ resultTokens: estimateTextTokens(toolResultText(msg.content)),
339
+ ts,
340
+ });
341
+ continue;
342
+ }
225
343
  if (!msg || msg.role !== "assistant" || !msg.usage || !msg.provider || !msg.model) continue;
226
344
 
227
345
  const input = numeric(msg.usage.input);
@@ -230,13 +348,7 @@ async function parseSessionFile(
230
348
  const cacheWrite = numeric(msg.usage.cacheWrite);
231
349
  const cost = numeric(msg.usage.cost?.total);
232
350
 
233
- const tsCandidate =
234
- typeof msg.timestamp === "number"
235
- ? msg.timestamp
236
- : entry.timestamp
237
- ? Date.parse(entry.timestamp)
238
- : 0;
239
- const ts = Number.isFinite(tsCandidate) ? Number(tsCandidate) : 0;
351
+ const ts = timestampForMessage(entry, msg);
240
352
 
241
353
  const fp = turnFingerprint({ input, output, cacheRead, cacheWrite, ts });
242
354
  if (seen.has(fp)) continue;
@@ -257,7 +369,8 @@ async function parseSessionFile(
257
369
 
258
370
  if (!sessionId) return null;
259
371
  for (const turn of turns) turn.sessionId = sessionId;
260
- return { sessionId, turns };
372
+ for (const tool of tools) tool.sessionId = sessionId;
373
+ return { sessionId, turns, tools };
261
374
  }
262
375
 
263
376
  function numeric(value: unknown): number {
@@ -265,6 +378,48 @@ function numeric(value: unknown): number {
265
378
  return Number.isFinite(n) ? n : 0;
266
379
  }
267
380
 
381
+ function timestampForMessage(entry: any, msg: any): number {
382
+ const tsCandidate =
383
+ typeof msg?.timestamp === "number"
384
+ ? msg.timestamp
385
+ : entry.timestamp
386
+ ? Date.parse(entry.timestamp)
387
+ : 0;
388
+ return Number.isFinite(tsCandidate) ? Number(tsCandidate) : 0;
389
+ }
390
+
391
+ function toolResultText(content: unknown): string {
392
+ if (typeof content === "string") return content;
393
+ if (!Array.isArray(content)) return "";
394
+ return content
395
+ .map((part) => {
396
+ if (typeof part === "string") return part;
397
+ if (part && typeof part === "object" && "text" in part) return String((part as { text?: unknown }).text ?? "");
398
+ return "";
399
+ })
400
+ .join("\n");
401
+ }
402
+
403
+ function estimateTextTokens(text: string): number {
404
+ if (!text) return 0;
405
+ return Math.ceil(text.length / 4);
406
+ }
407
+
408
+ function groupForTool(name: string, registry: ToolRegistry): string {
409
+ const registeredGroup = registry.get(name);
410
+ if (registeredGroup) return registeredGroup;
411
+ if (BUILT_IN_TOOLS.has(name)) return "Built-in";
412
+ return "Other";
413
+ }
414
+
415
+ function titleCaseWords(value: string): string {
416
+ return value
417
+ .split(/[\s_-]+/)
418
+ .filter(Boolean)
419
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
420
+ .join(" ");
421
+ }
422
+
268
423
  // ---------------------------------------------------------------------------
269
424
  // Aggregation
270
425
  // ---------------------------------------------------------------------------
@@ -281,9 +436,18 @@ function emptyProvider(): ProviderBucket {
281
436
  return { ...emptyAggregate(), models: new Map() };
282
437
  }
283
438
 
439
+ function emptyToolBucket(): ToolBucket {
440
+ return { calls: 0, resultTokens: 0, sessions: new Set() };
441
+ }
442
+
443
+ function emptyToolGroup(): ToolGroupBucket {
444
+ return { ...emptyToolBucket(), tools: new Map() };
445
+ }
446
+
284
447
  function emptyPeriod(): PeriodReport {
285
448
  return {
286
449
  providers: new Map(),
450
+ toolGroups: new Map(),
287
451
  totals: emptyAggregate(),
288
452
  turns: [],
289
453
  insights: [],
@@ -300,11 +464,17 @@ function applyTurn(target: Aggregate, sessionId: string, turn: RawTurn): void {
300
464
  target.sessions.add(sessionId);
301
465
  }
302
466
 
467
+ function applyTool(target: ToolBucket, sessionId: string, tool: RawToolUse): void {
468
+ target.calls += 1;
469
+ target.resultTokens += tool.resultTokens;
470
+ target.sessions.add(sessionId);
471
+ }
472
+
303
473
  function periodsFor(ts: number, b: PeriodBoundaries): Period[] {
304
474
  const periods: Period[] = ["all"];
305
475
  if (ts >= b.dayStart) periods.push("day");
306
476
  if (ts >= b.weekStart) periods.push("week");
307
- else if (ts >= b.lastWeekStart) periods.push("lastWeek");
477
+ if (ts >= b.monthStart) periods.push("month");
308
478
  return periods;
309
479
  }
310
480
 
@@ -318,13 +488,14 @@ function computeBoundaries(now = new Date()): PeriodBoundaries {
318
488
  week.setDate(week.getDate() - offsetToMonday);
319
489
  week.setHours(0, 0, 0, 0);
320
490
 
321
- const lastWeek = new Date(week);
322
- lastWeek.setDate(lastWeek.getDate() - 7);
491
+ const month = new Date(now);
492
+ month.setDate(1);
493
+ month.setHours(0, 0, 0, 0);
323
494
 
324
495
  return {
325
496
  dayStart: day.getTime(),
326
497
  weekStart: week.getTime(),
327
- lastWeekStart: lastWeek.getTime(),
498
+ monthStart: month.getTime(),
328
499
  };
329
500
  }
330
501
 
@@ -360,12 +531,34 @@ function placeTurn(
360
531
  }
361
532
  }
362
533
 
534
+ function placeTool(
535
+ report: UsageReport,
536
+ tool: RawToolUse,
537
+ boundaries: PeriodBoundaries,
538
+ toolRegistry: ToolRegistry,
539
+ ): void {
540
+ for (const period of periodsFor(tool.ts, boundaries)) {
541
+ const slice = report[period];
542
+ const groupName = groupForTool(tool.name, toolRegistry);
543
+ const group = slice.toolGroups.get(groupName) ?? emptyToolGroup();
544
+ applyTool(group, tool.sessionId, tool);
545
+
546
+ const toolBucket = group.tools.get(tool.name) ?? emptyToolBucket();
547
+ applyTool(toolBucket, tool.sessionId, tool);
548
+
549
+ group.tools.set(tool.name, toolBucket);
550
+ slice.toolGroups.set(groupName, group);
551
+ }
552
+ }
553
+
363
554
  async function buildReport(signal?: AbortSignal): Promise<UsageReport | null> {
364
555
  const boundaries = computeBoundaries();
556
+ const toolRegistry = await buildToolRegistry(signal);
557
+ if (signal?.aborted) return null;
365
558
  const report: UsageReport = {
366
559
  day: emptyPeriod(),
367
560
  week: emptyPeriod(),
368
- lastWeek: emptyPeriod(),
561
+ month: emptyPeriod(),
369
562
  all: emptyPeriod(),
370
563
  lifespans: new Map(),
371
564
  };
@@ -379,6 +572,7 @@ async function buildReport(signal?: AbortSignal): Promise<UsageReport | null> {
379
572
  const session = await parseSessionFile(file, seen, signal);
380
573
  if (!session) continue;
381
574
  for (const turn of session.turns) placeTurn(report, turn, boundaries);
575
+ for (const tool of session.tools) placeTool(report, tool, boundaries, toolRegistry);
382
576
  await new Promise<void>((resolve) => setImmediate(resolve));
383
577
  }
384
578
 
@@ -571,6 +765,35 @@ function formatPercent(p: number): string {
571
765
  return `${(Math.round(p * 10) / 10).toFixed(1)}%`;
572
766
  }
573
767
 
768
+ function formatDate(date: Date): string {
769
+ return new Intl.DateTimeFormat(undefined, {
770
+ month: "short",
771
+ day: "numeric",
772
+ year: "numeric",
773
+ }).format(date);
774
+ }
775
+
776
+ function dateRangeForPeriod(period: Period, report: UsageReport, now = new Date()): [Date, Date] {
777
+ const boundaries = computeBoundaries(now);
778
+ switch (period) {
779
+ case "day":
780
+ return [new Date(boundaries.dayStart), now];
781
+ case "week":
782
+ return [new Date(boundaries.weekStart), now];
783
+ case "month":
784
+ return [new Date(boundaries.monthStart), now];
785
+ case "all": {
786
+ let first = Number.POSITIVE_INFINITY;
787
+ let last = 0;
788
+ for (const span of report.lifespans.values()) {
789
+ if (span.first > 0 && span.first < first) first = span.first;
790
+ if (span.last > last) last = span.last;
791
+ }
792
+ return Number.isFinite(first) ? [new Date(first), new Date(last)] : [now, now];
793
+ }
794
+ }
795
+ }
796
+
574
797
  function humanThreshold(n: number): string {
575
798
  if (n >= 1_000_000) return `${n / 1_000_000}M`;
576
799
  if (n >= 1_000) return `${n / 1_000}k`;
@@ -597,6 +820,11 @@ function pickFitting(width: number, options: string[]): string {
597
820
  return options[options.length - 1] ?? "";
598
821
  }
599
822
 
823
+ function randomItem<T>(items: readonly T[]): T | undefined {
824
+ if (items.length === 0) return undefined;
825
+ return items[Math.floor(Math.random() * items.length)];
826
+ }
827
+
600
828
  // ---------------------------------------------------------------------------
601
829
  // Table layout
602
830
  // ---------------------------------------------------------------------------
@@ -608,6 +836,12 @@ interface TableColumn {
608
836
  value: (row: Aggregate) => string;
609
837
  }
610
838
 
839
+ interface ToolColumn {
840
+ label: string;
841
+ width: number;
842
+ value: (row: ToolBucket) => string;
843
+ }
844
+
611
845
  const COL_SESSIONS: TableColumn = {
612
846
  label: "Sess",
613
847
  width: 7,
@@ -663,6 +897,14 @@ interface TableLayout {
663
897
  compact: boolean;
664
898
  }
665
899
 
900
+ function toolColumns(): ToolColumn[] {
901
+ return [
902
+ { label: "Calls", width: 8, value: (r) => formatCount(r.calls) },
903
+ { label: "Result", width: 9, value: (r) => formatTokens(r.resultTokens) },
904
+ { label: "Sess", width: 7, value: (r) => formatCount(r.sessions.size) },
905
+ ];
906
+ }
907
+
666
908
  function pickLayout(width: number): TableLayout {
667
909
  const safe = Math.max(width, 0);
668
910
  const choose = (candidate: LayoutCandidate): TableLayout => {
@@ -693,6 +935,7 @@ class UsagePanel {
693
935
  private cursor = 0;
694
936
  private expanded = new Set<string>();
695
937
  private providerOrder: string[] = [];
938
+ private toolGroupOrder: string[] = [];
696
939
  private impactCache = new Map<Period, AiEstimateResult | null>();
697
940
 
698
941
  constructor(
@@ -702,6 +945,7 @@ class UsagePanel {
702
945
  private readonly close: () => void,
703
946
  ) {
704
947
  this.refreshProviderOrder();
948
+ this.refreshToolGroupOrder();
705
949
  }
706
950
 
707
951
  handleInput(input: string): void {
@@ -725,20 +969,23 @@ class UsagePanel {
725
969
  if (input === "1") return this.gotoView("summary");
726
970
  if (input === "2") return this.gotoView("providers");
727
971
  if (input === "3") return this.gotoView("patterns");
972
+ if (input === "4") return this.gotoView("tools");
728
973
 
729
- if (this.view !== "providers") return;
974
+ if (this.view !== "providers" && this.view !== "tools") return;
975
+
976
+ const order = this.view === "providers" ? this.providerOrder : this.toolGroupOrder;
730
977
 
731
978
  if (matchesKey(input, "up") && this.cursor > 0) {
732
979
  this.cursor--;
733
980
  this.requestRender();
734
- } else if (matchesKey(input, "down") && this.cursor < this.providerOrder.length - 1) {
981
+ } else if (matchesKey(input, "down") && this.cursor < order.length - 1) {
735
982
  this.cursor++;
736
983
  this.requestRender();
737
984
  } else if (matchesKey(input, "enter") || matchesKey(input, "space")) {
738
- const provider = this.providerOrder[this.cursor];
739
- if (provider) {
740
- if (this.expanded.has(provider)) this.expanded.delete(provider);
741
- else this.expanded.add(provider);
985
+ const expandable = order[this.cursor];
986
+ if (expandable) {
987
+ if (this.expanded.has(expandable)) this.expanded.delete(expandable);
988
+ else this.expanded.add(expandable);
742
989
  this.requestRender();
743
990
  }
744
991
  }
@@ -755,6 +1002,10 @@ class UsagePanel {
755
1002
  }
756
1003
  case "patterns":
757
1004
  return clipLines([...head, ...this.renderPatterns(width)], width);
1005
+ case "tools": {
1006
+ const layout = pickLayout(width);
1007
+ return clipLines([...head, ...this.renderTools(layout)], width);
1008
+ }
758
1009
  }
759
1010
  }
760
1011
 
@@ -771,11 +1022,20 @@ class UsagePanel {
771
1022
  this.cursor = Math.min(this.cursor, Math.max(0, this.providerOrder.length - 1));
772
1023
  }
773
1024
 
1025
+ private refreshToolGroupOrder(): void {
1026
+ const slice = this.report[this.period];
1027
+ this.toolGroupOrder = Array.from(slice.toolGroups.entries())
1028
+ .sort((a, b) => b[1].calls - a[1].calls || b[1].resultTokens - a[1].resultTokens)
1029
+ .map(([name]) => name);
1030
+ this.cursor = Math.min(this.cursor, Math.max(0, this.toolGroupOrder.length - 1));
1031
+ }
1032
+
774
1033
  private shiftPeriod(direction: 1 | -1): void {
775
1034
  const idx = PERIOD_ORDER.indexOf(this.period);
776
1035
  const next = (idx + direction + PERIOD_ORDER.length) % PERIOD_ORDER.length;
777
1036
  this.period = PERIOD_ORDER[next]!;
778
1037
  this.refreshProviderOrder();
1038
+ this.refreshToolGroupOrder();
779
1039
  this.requestRender();
780
1040
  }
781
1041
 
@@ -808,7 +1068,8 @@ class UsagePanel {
808
1068
  const title = th.fg("accent", th.bold("Pi Usage"));
809
1069
  const tabs = this.renderViewTabs();
810
1070
  const periods = this.renderPeriodTabs(width);
811
- return [title, "", periods, tabs, ""];
1071
+ const dateRange = th.fg("dim", this.renderPeriodDateRange());
1072
+ return [title, "", periods, tabs, dateRange, ""];
812
1073
  }
813
1074
 
814
1075
  private renderViewTabs(): string {
@@ -830,6 +1091,11 @@ class UsagePanel {
830
1091
  return pickFitting(width, [full, `${fallback} ${th.fg("dim", "[Tab/←→]")}`, fallback]);
831
1092
  }
832
1093
 
1094
+ private renderPeriodDateRange(): string {
1095
+ const [from, to] = dateRangeForPeriod(this.period, this.report);
1096
+ return `From ${formatDate(from)} to ${formatDate(to)}`;
1097
+ }
1098
+
833
1099
  // ----- summary view -----------------------------------------------------
834
1100
 
835
1101
  private renderSummary(width: number): string[] {
@@ -857,7 +1123,7 @@ class UsagePanel {
857
1123
  }
858
1124
 
859
1125
  lines.push(th.bold("Sustainability"));
860
- lines.push(th.fg("dim", "Estimated using impact-equivalences (illustrative ranges)."));
1126
+ lines.push(th.fg("dim", "AI estimates are approximate inference ranges using impact-equivalences."));
861
1127
  lines.push("");
862
1128
  lines.push(...this.renderImpactBlock(width));
863
1129
  lines.push("");
@@ -921,7 +1187,7 @@ class UsagePanel {
921
1187
  const carbon = impact.carbon.kgCO2e;
922
1188
  const lines: string[] = [];
923
1189
 
924
- const profileNote = `${impact.profile.label} · grid ${impact.region.label}`;
1190
+ const profileNote = `${impact.profile.label} · ${impact.region.label}`;
925
1191
  lines.push(`${indent}${th.fg("dim", profileNote)}`);
926
1192
 
927
1193
  lines.push(
@@ -931,24 +1197,11 @@ class UsagePanel {
931
1197
  `${indent}${th.fg("dim", padTo("Carbon", 12, "right"))} ${th.bold(formatRange(carbon.min, carbon.typical, carbon.max, "kg CO₂e"))}`,
932
1198
  );
933
1199
 
934
- const equivalents = impact.equivalents.slice(0, 3);
935
- if (equivalents.length > 0) {
936
- lines.push("");
937
- lines.push(`${indent}${th.fg("dim", "Roughly equivalent to:")}`);
938
- const bodyWidth = Math.max(20, width - indent.length - 4);
939
- for (const phrase of equivalents) {
940
- const wrapped = wrapTextWithAnsi(`• ${phrase}`, bodyWidth);
941
- for (let i = 0; i < wrapped.length; i++) {
942
- const prefix = i === 0 ? `${indent} ` : `${indent} `;
943
- lines.push(`${prefix}${wrapped[i]}`);
944
- }
945
- }
946
- }
947
-
948
- if (impact.disclaimer) {
1200
+ const equivalent = randomItem(impact.equivalents);
1201
+ if (equivalent) {
949
1202
  lines.push("");
950
1203
  const wrapped = wrapTextWithAnsi(
951
- th.fg("dim", impact.disclaimer),
1204
+ th.fg("dim", `Roughly equivalent to ${equivalent}`),
952
1205
  Math.max(20, width - indent.length),
953
1206
  );
954
1207
  for (const part of wrapped) lines.push(`${indent}${part}`);
@@ -1047,6 +1300,96 @@ class UsagePanel {
1047
1300
  return [th.fg("border", "─".repeat(layout.totalWidth)), row, ""];
1048
1301
  }
1049
1302
 
1303
+ // ----- tools view --------------------------------------------------------
1304
+
1305
+ private renderTools(layout: TableLayout): string[] {
1306
+ const th = this.theme;
1307
+ const slice = this.report[this.period];
1308
+ const lines: string[] = [];
1309
+ const columns = toolColumns();
1310
+ const totalWidth = layout.nameWidth + columns.reduce((acc, col) => acc + col.width, 0);
1311
+
1312
+ lines.push(th.bold("Extensions / tools"));
1313
+ lines.push(th.fg("dim", "Sorted by call count. Result tokens are estimated from tool output size."));
1314
+ lines.push("");
1315
+ lines.push(this.renderToolHeader(layout.nameWidth, columns));
1316
+ lines.push(th.fg("border", "─".repeat(totalWidth)));
1317
+
1318
+ if (this.toolGroupOrder.length === 0) {
1319
+ lines.push(th.fg("dim", " No tool usage recorded for this period."));
1320
+ } else {
1321
+ for (let i = 0; i < this.toolGroupOrder.length; i++) {
1322
+ const name = this.toolGroupOrder[i]!;
1323
+ const group = slice.toolGroups.get(name)!;
1324
+ const isSelected = i === this.cursor;
1325
+ const isExpanded = this.expanded.has(name);
1326
+ lines.push(this.renderToolGroupRow(name, group, layout.nameWidth, columns, isSelected, isExpanded));
1327
+
1328
+ if (isExpanded) {
1329
+ const tools = Array.from(group.tools.entries()).sort(
1330
+ (a, b) => b[1].calls - a[1].calls || b[1].resultTokens - a[1].resultTokens,
1331
+ );
1332
+ for (const [toolName, stats] of tools) {
1333
+ lines.push(this.renderToolRow(toolName, stats, layout.nameWidth, columns));
1334
+ }
1335
+ }
1336
+ }
1337
+ }
1338
+
1339
+ lines.push(th.fg("border", "─".repeat(totalWidth)));
1340
+ lines.push(this.renderToolTotalRow(slice, layout.nameWidth, columns));
1341
+ lines.push("");
1342
+ lines.push(...this.renderHelp(totalWidth));
1343
+ return lines;
1344
+ }
1345
+
1346
+ private renderToolHeader(nameWidth: number, columns: ToolColumn[]): string {
1347
+ const th = this.theme;
1348
+ let header = padTo("Extension / Tool", nameWidth);
1349
+ for (const col of columns) header += th.fg("dim", padTo(col.label, col.width, "left"));
1350
+ return th.fg("muted", header);
1351
+ }
1352
+
1353
+ private renderToolGroupRow(
1354
+ name: string,
1355
+ stats: ToolGroupBucket,
1356
+ nameWidth: number,
1357
+ columns: ToolColumn[],
1358
+ selected: boolean,
1359
+ expanded: boolean,
1360
+ ): string {
1361
+ const th = this.theme;
1362
+ const arrow = expanded ? "▾" : "▸";
1363
+ const prefix = selected ? th.fg("accent", `${arrow} `) : th.fg("dim", `${arrow} `);
1364
+ const innerWidth = Math.max(nameWidth - 2, 0);
1365
+ const display = innerWidth > 0 ? truncateToWidth(name, innerWidth) : "";
1366
+ const styled = selected ? th.fg("accent", display) : display;
1367
+ return prefix + padTo(styled, innerWidth) + columns.map((col) => padTo(col.value(stats), col.width, "left")).join("");
1368
+ }
1369
+
1370
+ private renderToolRow(
1371
+ name: string,
1372
+ stats: ToolBucket,
1373
+ nameWidth: number,
1374
+ columns: ToolColumn[],
1375
+ ): string {
1376
+ const th = this.theme;
1377
+ const indent = " ";
1378
+ const innerWidth = Math.max(nameWidth - indent.length, 0);
1379
+ const display = innerWidth > 0 ? truncateToWidth(name, innerWidth) : "";
1380
+ return indent + padTo(th.fg("dim", display), innerWidth) + columns.map((col) => th.fg("dim", padTo(col.value(stats), col.width, "left"))).join("");
1381
+ }
1382
+
1383
+ private renderToolTotalRow(slice: PeriodReport, nameWidth: number, columns: ToolColumn[]): string {
1384
+ const total = emptyToolBucket();
1385
+ for (const group of slice.toolGroups.values()) {
1386
+ total.calls += group.calls;
1387
+ total.resultTokens += group.resultTokens;
1388
+ for (const sessionId of group.sessions) total.sessions.add(sessionId);
1389
+ }
1390
+ return padTo(this.theme.bold("Total"), nameWidth) + columns.map((col) => padTo(col.value(total), col.width, "left")).join("");
1391
+ }
1392
+
1050
1393
  // ----- patterns view ----------------------------------------------------
1051
1394
 
1052
1395
  private renderPatterns(width: number): string[] {
@@ -1086,14 +1429,14 @@ class UsagePanel {
1086
1429
  private renderHelp(width: number): string[] {
1087
1430
  const th = this.theme;
1088
1431
  const variants =
1089
- this.view === "providers"
1432
+ this.view === "providers" || this.view === "tools"
1090
1433
  ? [
1091
- "[Tab/←→] period · [↑↓] select · [Enter] expand · [v/1-3] view · [q] close",
1434
+ "[Tab/←→] period · [↑↓] select · [Enter] expand · [v/1-4] view · [q] close",
1092
1435
  "[Tab] period · [↑↓] · [Enter] · [v] view · [q]",
1093
1436
  "[↑↓] · [Enter] · [q]",
1094
1437
  ]
1095
1438
  : [
1096
- "[Tab/←→] period · [v/1-3] view · [q] close",
1439
+ "[Tab/←→] period · [v/1-4] view · [q] close",
1097
1440
  "[Tab] period · [v] view · [q]",
1098
1441
  "[v] view · [q]",
1099
1442
  ];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mono-usage",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Pi extension that aggregates local session usage, cost and sustainability impact",
5
5
  "keywords": [
6
6
  "pi-package",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mono-all",
3
- "version": "1.2.0",
3
+ "version": "1.2.1",
4
4
  "description": "All pi-mono extensions and bundled skills",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -10,23 +10,23 @@
10
10
  ],
11
11
  "dependencies": {
12
12
  "pi-mono-ask-user-question": "1.7.4",
13
+ "pi-mono-auto-fix": "0.3.1",
13
14
  "pi-mono-btw": "1.7.4",
14
15
  "pi-mono-clear": "1.7.3",
15
16
  "pi-mono-context": "0.1.1",
16
- "pi-mono-auto-fix": "0.3.1",
17
17
  "pi-mono-context-guard": "1.7.3",
18
18
  "pi-mono-linear": "0.2.2",
19
19
  "pi-mono-figma": "0.2.2",
20
- "pi-mono-loop": "1.7.3",
21
- "pi-mono-multi-edit": "1.7.3",
22
- "pi-mono-review": "1.8.2",
23
20
  "pi-common": "0.1.1",
24
- "pi-mono-sentinel": "1.10.2",
21
+ "pi-mono-review": "1.8.2",
22
+ "pi-mono-loop": "1.7.3",
25
23
  "pi-mono-simplify": "1.7.3",
26
- "pi-mono-team-mode": "2.3.2",
27
24
  "pi-mono-status-line": "1.7.3",
28
- "pi-mono-usage": "0.1.0",
29
- "pi-mono-web-search": "0.1.0"
25
+ "pi-mono-multi-edit": "1.7.3",
26
+ "pi-mono-team-mode": "2.3.2",
27
+ "pi-mono-usage": "0.1.1",
28
+ "pi-mono-web-search": "0.1.0",
29
+ "pi-mono-sentinel": "1.10.2"
30
30
  },
31
31
  "bundledDependencies": [
32
32
  "pi-mono-ask-user-question",