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 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
+ ![screenshot](https://img.shields.io/badge/pi-package-blue)
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
+ }