pi-context-inspector 0.0.1

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,23 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuri Teixeira
4
+ Copyright (c) 2026 YuGiMob
5
+ Copyright (c) 2026 Agnish Chakraborty
6
+
7
+ Permission is hereby granted, free of charge, to any person obtaining a copy
8
+ of this software and associated documentation files (the "Software"), to deal
9
+ in the Software without restriction, including without limitation the rights
10
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
11
+ copies of the Software, and to permit persons to whom the Software is
12
+ furnished to do so, subject to the following conditions:
13
+
14
+ The above copyright notice and this permission notice shall be included in all
15
+ copies or substantial portions of the Software.
16
+
17
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
18
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
19
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
20
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
21
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
22
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
23
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,87 @@
1
+ # pi-context-inspector
2
+
3
+ PS: *Forked from the extinct https://github.com/YuGiMob/pi-context-inspector*
4
+
5
+ Opens a tabbed overlay with the full LLM context of the current session in [pi-coding-agent](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent): token breakdown, system prompt, active tools, every message, and a complete context dump.
6
+
7
+ ## What you get
8
+
9
+ - **One command, five views.** `/context` opens a centered overlay with Stats, System, Tools, Messages and Full tabs.
10
+ - **Token breakdown that matches reality.** Raw character-based estimates are scaled to the provider-reported token count, so category percentages are proportional to the real usage — not to a guess.
11
+ - **A visual usage grid.** A 10×5 colored grid (50 blocks, 2% each) shows at a glance how much of the context window is system prompt, tools, skills, messages, available space, and the auto-compact buffer.
12
+ - **Scroll, search, copy.** Every content tab supports vim-style scrolling, live `/` search with match navigation, and `y` to copy the raw text to the clipboard.
13
+ - **Skill-aware accounting.** Tool calls that read skill files (`.agents/skills/`, `.pi/agent/*/skills/`, `skills/*/SKILL.md`) are counted under Skills instead of Tools.
14
+
15
+ ## Quick start
16
+
17
+ Run `/context` at any point during a session:
18
+
19
+ ```text
20
+ /context
21
+ ```
22
+
23
+ The overlay opens with the Stats tab active. `Tab` / `Shift+Tab` cycles tabs, `q` or `Escape` closes the overlay.
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pi install npm:pi-context-inspector
29
+ ```
30
+
31
+ From a local checkout:
32
+
33
+ ```bash
34
+ pi install /path/to/pi-context-inspector
35
+ ```
36
+
37
+ ## The tabs
38
+
39
+ | Tab | Shows |
40
+ | --- | --- |
41
+ | **Stats** | Model name, token usage vs. context window (with percent), a 10×5 colored usage grid, a per-category breakdown (system prompt, system tools, tools, skills, messages, available, auto-compact buffer), and safe-left tokens. |
42
+ | **System** | The full system prompt, line-numbered, scrollable and searchable. |
43
+ | **Tools** | Active tool definitions with their parameter schemas (required vs. optional, descriptions). |
44
+ | **Messages** | All session messages formatted with roles, model, token usage, stop reasons, tool calls, image placeholders and errors. |
45
+ | **Full** | Complete context dump: system prompt + messages + context usage, ready to copy. |
46
+
47
+ ## Keyboard
48
+
49
+ | Key | Action |
50
+ | --- | --- |
51
+ | `Tab` / `Shift+Tab` | Cycle tabs |
52
+ | `↑` / `↓`, `j` / `k` | Scroll |
53
+ | `g` / `G` | Jump to top / bottom |
54
+ | `PgUp` / `PgDn`, `Ctrl+b` / `Ctrl+f`, `Ctrl+u` / `Ctrl+d` | Page scroll |
55
+ | `/` | Live search (type, `Enter` to commit) |
56
+ | `n` / `N` | Next / previous match |
57
+ | `y` | Copy the tab's raw text to the clipboard |
58
+ | `q` / `Escape` | Close the overlay |
59
+
60
+ ## How the token breakdown works
61
+
62
+ Each category is estimated from raw text (chars ÷ 4, using pi's own per-message token estimator so thinking and image content are counted), then all estimates are scaled by a single ratio so they sum to the provider-reported token count. The grid reserves the auto-compact buffer as its own segment; the remaining blocks are filled proportionally by category. `safeAvailable` is the context window minus the reserve minus current usage — when it hits zero, the overlay reports that the auto-compact threshold has been reached.
63
+
64
+ ## Troubleshooting
65
+
66
+ - **"No context usage data available."** Send a message first, then re-open `/context` — usage is only reported once a turn has run.
67
+ - **The overlay doesn't open.** `/context` requires interactive (TUI) mode; it is a no-op when `ctx.hasUI` is false.
68
+ - **Percentages look off.** The breakdown scales estimates to the provider's reported total, so category sizes are proportional — but the provider total itself is only as accurate as the provider's usage reporting.
69
+
70
+ ## Development
71
+
72
+ Requires [Node.js](https://nodejs.org) ≥ 22.19 and npm.
73
+
74
+ ```bash
75
+ npm install
76
+ npm test
77
+ npm run typecheck
78
+ ```
79
+
80
+ ## Credits
81
+
82
+ - [badlogic](https://github.com/badlogic), pi-coding-agent and the TUI APIs this overlay is built on
83
+ - [Agnish Chakraborty](https://github.com/agnishcc), author of [@agnishc/edb-context-viewer](https://github.com/agnishcc/pi-extention-monorepo/tree/main/packages/edb-context-viewer) — this package is a fork of that extension
84
+
85
+ ## License
86
+
87
+ [MIT](LICENSE)
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.js";
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-context-inspector",
3
+ "author": "yuriteixeira",
4
+ "version": "0.0.1",
5
+ "type": "module",
6
+ "description": "Pi extension: inspect the system prompt, active tools, messages, token breakdown and full LLM context in a tabbed overlay",
7
+ "main": "index.ts",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/yuriteixeira/pi-context-inspector.git"
11
+ },
12
+ "keywords": [
13
+ "pi-package",
14
+ "pi",
15
+ "coding-agent",
16
+ "extension",
17
+ "context",
18
+ "inspector",
19
+ "overlay",
20
+ "tokens"
21
+ ],
22
+ "license": "MIT",
23
+ "files": [
24
+ "index.ts",
25
+ "src",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "./index.ts"
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "@earendil-works/pi-tui": "*"
37
+ },
38
+ "engines": {
39
+ "node": ">=22.19.0"
40
+ },
41
+ "devDependencies": {
42
+ "@earendil-works/pi-coding-agent": "^0.84.0",
43
+ "@types/node": "^24.0.0",
44
+ "typescript": "~5.9.3",
45
+ "vitest": "^4.1.9",
46
+ "@earendil-works/pi-tui": "^0.84.0"
47
+ },
48
+ "scripts": {
49
+ "test": "vitest run",
50
+ "typecheck": "tsc --noEmit"
51
+ }
52
+ }
package/src/index.ts ADDED
@@ -0,0 +1,429 @@
1
+ /**
2
+ * pi-context-inspector
3
+ *
4
+ * Single command for inspecting the full LLM context in a tabbed overlay:
5
+ *
6
+ * /context — opens a tabbed overlay with:
7
+ * [Stats] token distribution grid + category breakdown
8
+ * [System] full system prompt (scrollable)
9
+ * [Tools] active tool definitions (scrollable)
10
+ * [Messages] all session messages (scrollable)
11
+ * [Full] complete context dump (scrollable)
12
+ *
13
+ * Tab / Shift+Tab navigates between views.
14
+ * Each content tab supports: line numbers, scroll, live search (/), clipboard copy (y).
15
+ */
16
+
17
+ import {
18
+ buildSessionContext,
19
+ type ContextUsage,
20
+ DEFAULT_COMPACTION_SETTINGS,
21
+ estimateTokens,
22
+ type ExtensionAPI,
23
+ type ExtensionCommandContext,
24
+ type SessionContext,
25
+ type SessionEntry,
26
+ type Theme,
27
+ type ToolInfo,
28
+ } from "@earendil-works/pi-coding-agent";
29
+ import { ScrollableTabContent } from "./scrollable-tab-content.js";
30
+ import { type ContextTokenBreakdown, StatsTabContent } from "./stats-tab-content.js";
31
+ import { TabbedOverlay } from "./tabbed-overlay.js";
32
+ import { formatTokens } from "./utils.js";
33
+
34
+ type AgentMessage = SessionContext["messages"][number];
35
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
36
+ type ToolCallBlock = Extract<AssistantMessage["content"][number], { type: "toolCall" }>;
37
+ type MessageContent = Extract<AgentMessage, { content: unknown }>["content"];
38
+ type ToolDefView = Pick<ToolInfo, "name"> & { description?: string; parameters?: unknown };
39
+ // ── Helpers ────────────────────────────────────────────────────────────────────
40
+
41
+ /** Build display lines with line numbers from raw text. */
42
+ export function buildNumberedLines(text: string, theme: Theme): string[] {
43
+ const rawLines = text.split("\n");
44
+ const numWidth = String(rawLines.length).length;
45
+ return rawLines.map((line, i) => {
46
+ const num = String(i + 1).padStart(numWidth, " ");
47
+ return `${theme.fg("dim", num)} ${theme.fg("dim", "│")} ${line}`;
48
+ });
49
+ }
50
+
51
+ function numberedTab(text: string, name: string, theme: Theme): ScrollableTabContent {
52
+ return new ScrollableTabContent(
53
+ { rawText: text, displayLines: buildNumberedLines(text, theme), theme },
54
+ name,
55
+ );
56
+ }
57
+
58
+ function formatContent(content: MessageContent): string[] {
59
+ if (typeof content === "string") return [content];
60
+
61
+ const lines: string[] = [];
62
+ for (const block of content) {
63
+ switch (block.type) {
64
+ case "text":
65
+ lines.push(block.text);
66
+ break;
67
+ case "thinking":
68
+ lines.push(`[Thinking: ${block.thinking}]`);
69
+ break;
70
+ case "toolCall":
71
+ lines.push(`[Tool Call: ${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
72
+ break;
73
+ case "image":
74
+ lines.push(`[Image: ${block.mimeType ?? "unknown"}]`);
75
+ break;
76
+ default:
77
+ lines.push(`[${(block as { type?: string }).type ?? "unknown"}]`);
78
+ }
79
+ }
80
+ return lines;
81
+ }
82
+
83
+ function formatUsage(usage: AssistantMessage["usage"]): string {
84
+ const parts: string[] = [];
85
+ parts.push(`input: ${usage.input}`);
86
+ parts.push(`output: ${usage.output}`);
87
+ parts.push(`cache-read: ${usage.cacheRead}`);
88
+ parts.push(`cache-write: ${usage.cacheWrite}`);
89
+ parts.push(`total: ${usage.totalTokens}`);
90
+ return `Tokens: ${parts.join(", ")}`;
91
+ }
92
+ export function formatMessageForDisplay(message: SessionContext["messages"][number], index: number): string[] {
93
+ const lines: string[] = ["", `──── Message ${index + 1} ────`, `Role: ${message.role}`];
94
+
95
+ if (message.role === "assistant") {
96
+ lines.push(`Model: ${[message.provider, message.model].filter(Boolean).join("/")}`);
97
+ lines.push(formatUsage(message.usage));
98
+ lines.push(`Stop: ${message.stopReason}`);
99
+ if (message.errorMessage) lines.push(`Error: ${message.errorMessage}`);
100
+ }
101
+
102
+ if (message.role === "toolResult") {
103
+ lines.push(`Tool: ${message.toolName ?? "unknown"}`);
104
+ lines.push(`Tool Call ID: ${message.toolCallId ?? "unknown"}`);
105
+ lines.push(`Error: ${message.isError ? "yes" : "no"}`);
106
+ }
107
+
108
+ if (message.role === "bashExecution") {
109
+ lines.push(`Command: ${message.command}`);
110
+ lines.push(...(message.output ? message.output.split("\n") : ["(no output)"]));
111
+ const status: string[] = [];
112
+ if (message.cancelled) status.push("cancelled");
113
+ if (message.truncated) status.push("truncated");
114
+ lines.push(
115
+ `Exit: ${message.exitCode == null ? "unknown" : message.exitCode}${status.length > 0 ? ` (${status.join(", ")})` : ""}`,
116
+ );
117
+ if (message.truncated && message.fullOutputPath) {
118
+ lines.push(`Full output: ${message.fullOutputPath}`);
119
+ }
120
+ return lines;
121
+ }
122
+
123
+ if (message.role === "branchSummary") {
124
+ lines.push(`Branch from: ${message.fromId}`);
125
+ lines.push(message.summary);
126
+ return lines;
127
+ }
128
+
129
+ if (message.role === "compactionSummary") {
130
+ lines.push(`Tokens before: ${message.tokensBefore}`);
131
+ lines.push(message.summary);
132
+ return lines;
133
+ }
134
+
135
+ if (message.role === "custom") {
136
+ lines.push(`Custom type: ${message.customType}`);
137
+ }
138
+
139
+ if ("content" in message) {
140
+ lines.push(...formatContent(message.content));
141
+ }
142
+ return lines;
143
+ }
144
+
145
+ export function formatMessagesText(context: SessionContext): string {
146
+ const lines: string[] = [];
147
+ if (context.messages.length > 0) {
148
+ for (let i = 0; i < context.messages.length; i++) {
149
+ lines.push(...formatMessageForDisplay(context.messages[i]!, i));
150
+ }
151
+ } else {
152
+ lines.push("(no messages yet)");
153
+ }
154
+ return lines.join("\n");
155
+ }
156
+
157
+ interface ContextViewerModelInfo {
158
+ provider: string;
159
+ id: string;
160
+ contextWindow?: number;
161
+ }
162
+
163
+ export function buildTotalContextText(
164
+ systemPrompt: string,
165
+ context: SessionContext,
166
+ usage: ContextUsage | undefined,
167
+ model: ContextViewerModelInfo | undefined,
168
+ ): string {
169
+ const sections: string[] = [];
170
+
171
+ sections.push("═══════════════════════════════════════════════════════");
172
+ sections.push("SYSTEM PROMPT");
173
+ sections.push("═══════════════════════════════════════════════════════");
174
+ sections.push(systemPrompt);
175
+ sections.push("");
176
+
177
+ sections.push("═══════════════════════════════════════════════════════");
178
+ sections.push("MESSAGES");
179
+ sections.push("═══════════════════════════════════════════════════════");
180
+
181
+ sections.push(formatMessagesText(context));
182
+
183
+ sections.push("");
184
+ sections.push("═══════════════════════════════════════════════════════");
185
+ sections.push("CONTEXT USAGE");
186
+ sections.push("═══════════════════════════════════════════════════════");
187
+ if (usage) {
188
+ sections.push(`Tokens: ${usage.tokens?.toLocaleString() ?? "unknown"}`);
189
+ if (model) {
190
+ sections.push(`Model: ${model.provider}/${model.id}`);
191
+ const contextWindow = model.contextWindow ?? usage.contextWindow;
192
+ if (contextWindow) {
193
+ const pct = usage.percent ?? (usage.tokens == null ? null : (usage.tokens / contextWindow) * 100);
194
+ sections.push(
195
+ `Usage: ${usage.tokens?.toLocaleString() ?? "unknown"} / ${contextWindow.toLocaleString()} (${pct == null ? "unknown" : `${pct.toFixed(1)}%`})`,
196
+ );
197
+ }
198
+ }
199
+ } else {
200
+ sections.push("(no usage data available)");
201
+ }
202
+
203
+ return sections.join("\n");
204
+ }
205
+
206
+ /** Format active tool definitions as readable text for the Tools tab. */
207
+ export function buildToolsText(activeToolDefs: ToolDefView[]): string {
208
+ if (activeToolDefs.length === 0) return "(no active tools)";
209
+
210
+ const sections: string[] = [];
211
+ for (const tool of activeToolDefs) {
212
+ sections.push(`${"─".repeat(56)}`);
213
+ sections.push(`Tool: ${tool.name}`);
214
+ if (tool.description) {
215
+ sections.push(`Description: ${tool.description}`);
216
+ }
217
+ if (tool.parameters) {
218
+ sections.push("Parameters:");
219
+ const params = tool.parameters as { properties?: Record<string, { type?: string; description?: string }>; required?: string[] };
220
+ if (params?.properties) {
221
+ for (const [key, val] of Object.entries(params.properties)) {
222
+ const required = params.required?.includes(key) ? "" : " (optional)";
223
+ const type = val.type ?? "unknown";
224
+ const desc = val.description ? `: ${val.description}` : "";
225
+ sections.push(` ${key} (${type}${required})${desc}`);
226
+ }
227
+ } else {
228
+ sections.push(` ${JSON.stringify(tool.parameters, null, 2).split("\n").join("\n ")}`);
229
+ }
230
+ }
231
+ sections.push("");
232
+ }
233
+
234
+ return sections.join("\n");
235
+ }
236
+
237
+ /** Build the token breakdown, scaling raw char-based estimates to match actual token count. */
238
+ function isSkillPath(path: unknown): boolean {
239
+ if (typeof path !== "string") return false;
240
+ return /(^|\/)\.agents\/skills\/|(^|\/)\.pi\/agent\/.*\/skills\/|(^|\/)skills\/[^/]+\/SKILL\.md$/i.test(path);
241
+ }
242
+
243
+ function isSkillReadToolCall(block: ToolCallBlock): boolean {
244
+ if (block.name !== "read") return false;
245
+ return isSkillPath(block.arguments?.path);
246
+ }
247
+
248
+ const ESTIMATED_IMAGE_CHARS = 4800;
249
+
250
+ export function buildTokenBreakdown(
251
+ systemPrompt: string,
252
+ activeToolDefs: ToolInfo[],
253
+ branch: SessionEntry[],
254
+ usage: ContextUsage | undefined,
255
+ ): ContextTokenBreakdown | null {
256
+ if (usage == null || usage.tokens == null || !usage.contextWindow) return null;
257
+
258
+ const estimateChars = (text: string) => Math.ceil(text.length / 4);
259
+ const reserveTokens = Math.min(DEFAULT_COMPACTION_SETTINGS.reserveTokens, usage.contextWindow);
260
+
261
+ const systemRaw = estimateChars(systemPrompt);
262
+ const toolDefsRaw = estimateChars(JSON.stringify(activeToolDefs));
263
+
264
+ let msgTokensRaw = 0;
265
+ let toolsRaw = 0;
266
+ let skillsRaw = 0;
267
+ const skillToolCallIds = new Set<string>();
268
+
269
+ for (const entry of branch) {
270
+ if (entry.type === "message") {
271
+ const message = entry.message;
272
+ const messageTotal = estimateTokens(message);
273
+ const weights = { messages: 0, tools: 0, skills: 0 };
274
+
275
+ if (message.role === "user" || message.role === "custom") {
276
+ if (typeof message.content === "string") {
277
+ weights.messages += estimateChars(message.content);
278
+ } else {
279
+ for (const block of message.content) {
280
+ if (block.type === "text") weights.messages += estimateChars(block.text);
281
+ else if (block.type === "image") weights.messages += ESTIMATED_IMAGE_CHARS;
282
+ }
283
+ }
284
+ } else if (message.role === "assistant") {
285
+ for (const block of message.content) {
286
+ if (block.type === "text") weights.messages += estimateChars(block.text);
287
+ else if (block.type === "thinking") weights.messages += estimateChars(block.thinking);
288
+ else if (block.type === "toolCall") {
289
+ if (isSkillReadToolCall(block)) {
290
+ weights.skills += estimateChars(JSON.stringify(block));
291
+ skillToolCallIds.add(block.id);
292
+ } else {
293
+ weights.tools += estimateChars(JSON.stringify(block));
294
+ }
295
+ }
296
+ }
297
+ } else if (message.role === "toolResult") {
298
+ const isSkillResult = skillToolCallIds.has(message.toolCallId);
299
+ for (const block of message.content) {
300
+ if (block.type === "text") {
301
+ if (isSkillResult) weights.skills += estimateChars(block.text);
302
+ else weights.tools += estimateChars(block.text);
303
+ }
304
+ }
305
+ } else if (message.role === "bashExecution") {
306
+ weights.tools += estimateChars(message.command) + estimateChars(message.output);
307
+ }
308
+
309
+ const weightSum = weights.messages + weights.tools + weights.skills;
310
+ if (weightSum > 0) {
311
+ const scale = messageTotal / weightSum;
312
+ msgTokensRaw += weights.messages * scale;
313
+ toolsRaw += weights.tools * scale;
314
+ skillsRaw += weights.skills * scale;
315
+ }
316
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
317
+ msgTokensRaw += estimateChars(entry.summary);
318
+ }
319
+ }
320
+
321
+ const totalRaw = systemRaw + skillsRaw + toolDefsRaw + msgTokensRaw + toolsRaw;
322
+ const ratio = totalRaw > 0 ? usage.tokens / totalRaw : 1;
323
+
324
+ const exact = {
325
+ systemPrompt: systemRaw * ratio,
326
+ systemTools: toolDefsRaw * ratio,
327
+ tools: toolsRaw * ratio,
328
+ skills: skillsRaw * ratio,
329
+ messages: msgTokensRaw * ratio,
330
+ };
331
+ const allocated = { systemPrompt: 0, systemTools: 0, tools: 0, skills: 0, messages: 0 };
332
+ let remainder = usage.tokens;
333
+ const keys = Object.keys(exact) as (keyof typeof exact)[];
334
+ for (const key of keys) {
335
+ const value = Math.floor(exact[key]);
336
+ allocated[key] = value;
337
+ remainder -= value;
338
+ }
339
+ if (totalRaw > 0) {
340
+ const byFraction = [...keys].sort((a, b) => exact[b] % 1 - exact[a] % 1);
341
+ for (const key of byFraction) {
342
+ if (remainder <= 0) break;
343
+ allocated[key] += 1;
344
+ remainder -= 1;
345
+ }
346
+ }
347
+
348
+ return {
349
+ total: usage.tokens,
350
+ contextWindow: usage.contextWindow,
351
+ percent: usage.percent ?? (usage.tokens / usage.contextWindow) * 100,
352
+ reserveTokens,
353
+ safeAvailable: Math.max(0, usage.contextWindow - reserveTokens - usage.tokens),
354
+ systemPrompt: allocated.systemPrompt,
355
+ systemTools: allocated.systemTools,
356
+ tools: allocated.tools,
357
+ skills: allocated.skills,
358
+ messages: allocated.messages,
359
+ other: Math.max(0, usage.tokens - (allocated.systemPrompt + allocated.systemTools + allocated.tools + allocated.skills + allocated.messages)),
360
+ };
361
+ }
362
+
363
+ /** Overlay options shared across all tabs. */
364
+ const OVERLAY_OPTIONS = {
365
+ overlay: true,
366
+ overlayOptions: {
367
+ anchor: "center" as const,
368
+ width: "90%" as const,
369
+ minWidth: 60,
370
+ maxHeight: "90%" as const,
371
+ },
372
+ };
373
+
374
+ // ── Extension ──────────────────────────────────────────────────────────────────
375
+
376
+ export default function contextViewerExtension(pi: ExtensionAPI): void {
377
+ pi.registerCommand("context", {
378
+ description: "Inspect context usage, system prompt, tools, messages, and full LLM context in a tabbed overlay",
379
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
380
+ if (!ctx.hasUI) return;
381
+
382
+ // ── Gather data ─────────────────────────────────────────────────────
383
+ const systemPrompt = ctx.getSystemPrompt() ?? "";
384
+ const usage = ctx.getContextUsage();
385
+
386
+ const allTools = pi.getAllTools();
387
+ const activeToolNames = pi.getActiveTools();
388
+ const activeToolDefs = allTools.filter((t) => activeToolNames.includes(t.name));
389
+
390
+ const branch = ctx.sessionManager.getBranch();
391
+ const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
392
+
393
+ const breakdown = buildTokenBreakdown(systemPrompt, activeToolDefs, branch, usage);
394
+ const toolsText = buildToolsText(activeToolDefs);
395
+ const fullText = buildTotalContextText(systemPrompt, context, usage, ctx.model);
396
+ const messagesText = formatMessagesText(context);
397
+
398
+ // ── Subtitle ────────────────────────────────────────────────────────
399
+ const subtitle =
400
+ usage?.tokens != null && usage.contextWindow != null
401
+ ? `${formatTokens(usage.tokens)} / ${formatTokens(usage.contextWindow)} (${(usage.percent ?? (usage.tokens / usage.contextWindow) * 100).toFixed(1)}%)`
402
+ : "no usage data yet";
403
+
404
+ // ── Build and open the overlay ──────────────────────────────────────
405
+ await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
406
+ const modelName = ctx.model?.id ?? "unknown model";
407
+
408
+ const tabs = [
409
+ new StatsTabContent(breakdown, theme, {
410
+ name: modelName,
411
+ contextWindow: ctx.model?.contextWindow ?? usage?.contextWindow,
412
+ }),
413
+ numberedTab(systemPrompt, "System", theme),
414
+ numberedTab(toolsText, "Tools", theme),
415
+ numberedTab(messagesText, "Messages", theme),
416
+ numberedTab(fullText, "Full", theme),
417
+ ];
418
+
419
+ return new TabbedOverlay({
420
+ title: "Context Viewer",
421
+ subtitle,
422
+ tabs,
423
+ theme,
424
+ done,
425
+ });
426
+ }, OVERLAY_OPTIONS);
427
+ },
428
+ });
429
+ }
@@ -0,0 +1,230 @@
1
+ import { copyToClipboard as copyTextToClipboard, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, sliceByColumn, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
3
+ import { CONTENT_HEIGHT } from "./utils.js";
4
+
5
+ export abstract class ScrollableBase {
6
+ protected scrollOffset = 0;
7
+ protected searchMode = false;
8
+ protected searchQuery = "";
9
+ protected searchMatches: number[] = [];
10
+ protected currentMatchIndex = -1;
11
+ protected copyFlash = false;
12
+ protected copyFlashTimer: ReturnType<typeof setTimeout> | undefined;
13
+ protected visualLines: string[] = [];
14
+ protected visualToLogical: number[] = [];
15
+ protected visualTotal = 0;
16
+
17
+ protected abstract get rawText(): string;
18
+ protected abstract get displayLines(): string[];
19
+ protected abstract get theme(): Theme;
20
+
21
+ protected getVisibleLines(): number {
22
+ return CONTENT_HEIGHT;
23
+ }
24
+
25
+ protected buildVisualLines(innerWidth: number): void {
26
+ const th = this.theme;
27
+ const total = this.displayLines.length;
28
+ const numWidth = String(total).length;
29
+ const prefixWidth = numWidth + 3;
30
+ const continuationPrefix = th.fg("dim", " ".repeat(numWidth) + " · ");
31
+
32
+ this.visualLines = [];
33
+ this.visualToLogical = [];
34
+
35
+ for (let logicalIdx = 0; logicalIdx < total; logicalIdx++) {
36
+ const displayLine = this.displayLines[logicalIdx]!;
37
+ const lineWidth = visibleWidth(displayLine);
38
+
39
+ if (lineWidth <= innerWidth) {
40
+ this.visualLines.push(displayLine);
41
+ this.visualToLogical.push(logicalIdx);
42
+ continue;
43
+ }
44
+
45
+ const contentMaxWidth = innerWidth - prefixWidth;
46
+ const origPrefix = sliceByColumn(displayLine, 0, prefixWidth);
47
+ const content = sliceByColumn(displayLine, prefixWidth, lineWidth - prefixWidth);
48
+ const wrapped = wrapTextWithAnsi(content, contentMaxWidth);
49
+
50
+ for (let w = 0; w < wrapped.length; w++) {
51
+ this.visualLines.push(w === 0 ? origPrefix + wrapped[w]! : continuationPrefix + wrapped[w]!);
52
+ this.visualToLogical.push(logicalIdx);
53
+ }
54
+ }
55
+
56
+ this.visualTotal = this.visualLines.length;
57
+ }
58
+
59
+ protected handleSearchInput(data: string): boolean {
60
+ if (!this.searchMode) return false;
61
+
62
+ if (matchesKey(data, Key.escape)) {
63
+ this.searchMode = false;
64
+ this.searchQuery = "";
65
+ this.searchMatches = [];
66
+ this.currentMatchIndex = -1;
67
+ return true;
68
+ }
69
+ if (matchesKey(data, Key.enter)) {
70
+ if (this.searchQuery.length > 0) {
71
+ this.findMatches();
72
+ if (this.searchMatches.length > 0) {
73
+ this.currentMatchIndex = 0;
74
+ this.scrollToMatch(this.getVisibleLines());
75
+ }
76
+ }
77
+ this.searchMode = false;
78
+ return true;
79
+ }
80
+ if (matchesKey(data, Key.backspace)) {
81
+ this.searchQuery = this.searchQuery.slice(0, -1);
82
+ this.findMatches();
83
+ if (this.searchMatches.length > 0) {
84
+ this.currentMatchIndex = 0;
85
+ this.scrollToMatch(this.getVisibleLines());
86
+ }
87
+ return true;
88
+ }
89
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
90
+ this.searchQuery += data;
91
+ this.findMatches();
92
+ if (this.searchMatches.length > 0) {
93
+ this.currentMatchIndex = 0;
94
+ this.scrollToMatch(this.getVisibleLines());
95
+ }
96
+ return true;
97
+ }
98
+ return true;
99
+ }
100
+
101
+ protected handleScrollKey(data: string): boolean {
102
+ if (this.handleSearchInput(data)) return true;
103
+
104
+ const visibleLines = this.getVisibleLines();
105
+ const maxOffset = Math.max(0, this.visualTotal - visibleLines);
106
+
107
+ if (matchesKey(data, Key.down) || data === "j") {
108
+ this.scrollDown(1, maxOffset);
109
+ return true;
110
+ }
111
+ if (matchesKey(data, Key.up) || data === "k") {
112
+ this.scrollUp(1);
113
+ return true;
114
+ }
115
+ if (matchesKey(data, Key.home) || data === "g") {
116
+ this.scrollOffset = 0;
117
+ return true;
118
+ }
119
+ if (matchesKey(data, Key.end) || data === "G") {
120
+ this.scrollToBottom(maxOffset);
121
+ return true;
122
+ }
123
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
124
+ this.scrollDown(visibleLines - 2, maxOffset);
125
+ return true;
126
+ }
127
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b"))) {
128
+ this.scrollUp(visibleLines - 2);
129
+ return true;
130
+ }
131
+ if (matchesKey(data, Key.ctrl("d"))) {
132
+ this.scrollDown(Math.floor(visibleLines / 2), maxOffset);
133
+ return true;
134
+ }
135
+ if (matchesKey(data, Key.ctrl("u"))) {
136
+ this.scrollUp(Math.floor(visibleLines / 2));
137
+ return true;
138
+ }
139
+ if (data === "/") {
140
+ this.searchMode = true;
141
+ this.searchQuery = "";
142
+ this.searchMatches = [];
143
+ this.currentMatchIndex = -1;
144
+ return true;
145
+ }
146
+ if (data === "n") {
147
+ this.nextMatch();
148
+ return true;
149
+ }
150
+ if (data === "N") {
151
+ this.prevMatch();
152
+ return true;
153
+ }
154
+ if (data === "y") {
155
+ void this.copyToClipboard();
156
+ return true;
157
+ }
158
+
159
+ return false;
160
+ }
161
+ protected findMatches(): void {
162
+ const query = this.searchQuery.toLowerCase();
163
+ const rawLines = this.rawText.split("\n");
164
+ this.searchMatches = [];
165
+ if (query.length === 0) {
166
+ this.currentMatchIndex = -1;
167
+ return;
168
+ }
169
+ for (let i = 0; i < rawLines.length; i++) {
170
+ if (rawLines[i]!.toLowerCase().includes(query)) {
171
+ this.searchMatches.push(i);
172
+ }
173
+ }
174
+ }
175
+
176
+ protected scrollToMatch(visibleLines: number): void {
177
+ if (this.currentMatchIndex >= 0 && this.currentMatchIndex < this.searchMatches.length) {
178
+ const logicalLine = this.searchMatches[this.currentMatchIndex]!;
179
+ const targetLine = this.visualToLogical.indexOf(logicalLine);
180
+ if (targetLine >= 0) {
181
+ if (targetLine < this.scrollOffset || targetLine >= this.scrollOffset + visibleLines) {
182
+ this.scrollOffset = Math.max(0, targetLine - Math.floor(visibleLines / 3));
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ protected nextMatch(): void {
189
+ if (this.searchMatches.length === 0) return;
190
+ this.currentMatchIndex = (this.currentMatchIndex + 1) % this.searchMatches.length;
191
+ this.scrollToMatch(this.getVisibleLines());
192
+ }
193
+
194
+ protected prevMatch(): void {
195
+ if (this.searchMatches.length === 0) return;
196
+ this.currentMatchIndex = (this.currentMatchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
197
+ this.scrollToMatch(this.getVisibleLines());
198
+ }
199
+
200
+ protected async copyToClipboard(): Promise<void> {
201
+ this.copyFlash = true;
202
+ try {
203
+ await copyTextToClipboard(this.rawText);
204
+ } catch {
205
+ // Silently fail if clipboard tools aren't available
206
+ }
207
+ clearTimeout(this.copyFlashTimer);
208
+ this.copyFlashTimer = setTimeout(() => {
209
+ this.copyFlash = false;
210
+ }, 1500);
211
+ }
212
+
213
+ protected scrollDown(amount: number, maxOffset: number): void {
214
+ this.scrollOffset = Math.min(this.scrollOffset + amount, maxOffset);
215
+ }
216
+
217
+ protected scrollUp(amount: number): void {
218
+ this.scrollOffset = Math.max(0, this.scrollOffset - amount);
219
+ }
220
+
221
+ protected scrollToBottom(maxOffset: number): void {
222
+ this.scrollOffset = Math.max(0, maxOffset);
223
+ }
224
+
225
+ invalidate(): void {
226
+ this.visualLines = [];
227
+ this.visualToLogical = [];
228
+ this.visualTotal = 0;
229
+ }
230
+ }
@@ -0,0 +1,101 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { TabContent } from "./tabbed-overlay.js";
3
+ import { ScrollableBase } from "./scrollable-base.js";
4
+
5
+ export interface ScrollableTabContentOptions {
6
+ rawText: string;
7
+ displayLines: string[];
8
+ theme: Theme;
9
+ }
10
+
11
+ export class ScrollableTabContent extends ScrollableBase implements TabContent {
12
+ constructor(
13
+ private opts: ScrollableTabContentOptions,
14
+ public readonly name: string = "",
15
+ ) {
16
+ super();
17
+ }
18
+
19
+ protected get rawText(): string { return this.opts.rawText; }
20
+ protected get displayLines(): string[] { return this.opts.displayLines; }
21
+ protected get theme(): Theme { return this.opts.theme; }
22
+
23
+ getAboveContentLine(_innerWidth: number): string | null {
24
+ const th = this.opts.theme;
25
+ if (this.searchMode) {
26
+ return ` ${th.fg("accent", "/")} ${this.searchQuery}${th.fg("dim", "▏")}`;
27
+ }
28
+ if (this.searchMatches.length > 0) {
29
+ return ` ${th.fg("accent", "/")} ${th.fg("text", this.searchQuery)} ${th.fg("dim", "—")} ${th.fg("accent", `${this.currentMatchIndex + 1}/${this.searchMatches.length}`)}`;
30
+ }
31
+ if (this.searchQuery.length > 0) {
32
+ return ` ${th.fg("accent", "/")} ${th.fg("text", this.searchQuery)} ${th.fg("dim", "—")} ${th.fg("warning", "0 matches")}`;
33
+ }
34
+ return null;
35
+ }
36
+
37
+ getFooterLeft(): string {
38
+ const th = this.opts.theme;
39
+ const total = this.visualTotal > 0 ? this.visualTotal : this.opts.displayLines.length;
40
+ const maxScroll = Math.max(0, total - 1);
41
+ const visibleEnd = Math.min(this.scrollOffset + 1, total);
42
+
43
+ const scrollPercent =
44
+ total === 0
45
+ ? "All"
46
+ : this.scrollOffset === 0
47
+ ? "Top"
48
+ : this.scrollOffset >= maxScroll
49
+ ? "Bot"
50
+ : `${Math.round(((this.scrollOffset + 1) / total) * 100)}%`;
51
+
52
+ let left = `${visibleEnd}/${total} [${scrollPercent}]`;
53
+ if (this.copyFlash) {
54
+ left += th.fg("success", " ✓ Copied!");
55
+ }
56
+ return left;
57
+ }
58
+
59
+ readonly footerHints = "↑↓ scroll · / search · n/N next · y copy";
60
+
61
+ handleInput(data: string): boolean {
62
+ return this.handleScrollKey(data);
63
+ }
64
+
65
+ renderContent(innerWidth: number, height: number): string[] {
66
+ this.buildVisualLines(innerWidth);
67
+ const th = this.opts.theme;
68
+ const lines: string[] = [];
69
+
70
+ const maxScroll = Math.max(0, this.visualTotal - height);
71
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
72
+ this.scrollOffset = Math.max(0, this.scrollOffset);
73
+
74
+ for (let i = 0; i < height; i++) {
75
+ const lineIdx = this.scrollOffset + i;
76
+ if (lineIdx < this.visualTotal) {
77
+ let line = this.visualLines[lineIdx]!;
78
+
79
+ const logicalIdx = this.visualToLogical[lineIdx]!;
80
+ const isCurrentMatch =
81
+ this.searchMatches.length > 0 &&
82
+ this.currentMatchIndex >= 0 &&
83
+ this.searchMatches[this.currentMatchIndex] === logicalIdx;
84
+ const isOtherMatch =
85
+ this.searchMatches.length > 0 && this.searchMatches.includes(logicalIdx) && !isCurrentMatch;
86
+
87
+ if (isCurrentMatch) {
88
+ line = th.bg("selectedBg", line);
89
+ } else if (isOtherMatch) {
90
+ line = th.fg("warning", line);
91
+ }
92
+
93
+ lines.push(line);
94
+ } else {
95
+ lines.push(th.fg("dim", "~"));
96
+ }
97
+ }
98
+
99
+ return lines;
100
+ }
101
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * StatsTabContent — token distribution grid + category breakdown table.
3
+ *
4
+ * Shows a compact model/context summary, then a colored 10×5 grid beside an
5
+ * estimated per-category usage breakdown. The final grid segment is reserved for
6
+ * Pi's auto-compaction response buffer.
7
+ */
8
+
9
+ import type { Theme } from "@earendil-works/pi-coding-agent";
10
+ import { visibleWidth } from "@earendil-works/pi-tui";
11
+ import type { TabContent } from "./tabbed-overlay.js";
12
+ import { formatTokens } from "./utils.js";
13
+
14
+ const GRID_WIDTH = 10;
15
+ const GRID_HEIGHT = 5;
16
+ const TOTAL_BLOCKS = GRID_WIDTH * GRID_HEIGHT; // 50 blocks = 2% each
17
+
18
+ const ANSI_RESET = "\x1b[0m";
19
+ const ANSI_BOLD = "\x1b[1m";
20
+
21
+ function fg(hex: string, text: string): string {
22
+ const normalized = hex.replace("#", "");
23
+ const r = Number.parseInt(normalized.slice(0, 2), 16);
24
+ const g = Number.parseInt(normalized.slice(2, 4), 16);
25
+ const b = Number.parseInt(normalized.slice(4, 6), 16);
26
+ return `\x1b[38;2;${r};${g};${b}m${text}${ANSI_RESET}`;
27
+ }
28
+
29
+ function bold(text: string): string {
30
+ return `${ANSI_BOLD}${text}${ANSI_RESET}`;
31
+ }
32
+
33
+ export interface ContextTokenBreakdown {
34
+ total: number;
35
+ contextWindow: number;
36
+ percent: number;
37
+ reserveTokens: number;
38
+ safeAvailable: number;
39
+ systemPrompt: number;
40
+ systemTools: number;
41
+ tools: number;
42
+ skills: number;
43
+ messages: number;
44
+ other: number;
45
+ }
46
+
47
+ export interface StatsModelInfo {
48
+ name: string;
49
+ contextWindow?: number;
50
+ }
51
+
52
+ interface Category {
53
+ key: keyof Pick<
54
+ ContextTokenBreakdown,
55
+ "systemPrompt" | "systemTools" | "tools" | "skills" | "messages" | "safeAvailable" | "reserveTokens"
56
+ >;
57
+ label: string;
58
+ icon: string;
59
+ value: number;
60
+ hex: string;
61
+ block: string;
62
+ }
63
+
64
+ const CATEGORY_META = {
65
+ systemPrompt: {
66
+ label: "System prompt",
67
+ icon: "󰈙",
68
+ hex: "#A78BFA",
69
+ block: "󰈙",
70
+ },
71
+ systemTools: {
72
+ label: "System tools",
73
+ icon: "󰒓",
74
+ hex: "#22D3EE",
75
+ block: "󰒓",
76
+ },
77
+ tools: {
78
+ label: "Tools",
79
+ icon: "󰐥",
80
+ hex: "#34D399",
81
+ block: "󰐥",
82
+ },
83
+ skills: {
84
+ label: "Skills",
85
+ icon: "󰌵",
86
+ hex: "#FBBF24",
87
+ block: "󰌵",
88
+ },
89
+ messages: {
90
+ label: "Messages",
91
+ icon: "󰍩",
92
+ hex: "#60A5FA",
93
+ block: "󰍩",
94
+ },
95
+ safeAvailable: {
96
+ label: "Available",
97
+ icon: "󰋙",
98
+ hex: "#6B7280",
99
+ block: "󰋙",
100
+ },
101
+ reserveTokens: {
102
+ label: "Auto-compact buffer",
103
+ icon: "󰅐",
104
+ hex: "#FB923C",
105
+ block: "󰅐",
106
+ },
107
+ } as const;
108
+
109
+ export class StatsTabContent implements TabContent {
110
+ readonly name = "Stats";
111
+ readonly footerHints = "";
112
+
113
+ constructor(
114
+ private breakdown: ContextTokenBreakdown | null,
115
+ private theme: Theme,
116
+ private modelInfo?: StatsModelInfo,
117
+ ) {}
118
+
119
+ /** Stats view has no interactive search bar — always use border separator. */
120
+ getAboveContentLine(_innerWidth: number): string | null {
121
+ return null;
122
+ }
123
+
124
+ getFooterLeft(): string {
125
+ if (!this.breakdown) return "";
126
+ const { total, contextWindow, percent, safeAvailable } = this.breakdown;
127
+ return `${formatTokens(total)} / ${formatTokens(contextWindow)} (${percent.toFixed(1)}%) · ${formatTokens(safeAvailable)} safe left`;
128
+ }
129
+
130
+ /** Stats view has no keyboard interactions. */
131
+ handleInput(_data: string): boolean {
132
+ return false;
133
+ }
134
+
135
+ invalidate(): void {}
136
+
137
+ renderContent(_innerWidth: number, height: number): string[] {
138
+ const th = this.theme;
139
+
140
+ if (!this.breakdown) {
141
+ const lines: string[] = [
142
+ "",
143
+ ` ${th.fg("warning", "No context usage data available.")}`,
144
+ ` ${th.fg("dim", "Send a message first, then re-open /context.")}`,
145
+ ];
146
+ while (lines.length < height) lines.push("");
147
+ return lines;
148
+ }
149
+
150
+ const { total, contextWindow, percent, reserveTokens, safeAvailable } = this.breakdown;
151
+ const modelName = this.modelInfo?.name ?? "unknown model";
152
+ const safeLeftText =
153
+ safeAvailable > 0 ? `${formatTokens(safeAvailable)} safe left` : "auto-compact threshold reached";
154
+
155
+ const categories = this.getCategories();
156
+ const gridLines = this.renderGrid(categories);
157
+ const breakdownLines = this.renderBreakdown(categories);
158
+
159
+ const lines: string[] = [
160
+ ` ${bold(`${modelName} · ${formatTokens(total)}/${formatTokens(contextWindow)} tokens (${percent.toFixed(1)}%)`)} ${th.fg("dim", `· ${safeLeftText}`)}`,
161
+ "",
162
+ "",
163
+ ` ${th.fg("dim", "Estimated usage by category")}`,
164
+ "",
165
+ ];
166
+
167
+ const GRID_VIS_W = GRID_WIDTH * 2 - 1;
168
+ const maxRows = Math.max(gridLines.length, breakdownLines.length);
169
+ for (let i = 0; i < maxRows; i++) {
170
+ const leftRaw = gridLines[i] ?? "";
171
+ const leftVisW = visibleWidth(leftRaw);
172
+ const pad = " ".repeat(Math.max(0, GRID_VIS_W - leftVisW));
173
+ const right = breakdownLines[i] ?? "";
174
+ lines.push(` ${leftRaw}${pad} ${right}`);
175
+ }
176
+
177
+ if (safeAvailable <= 0) {
178
+ lines.push("");
179
+ lines.push(` ${fg(CATEGORY_META.reserveTokens.hex, "Auto-compact buffer is being used")}`);
180
+ } else {
181
+ lines.push("");
182
+ lines.push(
183
+ ` ${th.fg("dim", `Auto-compact starts after ${formatTokens(contextWindow - reserveTokens)} tokens`)}`,
184
+ );
185
+ }
186
+
187
+ while (lines.length < height) lines.push("");
188
+ return lines.slice(0, height);
189
+ }
190
+
191
+ private getCategories(): Category[] {
192
+ const b = this.breakdown!;
193
+ return [
194
+ this.category("systemPrompt", b.systemPrompt),
195
+ this.category("systemTools", b.systemTools),
196
+ this.category("tools", b.tools),
197
+ this.category("skills", b.skills),
198
+ this.category("messages", b.messages + b.other),
199
+ this.category("safeAvailable", b.safeAvailable),
200
+ this.category("reserveTokens", b.reserveTokens),
201
+ ];
202
+ }
203
+
204
+ private category(key: Category["key"], value: number): Category {
205
+ const meta = CATEGORY_META[key];
206
+ return { key, value, ...meta };
207
+ }
208
+
209
+ private renderGrid(categories: Category[]): string[] {
210
+ const blocks: string[] = [];
211
+ const { contextWindow, reserveTokens } = this.breakdown!;
212
+ const reserveBlockCount = reserveTokens > 0 ? Math.max(1, Math.round((reserveTokens / contextWindow) * TOTAL_BLOCKS)) : 0;
213
+ const safeBlockCount = Math.max(0, TOTAL_BLOCKS - reserveBlockCount);
214
+ const safeWindow = Math.max(1, contextWindow - reserveTokens);
215
+ const safeCategories = categories.filter((cat) => cat.key !== "reserveTokens");
216
+
217
+ for (const cat of safeCategories) {
218
+ let count = Math.round((cat.value / safeWindow) * safeBlockCount);
219
+ if (count === 0 && cat.value > 0) count = 1;
220
+ for (let j = 0; j < count && blocks.length < safeBlockCount; j++) {
221
+ blocks.push(fg(cat.hex, cat.block));
222
+ }
223
+ }
224
+
225
+ while (blocks.length < safeBlockCount) {
226
+ blocks.push(fg(CATEGORY_META.safeAvailable.hex, CATEGORY_META.safeAvailable.block));
227
+ }
228
+
229
+ for (let i = 0; i < reserveBlockCount && blocks.length < TOTAL_BLOCKS; i++) {
230
+ blocks.push(fg(CATEGORY_META.reserveTokens.hex, CATEGORY_META.reserveTokens.block));
231
+ }
232
+
233
+ const gridLines: string[] = [];
234
+ for (let r = 0; r < GRID_HEIGHT; r++) {
235
+ let row = "";
236
+ for (let c = 0; c < GRID_WIDTH; c++) {
237
+ row += blocks[r * GRID_WIDTH + c] ?? fg(CATEGORY_META.safeAvailable.hex, CATEGORY_META.safeAvailable.block);
238
+ if (c < GRID_WIDTH - 1) row += " ";
239
+ }
240
+ gridLines.push(row);
241
+ }
242
+ return gridLines;
243
+ }
244
+
245
+ private renderBreakdown(categories: Category[]): string[] {
246
+ const LABEL_W = 21;
247
+ const TOKEN_W = 7;
248
+
249
+ return categories.map((cat) => {
250
+ const pct = this.breakdown!.contextWindow > 0 ? (cat.value / this.breakdown!.contextWindow) * 100 : 0;
251
+ const icon = fg(cat.hex, cat.icon);
252
+ const label = fg(cat.hex, cat.label.padEnd(LABEL_W));
253
+ const tokens = fg(cat.hex, formatTokens(cat.value).padStart(TOKEN_W));
254
+ const percent = this.theme.fg("dim", `(${pct.toFixed(1).padStart(5)}%)`);
255
+ return `${icon} ${label} ${tokens} ${percent}`;
256
+ });
257
+ }
258
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * TabbedOverlay — a bordered overlay with a tab bar at the top.
3
+ *
4
+ * Manages multiple tab views (StatsTabContent, ScrollableTabContent) and renders
5
+ * the full frame (title, tab bar, borders, footer). Each tab handles its own content
6
+ * rendering and keyboard input.
7
+ *
8
+ * Navigation:
9
+ * Tab / Shift+Tab → cycle between tabs
10
+ * Escape / q → close the overlay (unless a tab has intercepted the key)
11
+ */
12
+
13
+ import type { Theme } from "@earendil-works/pi-coding-agent";
14
+ import { Key, matchesKey } from "@earendil-works/pi-tui";
15
+ import { createBorderHelpers, createTitle, CONTENT_HEIGHT } from "./utils.js";
16
+
17
+ /**
18
+ * Interface that each tab must implement.
19
+ *
20
+ * Key contract: `handleInput` returns `true` if the key was consumed (so the
21
+ * outer overlay won't act on it). This lets content tabs swallow Escape when
22
+ * in search mode rather than closing the overlay.
23
+ */
24
+ export interface TabContent {
25
+ readonly name: string;
26
+
27
+ /**
28
+ * Returns a styled string to render between the tab bar and the content area,
29
+ * or `null` to render a plain border separator.
30
+ * Used by scrollable tabs for the live search / match-info row.
31
+ */
32
+ getAboveContentLine(innerWidth: number): string | null;
33
+
34
+ /** Render exactly `height` lines of content. */
35
+ renderContent(innerWidth: number, height: number): string[];
36
+
37
+ /** Left portion of the footer row (e.g. scroll position, copy flash). */
38
+ getFooterLeft(): string;
39
+
40
+ /** Hint items shown in the footer (right side). Omit Tab / q hints — they are added by TabbedOverlay. */
41
+ readonly footerHints: string;
42
+
43
+ /**
44
+ * Handle a keyboard event. Return `true` if consumed (prevents TabbedOverlay
45
+ * from acting on the key), `false` to let the outer overlay handle it.
46
+ */
47
+ handleInput(data: string): boolean;
48
+
49
+ /** Reset any cached rendering state. */
50
+ invalidate(): void;
51
+ }
52
+
53
+ export interface TabbedOverlayOptions {
54
+ /** Title text shown in the top header row. */
55
+ title: string;
56
+ /** Subtitle / stats text shown dimmed after the title. */
57
+ subtitle: string;
58
+ /** Ordered list of tab views. */
59
+ tabs: TabContent[];
60
+ /** Active theme. */
61
+ theme: Theme;
62
+ /** Called when the user closes the overlay (Escape / q). */
63
+ done: () => void;
64
+ }
65
+
66
+ export class TabbedOverlay {
67
+ private activeTabIndex = 0;
68
+
69
+ constructor(private opts: TabbedOverlayOptions) {}
70
+
71
+ private get activeTab(): TabContent {
72
+ return this.opts.tabs[this.activeTabIndex]!;
73
+ }
74
+
75
+ handleInput(data: string): void {
76
+ // Tab / Shift+Tab always switch tabs (not delegated to content).
77
+ if (matchesKey(data, Key.tab)) {
78
+ this.activeTabIndex = (this.activeTabIndex + 1) % this.opts.tabs.length;
79
+ return;
80
+ }
81
+ if (matchesKey(data, Key.shift("tab"))) {
82
+ this.activeTabIndex = (this.activeTabIndex - 1 + this.opts.tabs.length) % this.opts.tabs.length;
83
+ return;
84
+ }
85
+
86
+ // Delegate to the active tab first. If it consumes the key, stop.
87
+ const consumed = this.activeTab.handleInput(data);
88
+ if (consumed) return;
89
+
90
+ // Outer-level: close the overlay.
91
+ if (matchesKey(data, Key.escape) || data === "q") {
92
+ this.opts.done();
93
+ }
94
+ }
95
+
96
+ render(width: number): string[] {
97
+ const th = this.opts.theme;
98
+ const innerW = width - 2;
99
+ const lines: string[] = [];
100
+
101
+ const { row, borderTop, borderSep, borderBottom } = createBorderHelpers(th, innerW);
102
+ const title = createTitle(th, this.opts.title, this.opts.subtitle);
103
+
104
+ lines.push(borderTop);
105
+ lines.push(row(title));
106
+
107
+ // ── Tab bar ─────────────────────────────────────────────────────────────
108
+ let tabBar = " ";
109
+ for (let i = 0; i < this.opts.tabs.length; i++) {
110
+ const tab = this.opts.tabs[i]!;
111
+ if (i === this.activeTabIndex) {
112
+ tabBar += th.fg("accent", th.bold(`[${tab.name}]`));
113
+ } else {
114
+ tabBar += th.fg("dim", `[${tab.name}]`);
115
+ }
116
+ if (i < this.opts.tabs.length - 1) tabBar += " ";
117
+ }
118
+ lines.push(row(tabBar));
119
+
120
+ // ── Above-content row (search bar or border) ─────────────────────────────
121
+ const aboveLine = this.activeTab.getAboveContentLine(innerW);
122
+ if (aboveLine !== null) {
123
+ lines.push(row(` ${aboveLine}`));
124
+ } else {
125
+ lines.push(borderSep);
126
+ }
127
+
128
+ // ── Content area ─────────────────────────────────────────────────────────
129
+ const contentLines = this.activeTab.renderContent(innerW, CONTENT_HEIGHT);
130
+ for (let i = 0; i < CONTENT_HEIGHT; i++) {
131
+ if (i < contentLines.length) {
132
+ lines.push(row(contentLines[i]!));
133
+ } else {
134
+ lines.push(row(th.fg("dim", "~")));
135
+ }
136
+ }
137
+
138
+ // ── Footer ───────────────────────────────────────────────────────────────
139
+ lines.push(borderSep);
140
+
141
+ const footerLeft = this.activeTab.getFooterLeft();
142
+ const tabHints = this.opts.tabs.length > 1 ? "Tab switch" : "";
143
+ const activeHints = this.activeTab.footerHints;
144
+ const hintParts = [tabHints, activeHints, "q close"].filter(Boolean);
145
+ const footerHintsText = th.fg("dim", hintParts.join(" · "));
146
+
147
+ let footerContent = footerHintsText;
148
+ if (footerLeft) {
149
+ footerContent = th.fg("dim", ` ${footerLeft} `) + footerHintsText;
150
+ }
151
+ lines.push(row(footerContent));
152
+ lines.push(borderBottom);
153
+
154
+ return lines;
155
+ }
156
+
157
+ invalidate(): void {
158
+ for (const tab of this.opts.tabs) {
159
+ tab.invalidate();
160
+ }
161
+ }
162
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Shared utility helpers for pi-context-inspector.
3
+ */
4
+
5
+ import { visibleWidth } from "@earendil-works/pi-tui";
6
+ import type { Theme } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const formatTokens = (n: number | null | undefined): string => {
9
+ if (n == null) return "N/A";
10
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
11
+ if (n >= 1_000) return `${Math.min(999, Math.round(n / 1_000))}k`;
12
+ return n.toString();
13
+ };
14
+
15
+ export const CONTENT_HEIGHT = 28;
16
+
17
+ export interface BorderHelpers {
18
+ pad: (s: string, len: number) => string;
19
+ row: (content: string) => string;
20
+ borderTop: string;
21
+ borderSep: string;
22
+ borderBottom: string;
23
+ }
24
+
25
+ export function createBorderHelpers(th: Theme, innerW: number): BorderHelpers {
26
+ const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
27
+ const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
28
+ const borderTop = th.fg("border", `╭${"─".repeat(innerW)}╮`);
29
+ const borderSep = th.fg("border", `├${"─".repeat(innerW)}┤`);
30
+ const borderBottom = th.fg("border", `╰${"─".repeat(innerW)}╯`);
31
+ return { pad, row, borderTop, borderSep, borderBottom };
32
+ }
33
+
34
+ export function createTitle(th: Theme, title: string, subtitle: string): string {
35
+ return ` ${th.fg("accent", th.bold(title))} ${th.fg("dim", `(${subtitle})`)}`;
36
+ }