pi-context-viz 1.0.0
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/LICENSE +21 -0
- package/README.md +77 -0
- package/extensions/context.ts +1077 -0
- package/package.json +23 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# pi-context-viz
|
|
2
|
+
|
|
3
|
+
Interactive context window visualizer for [pi](https://github.com/earendil-works/pi-mono) — a colored overlay showing token usage breakdown, session stats, and optimization suggestions.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Colored grid** — visual breakdown of context window by category
|
|
10
|
+
- **Progress bar** — at-a-glance usage overview
|
|
11
|
+
- **3 view modes** — Grid (`default`), Table (`T`), Detail (`Enter`)
|
|
12
|
+
- **Interactive navigation** — arrow keys / `jk` to browse categories, `Enter` for details
|
|
13
|
+
- **Remaining turns estimate** — how many more turns before compaction needed
|
|
14
|
+
- **Cost projection** — current cost + projected cost for full context
|
|
15
|
+
- **Session history** — trend of context usage across `/context` invocations
|
|
16
|
+
- **Smart warnings** — context >80%, tools >25%, thinking >40%, compaction bloat
|
|
17
|
+
- **Compact mode** — auto-adapts for narrow terminals (<60 cols)
|
|
18
|
+
- **Save report** — `S` copies a text report to clipboard
|
|
19
|
+
|
|
20
|
+
## Categories tracked
|
|
21
|
+
|
|
22
|
+
| Category | Description |
|
|
23
|
+
|----------|-------------|
|
|
24
|
+
| System Prompt | Estimated system prompt tokens |
|
|
25
|
+
| User Messages | All user input tokens |
|
|
26
|
+
| Assistant Text | Model output (excluding thinking) |
|
|
27
|
+
| Thinking | Hidden reasoning tokens |
|
|
28
|
+
| Tool: read/bash/edit/… | Tool result tokens, per tool |
|
|
29
|
+
| Compaction | Compaction summary tokens |
|
|
30
|
+
| Custom Messages | Injected/custom message tokens |
|
|
31
|
+
| Images | Estimated image tokens |
|
|
32
|
+
| Free | Available context space |
|
|
33
|
+
|
|
34
|
+
## Install
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pi install git:github.com/viartemev/pi-context-viz
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Or try without installing:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pi -e git:github.com/viartemev/pi-context-viz
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Type `/context` in pi to open the overlay.
|
|
49
|
+
|
|
50
|
+
### Keybindings
|
|
51
|
+
|
|
52
|
+
| Key | Action |
|
|
53
|
+
|-----|--------|
|
|
54
|
+
| `↑` `↓` / `j` `k` | Navigate categories |
|
|
55
|
+
| `Enter` | Show category details |
|
|
56
|
+
| `T` | Toggle table/grid view |
|
|
57
|
+
| `S` | Save report to clipboard |
|
|
58
|
+
| `Esc` / `q` | Close overlay |
|
|
59
|
+
|
|
60
|
+
### Detail mode
|
|
61
|
+
|
|
62
|
+
Pressing `Enter` on a category shows:
|
|
63
|
+
- **Tools**: call count, avg tokens/call, max call size
|
|
64
|
+
- **User/Assistant**: message count, text vs thinking split
|
|
65
|
+
- **Images**: image count
|
|
66
|
+
- **Free**: estimated remaining turns
|
|
67
|
+
|
|
68
|
+
## How it works
|
|
69
|
+
|
|
70
|
+
1. Iterates the session branch to extract message types and tool calls
|
|
71
|
+
2. Estimates tokens using ~4 chars/token heuristic (consistent with pi's estimator)
|
|
72
|
+
3. Uses `ctx.getContextUsage()` for accurate total when available
|
|
73
|
+
4. Renders as an interactive TUI overlay via `ctx.ui.custom()`
|
|
74
|
+
|
|
75
|
+
## License
|
|
76
|
+
|
|
77
|
+
MIT
|
|
@@ -0,0 +1,1077 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /context — Visualize current context usage as a colored overlay.
|
|
3
|
+
*
|
|
4
|
+
* Shows a grid of colored squares representing token usage, broken down by:
|
|
5
|
+
* - System prompt
|
|
6
|
+
* - User messages
|
|
7
|
+
* - Assistant text
|
|
8
|
+
* - Assistant thinking
|
|
9
|
+
* - Tool results (per tool: read, bash, edit, write, grep, find, ls, custom)
|
|
10
|
+
* - Compaction summaries
|
|
11
|
+
* - Custom/injected messages
|
|
12
|
+
* - Images (estimated)
|
|
13
|
+
* - Free space
|
|
14
|
+
*
|
|
15
|
+
* Interactive: arrow keys navigate categories, Enter shows details,
|
|
16
|
+
* T toggles table/grid view, S saves a text report.
|
|
17
|
+
* Also shows cache stats, cost projections, and optimization suggestions.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
ExtensionAPI,
|
|
22
|
+
ExtensionCommandContext,
|
|
23
|
+
ContextUsage,
|
|
24
|
+
Theme,
|
|
25
|
+
} from "@mariozechner/pi-coding-agent";
|
|
26
|
+
import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai";
|
|
27
|
+
import { matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
28
|
+
|
|
29
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
30
|
+
// Constants
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
32
|
+
|
|
33
|
+
const TOKENS_PER_CHAR = 4;
|
|
34
|
+
const TOKENS_PER_IMAGE = 1600;
|
|
35
|
+
const GRID_SQUARE_W = 2;
|
|
36
|
+
const GRID_MIN_ROWS = 6;
|
|
37
|
+
const GRID_MAX_ROWS = 15;
|
|
38
|
+
|
|
39
|
+
const CONTEXT_WARN = 0.8;
|
|
40
|
+
const CONTEXT_CRITICAL = 0.95;
|
|
41
|
+
const CATEGORY_WARN = 0.15;
|
|
42
|
+
const CATEGORY_DANGER = 0.25;
|
|
43
|
+
const TOOL_BIG_CONSUMER = 0.2;
|
|
44
|
+
const THINKING_HIGH = 0.4;
|
|
45
|
+
const SINGLE_RESOURCE_HIGH = 0.3;
|
|
46
|
+
|
|
47
|
+
const COMPACT_MODE_WIDTH = 60;
|
|
48
|
+
|
|
49
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
50
|
+
// Types
|
|
51
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
52
|
+
|
|
53
|
+
interface Category {
|
|
54
|
+
key: string;
|
|
55
|
+
label: string;
|
|
56
|
+
tokens: number;
|
|
57
|
+
colorCode: number;
|
|
58
|
+
square: string;
|
|
59
|
+
highlightedSquare: string;
|
|
60
|
+
pct: number; // precomputed percentage of contextWindow
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ToolStats {
|
|
64
|
+
tokens: number;
|
|
65
|
+
callCount: number;
|
|
66
|
+
maxCallTokens: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ContextBreakdown {
|
|
70
|
+
categories: Category[];
|
|
71
|
+
totalTokens: number;
|
|
72
|
+
contextWindow: number;
|
|
73
|
+
percent: number | null;
|
|
74
|
+
cacheRead: number;
|
|
75
|
+
cacheWrite: number;
|
|
76
|
+
totalCost: number;
|
|
77
|
+
messageCount: number;
|
|
78
|
+
turnCount: number;
|
|
79
|
+
userMessageCount: number;
|
|
80
|
+
imageCount: number;
|
|
81
|
+
toolStats: Record<string, ToolStats>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ContextSnapshot {
|
|
85
|
+
turnCount: number;
|
|
86
|
+
totalTokens: number;
|
|
87
|
+
contextWindow: number;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
type ViewMode = "grid" | "table" | "detail";
|
|
91
|
+
|
|
92
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
93
|
+
// ANSI helpers
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
function ansi256Bg(code: number, text: string): string {
|
|
97
|
+
return `\x1b[48;5;${code}m${text}\x1b[0m`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ansi256Fg(code: number, text: string): string {
|
|
101
|
+
return `\x1b[38;5;${code}m${text}\x1b[0m`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function brightenColor(code: number): number {
|
|
105
|
+
// Grays 232–255: move toward white
|
|
106
|
+
if (code >= 232) return Math.min(255, code + 12);
|
|
107
|
+
// Color cube 16–231: jump one row lighter
|
|
108
|
+
return Math.min(231, code + 36);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
112
|
+
// History (module-level, persists across /context invocations)
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
114
|
+
|
|
115
|
+
const contextHistory: ContextSnapshot[] = [];
|
|
116
|
+
|
|
117
|
+
function addSnapshot(b: ContextBreakdown): void {
|
|
118
|
+
contextHistory.push({
|
|
119
|
+
turnCount: b.turnCount,
|
|
120
|
+
totalTokens: b.totalTokens,
|
|
121
|
+
contextWindow: b.contextWindow,
|
|
122
|
+
});
|
|
123
|
+
// Keep last 20 snapshots
|
|
124
|
+
if (contextHistory.length > 20) contextHistory.shift();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
128
|
+
// Token estimation
|
|
129
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
130
|
+
|
|
131
|
+
function estimateStringTokens(text: string): number {
|
|
132
|
+
return Math.ceil(text.length / TOKENS_PER_CHAR);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function estimateContentTokens(
|
|
136
|
+
content: string | Array<{ type: string; [k: string]: any }>,
|
|
137
|
+
): { textTokens: number; imageTokens: number } {
|
|
138
|
+
if (typeof content === "string") {
|
|
139
|
+
return { textTokens: estimateStringTokens(content), imageTokens: 0 };
|
|
140
|
+
}
|
|
141
|
+
let text = 0;
|
|
142
|
+
let img = 0;
|
|
143
|
+
for (const block of content) {
|
|
144
|
+
if (block.type === "text") {
|
|
145
|
+
text += estimateStringTokens(block.text ?? "");
|
|
146
|
+
} else if (block.type === "image") {
|
|
147
|
+
img += TOKENS_PER_IMAGE;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return { textTokens: text, imageTokens: img };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
154
|
+
// Breakdown computation helpers
|
|
155
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
156
|
+
|
|
157
|
+
function estimateSystemPrompt(ctx: any): number {
|
|
158
|
+
try {
|
|
159
|
+
const sp = ctx.getSystemPrompt();
|
|
160
|
+
return sp ? estimateStringTokens(sp) : 0;
|
|
161
|
+
} catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function processUserMessage(
|
|
167
|
+
msg: UserMessage,
|
|
168
|
+
): { userTokens: number; imageTokens: number; imageCount: number } {
|
|
169
|
+
const { textTokens, imageTokens } = estimateContentTokens(msg.content);
|
|
170
|
+
return {
|
|
171
|
+
userTokens: textTokens,
|
|
172
|
+
imageTokens,
|
|
173
|
+
imageCount: imageTokens > 0 ? 1 : 0,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function processAssistantMessage(
|
|
178
|
+
msg: AssistantMessage,
|
|
179
|
+
): {
|
|
180
|
+
assistantTextTokens: number;
|
|
181
|
+
thinkingTokens: number;
|
|
182
|
+
cacheRead: number;
|
|
183
|
+
cacheWrite: number;
|
|
184
|
+
totalCost: number;
|
|
185
|
+
} {
|
|
186
|
+
let text = 0;
|
|
187
|
+
let thinking = 0;
|
|
188
|
+
|
|
189
|
+
for (const block of msg.content) {
|
|
190
|
+
if (block.type === "text") {
|
|
191
|
+
text += estimateStringTokens(block.text);
|
|
192
|
+
} else if (block.type === "thinking") {
|
|
193
|
+
thinking += estimateStringTokens(block.thinking);
|
|
194
|
+
}
|
|
195
|
+
// Tool call blocks: small (function name + args JSON)
|
|
196
|
+
if ((block as any).type === "tool_use") {
|
|
197
|
+
text += estimateStringTokens(JSON.stringify((block as any).arguments ?? {}));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
assistantTextTokens: text,
|
|
203
|
+
thinkingTokens: thinking,
|
|
204
|
+
cacheRead: msg.usage.cacheRead,
|
|
205
|
+
cacheWrite: msg.usage.cacheWrite,
|
|
206
|
+
totalCost: msg.usage.cost.total,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function processToolResult(
|
|
211
|
+
msg: ToolResultMessage,
|
|
212
|
+
): { name: string; tokens: number } {
|
|
213
|
+
const name = msg.toolName || "unknown";
|
|
214
|
+
const { textTokens, imageTokens } = estimateContentTokens(msg.content);
|
|
215
|
+
return { name, tokens: textTokens + imageTokens };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Category builder ──────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
interface BuiltinToolInfo {
|
|
221
|
+
label: string;
|
|
222
|
+
colorCode: number;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const BUILTIN_TOOLS: Record<string, BuiltinToolInfo> = {
|
|
226
|
+
read: { label: "Tool: read", colorCode: 73 },
|
|
227
|
+
bash: { label: "Tool: bash", colorCode: 167 },
|
|
228
|
+
edit: { label: "Tool: edit", colorCode: 179 },
|
|
229
|
+
write: { label: "Tool: write", colorCode: 143 },
|
|
230
|
+
grep: { label: "Tool: grep", colorCode: 109 },
|
|
231
|
+
find: { label: "Tool: find", colorCode: 146 },
|
|
232
|
+
ls: { label: "Tool: ls", colorCode: 108 },
|
|
233
|
+
subagent: { label: "Tool: subagent", colorCode: 175 },
|
|
234
|
+
web_search: { label: "Tool: web_search", colorCode: 74 },
|
|
235
|
+
web_fetch: { label: "Tool: web_fetch", colorCode: 38 },
|
|
236
|
+
ask_user_question: { label: "Tool: ask_user", colorCode: 183 },
|
|
237
|
+
video_extract: { label: "Tool: video", colorCode: 204 },
|
|
238
|
+
google_image_search: { label: "Tool: img_search", colorCode: 214 },
|
|
239
|
+
youtube_search: { label: "Tool: yt_search", colorCode: 196 },
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const CUSTOM_TOOL_COLORS = [132, 166, 130, 97, 136, 169, 103, 172];
|
|
243
|
+
|
|
244
|
+
function buildCategories(
|
|
245
|
+
ctxWindow: number,
|
|
246
|
+
systemPromptTokens: number,
|
|
247
|
+
userTokens: number,
|
|
248
|
+
assistantTextTokens: number,
|
|
249
|
+
thinkingTokens: number,
|
|
250
|
+
compactionTokens: number,
|
|
251
|
+
customMessageTokens: number,
|
|
252
|
+
imageTokens: number,
|
|
253
|
+
toolStats: Record<string, ToolStats>,
|
|
254
|
+
totalTokens: number,
|
|
255
|
+
): Category[] {
|
|
256
|
+
const categories: Category[] = [];
|
|
257
|
+
|
|
258
|
+
const addCat = (key: string, label: string, tokens: number, colorCode: number) => {
|
|
259
|
+
if (tokens <= 0) return;
|
|
260
|
+
categories.push({
|
|
261
|
+
key,
|
|
262
|
+
label,
|
|
263
|
+
tokens,
|
|
264
|
+
colorCode,
|
|
265
|
+
square: ansi256Bg(colorCode, " "),
|
|
266
|
+
highlightedSquare: ansi256Bg(brightenColor(colorCode), "▐▌"),
|
|
267
|
+
pct: (tokens / ctxWindow) * 100,
|
|
268
|
+
});
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
addCat("system", "System Prompt", systemPromptTokens, 141);
|
|
272
|
+
addCat("user", "User Messages", userTokens, 75);
|
|
273
|
+
addCat("assistant", "Assistant Text", assistantTextTokens, 114);
|
|
274
|
+
addCat("thinking", "Thinking", thinkingTokens, 216);
|
|
275
|
+
|
|
276
|
+
// Tools — sorted by tokens descending
|
|
277
|
+
const sortedTools = Object.entries(toolStats).sort((a, b) => b[1].tokens - a[1].tokens);
|
|
278
|
+
let customColorIdx = 0;
|
|
279
|
+
for (const [name, stats] of sortedTools) {
|
|
280
|
+
const builtin = BUILTIN_TOOLS[name];
|
|
281
|
+
const colorCode = builtin?.colorCode ?? CUSTOM_TOOL_COLORS[customColorIdx++ % CUSTOM_TOOL_COLORS.length]!;
|
|
282
|
+
const label = builtin?.label ?? `Tool: ${name}`;
|
|
283
|
+
addCat(`tool:${name}`, label, stats.tokens, colorCode);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
addCat("compaction", "Compaction", compactionTokens, 245);
|
|
287
|
+
addCat("custom", "Custom Messages", customMessageTokens, 183);
|
|
288
|
+
addCat("images", "Images", imageTokens, 219);
|
|
289
|
+
|
|
290
|
+
// Free space
|
|
291
|
+
const freeTokens = Math.max(0, ctxWindow - totalTokens);
|
|
292
|
+
addCat("free", "Free", freeTokens, 236);
|
|
293
|
+
|
|
294
|
+
return categories;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Main breakdown ────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
function computeBreakdown(ctx: any): ContextBreakdown | null {
|
|
300
|
+
const usage: ContextUsage | undefined = ctx.getContextUsage();
|
|
301
|
+
if (!usage) return null;
|
|
302
|
+
|
|
303
|
+
const { contextWindow } = usage;
|
|
304
|
+
const branch = ctx.sessionManager.getBranch();
|
|
305
|
+
|
|
306
|
+
let systemPromptTokens = estimateSystemPrompt(ctx);
|
|
307
|
+
let userTokens = 0;
|
|
308
|
+
let assistantTextTokens = 0;
|
|
309
|
+
let thinkingTokens = 0;
|
|
310
|
+
let compactionTokens = 0;
|
|
311
|
+
let customMessageTokens = 0;
|
|
312
|
+
let imageTokens = 0;
|
|
313
|
+
const toolStats: Record<string, ToolStats> = {};
|
|
314
|
+
let cacheRead = 0;
|
|
315
|
+
let cacheWrite = 0;
|
|
316
|
+
let totalCost = 0;
|
|
317
|
+
let turnCount = 0;
|
|
318
|
+
let messageCount = 0;
|
|
319
|
+
let userMessageCount = 0;
|
|
320
|
+
let imageCount = 0;
|
|
321
|
+
|
|
322
|
+
for (const entry of branch) {
|
|
323
|
+
if (entry.type === "message") {
|
|
324
|
+
const msg = entry.message;
|
|
325
|
+
messageCount++;
|
|
326
|
+
|
|
327
|
+
if (msg.role === "user") {
|
|
328
|
+
userMessageCount++;
|
|
329
|
+
const u = processUserMessage(msg as UserMessage);
|
|
330
|
+
userTokens += u.userTokens;
|
|
331
|
+
imageTokens += u.imageTokens;
|
|
332
|
+
imageCount += u.imageCount;
|
|
333
|
+
} else if (msg.role === "assistant") {
|
|
334
|
+
turnCount++;
|
|
335
|
+
const a = processAssistantMessage(msg as AssistantMessage);
|
|
336
|
+
assistantTextTokens += a.assistantTextTokens;
|
|
337
|
+
thinkingTokens += a.thinkingTokens;
|
|
338
|
+
cacheRead += a.cacheRead;
|
|
339
|
+
cacheWrite += a.cacheWrite;
|
|
340
|
+
totalCost += a.totalCost;
|
|
341
|
+
} else if (msg.role === "toolResult") {
|
|
342
|
+
const tr = processToolResult(msg as ToolResultMessage);
|
|
343
|
+
const existing = toolStats[tr.name] ?? { tokens: 0, callCount: 0, maxCallTokens: 0 };
|
|
344
|
+
existing.tokens += tr.tokens;
|
|
345
|
+
existing.callCount++;
|
|
346
|
+
existing.maxCallTokens = Math.max(existing.maxCallTokens, tr.tokens);
|
|
347
|
+
toolStats[tr.name] = existing;
|
|
348
|
+
}
|
|
349
|
+
} else if (entry.type === "compaction" || entry.type === "branch_summary") {
|
|
350
|
+
compactionTokens += estimateStringTokens((entry as any).summary ?? "");
|
|
351
|
+
} else if (entry.type === "custom_message") {
|
|
352
|
+
const content = (entry as any).content ?? (entry as any).message?.content;
|
|
353
|
+
if (typeof content === "string") {
|
|
354
|
+
customMessageTokens += estimateStringTokens(content);
|
|
355
|
+
} else if (Array.isArray(content)) {
|
|
356
|
+
const { textTokens, imageTokens: imgs } = estimateContentTokens(content);
|
|
357
|
+
customMessageTokens += textTokens;
|
|
358
|
+
imageTokens += imgs;
|
|
359
|
+
if (imgs > 0) imageCount++;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const toolTokens = Object.values(toolStats).reduce((s, t) => s + t.tokens, 0);
|
|
365
|
+
const usedFromCategories =
|
|
366
|
+
systemPromptTokens + userTokens + assistantTextTokens + thinkingTokens +
|
|
367
|
+
toolTokens + compactionTokens + customMessageTokens + imageTokens;
|
|
368
|
+
|
|
369
|
+
const totalTokens = usage.tokens ?? usedFromCategories;
|
|
370
|
+
|
|
371
|
+
const categories = buildCategories(
|
|
372
|
+
contextWindow,
|
|
373
|
+
systemPromptTokens,
|
|
374
|
+
userTokens,
|
|
375
|
+
assistantTextTokens,
|
|
376
|
+
thinkingTokens,
|
|
377
|
+
compactionTokens,
|
|
378
|
+
customMessageTokens,
|
|
379
|
+
imageTokens,
|
|
380
|
+
toolStats,
|
|
381
|
+
totalTokens,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
categories,
|
|
386
|
+
totalTokens,
|
|
387
|
+
contextWindow,
|
|
388
|
+
percent: usage.percent,
|
|
389
|
+
cacheRead,
|
|
390
|
+
cacheWrite,
|
|
391
|
+
totalCost,
|
|
392
|
+
messageCount,
|
|
393
|
+
turnCount,
|
|
394
|
+
userMessageCount,
|
|
395
|
+
imageCount,
|
|
396
|
+
toolStats,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
401
|
+
// Suggestions
|
|
402
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
403
|
+
|
|
404
|
+
function generateSuggestions(breakdown: ContextBreakdown): string[] {
|
|
405
|
+
const s: string[] = [];
|
|
406
|
+
const pct = breakdown.percent;
|
|
407
|
+
|
|
408
|
+
// Context-level warnings
|
|
409
|
+
if (pct !== null && pct > CONTEXT_WARN) {
|
|
410
|
+
s.push("⚠ Context usage above 80% — consider /compact");
|
|
411
|
+
}
|
|
412
|
+
if (pct !== null && pct > CONTEXT_CRITICAL) {
|
|
413
|
+
s.push("🔴 Near context limit — compaction strongly recommended");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Per-category warnings
|
|
417
|
+
for (const cat of breakdown.categories) {
|
|
418
|
+
const catPct = cat.tokens / breakdown.contextWindow;
|
|
419
|
+
|
|
420
|
+
if (cat.key === "tools" || cat.key.startsWith("tool:")) {
|
|
421
|
+
if (catPct > CATEGORY_DANGER) {
|
|
422
|
+
s.push(
|
|
423
|
+
`🔴 ${cat.label} uses ${(catPct * 100).toFixed(0)}% of context — consider /compact`,
|
|
424
|
+
);
|
|
425
|
+
} else if (catPct > CATEGORY_WARN) {
|
|
426
|
+
s.push(
|
|
427
|
+
`💡 ${cat.label} uses ${(catPct * 100).toFixed(0)}% — summarize large outputs`,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (cat.key === "thinking" && catPct > THINKING_HIGH) {
|
|
433
|
+
s.push("💭 Thinking is >40% of context — try simplifying the task");
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (cat.key === "compaction" && catPct > 0.3) {
|
|
437
|
+
s.push("📦 Compaction summary is large — previous compaction may have been too verbose");
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Total tool usage
|
|
442
|
+
const totalToolPct =
|
|
443
|
+
breakdown.categories
|
|
444
|
+
.filter((c) => c.key.startsWith("tool:"))
|
|
445
|
+
.reduce((sum, c) => sum + c.tokens / breakdown.contextWindow, 0);
|
|
446
|
+
if (totalToolPct > 0.6) {
|
|
447
|
+
s.push("🔧 Tools consume >60% — /compact can summarize tool outputs");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
return s;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
454
|
+
// Formatting
|
|
455
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
456
|
+
|
|
457
|
+
function formatTokens(n: number): string {
|
|
458
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
459
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
460
|
+
return `${n}`;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function formatPct(n: number, total: number): string {
|
|
464
|
+
return `${((n / total) * 100).toFixed(1)}%`;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
468
|
+
// Render: progress bar
|
|
469
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
470
|
+
|
|
471
|
+
function renderProgressBar(
|
|
472
|
+
breakdown: ContextBreakdown,
|
|
473
|
+
width: number,
|
|
474
|
+
): string[] {
|
|
475
|
+
const barW = Math.min(width, 80);
|
|
476
|
+
const lines: string[] = [];
|
|
477
|
+
const nonFree = breakdown.categories.filter((c) => c.key !== "free");
|
|
478
|
+
|
|
479
|
+
let bar = "";
|
|
480
|
+
let remaining = barW;
|
|
481
|
+
for (const cat of nonFree) {
|
|
482
|
+
const segW = Math.max(1, Math.round((cat.tokens / breakdown.contextWindow) * barW));
|
|
483
|
+
const actualW = Math.min(segW, remaining);
|
|
484
|
+
if (actualW <= 0) continue;
|
|
485
|
+
// Use a thin slice of the category's color
|
|
486
|
+
bar += ansi256Bg(cat.colorCode, " ".repeat(actualW));
|
|
487
|
+
remaining -= actualW;
|
|
488
|
+
}
|
|
489
|
+
// Free space
|
|
490
|
+
if (remaining > 0) {
|
|
491
|
+
const freeCat = breakdown.categories.find((c) => c.key === "free");
|
|
492
|
+
bar += ansi256Bg(freeCat?.colorCode ?? 236, " ".repeat(remaining));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
lines.push(bar);
|
|
496
|
+
return lines;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
500
|
+
// Render: grid
|
|
501
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
502
|
+
|
|
503
|
+
function renderGrid(
|
|
504
|
+
breakdown: ContextBreakdown,
|
|
505
|
+
width: number,
|
|
506
|
+
highlightedKey: string | null,
|
|
507
|
+
): string[] {
|
|
508
|
+
const squareW = GRID_SQUARE_W;
|
|
509
|
+
const cols = Math.floor(width / squareW);
|
|
510
|
+
if (cols <= 0) return [];
|
|
511
|
+
|
|
512
|
+
const targetRows = Math.min(GRID_MAX_ROWS, Math.max(GRID_MIN_ROWS, Math.floor(width / 8)));
|
|
513
|
+
const cellsTotal = cols * targetRows;
|
|
514
|
+
const tokensPerCell = breakdown.contextWindow / cellsTotal;
|
|
515
|
+
|
|
516
|
+
// Build cell array (skip free — it's fill below)
|
|
517
|
+
const cells: string[] = [];
|
|
518
|
+
let cellIdx = 0;
|
|
519
|
+
for (const cat of breakdown.categories) {
|
|
520
|
+
if (cat.key === "free") continue;
|
|
521
|
+
const isHighlighted = cat.key === highlightedKey;
|
|
522
|
+
const square = isHighlighted ? cat.highlightedSquare : cat.square;
|
|
523
|
+
const numCells = Math.max(
|
|
524
|
+
cat.tokens > 0 ? 1 : 0,
|
|
525
|
+
Math.round(cat.tokens / tokensPerCell),
|
|
526
|
+
);
|
|
527
|
+
for (let i = 0; i < numCells && cellIdx < cellsTotal; i++) {
|
|
528
|
+
cells.push(square);
|
|
529
|
+
cellIdx++;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Fill remaining with free space
|
|
534
|
+
const freeCat = breakdown.categories.find((c) => c.key === "free");
|
|
535
|
+
const freeSquare = freeCat?.square ?? ansi256Bg(236, " ");
|
|
536
|
+
while (cellIdx < cellsTotal) {
|
|
537
|
+
cells.push(freeSquare);
|
|
538
|
+
cellIdx++;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Center grid
|
|
542
|
+
const gridW = cols * squareW;
|
|
543
|
+
const leftPad = Math.floor((width - gridW) / 2);
|
|
544
|
+
const leftPadStr = leftPad > 0 ? " ".repeat(leftPad) : "";
|
|
545
|
+
|
|
546
|
+
const lines: string[] = [];
|
|
547
|
+
for (let row = 0; row < targetRows; row++) {
|
|
548
|
+
const start = row * cols;
|
|
549
|
+
const end = Math.min(start + cols, cells.length);
|
|
550
|
+
let line = leftPadStr;
|
|
551
|
+
for (let i = start; i < end; i++) {
|
|
552
|
+
line += cells[i];
|
|
553
|
+
}
|
|
554
|
+
lines.push(line);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
return lines;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
561
|
+
// Render: table view
|
|
562
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
563
|
+
|
|
564
|
+
function renderTableView(
|
|
565
|
+
breakdown: ContextBreakdown,
|
|
566
|
+
width: number,
|
|
567
|
+
selectedIdx: number,
|
|
568
|
+
): string[] {
|
|
569
|
+
const lines: string[] = [];
|
|
570
|
+
const nonFree = breakdown.categories.filter((c) => c.key !== "free");
|
|
571
|
+
const freeCat = breakdown.categories.find((c) => c.key === "free");
|
|
572
|
+
const allCats = [...nonFree];
|
|
573
|
+
if (freeCat) allCats.push(freeCat);
|
|
574
|
+
|
|
575
|
+
const barW = Math.floor(width * 0.35);
|
|
576
|
+
|
|
577
|
+
for (let i = 0; i < allCats.length; i++) {
|
|
578
|
+
const cat = allCats[i]!;
|
|
579
|
+
const isSelected = i === selectedIdx;
|
|
580
|
+
const prefix = isSelected ? "▶ " : " ";
|
|
581
|
+
const marker = isSelected
|
|
582
|
+
? ansi256Bg(cat.colorCode, " ") + ansi256Fg(cat.colorCode, "●") + " "
|
|
583
|
+
: cat.square + " ";
|
|
584
|
+
|
|
585
|
+
// Mini bar
|
|
586
|
+
const segW = Math.max(1, Math.round((cat.tokens / breakdown.contextWindow) * barW));
|
|
587
|
+
const bar = ansi256Bg(
|
|
588
|
+
isSelected ? brightenColor(cat.colorCode) : cat.colorCode,
|
|
589
|
+
" ".repeat(segW),
|
|
590
|
+
) + " ".repeat(Math.max(0, barW - segW));
|
|
591
|
+
|
|
592
|
+
// Danger indicator
|
|
593
|
+
const catPct = cat.tokens / breakdown.contextWindow;
|
|
594
|
+
let dangerIcon = "";
|
|
595
|
+
if (cat.key !== "free" && catPct > CATEGORY_DANGER) dangerIcon = " 🔴";
|
|
596
|
+
else if (cat.key !== "free" && catPct > CATEGORY_WARN) dangerIcon = " ⚠";
|
|
597
|
+
|
|
598
|
+
const info = `${formatTokens(cat.tokens)} (${cat.pct.toFixed(1)}%)${dangerIcon}`;
|
|
599
|
+
const line = prefix + marker + bar + " " + ansi256Fg(cat.colorCode, cat.label) + " " + info;
|
|
600
|
+
lines.push(truncateToWidth(line, width));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return lines;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
607
|
+
// Render: detail view (selected category)
|
|
608
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
609
|
+
|
|
610
|
+
function renderDetailView(
|
|
611
|
+
breakdown: ContextBreakdown,
|
|
612
|
+
cat: Category,
|
|
613
|
+
width: number,
|
|
614
|
+
): string[] {
|
|
615
|
+
const lines: string[] = [];
|
|
616
|
+
const cw = breakdown.contextWindow;
|
|
617
|
+
const pct = cat.pct;
|
|
618
|
+
|
|
619
|
+
lines.push(ansi256Fg(cat.colorCode, `▌ ${cat.label}`));
|
|
620
|
+
lines.push("");
|
|
621
|
+
lines.push(`${formatTokens(cat.tokens)} tokens (${pct.toFixed(1)}% of context)`);
|
|
622
|
+
|
|
623
|
+
if (cat.key.startsWith("tool:")) {
|
|
624
|
+
const toolName = cat.key.slice(5);
|
|
625
|
+
const stats = breakdown.toolStats[toolName];
|
|
626
|
+
if (stats) {
|
|
627
|
+
const avg = stats.callCount > 0 ? Math.round(stats.tokens / stats.callCount) : 0;
|
|
628
|
+
lines.push("");
|
|
629
|
+
lines.push(`${stats.callCount} calls, avg ${formatTokens(avg)} tokens/call`);
|
|
630
|
+
lines.push(`Largest call: ${formatTokens(stats.maxCallTokens)} tokens`);
|
|
631
|
+
}
|
|
632
|
+
} else if (cat.key === "user") {
|
|
633
|
+
lines.push(`${breakdown.userMessageCount} messages`);
|
|
634
|
+
} else if (cat.key === "assistant") {
|
|
635
|
+
const thinkingCat = breakdown.categories.find((c) => c.key === "thinking");
|
|
636
|
+
const thinkingPct = thinkingCat ? thinkingCat.pct : 0;
|
|
637
|
+
lines.push(`Text: ${formatTokens(cat.tokens)} (${pct.toFixed(1)}%)`);
|
|
638
|
+
lines.push(`Thinking: ${thinkingPct.toFixed(1)}% of context`);
|
|
639
|
+
} else if (cat.key === "images") {
|
|
640
|
+
lines.push(`${breakdown.imageCount} images`);
|
|
641
|
+
} else if (cat.key === "free") {
|
|
642
|
+
const est = breakdown.turnCount > 0
|
|
643
|
+
? Math.floor((cat.tokens / breakdown.totalTokens) * breakdown.turnCount)
|
|
644
|
+
: null;
|
|
645
|
+
if (est !== null) lines.push(`Est. ~${est} turns remaining`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
// Category-specific suggestions
|
|
649
|
+
if (cat.tokens / cw > CATEGORY_DANGER && cat.key !== "free") {
|
|
650
|
+
lines.push("");
|
|
651
|
+
lines.push(`⚠ This category alone uses >${(CATEGORY_DANGER * 100).toFixed(0)}% of context.`);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return lines.map((l) => truncateToWidth(l, width));
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
658
|
+
// Render: history trend
|
|
659
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
660
|
+
|
|
661
|
+
function renderHistory(_breakdown: ContextBreakdown, width: number): string[] {
|
|
662
|
+
if (contextHistory.length < 2) return [];
|
|
663
|
+
|
|
664
|
+
const lines: string[] = [];
|
|
665
|
+
lines.push("Context trend:");
|
|
666
|
+
const barW = Math.min(width - 12, 40);
|
|
667
|
+
|
|
668
|
+
for (const snap of contextHistory) {
|
|
669
|
+
const pct = snap.totalTokens / snap.contextWindow;
|
|
670
|
+
const filled = Math.round(pct * barW);
|
|
671
|
+
const bar = "█".repeat(filled) + "░".repeat(Math.max(0, barW - filled));
|
|
672
|
+
const label = `T${snap.turnCount}`.padEnd(4);
|
|
673
|
+
lines.push(
|
|
674
|
+
`${label} ${bar} ${formatTokens(snap.totalTokens)}/${formatTokens(snap.contextWindow)}`,
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
return lines;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
682
|
+
// Interactive overlay component
|
|
683
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
684
|
+
|
|
685
|
+
type CachedRender = { width: number; lines: string[] } | null;
|
|
686
|
+
|
|
687
|
+
class ContextOverlay {
|
|
688
|
+
private mode: ViewMode = "grid";
|
|
689
|
+
private selectedIdx: number = -1; // index into non-free categories
|
|
690
|
+
private cache: CachedRender = null;
|
|
691
|
+
private cachedSelectedIdx: number = -1;
|
|
692
|
+
|
|
693
|
+
constructor(
|
|
694
|
+
private breakdown: ContextBreakdown,
|
|
695
|
+
private theme: Theme,
|
|
696
|
+
private done: (value: void) => void,
|
|
697
|
+
) {
|
|
698
|
+
addSnapshot(breakdown);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
handleInput(data: string): void {
|
|
702
|
+
const nonFree = this.breakdown.categories.filter((c) => c.key !== "free");
|
|
703
|
+
const freeCat = this.breakdown.categories.find((c) => c.key === "free");
|
|
704
|
+
const allCats = [...nonFree];
|
|
705
|
+
if (freeCat) allCats.push(freeCat);
|
|
706
|
+
|
|
707
|
+
if (this.mode === "detail") {
|
|
708
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
709
|
+
this.mode = "grid";
|
|
710
|
+
this.cache = null;
|
|
711
|
+
}
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
716
|
+
this.done(undefined);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (matchesKey(data, "enter")) {
|
|
721
|
+
if (this.selectedIdx >= 0 && this.selectedIdx < nonFree.length) {
|
|
722
|
+
this.mode = "detail";
|
|
723
|
+
this.cache = null;
|
|
724
|
+
}
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (matchesKey(data, "t")) {
|
|
729
|
+
this.mode = this.mode === "table" ? "grid" : "table";
|
|
730
|
+
this.cache = null;
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
if (matchesKey(data, "s")) {
|
|
735
|
+
this.saveReport();
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
if (matchesKey(data, "up") || matchesKey(data, "k")) {
|
|
740
|
+
if (this.selectedIdx <= 0) {
|
|
741
|
+
this.selectedIdx = allCats.length - 1;
|
|
742
|
+
} else {
|
|
743
|
+
this.selectedIdx--;
|
|
744
|
+
}
|
|
745
|
+
this.cache = null;
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (matchesKey(data, "down") || matchesKey(data, "j")) {
|
|
750
|
+
if (this.selectedIdx >= allCats.length - 1) {
|
|
751
|
+
this.selectedIdx = 0;
|
|
752
|
+
} else {
|
|
753
|
+
this.selectedIdx++;
|
|
754
|
+
}
|
|
755
|
+
this.cache = null;
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
invalidate(): void {
|
|
761
|
+
this.cache = null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
render(width: number): string[] {
|
|
765
|
+
if (this.cache && this.cache.width === width && this.cachedSelectedIdx === this.selectedIdx) {
|
|
766
|
+
return this.cache.lines;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const lines: string[] = [];
|
|
770
|
+
const innerW = width - 2;
|
|
771
|
+
|
|
772
|
+
const pad = (s: string, len: number) => {
|
|
773
|
+
const vis = visibleWidth(s);
|
|
774
|
+
return s + " ".repeat(Math.max(0, len - vis));
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const row = (content: string) =>
|
|
778
|
+
this.theme.fg("border", "│") + pad(` ${content}`, innerW) + this.theme.fg("border", "│");
|
|
779
|
+
|
|
780
|
+
const emptyRow = () => row("");
|
|
781
|
+
|
|
782
|
+
const hr = () =>
|
|
783
|
+
this.theme.fg("border", "│") +
|
|
784
|
+
this.theme.fg("dim", "─".repeat(innerW)) +
|
|
785
|
+
this.theme.fg("border", "│");
|
|
786
|
+
|
|
787
|
+
// ═══ Top border & title ═══
|
|
788
|
+
lines.push(this.theme.fg("border", `╭${"─".repeat(innerW)}╮`));
|
|
789
|
+
|
|
790
|
+
const modeTag = this.mode === "table" ? " [Table]" : this.mode === "detail" ? " [Detail]" : "";
|
|
791
|
+
const pctTag = this.breakdown.percent !== null ? ` (${this.breakdown.percent.toFixed(1)}%)` : "";
|
|
792
|
+
const title = `Context Window Usage${pctTag}${modeTag}`;
|
|
793
|
+
lines.push(row(this.theme.bold(this.theme.fg("accent", title))));
|
|
794
|
+
|
|
795
|
+
const sub = `${formatTokens(this.breakdown.totalTokens)} / ${formatTokens(this.breakdown.contextWindow)} tokens`;
|
|
796
|
+
lines.push(row(this.theme.fg("muted", sub)));
|
|
797
|
+
|
|
798
|
+
lines.push(emptyRow());
|
|
799
|
+
|
|
800
|
+
// ═══ Detail mode ═══
|
|
801
|
+
if (this.mode === "detail") {
|
|
802
|
+
const nonFree = this.breakdown.categories.filter((c) => c.key !== "free");
|
|
803
|
+
const cat = nonFree[this.selectedIdx];
|
|
804
|
+
if (cat) {
|
|
805
|
+
const detailLines = renderDetailView(this.breakdown, cat, innerW);
|
|
806
|
+
for (const dl of detailLines) {
|
|
807
|
+
lines.push(row(dl));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
lines.push(emptyRow());
|
|
811
|
+
lines.push(hr());
|
|
812
|
+
lines.push(emptyRow());
|
|
813
|
+
lines.push(row(this.theme.fg("dim", "Esc: back Arrows: navigate")));
|
|
814
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
815
|
+
|
|
816
|
+
this.cache = { width, lines };
|
|
817
|
+
this.cachedSelectedIdx = this.selectedIdx;
|
|
818
|
+
return lines;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// ═══ Compact mode (narrow terminals) ═══
|
|
822
|
+
if (width <= COMPACT_MODE_WIDTH) {
|
|
823
|
+
const tableLines = renderTableView(this.breakdown, innerW, this.selectedIdx);
|
|
824
|
+
for (const tl of tableLines) {
|
|
825
|
+
lines.push(row(tl));
|
|
826
|
+
}
|
|
827
|
+
lines.push(emptyRow());
|
|
828
|
+
lines.push(hr());
|
|
829
|
+
lines.push(emptyRow());
|
|
830
|
+
this.renderStatsCompact(lines, row);
|
|
831
|
+
lines.push(emptyRow());
|
|
832
|
+
lines.push(row(this.theme.fg("dim", "Arrows: navigate Enter: detail Esc: close")));
|
|
833
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
834
|
+
|
|
835
|
+
this.cache = { width, lines };
|
|
836
|
+
this.cachedSelectedIdx = this.selectedIdx;
|
|
837
|
+
return lines;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// ═══ Progress bar ═══
|
|
841
|
+
const barLines = renderProgressBar(this.breakdown, innerW);
|
|
842
|
+
for (const bl of barLines) {
|
|
843
|
+
lines.push(
|
|
844
|
+
this.theme.fg("border", "│") +
|
|
845
|
+
pad(bl, innerW) +
|
|
846
|
+
this.theme.fg("border", "│"),
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
lines.push(emptyRow());
|
|
850
|
+
|
|
851
|
+
// ═══ Grid or Table ═══
|
|
852
|
+
if (this.mode === "table") {
|
|
853
|
+
const tableLines = renderTableView(this.breakdown, innerW, this.selectedIdx);
|
|
854
|
+
for (const tl of tableLines) {
|
|
855
|
+
lines.push(row(tl));
|
|
856
|
+
}
|
|
857
|
+
} else {
|
|
858
|
+
const nonFree = this.breakdown.categories.filter((c) => c.key !== "free");
|
|
859
|
+
const highlightedKey = this.selectedIdx >= 0 && this.selectedIdx < nonFree.length
|
|
860
|
+
? nonFree[this.selectedIdx]!.key
|
|
861
|
+
: null;
|
|
862
|
+
|
|
863
|
+
const gridLines = renderGrid(this.breakdown, innerW, highlightedKey);
|
|
864
|
+
for (const gl of gridLines) {
|
|
865
|
+
lines.push(
|
|
866
|
+
this.theme.fg("border", "│") +
|
|
867
|
+
pad(gl, innerW) +
|
|
868
|
+
this.theme.fg("border", "│"),
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ═══ Remaining turns ═══
|
|
874
|
+
if (this.breakdown.turnCount > 0) {
|
|
875
|
+
const freeCat = this.breakdown.categories.find((c) => c.key === "free");
|
|
876
|
+
if (freeCat && freeCat.tokens > 0) {
|
|
877
|
+
const avgPerTurn = this.breakdown.totalTokens / this.breakdown.turnCount;
|
|
878
|
+
const remaining = Math.floor(freeCat.tokens / avgPerTurn);
|
|
879
|
+
lines.push(emptyRow());
|
|
880
|
+
lines.push(row(this.theme.fg("muted", `Est. ~${remaining} turns remaining (avg ${formatTokens(Math.round(avgPerTurn))} tokens/turn)`)));
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
lines.push(emptyRow());
|
|
885
|
+
lines.push(hr());
|
|
886
|
+
lines.push(emptyRow());
|
|
887
|
+
|
|
888
|
+
// ═══ Legend ═══
|
|
889
|
+
const nonFree = this.breakdown.categories.filter((c) => c.key !== "free");
|
|
890
|
+
const freeCat = this.breakdown.categories.find((c) => c.key === "free");
|
|
891
|
+
const colW = Math.floor((innerW - 2) / 2);
|
|
892
|
+
|
|
893
|
+
const formatEntry = (cat: Category, i: number, w: number): string => {
|
|
894
|
+
const isSelected = i === this.selectedIdx;
|
|
895
|
+
const prefix = isSelected ? "▶" : " ";
|
|
896
|
+
const square = isSelected ? cat.highlightedSquare : cat.square;
|
|
897
|
+
const label = `${prefix}${square} ${ansi256Fg(cat.colorCode, cat.label)}`;
|
|
898
|
+
const value = this.theme.fg(
|
|
899
|
+
isSelected ? "accent" : "dim",
|
|
900
|
+
`${formatTokens(cat.tokens)} (${cat.pct.toFixed(1)}%)`,
|
|
901
|
+
);
|
|
902
|
+
return pad(`${label} ${value}`, w);
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
for (let i = 0; i < nonFree.length; i += 2) {
|
|
906
|
+
const left = nonFree[i]!;
|
|
907
|
+
const right = nonFree[i + 1];
|
|
908
|
+
let content = " " + formatEntry(left, i, colW);
|
|
909
|
+
if (right) {
|
|
910
|
+
content += formatEntry(right, i + 1, colW);
|
|
911
|
+
}
|
|
912
|
+
lines.push(row(content));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (freeCat && freeCat.tokens > 0) {
|
|
916
|
+
const isSelected = this.selectedIdx === nonFree.length;
|
|
917
|
+
const prefix = isSelected ? "▶" : " ";
|
|
918
|
+
const square = isSelected ? freeCat.highlightedSquare : freeCat.square;
|
|
919
|
+
const label = `${prefix}${square} ${ansi256Fg(freeCat.colorCode, freeCat.label)}`;
|
|
920
|
+
const value = this.theme.fg(
|
|
921
|
+
isSelected ? "accent" : "dim",
|
|
922
|
+
`${formatTokens(freeCat.tokens)} (${freeCat.pct.toFixed(1)}%)`,
|
|
923
|
+
);
|
|
924
|
+
lines.push(row(`${label} ${value}`));
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
lines.push(emptyRow());
|
|
928
|
+
lines.push(hr());
|
|
929
|
+
lines.push(emptyRow());
|
|
930
|
+
|
|
931
|
+
// ═══ Stats ═══
|
|
932
|
+
lines.push(row(this.theme.fg("accent", this.theme.bold("Session Stats"))));
|
|
933
|
+
lines.push(row(this.renderStatsLine(innerW)));
|
|
934
|
+
|
|
935
|
+
// Cost projection
|
|
936
|
+
if (this.breakdown.totalCost > 0 && this.breakdown.totalTokens > 0) {
|
|
937
|
+
const costPerTok = this.breakdown.totalCost / this.breakdown.totalTokens;
|
|
938
|
+
const projected = costPerTok * this.breakdown.contextWindow;
|
|
939
|
+
lines.push(row(
|
|
940
|
+
this.theme.fg("muted", `Cost: $${this.breakdown.totalCost.toFixed(4)}`) +
|
|
941
|
+
this.theme.fg("dim", ` | ~$${projected.toFixed(2)} projected for full context`),
|
|
942
|
+
));
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
// ═══ Warnings ═══
|
|
946
|
+
const suggestions = generateSuggestions(this.breakdown);
|
|
947
|
+
if (suggestions.length > 0) {
|
|
948
|
+
lines.push(emptyRow());
|
|
949
|
+
lines.push(hr());
|
|
950
|
+
lines.push(emptyRow());
|
|
951
|
+
for (const sugg of suggestions) {
|
|
952
|
+
lines.push(row(this.theme.fg("warning", sugg)));
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// ═══ History ═══
|
|
957
|
+
const histLines = renderHistory(this.breakdown, innerW);
|
|
958
|
+
if (histLines.length > 0) {
|
|
959
|
+
lines.push(emptyRow());
|
|
960
|
+
lines.push(hr());
|
|
961
|
+
lines.push(emptyRow());
|
|
962
|
+
for (const hl of histLines) {
|
|
963
|
+
lines.push(row(this.theme.fg("dim", hl)));
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
lines.push(emptyRow());
|
|
968
|
+
|
|
969
|
+
// ═══ Help ═══
|
|
970
|
+
lines.push(row(this.theme.fg("dim", "Arrows: navigate Enter: details T: grid/table S: save report Esc: close")));
|
|
971
|
+
|
|
972
|
+
lines.push(this.theme.fg("border", `╰${"─".repeat(innerW)}╯`));
|
|
973
|
+
|
|
974
|
+
this.cache = { width, lines };
|
|
975
|
+
this.cachedSelectedIdx = this.selectedIdx;
|
|
976
|
+
return lines;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
private renderStatsLine(innerW: number): string {
|
|
980
|
+
const b = this.breakdown;
|
|
981
|
+
const parts = [
|
|
982
|
+
`Turns: ${b.turnCount}`,
|
|
983
|
+
`Messages: ${b.messageCount}`,
|
|
984
|
+
`Cache read: ${formatTokens(b.cacheRead)}`,
|
|
985
|
+
`Cache write: ${formatTokens(b.cacheWrite)}`,
|
|
986
|
+
`Cost: $${b.totalCost.toFixed(4)}`,
|
|
987
|
+
];
|
|
988
|
+
return parts.map((p) => this.theme.fg("muted", p)).join(this.theme.fg("dim", " │ "));
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
private renderStatsCompact(lines: string[], row: (s: string) => string): void {
|
|
992
|
+
const b = this.breakdown;
|
|
993
|
+
lines.push(row(this.theme.fg("accent", "Session Stats")));
|
|
994
|
+
lines.push(row(this.theme.fg("muted", `Turns: ${b.turnCount} Messages: ${b.messageCount} Cost: $${b.totalCost.toFixed(4)}`)));
|
|
995
|
+
if (b.cacheRead > 0 || b.cacheWrite > 0) {
|
|
996
|
+
lines.push(row(this.theme.fg("muted", `Cache read: ${formatTokens(b.cacheRead)} Cache write: ${formatTokens(b.cacheWrite)}`)));
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
private saveReport(): void {
|
|
1001
|
+
const b = this.breakdown;
|
|
1002
|
+
const lines: string[] = [];
|
|
1003
|
+
lines.push("=== Context Usage Report ===");
|
|
1004
|
+
lines.push(`Total: ${formatTokens(b.totalTokens)} / ${formatTokens(b.contextWindow)} (${b.percent?.toFixed(1) ?? "?"}%)`);
|
|
1005
|
+
lines.push(`Turns: ${b.turnCount} Messages: ${b.messageCount}`);
|
|
1006
|
+
lines.push(`Cost: $${b.totalCost.toFixed(4)}`);
|
|
1007
|
+
lines.push("");
|
|
1008
|
+
lines.push("--- Categories ---");
|
|
1009
|
+
for (const cat of b.categories) {
|
|
1010
|
+
lines.push(`${cat.label}: ${formatTokens(cat.tokens)} (${cat.pct.toFixed(1)}%)`);
|
|
1011
|
+
}
|
|
1012
|
+
lines.push("");
|
|
1013
|
+
lines.push("--- Tool Details ---");
|
|
1014
|
+
for (const [name, stats] of Object.entries(b.toolStats)) {
|
|
1015
|
+
lines.push(`${name}: ${stats.callCount} calls, ${formatTokens(stats.tokens)} tokens, max ${formatTokens(stats.maxCallTokens)}`);
|
|
1016
|
+
}
|
|
1017
|
+
lines.push("");
|
|
1018
|
+
const suggestions = generateSuggestions(b);
|
|
1019
|
+
if (suggestions.length > 0) {
|
|
1020
|
+
lines.push("--- Suggestions ---");
|
|
1021
|
+
for (const s of suggestions) lines.push(s);
|
|
1022
|
+
}
|
|
1023
|
+
lines.push("");
|
|
1024
|
+
|
|
1025
|
+
const report = lines.join("\n");
|
|
1026
|
+
// Copy to clipboard via OSC 52 if supported, otherwise just dump
|
|
1027
|
+
process.stdout.write(`\x1b]52;c;${Buffer.from(report).toString("base64")}\x07`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1032
|
+
// Extension entry point
|
|
1033
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
1034
|
+
|
|
1035
|
+
export default function (pi: ExtensionAPI) {
|
|
1036
|
+
pi.registerCommand("context", {
|
|
1037
|
+
description: "Visualize current context usage as a colored grid",
|
|
1038
|
+
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
1039
|
+
const breakdown = computeBreakdown(ctx);
|
|
1040
|
+
if (!breakdown) {
|
|
1041
|
+
ctx.ui.notify("No context usage data available yet. Send a message first.", "warning");
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
await ctx.ui.custom<void>(
|
|
1046
|
+
(tui, theme, _keybindings, done) => {
|
|
1047
|
+
const overlay = new ContextOverlay(breakdown, theme, done);
|
|
1048
|
+
|
|
1049
|
+
return {
|
|
1050
|
+
handleInput(data: string) {
|
|
1051
|
+
overlay.handleInput(data);
|
|
1052
|
+
tui.requestRender();
|
|
1053
|
+
},
|
|
1054
|
+
|
|
1055
|
+
render(width: number): string[] {
|
|
1056
|
+
return overlay.render(width);
|
|
1057
|
+
},
|
|
1058
|
+
|
|
1059
|
+
invalidate() {
|
|
1060
|
+
overlay.invalidate();
|
|
1061
|
+
},
|
|
1062
|
+
};
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
overlay: true,
|
|
1066
|
+
overlayOptions: {
|
|
1067
|
+
anchor: "center",
|
|
1068
|
+
width: "80%",
|
|
1069
|
+
maxWidth: 100,
|
|
1070
|
+
minWidth: 40,
|
|
1071
|
+
maxHeight: "90%",
|
|
1072
|
+
},
|
|
1073
|
+
},
|
|
1074
|
+
);
|
|
1075
|
+
},
|
|
1076
|
+
});
|
|
1077
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-context-viz",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Interactive context window visualizer for pi — colored grid overlay with token breakdown, stats, and optimization suggestions",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": ["pi-package", "pi-extension", "context-window", "token-visualizer"],
|
|
7
|
+
"author": "",
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/viartemev/pi-context-viz"
|
|
12
|
+
},
|
|
13
|
+
"pi": {
|
|
14
|
+
"extensions": ["./extensions"]
|
|
15
|
+
},
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
18
|
+
"@earendil-works/pi-ai": "*",
|
|
19
|
+
"@earendil-works/pi-tui": "*"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {},
|
|
22
|
+
"devDependencies": {}
|
|
23
|
+
}
|