pi-extensions 0.1.22 → 0.1.23

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.
@@ -0,0 +1,19 @@
1
+ name: Apply Skill Optimization
2
+ on:
3
+ issue_comment:
4
+ types: [created]
5
+
6
+ jobs:
7
+ apply:
8
+ if: >-
9
+ github.event.issue.pull_request &&
10
+ contains(github.event.comment.body, '/apply-optimize')
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ pull-requests: write
14
+ contents: write
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
18
+ with:
19
+ mode: 'apply'
@@ -0,0 +1,17 @@
1
+ name: Skill Review & Optimize
2
+ on:
3
+ pull_request:
4
+ paths: ['**/SKILL.md']
5
+
6
+ jobs:
7
+ review:
8
+ runs-on: ubuntu-latest
9
+ permissions:
10
+ pull-requests: write
11
+ contents: read
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: tesslio/skill-review-and-optimize@d81583861aaf29d1da7f10e6539efef4e27b0dd5
15
+ with:
16
+ optimize: 'true'
17
+ tessl-token: ${{ secrets.TESSL_API_TOKEN }}
@@ -1,30 +1,61 @@
1
1
  ---
2
2
  name: extending-pi
3
- description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then create and package them. Use when someone wants to extend Pi, add capabilities, create a skill, build an extension, or make a Pi package.
3
+ description: Guide for extending Pi — decide between skills, extensions, prompt templates, themes, context files, or custom models, then scaffold files, configure manifests, and package them. Use when someone wants to extend Pi, add capabilities, create a skill, build an extension, make a Pi package, scaffold extension files, or configure a manifest.json.
4
4
  ---
5
5
 
6
6
  # Extending Pi
7
7
 
8
- Help the user decide what to build and where to find guidance.
8
+ Help the user decide what to build, scaffold the right files, and point to detailed guidance.
9
9
 
10
10
  ## What to build
11
11
 
12
- | Goal | Build a… | Where |
13
- |------|----------|-------|
14
- | Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | Read `skill-creator/SKILL.md` for detailed guidance |
15
- | Give Pi a new tool, command, or runtime behavior | **Extension** | Read Pi docs: `docs/extensions.md` |
16
- | Reuse a prompt pattern with variables | **Prompt template** | Read Pi docs: `docs/prompt-templates.md` |
17
- | Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown |
18
- | Change Pi's appearance | **Theme** | Read Pi docs: `docs/themes.md` |
19
- | Add a model or provider | **Custom model** | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
20
- | Share any of the above | **Package** | Read Pi docs: `docs/packages.md` |
12
+ | Goal | Build a… | Key files to create | Where |
13
+ |------|----------|-------------------|-------|
14
+ | Teach Pi a workflow or how to use a tool/API/CLI | **Skill** | `SKILL.md` with YAML frontmatter + markdown body | Read `skill-creator/SKILL.md` for detailed guidance |
15
+ | Give Pi a new tool, command, or runtime behavior | **Extension** | `manifest.json` + `src/index.ts` entry point | Read Pi docs: `docs/extensions.md` |
16
+ | Reuse a prompt pattern with variables | **Prompt template** | `.md` file with `{{variable}}` placeholders | Read Pi docs: `docs/prompt-templates.md` |
17
+ | Set project-wide coding guidelines | **Context file** | `AGENTS.md` in project root or `.pi/agent/` — just markdown | No extra docs needed |
18
+ | Change Pi's appearance | **Theme** | `theme.json` with color and font definitions | Read Pi docs: `docs/themes.md` |
19
+ | Add a model or provider | **Custom model** | `models.json` or extension with provider registration | Read Pi docs: `docs/models.md` (JSON) or `docs/custom-provider.md` (extension) |
20
+ | Share any of the above | **Package** | `manifest.json` with dependencies and entry points | Read Pi docs: `docs/packages.md` |
21
21
 
22
22
  ## Skill vs Extension — the fuzzy boundary
23
23
 
