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.
- package/files-widget/CHANGELOG.md +11 -0
- package/files-widget/browser.ts +27 -13
- package/files-widget/constants.ts +0 -1
- package/files-widget/index.ts +2 -2
- package/files-widget/input-utils.ts +86 -2
- package/files-widget/package.json +1 -1
- package/files-widget/viewer.ts +100 -26
- package/package.json +1 -1
- package/usage-extension/CHANGELOG.md +23 -0
- package/usage-extension/README.md +28 -4
- package/usage-extension/index.ts +350 -15
- package/usage-extension/insights-screenshot.png +0 -0
- package/usage-extension/package.json +1 -1
|
@@ -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
|
package/files-widget/browser.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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 (
|
|
834
|
-
browser.
|
|
835
|
-
|
|
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(
|
|
881
|
+
browser.selectedIndex = Math.min(maxIndex, browser.selectedIndex + browser.browserHeight);
|
|
868
882
|
return;
|
|
869
883
|
}
|
|
870
884
|
if (matchesKey(data, Key.pageUp)) {
|
package/files-widget/index.ts
CHANGED
|
@@ -66,7 +66,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
|
|
|
66
66
|
},
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
-
pi.registerCommand("
|
|
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("
|
|
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
|
-
|
|
2
|
-
|
|
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
|
}
|
package/files-widget/viewer.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
|
126
|
-
state.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
312
|
-
|
|
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
|
|
326
|
-
|
|
327
|
-
|
|
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(
|
|
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(
|
|
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, "
|
|
386
|
-
state.scroll =
|
|
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,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
|
-

|
|
6
6
|
|
|
7
7
|
## Compatibility
|
|
8
8
|
|
|
9
9
|
- **Pi version:** 0.42.4+
|
|
10
|
-
- **Last updated:** 2026-04-
|
|
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
|
+

|
|
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
|
package/usage-extension/index.ts
CHANGED
|
@@ -45,9 +45,39 @@ interface TotalStats extends BaseStats {
|
|
|
45
45
|
sessions: number;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
interface Insight {
|
|
49
|
+
percent: number; // 0-100
|
|
50
|
+
headline: string;
|
|
51
|
+
advice: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface PeriodInsights {
|
|
55
|
+
insights: Insight[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface RawMessage {
|
|
59
|
+
sessionId: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
cost: number;
|
|
62
|
+
input: number;
|
|
63
|
+
cacheRead: number;
|
|
64
|
+
cacheWrite: number;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface PeriodRawData {
|
|
68
|
+
messages: RawMessage[];
|
|
69
|
+
sessionCosts: Map<string, number>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface GlobalSessionSpan {
|
|
73
|
+
startMs: number;
|
|
74
|
+
endMs: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
48
77
|
interface TimeFilteredStats {
|
|
49
78
|
providers: Map<string, ProviderStats>;
|
|
50
79
|
totals: TotalStats;
|
|
80
|
+
insights: PeriodInsights;
|
|
51
81
|
}
|
|
52
82
|
|
|
53
83
|
interface UsageData {
|
|
@@ -58,6 +88,7 @@ interface UsageData {
|
|
|
58
88
|
}
|
|
59
89
|
|
|
60
90
|
type TabName = "today" | "thisWeek" | "lastWeek" | "allTime";
|
|
91
|
+
type ViewMode = "table" | "insights";
|
|
61
92
|
|
|
62
93
|
// =============================================================================
|
|
63
94
|
// Column Configuration
|
|
@@ -296,9 +327,14 @@ function emptyTimeFilteredStats(): TimeFilteredStats {
|
|
|
296
327
|
return {
|
|
297
328
|
providers: new Map(),
|
|
298
329
|
totals: { sessions: 0, messages: 0, cost: 0, tokens: emptyTokens() },
|
|
330
|
+
insights: { insights: [] },
|
|
299
331
|
};
|
|
300
332
|
}
|
|
301
333
|
|
|
334
|
+
function emptyPeriodRawData(): PeriodRawData {
|
|
335
|
+
return { messages: [], sessionCosts: new Map() };
|
|
336
|
+
}
|
|
337
|
+
|
|
302
338
|
function emptyUsageData(): UsageData {
|
|
303
339
|
return {
|
|
304
340
|
today: emptyTimeFilteredStats(),
|
|
@@ -325,11 +361,25 @@ function addMessagesToUsageData(
|
|
|
325
361
|
messages: SessionMessage[],
|
|
326
362
|
todayMs: number,
|
|
327
363
|
weekStartMs: number,
|
|
328
|
-
lastWeekStartMs: number
|
|
364
|
+
lastWeekStartMs: number,
|
|
365
|
+
rawByPeriod: Record<TabName, PeriodRawData>,
|
|
366
|
+
globalSessionSpans: Map<string, GlobalSessionSpan>
|
|
329
367
|
): void {
|
|
330
368
|
const sessionContributed = { today: false, thisWeek: false, lastWeek: false, allTime: false };
|
|
331
369
|
|
|
332
370
|
for (const msg of messages) {
|
|
371
|
+
// Track real per-session lifetime across every message we see, regardless of
|
|
372
|
+
// which period the message falls into. Used later for the "8h+ session" insight.
|
|
373
|
+
if (msg.timestamp > 0) {
|
|
374
|
+
const span = globalSessionSpans.get(sessionId);
|
|
375
|
+
if (!span) {
|
|
376
|
+
globalSessionSpans.set(sessionId, { startMs: msg.timestamp, endMs: msg.timestamp });
|
|
377
|
+
} else {
|
|
378
|
+
if (msg.timestamp < span.startMs) span.startMs = msg.timestamp;
|
|
379
|
+
if (msg.timestamp > span.endMs) span.endMs = msg.timestamp;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
333
383
|
const periods = getPeriodsForTimestamp(msg.timestamp, todayMs, weekStartMs, lastWeekStartMs);
|
|
334
384
|
const tokens = {
|
|
335
385
|
// Count fresh tokens processed this turn.
|
|
@@ -365,6 +415,17 @@ function addMessagesToUsageData(
|
|
|
365
415
|
|
|
366
416
|
accumulateStats(stats.totals, msg.cost, tokens);
|
|
367
417
|
sessionContributed[period] = true;
|
|
418
|
+
|
|
419
|
+
const raw = rawByPeriod[period];
|
|
420
|
+
raw.messages.push({
|
|
421
|
+
sessionId,
|
|
422
|
+
timestamp: msg.timestamp,
|
|
423
|
+
cost: msg.cost,
|
|
424
|
+
input: msg.input,
|
|
425
|
+
cacheRead: msg.cacheRead,
|
|
426
|
+
cacheWrite: msg.cacheWrite,
|
|
427
|
+
});
|
|
428
|
+
raw.sessionCosts.set(sessionId, (raw.sessionCosts.get(sessionId) ?? 0) + msg.cost);
|
|
368
429
|
}
|
|
369
430
|
}
|
|
370
431
|
|
|
@@ -393,6 +454,13 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
393
454
|
const lastWeekStartMs = startOfLastWeek.getTime();
|
|
394
455
|
|
|
395
456
|
const data = emptyUsageData();
|
|
457
|
+
const rawByPeriod: Record<TabName, PeriodRawData> = {
|
|
458
|
+
today: emptyPeriodRawData(),
|
|
459
|
+
thisWeek: emptyPeriodRawData(),
|
|
460
|
+
lastWeek: emptyPeriodRawData(),
|
|
461
|
+
allTime: emptyPeriodRawData(),
|
|
462
|
+
};
|
|
463
|
+
const globalSessionSpans = new Map<string, GlobalSessionSpan>();
|
|
396
464
|
|
|
397
465
|
const sessionFiles = await getAllSessionFiles(signal);
|
|
398
466
|
if (signal?.aborted) return null;
|
|
@@ -404,14 +472,192 @@ async function collectUsageData(signal?: AbortSignal): Promise<UsageData | null>
|
|
|
404
472
|
if (signal?.aborted) return null;
|
|
405
473
|
if (!parsed) continue;
|
|
406
474
|
|
|
407
|
-
addMessagesToUsageData(
|
|
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
|
-
|
|
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
|
-
|
|
619
|
-
|
|
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
|
|
1029
|
+
private renderFormulaNote(width: number): string[] {
|
|
715
1030
|
const line = pickFittingText(width, [
|
|
716
|
-
"
|
|
717
|
-
"
|
|
718
|
-
"
|
|
719
|
-
"
|
|
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
|
|
|
Binary file
|