pi-extensions 0.1.27 → 0.1.29

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.
@@ -2,6 +2,17 @@
2
2
 
3
3
  All notable changes to this extension will be documented in this file.
4
4
 
5
+ ## [0.1.16] - 2026-04-19
6
+
7
+ ### Fixed
8
+ - Let `/readfiles` browser search accept `j` and `k` as search text instead of hijacking them for navigation.
9
+ - Fix viewer scrolling so the last lines of a file remain reachable.
10
+ - Restore `G` / `Shift+G` navigation to jump to the bottom of the viewer.
11
+ - Refresh an open viewer when the file changes on disk while `/readfiles` is open.
12
+ - Accept pasted, multi-character, and chunked bracketed-paste input in browser search and the inline comment prompt.
13
+ - Keep viewer search results in sync after live refreshes.
14
+ - Pause live refresh while a line selection or inline comment is active so comments stay anchored to what the user selected.
15
+
5
16
  ## [0.1.14] - 2026-02-03
6
17
 
7
18
  ### Added
@@ -22,7 +22,7 @@ import { buildFileTreeFromPaths, flattenTree, getIgnoredNames, sortChildren, upd
22
22
  import type { DiffStats, FileNode, FlatNode } from "./types";
23
23
  import { isIgnoredStatus, isUntrackedStatus } from "./utils";
24
24
  import { createViewer, type CommentPayload, type ViewerAction } from "./viewer";
25
- import { isPrintableChar } from "./input-utils";
25
+ import { createTextInputBuffer } from "./input-utils";
26
26
 
27
27
  export interface BrowserController {
28
28
  render(width: number): string[];
@@ -219,6 +219,7 @@ export function createFileBrowser(
219
219
  const gitBranch = repo ? getGitBranch(cwd) : "";
220
220
 
221
221
  const viewer = createViewer(cwd, theme, requestComment);
222
+ const textInput = createTextInputBuffer();
222
223
 
223
224
  const root = repo
224
225
  ? buildFileTreeFromPaths(cwd, getGitFileList(cwd), gitStatus, diffStats, ignored, agentModifiedFiles)
@@ -794,8 +795,10 @@ export function createFileBrowser(
794
795
 
795
796
  function handleBrowserInput(data: string): void {
796
797
  const displayList = getDisplayList();
798
+ const maxIndex = Math.max(0, displayList.length - 1);
797
799
 
798
800
  if (matchesKey(data, "q") && !browser.searchMode) {
801
+ textInput.reset();
799
802
  stopBackgroundTasks();
800
803
  onClose();
801
804
  return;
@@ -804,7 +807,9 @@ export function createFileBrowser(
804
807
  if (browser.searchMode) {
805
808
  browser.searchMode = false;
806
809
  browser.searchQuery = "";
810
+ textInput.reset();
807
811
  } else {
812
+ textInput.reset();
808
813
  stopBackgroundTasks();
809
814
  onClose();
810
815
  }
@@ -813,29 +818,38 @@ export function createFileBrowser(
813
818
  if (matchesKey(data, "/") && !browser.searchMode) {
814
819
  browser.searchMode = true;
815
820
  browser.searchQuery = "";
816
- return;
817
- }
818
- if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
819
- browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + 1);
820
- return;
821
- }
822
- if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
823
- browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
821
+ textInput.reset();
824
822
  return;
825
823
  }
826
824
  if (browser.searchMode) {
827
825
  if (matchesKey(data, Key.enter)) {
828
826
  browser.searchMode = false;
829
827
  browser.selectedIndex = 0;
828
+ textInput.reset();
830
829
  } else if (matchesKey(data, Key.backspace)) {
831
830
  browser.searchQuery = browser.searchQuery.slice(0, -1);
832
831
  browser.selectedIndex = 0;
833
- } else if (isPrintableChar(data)) {
834
- browser.searchQuery += data;
835
- browser.selectedIndex = 0;
832
+ } else if (matchesKey(data, Key.down)) {
833
+ browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
834
+ } else if (matchesKey(data, Key.up)) {
835
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
836
+ } else {
837
+ const text = textInput.push(data);
838
+ if (text) {
839
+ browser.searchQuery += text;
840
+ browser.selectedIndex = 0;
841
+ }
836
842
  }
837
843
  return;
838
844
  }
845
+ if (matchesKey(data, "j") || matchesKey(data, Key.down)) {
846
+ browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + 1);
847
+ return;
848
+ }
849
+ if (matchesKey(data, "k") || matchesKey(data, Key.up)) {
850
+ browser.selectedIndex = Math.max(0, browser.selectedIndex - 1);
851
+ return;
852
+ }
839
853
  if (matchesKey(data, Key.enter)) {
840
854
  const item = displayList[browser.selectedIndex];
841
855
  if (item) {
@@ -864,7 +878,7 @@ export function createFileBrowser(
864
878
  return;
865
879
  }
866
880
  if (matchesKey(data, Key.pageDown)) {
867
- browser.selectedIndex = Math.min(displayList.length - 1, browser.selectedIndex + browser.browserHeight);
881
+ browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + browser.browserHeight);
868
882
  return;
869
883
  }
870
884
  if (matchesKey(data, Key.pageUp)) {
@@ -14,5 +14,4 @@ export const MIN_PANEL_HEIGHT = 5;
14
14
  export const MAX_VIEWER_HEIGHT = 50;
15
15
  export const MAX_BROWSER_HEIGHT = 40;
16
16
 
17
- export const VIEWER_SCROLL_MARGIN = 10;
18
17
  export const SEARCH_SCROLL_OFFSET = 3;
@@ -66,7 +66,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
66
66
  },
67
67
  });
68
68
 
69
- pi.registerCommand("readfiles-review", {
69
+ pi.registerCommand("review", {
70
70
  description: "Open tuicr to review changes and send feedback to agent",
71
71
  handler: async (_args, ctx) => {
72
72
  if (!hasCommand("tuicr")) {
@@ -101,7 +101,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
101
101
  },
102
102
  });
103
103
 
104
- pi.registerCommand("readfiles-diff", {
104
+ pi.registerCommand("diff", {
105
105
  description: "Open critique to view diffs",
106
106
  handler: async (args, ctx) => {
107
107
  if (!hasCommand("bun")) {
@@ -1,3 +1,87 @@
1
- export function isPrintableChar(data: string): boolean {
2
- return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) < 127;
1
+ import { decodeKittyPrintable } from "@mariozechner/pi-tui";
2
+
3
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B-\u001F\u007F]/g;
4
+ const BRACKETED_PASTE_START = "\u001b[200~";
5
+ const BRACKETED_PASTE_END = "\u001b[201~";
6
+
7
+ function sanitizeTextInput(data: string): string {
8
+ const normalized = decodeKittyPrintable(data) ?? data;
9
+ if (!normalized || normalized.includes("\u001b")) {
10
+ return "";
11
+ }
12
+
13
+ return normalized
14
+ .replace(/\r\n?/g, "\n")
15
+ .replace(/\n/g, " ")
16
+ .replace(/\t/g, " ")
17
+ .replace(CONTROL_CHARS, "");
18
+ }
19
+
20
+ function getPendingStartSuffix(data: string): string {
21
+ const maxLength = BRACKETED_PASTE_START.length - 1;
22
+ for (let length = Math.min(data.length, maxLength); length > 0; length--) {
23
+ const suffix = data.slice(-length);
24
+ if (BRACKETED_PASTE_START.startsWith(suffix)) {
25
+ return suffix;
26
+ }
27
+ }
28
+ return "";
29
+ }
30
+
31
+ export interface TextInputBuffer {
32
+ push(data: string): string;
33
+ reset(): void;
34
+ }
35
+
36
+ export function createTextInputBuffer(): TextInputBuffer {
37
+ let isInPaste = false;
38
+ let pasteBuffer = "";
39
+ let pendingStart = "";
40
+
41
+ const push = (data: string): string => {
42
+ if (!data) {
43
+ return "";
44
+ }
45
+
46
+ const combined = pendingStart + data;
47
+ pendingStart = "";
48
+
49
+ if (!isInPaste) {
50
+ const startIndex = combined.indexOf(BRACKETED_PASTE_START);
51
+ if (startIndex === -1) {
52
+ const pendingSuffix = getPendingStartSuffix(combined);
53
+ const completeText = pendingSuffix ? combined.slice(0, combined.length - pendingSuffix.length) : combined;
54
+ pendingStart = pendingSuffix;
55
+ return sanitizeTextInput(completeText);
56
+ }
57
+
58
+ const beforePaste = combined.slice(0, startIndex);
59
+ const afterStart = combined.slice(startIndex + BRACKETED_PASTE_START.length);
60
+ isInPaste = true;
61
+ pasteBuffer = "";
62
+ return sanitizeTextInput(beforePaste) + push(afterStart);
63
+ }
64
+
65
+ pasteBuffer += combined;
66
+ const endIndex = pasteBuffer.indexOf(BRACKETED_PASTE_END);
67
+ if (endIndex === -1) {
68
+ return "";
69
+ }
70
+
71
+ const pastedText = pasteBuffer.slice(0, endIndex);
72
+ const remaining = pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
73
+ isInPaste = false;
74
+ pasteBuffer = "";
75
+
76
+ return sanitizeTextInput(pastedText) + push(remaining);
77
+ };
78
+
79
+ return {
80
+ push,
81
+ reset(): void {
82
+ isInPaste = false;
83
+ pasteBuffer = "";
84
+ pendingStart = "";
85
+ },
86
+ };
3
87
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { readFileSync } from "node:fs";
3
+ import { readFileSync, statSync } from "node:fs";
4
4
  import { relative } from "node:path";
5
5
 
6
6
  import {
@@ -8,12 +8,11 @@ import {
8
8
  MAX_VIEWER_HEIGHT,
9
9
  MIN_PANEL_HEIGHT,
10
10
  SEARCH_SCROLL_OFFSET,
11
- VIEWER_SCROLL_MARGIN,
12
11
  } from "./constants";
13
12
  import { loadFileContent } from "./file-viewer";
14
13
  import type { FileNode } from "./types";
15
14
  import { isUntrackedStatus } from "./utils";
16
- import { isPrintableChar } from "./input-utils";
15
+ import { createTextInputBuffer } from "./input-utils";
17
16
 
18
17
  export interface CommentPayload {
19
18
  relPath: string;
@@ -43,6 +42,7 @@ interface ViewerState {
43
42
  searchMatches: number[];
44
43
  searchIndex: number;
45
44
  lastRenderWidth: number;
45
+ lastLoadedMtimeMs: number | null;
46
46
  height: number;
47
47
  }
48
48
 
@@ -61,6 +61,8 @@ export function createViewer(
61
61
  theme: Theme,
62
62
  requestComment: (payload: CommentPayload, comment: string) => void
63
63
  ): ViewerController {
64
+ const textInput = createTextInputBuffer();
65
+
64
66
  const state: ViewerState = {
65
67
  file: null,
66
68
  content: [],
@@ -75,6 +77,7 @@ export function createViewer(
75
77
  searchMatches: [],
76
78
  searchIndex: 0,
77
79
  lastRenderWidth: 0,
80
+ lastLoadedMtimeMs: null,
78
81
  height: DEFAULT_VIEWER_HEIGHT,
79
82
  };
80
83
 
@@ -94,6 +97,10 @@ export function createViewer(
94
97
  }
95
98
 
96
99
  function setMode(mode: ViewerMode): void {
100
+ if (mode !== state.mode) {
101
+ textInput.reset();
102
+ }
103
+
97
104
  state.mode = mode;
98
105
  if (mode !== "search") resetSearch();
99
106
  if (mode !== "comment") resetComment();
@@ -102,16 +109,59 @@ export function createViewer(
102
109
  }
103
110
  }
104
111
 
112
+ function getMaxScroll(): number {
113
+ return Math.max(0, state.content.length - state.height);
114
+ }
115
+
116
+ function refreshRawContent(): void {
117
+ if (!state.file) return;
118
+
119
+ try {
120
+ const fileStat = statSync(state.file.path);
121
+ state.rawContent = readFileSync(state.file.path, "utf-8");
122
+ state.file.lineCount = state.rawContent.split("\n").length;
123
+ state.lastLoadedMtimeMs = fileStat.mtimeMs;
124
+ } catch {
125
+ state.rawContent = "";
126
+ state.file.lineCount = undefined;
127
+ state.lastLoadedMtimeMs = null;
128
+ }
129
+ }
130
+
131
+ function hasFileChangedOnDisk(): boolean {
132
+ if (!state.file) return false;
133
+
134
+ try {
135
+ return state.lastLoadedMtimeMs === null || statSync(state.file.path).mtimeMs !== state.lastLoadedMtimeMs;
136
+ } catch {
137
+ return state.lastLoadedMtimeMs !== null;
138
+ }
139
+ }
140
+
141
+ function clampScroll(): void {
142
+ state.scroll = Math.min(getMaxScroll(), Math.max(0, state.scroll));
143
+ }
144
+
105
145
  function reloadContent(width: number): void {
106
146
  if (!state.file) return;
147
+ refreshRawContent();
107
148
  const hasChanges = !!state.file.gitStatus;
108
149
  state.content = loadFileContent(state.file.path, cwd, state.diffMode, hasChanges, width);
109
150
  state.lastRenderWidth = width;
151
+ clampScroll();
152
+ if (state.searchQuery) {
153
+ updateSearchMatches({ preserveActiveMatch: true });
154
+ }
110
155
  }
111
156
 
112
- function updateSearchMatches(): void {
157
+ function updateSearchMatches(options: { preserveActiveMatch?: boolean } = {}): void {
158
+ const activeMatch = options.preserveActiveMatch ? state.searchMatches[state.searchIndex] : undefined;
159
+
113
160
  state.searchMatches = [];
114
- if (!state.searchQuery) return;
161
+ if (!state.searchQuery) {
162
+ state.searchIndex = 0;
163
+ return;
164
+ }
115
165
 
116
166
  const q = state.searchQuery.toLowerCase();
117
167
  const rawLines = state.rawContent.split("\n");
@@ -120,11 +170,29 @@ export function createViewer(
120
170
  state.searchMatches.push(i);
121
171
  }
122
172
  }
123
- state.searchIndex = 0;
124
173
 
125
- if (state.searchMatches.length > 0) {
126
- state.scroll = Math.max(0, state.searchMatches[0] - SEARCH_SCROLL_OFFSET);
174
+ if (state.searchMatches.length === 0) {
175
+ state.searchIndex = 0;
176
+ return;
127
177
  }
178
+
179
+ if (activeMatch === undefined) {
180
+ state.searchIndex = 0;
181
+ } else {
182
+ let nearestIndex = 0;
183
+ let nearestDistance = Number.POSITIVE_INFINITY;
184
+ for (let i = 0; i < state.searchMatches.length; i++) {
185
+ const distance = Math.abs(state.searchMatches[i] - activeMatch);
186
+ if (distance < nearestDistance) {
187
+ nearestDistance = distance;
188
+ nearestIndex = i;
189
+ }
190
+ }
191
+ state.searchIndex = nearestIndex;
192
+ }
193
+
194
+ state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
195
+ clampScroll();
128
196
  }
129
197
 
130
198
  function jumpToNextMatch(direction: 1 | -1): void {
@@ -133,6 +201,7 @@ export function createViewer(
133
201
  if (state.searchIndex < 0) state.searchIndex = state.searchMatches.length - 1;
134
202
  if (state.searchIndex >= state.searchMatches.length) state.searchIndex = 0;
135
203
  state.scroll = Math.max(0, state.searchMatches[state.searchIndex] - SEARCH_SCROLL_OFFSET);
204
+ clampScroll();
136
205
  }
137
206
 
138
207
  function buildCommentPayload(): CommentPayload | null {
@@ -242,14 +311,8 @@ export function createViewer(
242
311
  setMode("normal");
243
312
  state.content = [];
244
313
  state.lastRenderWidth = 0;
245
-
246
- try {
247
- state.rawContent = readFileSync(file.path, "utf-8");
248
- file.lineCount = state.rawContent.split("\n").length;
249
- } catch {
250
- state.rawContent = "";
251
- file.lineCount = undefined;
252
- }
314
+ state.lastLoadedMtimeMs = null;
315
+ refreshRawContent();
253
316
  },
254
317
 
255
318
  updateFileRef(file: FileNode | null): void {
@@ -259,13 +322,16 @@ export function createViewer(
259
322
  close(): void {
260
323
  state.file = null;
261
324
  state.content = [];
325
+ state.rawContent = "";
326
+ state.lastLoadedMtimeMs = null;
262
327
  setMode("normal");
263
328
  },
264
329
 
265
330
  render(width: number): string[] {
266
331
  if (!state.file) return [];
267
332
 
268
- if (state.lastRenderWidth !== width || state.content.length === 0) {
333
+ const shouldAutoRefresh = state.mode !== "select" && state.mode !== "comment";
334
+ if (state.lastRenderWidth !== width || state.content.length === 0 || (shouldAutoRefresh && hasFileChangedOnDisk())) {
269
335
  reloadContent(width);
270
336
  }
271
337
 
@@ -308,8 +374,11 @@ export function createViewer(
308
374
  setMode("normal");
309
375
  } else if (matchesKey(data, Key.backspace)) {
310
376
  state.commentText = state.commentText.slice(0, -1);
311
- } else if (isPrintableChar(data)) {
312
- state.commentText += data;
377
+ } else {
378
+ const text = textInput.push(data);
379
+ if (text) {
380
+ state.commentText += text;
381
+ }
313
382
  }
314
383
  return { type: "none" };
315
384
  }
@@ -322,9 +391,12 @@ export function createViewer(
322
391
  } else if (matchesKey(data, Key.backspace)) {
323
392
  state.searchQuery = state.searchQuery.slice(0, -1);
324
393
  updateSearchMatches();
325
- } else if (isPrintableChar(data)) {
326
- state.searchQuery += data;
327
- updateSearchMatches();
394
+ } else {
395
+ const text = textInput.push(data);
396
+ if (text) {
397
+ state.searchQuery += text;
398
+ updateSearchMatches();
399
+ }
328
400
  }
329
401
  return { type: "none" };
330
402
  }
@@ -358,7 +430,7 @@ export function createViewer(
358
430
  if (state.mode === "select") {
359
431
  state.selectEnd = Math.min(state.content.length - 1, state.selectEnd + 1);
360
432
  } else {
361
- state.scroll = Math.min(Math.max(0, state.content.length - VIEWER_SCROLL_MARGIN), state.scroll + 1);
433
+ state.scroll = Math.min(getMaxScroll(), state.scroll + 1);
362
434
  }
363
435
  return { type: "none" };
364
436
  }
@@ -371,7 +443,7 @@ export function createViewer(
371
443
  return { type: "none" };
372
444
  }
373
445
  if (matchesKey(data, Key.pageDown)) {
374
- state.scroll = Math.min(Math.max(0, state.content.length - state.height), state.scroll + state.height);
446
+ state.scroll = Math.min(getMaxScroll(), state.scroll + state.height);
375
447
  return { type: "none" };
376
448
  }
377
449
  if (matchesKey(data, Key.pageUp)) {
@@ -382,16 +454,18 @@ export function createViewer(
382
454
  state.scroll = 0;
383
455
  return { type: "none" };
384
456
  }
385
- if (matchesKey(data, "G")) {
386
- state.scroll = Math.max(0, state.content.length - state.height);
457
+ if (matchesKey(data, "shift+g")) {
458
+ state.scroll = getMaxScroll();
387
459
  return { type: "none" };
388
460
  }
389
461
  if (matchesKey(data, "+") || matchesKey(data, "=")) {
390
462
  state.height = Math.min(MAX_VIEWER_HEIGHT, state.height + 5);
463
+ clampScroll();
391
464
  return { type: "none" };
392
465
  }
393
466
  if (matchesKey(data, "-") || matchesKey(data, "_")) {
394
467
  state.height = Math.max(MIN_PANEL_HEIGHT, state.height - 5);
468
+ clampScroll();
395
469
  return { type: "none" };
396
470
  }
397
471
  if (matchesKey(data, "d") && state.mode !== "select" && state.file.gitStatus && !isUntrackedStatus(state.file.gitStatus)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -1,5 +1,28 @@
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
+
22
+ ## 0.2.1 - 2026-04-17
23
+ - Add a one-line formula footer to the `/usage` dashboard (`Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite`)
24
+ - README now calls out the 0.2.0 formula change explicitly under the columns table
25
+
3
26
  ## 0.2.0 - 2026-04-17
4
27
  - Include `cacheWrite` in the main `Tokens` total and in the `↑In` column so providers like Anthropic that report fresh prompt work under `cacheWrite` are no longer undercounted
5
28
  - Keep `cacheRead` out of `Tokens` so repeated cache hits do not swamp the dashboard
@@ -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
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 |
@@ -83,6 +104,8 @@ Time periods are calculated in the local timezone where Pi runs. If you want to
83
104
  | **↓Out** | Output tokens *(dimmed)* |
84
105
  | **Cache** | Cache read + write tokens *(dimmed; informational)* |
85
106
 
107
+ > **As of 0.2.0:** `Tokens = Input + Output + CacheWrite` and `↑In = Input + CacheWrite`. `CacheRead` stays out of `Tokens` so repeated cache hits don't swamp the dashboard. The dashboard itself shows a one-line footer reminder.
108
+
86
109
  On narrow terminals, `/usage` automatically switches to a compact table instead of overflowing the terminal. Hidden columns reappear as soon as you widen the terminal.
87
110
 
88
111
  ### Navigation
@@ -90,8 +113,9 @@ On narrow terminals, `/usage` automatically switches to a compact table instead
90
113
  | Key | Action |
91
114
  |-----|--------|
92
115
  | `Tab` / `←` `→` | Switch time period |
93
- | `↑` `↓` | Select provider |
94
- | `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 |
95
119
  | `q` / `Esc` | Close |
96
120
 
97
121
  ## Provider Notes
@@ -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
  [
@@ -590,6 +855,7 @@ class UsageComponent {
590
855
  ...this.renderHeader(layout),
591
856
  ...this.renderRows(layout),
592
857
  ...this.renderTotals(layout),
858
+ ...this.renderFormulaNote(width),
593
859
  ...this.renderHelp(width),
594
860
  ],
595
861
  width
@@ -598,7 +864,54 @@ class UsageComponent {
598
864
 
599
865
  private renderTitle(): string[] {
600
866
  const th = this.theme;
601
- 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;
602
915
  }
603
916
 
604
917
  private renderTabs(width: number, layout: TableLayout): string[] {
@@ -615,9 +928,11 @@ class UsageComponent {
615
928
  activeTabOnly,
616
929
  ]);
617
930
 
618
- const infoLines = layout.compact
619
- ? wrapTextWithAnsi(th.fg("dim", "Compact view. Widen the terminal for more columns."), Math.max(width, 1))
620
- : [];
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
+ : [];
621
936
 
622
937
  return [tabLine, ...infoLines, ""];
623
938
  }
@@ -711,14 +1026,34 @@ class UsageComponent {
711
1026
  return [th.fg("border", "─".repeat(layout.tableWidth)), totalRow, ""];
712
1027
  }
713
1028
 
714
- private renderHelp(width: number): string[] {
1029
+ private renderFormulaNote(width: number): string[] {
715
1030
  const line = pickFittingText(width, [
716
- "[Tab/←→] period [↑↓] select [Enter] expand [q] close",
717
- "[Tab] period [↑↓] select [Enter] expand [q] close",
718
- "[↑↓] select [Enter] expand [q] close",
719
- "[↑↓] select [q] close",
720
- "[q] close",
1031
+ "Tokens = Input + Output + CacheWrite · ↑In = Input + CacheWrite (as of 0.2.0)",
1032
+ "Tokens = In + Out + CacheWrite · ↑In = In + CacheWrite (v0.2.0+)",
1033
+ "Tokens & ↑In include CacheWrite (v0.2.0+)",
1034
+ "Incl. CacheWrite (v0.2.0+)",
721
1035
  ]);
1036
+ return [this.theme.fg("dim", line), ""];
1037
+ }
1038
+
1039
+ private renderHelp(width: number): string[] {
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);
722
1057
  return [this.theme.fg("dim", line)];
723
1058
  }
724
1059
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-usage-extension",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Usage statistics dashboard for Pi sessions.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",