24
- If `bash` + instructions can do it, prefer a **skill** (simpler, no code to maintain). If you need event hooks, typed tools, UI components, or policy enforcement, use an **extension**.
24
+ If `bash` + instructions can do it, prefer a **Skill** (simpler, no code to maintain). If you need event hooks, typed tools, UI components, or policy enforcement, use an **Extension**.
25
25
 
26
26
  Examples:
27
27
  - "Pi should know our deploy process" → **Skill** (workflow instructions)
28
28
  - "Pi should confirm before `rm -rf`" → **Extension** (event interception)
29
29
  - "Pi should use Brave Search" → **Skill** (instructions + CLI scripts)
30
30
  - "Pi should have a structured `db_query` tool" → **Extension** (registerTool)
31
+
32
+ ## Minimal working examples
33
+
34
+ **Skill** — place in `.pi/skills/my-skill/SKILL.md`:
35
+ ```markdown
36
+ ---
37
+ name: my-skill
38
+ description: Does X when the user asks to Y.
39
+ ---
40
+ # My Skill
41
+ Step 1: ...
42
+ Step 2: ...
43
+ ```
44
+
45
+ **Extension** — create `manifest.json` + `src/index.ts`:
46
+ ```json
47
+ { "name": "my-extension", "version": "0.1.0", "entry": "src/index.ts" }
48
+ ```
49
+ ```typescript
50
+ import { registerTool } from "@anthropic/pi-sdk";
51
+ registerTool("my_tool", { description: "..." }, async (input) => { /* ... */ });
52
+ ```
53
+
54
+ ## Quick-start steps
55
+
56
+ 1. **Pick the artifact type** from the table above.
57
+ 2. **Scaffold the files** — create the key files using the minimal examples above.
58
+ 3. **Validate locally**:
59
+ - Skills: place `SKILL.md` in `.pi/skills/<name>/` and invoke the skill — if it doesn't trigger, check that `name` and `description` in frontmatter are set correctly.
60
+ - Extensions: run `pi ext install .` — if it fails with "missing entry", verify the `entry` path in `manifest.json` points to an existing file.
61
+ 4. **Package and share** — follow `docs/packages.md` to bundle and publish.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.22",
3
+ "version": "0.1.23",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -15,15 +15,11 @@
15
15
  "./arcade/tetris.ts",
16
16
  "./arcade/mario-not/mario-not.ts",
17
17
  "./code-actions/index.ts",
18
- "./control/control.ts",
19
18
  "./files-widget/index.ts",
20
19
  "./raw-paste/index.ts",
21
20
  "./ralph-wiggum/index.ts",
22
- "./review/review.ts",
23
- "./relaunch/index.ts",
24
21
  "./tab-status/tab-status.ts",
25
- "./usage-extension/index.ts",
26
- "./ready-status/ready-status.ts"
22
+ "./usage-extension/index.ts"
27
23
  ],
28
24
  "skills": [
29
25
  "./extending-pi/SKILL.md",
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4 - 2026-04-09
4
+ - Scan session files recursively so nested subagent runs are included in `/usage`
5
+ - Add deduped vs raw counting modes to compare copied branch history against raw file totals
6
+
3
7
  ## 0.1.3 - 2026-02-03
4
8
  - Add preview image metadata for the extension listing.
5
9
 
@@ -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-01-13
10
+ - **Last updated:** 2026-04-09
11
11
 
12
12
  ## Installation
13
13
 
@@ -65,6 +65,15 @@ In Pi, run:
65
65
 
66
66
  Use `Tab` or `←`/`→` to switch between periods.
67
67
 
68
+ ### Count Modes
69
+
70
+ | Mode | Definition |
71
+ |------|------------|
72
+ | **Deduped** | Default. Deduplicates copied assistant history across branched session files |
73
+ | **Raw** | Counts every assistant message found in every session file |
74
+
75
+ Both modes scan nested session files recursively, so subagent runs are included.
76
+
68
77
  ### Timezone
69
78
 
70
79
  Time periods are calculated in the local timezone where Pi runs. If you want to override it, set the `TZ` environment variable (IANA timezone, e.g. `TZ=UTC` or `TZ=America/New_York`) before launching Pi.
@@ -87,6 +96,9 @@ Time periods are calculated in the local timezone where Pi runs. If you want to
87
96
  | Key | Action |
88
97
  |-----|--------|
89
98
  | `Tab` / `←` `→` | Switch time period |
99
+ | `m` | Cycle count mode |
100
+ | `d` | Switch to deduped mode |
101
+ | `r` | Switch to raw mode |
90
102
  | `↑` `↓` | Select provider |
91
103
  | `Enter` / `Space` | Expand/collapse provider to show models |
92
104
  | `q` / `Esc` | Close |
@@ -111,7 +123,11 @@ The "Cache" column combines both read and write tokens.
111
123
 
112
124
  ## Data Source
113
125
 
114
- Statistics are parsed from session files in `~/.pi/agent/sessions/`. Each session is a JSONL file containing message entries with usage data. Assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens (matching ccusage).
126
+ 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.
127
+
128
+ In **Deduped** mode, assistant messages duplicated across branched session files are deduplicated by timestamp + total tokens (matching the extension's previous behavior and keeping totals comparable with earlier releases).
129
+
130
+ In **Raw** mode, every assistant message found in every session file is counted.
115
131
 
116
132
  Respects the `PI_CODING_AGENT_DIR` environment variable if set.
117
133
 
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Shows an inline view with usage stats grouped by provider.
5
5
  * - Tab cycles: Today → This Week → All Time
6
+ * - D toggles deduped view, R toggles raw view, M cycles both
6
7
  * - Arrow keys navigate providers
7
8
  * - Enter expands/collapses to show models
8
9
  */
@@ -56,6 +57,8 @@ interface UsageData {
56
57
  }
57
58
 
58
59
  type TabName = "today" | "thisWeek" | "allTime";
60
+ type UsageCountMode = "deduped" | "raw";
61
+ type UsageDataByMode = Record<UsageCountMode, UsageData>;
59
62
 
60
63
  // =============================================================================
61
64
  // Column Configuration
@@ -96,31 +99,27 @@ function getSessionsDir(): string {
96
99
  return join(agentDir, "sessions");
97
100
  }
98
101
 
99
- async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
100
- const sessionsDir = getSessionsDir();
101
- const files: string[] = [];
102
-
102
+ async function collectSessionFilesRecursively(dir: string, files: string[], signal?: AbortSignal): Promise<void> {
103
103
  try {
104
- const cwdDirs = await readdir(sessionsDir, { withFileTypes: true });
105
- for (const dir of cwdDirs) {
106
- if (signal?.aborted) return files;
107
- if (!dir.isDirectory()) continue;
108
- const cwdPath = join(sessionsDir, dir.name);
109
- try {
110
- const sessionFiles = await readdir(cwdPath);
111
- for (const file of sessionFiles) {
112
- if (file.endsWith(".jsonl")) {
113
- files.push(join(cwdPath, file));
114
- }
115
- }
116
- } catch {
117
- // Skip directories we can't read
104
+ const entries = await readdir(dir, { withFileTypes: true });
105
+ for (const entry of entries) {
106
+ if (signal?.aborted) return;
107
+ const entryPath = join(dir, entry.name);
108
+ if (entry.isDirectory()) {
109
+ await collectSessionFilesRecursively(entryPath, files, signal);
110
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
111
+ files.push(entryPath);
118
112
  }
119
113
  }
120
114
  } catch {
121
- // Return empty if we can't read sessions dir
115
+ // Skip directories we can't read
122
116
  }
117
+ }
123
118
 
119
+ async function getAllSessionFiles(signal?: AbortSignal): Promise<string[]> {
120
+ const files: string[] = [];
121
+ await collectSessionFilesRecursively(getSessionsDir(), files, signal);
122
+ files.sort();
124
123
  return files;
125
124
  }
126
125
 
@@ -135,16 +134,23 @@ interface SessionMessage {
135
134
  timestamp: number;
136
135
  }
137
136
 
137
+ interface ParsedSessionFile {
138
+ sessionId: string;
139
+ rawMessages: SessionMessage[];
140
+ dedupedMessages: SessionMessage[];
141
+ }
142
+
138
143
  async function parseSessionFile(
139
144
  filePath: string,
140
145
  seenHashes: Set<string>,
141
146
  signal?: AbortSignal
142
- ): Promise<{ sessionId: string; messages: SessionMessage[] } | null> {
147
+ ): Promise<ParsedSessionFile | null> {
143
148
  try {
144
149
  const content = await readFile(filePath, "utf8");
145
150
  if (signal?.aborted) return null;
146
151
  const lines = content.trim().split("\n");
147
- const messages: SessionMessage[] = [];
152
+ const rawMessages: SessionMessage[] = [];
153
+ const dedupedMessages: SessionMessage[] = [];
148
154
  let sessionId = "";
149
155
 
150
156
  for (let i = 0; i < lines.length; i++) {
@@ -169,14 +175,7 @@ async function parseSessionFile(
169
175
  const fallbackTs = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
170
176
  const timestamp = msg.timestamp || (Number.isNaN(fallbackTs) ? 0 : fallbackTs);
171
177
 
172
- // Deduplicate by timestamp + total tokens (same as ccusage)
173
- // Session files contain many duplicate entries
174
- const totalTokens = input + output + cacheRead + cacheWrite;
175
- const hash = `${timestamp}:${totalTokens}`;
176
- if (seenHashes.has(hash)) continue;
177
- seenHashes.add(hash);
178
-
179
- messages.push({
178
+ const sessionMessage: SessionMessage = {
180
179
  provider: msg.provider,
181
180
  model: msg.model,
182
181
  cost: msg.usage.cost?.total || 0,
@@ -185,7 +184,16 @@ async function parseSessionFile(
185
184
  cacheRead,
186
185
  cacheWrite,
187
186
  timestamp,
188
- });
187
+ };
188
+ rawMessages.push(sessionMessage);
189
+
190
+ // Deduplicate copied history across branched session files.
191
+ // Keep the existing ccusage-style hash so current totals remain comparable.
192
+ const totalTokens = input + output + cacheRead + cacheWrite;
193
+ const hash = `${timestamp}:${totalTokens}`;
194
+ if (seenHashes.has(hash)) continue;
195
+ seenHashes.add(hash);
196
+ dedupedMessages.push(sessionMessage);
189
197
  }
190
198
  }
191
199
  } catch {
@@ -193,7 +201,7 @@ async function parseSessionFile(
193
201
  }
194
202
  }
195
203
 
196
- return sessionId ? { sessionId, messages } : null;
204
+ return sessionId ? { sessionId, rawMessages, dedupedMessages } : null;
197
205
  } catch {
198
206
  return null;
199
207
  }
@@ -232,7 +240,74 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
232
240
  };
233
241
  }
234
242
 
235
- async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null> {
243
+ function emptyUsageData(): UsageData {
244
+ return {
245
+ today: emptyTimeFilteredStats(),
246
+ thisWeek: emptyTimeFilteredStats(),
247
+ allTime: emptyTimeFilteredStats(),
248
+ };
249
+ }
250
+
251
+ function getPeriodsForTimestamp(timestamp: number, todayMs: number, weekStartMs: number): TabName[] {
252
+ const periods: TabName[] = ["allTime"];
253
+ if (timestamp >= todayMs) periods.push("today");
254
+ if (timestamp >= weekStartMs) periods.push("thisWeek");
255
+ return periods;
256
+ }
257
+
258
+ function addMessagesToUsageData(
259
+ data: UsageData,
260
+ sessionId: string,
261
+ messages: SessionMessage[],
262
+ todayMs: number,
263
+ weekStartMs: number
264
+ ): void {
265
+ const sessionContributed = { today: false, thisWeek: false, allTime: false };
266
+
267
+ for (const msg of messages) {
268
+ const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs);
269
+ const tokens = {
270
+ // Total = input + output only. cacheRead/cacheWrite are tracked separately.
271
+ // cacheRead tokens were already counted when first sent, so including them
272
+ // would double-count and massively inflate totals (cache hits repeat every message).
273
+ total: msg.input + msg.output,
274
+ input: msg.input,
275
+ output: msg.output,
276
+ cache: msg.cacheRead + msg.cacheWrite,
277
+ };
278
+
279
+ for (const period of periods) {
280
+ const stats = data[period];
281
+
282
+ let providerStats = stats.providers.get(msg.provider);
283
+ if (!providerStats) {
284
+ providerStats = emptyProviderStats();
285
+ stats.providers.set(msg.provider, providerStats);
286
+ }
287
+
288
+ let modelStats = providerStats.models.get(msg.model);
289
+ if (!modelStats) {
290
+ modelStats = emptyModelStats();
291
+ providerStats.models.set(msg.model, modelStats);
292
+ }
293
+
294
+ modelStats.sessions.add(sessionId);
295
+ accumulateStats(modelStats, msg.cost, tokens);
296
+
297
+ providerStats.sessions.add(sessionId);
298
+ accumulateStats(providerStats, msg.cost, tokens);
299
+
300
+ accumulateStats(stats.totals, msg.cost, tokens);
301
+ sessionContributed[period] = true;
302
+ }
303
+ }
304
+
305
+ if (sessionContributed.today) data.today.totals.sessions++;
306
+ if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
307
+ if (sessionContributed.allTime) data.allTime.totals.sessions++;
308
+ }
309
+
310
+ async function collectUsageData(signal?: AbortSignal): Promise<UsageDataByMode | null> {
236
311
  const startOfToday = new Date();
237
312
  startOfToday.setHours(0, 0, 0, 0);
238
313
  const todayMs = startOfToday.getTime();
@@ -245,15 +320,14 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
245
320
  startOfWeek.setHours(0, 0, 0, 0);
246
321
  const weekStartMs = startOfWeek.getTime();
247
322
 
248
- const data: UsageData = {
249
- today: emptyTimeFilteredStats(),
250
- thisWeek: emptyTimeFilteredStats(),
251
- allTime: emptyTimeFilteredStats(),
323
+ const data: UsageDataByMode = {
324
+ deduped: emptyUsageData(),
325
+ raw: emptyUsageData(),
252
326
  };
253
327
 
254
328
  const sessionFiles = await getAllSessionFiles(signal);
255
329
  if (signal?.aborted) return null;
256
- const seenHashes = new Set<string>(); // Deduplicate across all files
330
+ const seenHashes = new Set<string>();
257
331
 
258
332
  for (const filePath of sessionFiles) {
259
333
  if (signal?.aborted) return null;
@@ -261,59 +335,8 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
261
335
  if (signal?.aborted) return null;
262
336
  if (!parsed) continue;
263
337
 
264
- const { sessionId, messages } = parsed;
265
- const sessionContributed = { today: false, thisWeek: false, allTime: false };
266
-
267
- for (const msg of messages) {
268
- if (signal?.aborted) return null;
269
- const periods: TabName[] = ["allTime"];
270
- if (msg.timestamp >= todayMs) periods.push("today");
271
- if (msg.timestamp >= weekStartMs) periods.push("thisWeek");
272
-
273
- const tokens = {
274
- // Total = input + output only. cacheRead/cacheWrite are tracked separately.
275
- // cacheRead tokens were already counted when first sent, so including them
276
- // would double-count and massively inflate totals (cache hits repeat every message).
277
- total: msg.input + msg.output,
278
- input: msg.input,
279
- output: msg.output,
280
- cache: msg.cacheRead + msg.cacheWrite,
281
- };
282
-
283
- for (const period of periods) {
284
- const stats = data[period];
285
-
286
- // Get or create provider stats
287
- let providerStats = stats.providers.get(msg.provider);
288
- if (!providerStats) {
289
- providerStats = emptyProviderStats();
290
- stats.providers.set(msg.provider, providerStats);
291
- }
292
-
293
- // Get or create model stats
294
- let modelStats = providerStats.models.get(msg.model);
295
- if (!modelStats) {
296
- modelStats = emptyModelStats();
297
- providerStats.models.set(msg.model, modelStats);
298
- }
299
-
300
- // Accumulate stats at all levels
301
- modelStats.sessions.add(sessionId);
302
- accumulateStats(modelStats, msg.cost, tokens);
303
-
304
- providerStats.sessions.add(sessionId);
305
- accumulateStats(providerStats, msg.cost, tokens);
306
-
307
- accumulateStats(stats.totals, msg.cost, tokens);
308
-
309
- sessionContributed[period] = true;
310
- }
311
- }
312
-
313
- // Count unique sessions per period
314
- if (sessionContributed.today) data.today.totals.sessions++;
315
- if (sessionContributed.thisWeek) data.thisWeek.totals.sessions++;
316
- if (sessionContributed.allTime) data.allTime.totals.sessions++;
338
+ addMessagesToUsageData(data.raw, parsed.sessionId, parsed.rawMessages, todayMs, weekStartMs);
339
+ addMessagesToUsageData(data.deduped, parsed.sessionId, parsed.dedupedMessages, todayMs, weekStartMs);
317
340
 
318
341
  await new Promise<void>((resolve) => setImmediate(resolve));
319
342
  }
@@ -372,9 +395,17 @@ const TAB_LABELS: Record<TabName, string> = {
372
395
 
373
396
  const TAB_ORDER: TabName[] = ["today", "thisWeek", "allTime"];
374
397
 
398
+ const MODE_LABELS: Record<UsageCountMode, string> = {
399
+ deduped: "Deduped",
400
+ raw: "Raw",
401
+ };
402
+
403
+ const MODE_ORDER: UsageCountMode[] = ["deduped", "raw"];
404
+
375
405
  class UsageComponent {
376
406
  private activeTab: TabName = "allTime";
377
- private data: UsageData;
407
+ private activeMode: UsageCountMode = "deduped";
408
+ private data: UsageDataByMode;
378
409
  private selectedIndex = 0;
379
410
  private expanded = new Set<string>();
380
411
  private providerOrder: string[] = [];
@@ -382,7 +413,7 @@ class UsageComponent {
382
413
  private requestRender: () => void;
383
414
  private done: () => void;
384
415
 
385
- constructor(theme: Theme, data: UsageData, requestRender: () => void, done: () => void) {
416
+ constructor(theme: Theme, data: UsageDataByMode, requestRender: () => void, done: () => void) {
386
417
  this.theme = theme;
387
418
  this.requestRender = requestRender;
388
419
  this.done = done;
@@ -390,14 +421,25 @@ class UsageComponent {
390
421
  this.updateProviderOrder();
391
422
  }
392
423
 
424
+ private getActiveStats(): TimeFilteredStats {
425
+ return this.data[this.activeMode][this.activeTab];
426
+ }
427
+
393
428
  private updateProviderOrder(): void {
394
- const stats = this.data[this.activeTab];
429
+ const stats = this.getActiveStats();
395
430
  this.providerOrder = Array.from(stats.providers.entries())
396
431
  .sort((a, b) => b[1].cost - a[1].cost)
397
432
  .map(([name]) => name);
398
433
  this.selectedIndex = Math.min(this.selectedIndex, Math.max(0, this.providerOrder.length - 1));
399
434
  }
400
435
 
436
+ private cycleMode(step: 1 | -1): void {
437
+ const idx = MODE_ORDER.indexOf(this.activeMode);
438
+ this.activeMode = MODE_ORDER[(idx + step + MODE_ORDER.length) % MODE_ORDER.length]!;
439
+ this.updateProviderOrder();
440
+ this.requestRender();
441
+ }
442
+
401
443
  handleInput(data: string): void {
402
444
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
403
445
  this.done();
@@ -414,6 +456,20 @@ class UsageComponent {
414
456
  this.activeTab = TAB_ORDER[(idx - 1 + TAB_ORDER.length) % TAB_ORDER.length]!;
415
457
  this.updateProviderOrder();
416
458
  this.requestRender();
459
+ } else if (matchesKey(data, "m")) {
460
+ this.cycleMode(1);
461
+ } else if (matchesKey(data, "d")) {
462
+ if (this.activeMode !== "deduped") {
463
+ this.activeMode = "deduped";
464
+ this.updateProviderOrder();
465
+ this.requestRender();
466
+ }
467
+ } else if (matchesKey(data, "r")) {
468
+ if (this.activeMode !== "raw") {
469
+ this.activeMode = "raw";
470
+ this.updateProviderOrder();
471
+ this.requestRender();
472
+ }
417
473
  } else if (matchesKey(data, "up")) {
418
474
  if (this.selectedIndex > 0) {
419
475
  this.selectedIndex--;
@@ -445,6 +501,7 @@ class UsageComponent {
445
501
  return [
446
502
  ...this.renderTitle(),
447
503
  ...this.renderTabs(),
504
+ ...this.renderModes(),
448
505
  ...this.renderHeader(),
449
506
  ...this.renderRows(),
450
507
  ...this.renderTotals(),
@@ -463,7 +520,19 @@ class UsageComponent {
463
520
  const label = TAB_LABELS[tab];
464
521
  return tab === this.activeTab ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
465
522
  }).join(" ");
466
- return [tabs, ""];
523
+ return [tabs];
524
+ }
525
+
526
+ private renderModes(): string[] {
527
+ const th = this.theme;
528
+ const modes = MODE_ORDER.map((mode) => {
529
+ const label = MODE_LABELS[mode];
530
+ return mode === this.activeMode ? th.fg("accent", `[${label}]`) : th.fg("dim", ` ${label} `);
531
+ }).join(" ");
532
+ const note = this.activeMode === "deduped"
533
+ ? "Dedupes copied branched-history messages. Recursive subagent sessions included."
534
+ : "Counts raw message totals from all session files. Recursive subagent sessions included.";
535
+ return [modes, th.fg("dim", note), ""];
467
536
  }
468
537
 
469
538
  private renderHeader(): string[] {
@@ -504,7 +573,7 @@ class UsageComponent {
504
573
 
505
574
  private renderRows(): string[] {
506
575
  const th = this.theme;
507
- const stats = this.data[this.activeTab];
576
+ const stats = this.getActiveStats();
508
577
  const lines: string[] = [];
509
578
 
510
579
  if (this.providerOrder.length === 0) {
@@ -542,7 +611,7 @@ class UsageComponent {
542
611
 
543
612
  private renderTotals(): string[] {
544
613
  const th = this.theme;
545
- const stats = this.data[this.activeTab];
614
+ const stats = this.getActiveStats();
546
615
 
547
616
  let totalRow = padRight(th.bold("Total"), NAME_COL_WIDTH);
548
617
  for (const col of DATA_COLUMNS) {
@@ -554,7 +623,7 @@ class UsageComponent {
554
623
  }
555
624
 
556
625
  private renderHelp(): string[] {
557
- return [this.theme.fg("dim", "[Tab/←→] period [↑↓] select [Enter] expand [q] close")];
626
+ return [this.theme.fg("dim", "[Tab/←→] period [m/d/r] count mode [↑↓] select [Enter] expand [q] close")];
558
627
  }
559
628
 
560
629
  invalidate(): void {}
@@ -573,7 +642,7 @@ export default function (pi: ExtensionAPI) {
573
642
  return;
574
643
  }
575
644
 
576
- const data = await ctx.ui.custom<UsageData | null>((tui, theme, _kb, done) => {
645
+ const data = await ctx.ui.custom<UsageDataByMode | null>((tui, theme, _kb, done) => {
577
646
  const loader = new CancellableLoader(
578
647
  tui,
579
648
  (s: string) => theme.fg("accent", s),
@@ -581,7 +650,7 @@ export default function (pi: ExtensionAPI) {
581
650
  "Loading Usage..."
582
651
  );
583
652
  let finished = false;
584
- const finish = (value: UsageData | null) => {
653
+ const finish = (value: UsageDataByMode | null) => {
585
654
  if (finished) return;
586
655
  finished = true;
587
656
  loader.dispose();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",