@agnishc/edb-context-viewer 0.8.1 → 0.10.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.10.3] - 2026-05-15
4
+
5
+ ## [0.9.0] - 2026-05-15
6
+
7
+ ### Changed
8
+ - Replaced `/system-prompt-data` and `/total-context-data` commands with a single unified `/context-viewer` command
9
+ - New tabbed overlay UI with five tabs: **Stats**, **System**, **Tools**, **Messages**, **Full**
10
+ - Tab navigation with `Tab` / `Shift+Tab`
11
+
12
+ ### Added
13
+ - **Stats tab** — token distribution grid (10×5 colored blocks) + per-category breakdown table, inspired by [pi-context](https://github.com/ttttmr/pi-context)
14
+ - **Tools tab** — scrollable view of all active tool definitions with parameter schemas
15
+ - `formatTokens` utility (k/M suffixes)
16
+ - `StatsTabContent`, `ScrollableTabContent`, `TabbedOverlay` components
17
+
18
+ ## [0.8.2] - 2026-05-11
19
+
3
20
  ## [0.8.1] - 2026-05-11
4
21
 
5
22
  ## [0.6.0] - 2026-05-11
package/README.md CHANGED
@@ -1,25 +1,45 @@
1
1
  # @agnishc/edb-context-viewer
2
2
 
3
- Pi CLI extension for inspecting the LLM context. Two commands let you see exactly what the model sees:
3
+ Pi CLI extension for inspecting the full LLM context. A single command opens a tabbed overlay where you can explore token usage, the system prompt, tool definitions, messages, and the complete context — all in one place.
4
4
 
5
- - **`/system-prompt-data`** — full system prompt with line numbers
6
- - **`/total-context-data`** — complete LLM context (system prompt + all messages + usage stats)
5
+ ## Command
7
6
 
8
- Both open as scrollable overlay popups with search and clipboard copy.
7
+ ```
8
+ /context-viewer
9
+ ```
10
+
11
+ Opens a tabbed overlay with five views you can navigate using `Tab` / `Shift+Tab`.
12
+
13
+ ## Tabs
14
+
15
+ | Tab | What it shows |
16
+ |-----|--------------|
17
+ | **Stats** | Token distribution grid (10×5 colored blocks) + per-category breakdown table |
18
+ | **System** | The full system prompt (includes tools, skills, guidelines, AGENTS.md, etc.) |
19
+ | **Tools** | All active tool definitions with descriptions and parameter schemas |
20
+ | **Messages** | All session messages (user, assistant, tool calls/results) |
21
+ | **Full** | Complete context dump: system prompt + messages + usage stats |
22
+
23
+ ### Stats tab
24
+
25
+ Visualizes how the context window is used, broken down by category:
9
26
 
10
- ## Commands
27
+ - **System Prompt** — the static system prompt text
28
+ - **System Tools** — tool definition schemas
29
+ - **Tool Calls** — tool call arguments and results
30
+ - **Messages** — user/assistant conversation text
31
+ - **Available** — unused context window space
11
32
 
12
- | Command | What it shows |
13
- |---------|--------------|
14
- | `/system-prompt-data` | The full system prompt (includes tools, skills, guidelines, AGENTS.md, etc.) |
15
- | `/total-context-data` | System prompt + all messages (user, assistant, tool calls/results) + context usage stats |
33
+ Token counts are estimated using a 4-chars-per-token heuristic, then scaled proportionally to match the actual total reported by the API.
16
34
 
17
35
  ## Controls
18
36
 
19
37
  | Key | Action |
20
38
  |-----|--------|
21
- | `↑` / `k` | Scroll up |
22
- | `↓` / `j` | Scroll down |
39
+ | `Tab` | Next tab |
40
+ | `Shift+Tab` | Previous tab |
41
+ | `↑` / `k` | Scroll up (content tabs) |
42
+ | `↓` / `j` | Scroll down (content tabs) |
23
43
  | `Page Up` / `Ctrl+B` | Scroll up one page |
24
44
  | `Page Down` / `Ctrl+F` | Scroll down one page |
25
45
  | `Ctrl+U` / `Ctrl+D` | Scroll half page |
@@ -28,7 +48,7 @@ Both open as scrollable overlay popups with search and clipboard copy.
28
48
  | `/` | Search (live matching as you type) |
29
49
  | `n` / `N` | Next / previous match |
30
50
  | `y` | Copy full content to clipboard |
31
- | `Escape` / `q` | Close popup |
51
+ | `Escape` / `q` | Close overlay |
32
52
 
33
53
  ## Install
34
54
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agnishc/edb-context-viewer",
3
- "version": "0.8.1",
3
+ "version": "0.10.3",
4
4
  "description": "Pi extension: inspect the system prompt and full LLM context in scrollable overlay popups",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/index.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  /**
2
- * pi-context-viewer
2
+ * edb-context-viewer
3
3
  *
4
- * Two commands for inspecting what the LLM sees:
4
+ * Single command for inspecting the full LLM context in a tabbed overlay:
5
5
  *
6
- * /system-prompt-datashows the full system prompt in a scrollable overlay
7
- * /total-context-data shows the complete LLM context (system prompt + all messages)
6
+ * /context-vieweropens 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)
8
12
  *
9
- * Both overlays support: line numbers, scroll, live search (/), clipboard copy (y).
13
+ * Tab / Shift+Tab navigates between views.
14
+ * Each content tab supports: line numbers, scroll, live search (/), clipboard copy (y).
10
15
  */
11
16
 
12
17
  import {
@@ -17,7 +22,10 @@ import {
17
22
  type SessionContext,
18
23
  type Theme,
19
24
  } from "@earendil-works/pi-coding-agent";
20
- import { ScrollableOverlay } from "./scrollable-overlay";
25
+ import { ScrollableTabContent } from "./scrollable-tab-content.js";
26
+ import { type ContextTokenBreakdown, StatsTabContent } from "./stats-tab-content.js";
27
+ import { TabbedOverlay } from "./tabbed-overlay.js";
28
+ import { formatTokens } from "./utils.js";
21
29
 
22
30
  // ── Helpers ────────────────────────────────────────────────────────────────────
23
31
 
@@ -156,66 +164,188 @@ export function buildTotalContextText(
156
164
  return sections.join("\n");
157
165
  }
158
166
 
159
- /** Overlay options shared by both commands. */
167
+ /** Format active tool definitions as readable text for the Tools tab. */
168
+ function buildToolsText(activeToolDefs: { name: string; description?: string; parameters?: unknown }[]): string {
169
+ if (activeToolDefs.length === 0) return "(no active tools)";
170
+
171
+ const sections: string[] = [];
172
+ for (const tool of activeToolDefs) {
173
+ sections.push(`${"─".repeat(56)}`);
174
+ sections.push(`Tool: ${tool.name}`);
175
+ if (tool.description) {
176
+ sections.push(`Description: ${tool.description}`);
177
+ }
178
+ if (tool.parameters) {
179
+ sections.push("Parameters:");
180
+ const params = tool.parameters as any;
181
+ if (params?.properties) {
182
+ for (const [key, val] of Object.entries(params.properties)) {
183
+ const v = val as any;
184
+ const required = params.required?.includes(key) ? "" : " (optional)";
185
+ const type = v.type ?? "unknown";
186
+ const desc = v.description ? `: ${v.description}` : "";
187
+ sections.push(` ${key} (${type}${required})${desc}`);
188
+ }
189
+ } else {
190
+ sections.push(` ${JSON.stringify(tool.parameters, null, 2).split("\n").join("\n ")}`);
191
+ }
192
+ }
193
+ sections.push("");
194
+ }
195
+
196
+ return sections.join("\n");
197
+ }
198
+
199
+ /** Build the token breakdown, scaling raw char-based estimates to match actual token count. */
200
+ function buildTokenBreakdown(
201
+ systemPrompt: string,
202
+ activeToolDefs: unknown[],
203
+ branch: { type: string; message?: any; summary?: string }[],
204
+ usage: ContextUsage | undefined,
205
+ ): ContextTokenBreakdown | null {
206
+ if (!usage?.tokens || !usage.contextWindow) return null;
207
+
208
+ const estimateTokens = (text: string) => Math.ceil(text.length / 4);
209
+
210
+ const systemRaw = estimateTokens(systemPrompt);
211
+ const toolDefsRaw = estimateTokens(JSON.stringify(activeToolDefs));
212
+
213
+ let msgTokensRaw = 0;
214
+ let toolCallTokensRaw = 0;
215
+ let toolResultTokensRaw = 0;
216
+
217
+ for (const entry of branch) {
218
+ if (entry.type === "message" && entry.message) {
219
+ const m = entry.message;
220
+
221
+ if (m.role === "user") {
222
+ if (typeof m.content === "string") msgTokensRaw += estimateTokens(m.content);
223
+ else if (Array.isArray(m.content)) {
224
+ for (const p of m.content) {
225
+ if (p?.type === "text") msgTokensRaw += estimateTokens(p.text ?? "");
226
+ }
227
+ }
228
+ } else if (m.role === "assistant") {
229
+ if (typeof m.content === "string") msgTokensRaw += estimateTokens(m.content);
230
+ else if (Array.isArray(m.content)) {
231
+ for (const p of m.content) {
232
+ if (p?.type === "text") msgTokensRaw += estimateTokens(p.text ?? "");
233
+ else if (p?.type === "toolCall") toolCallTokensRaw += estimateTokens(JSON.stringify(p));
234
+ }
235
+ }
236
+ } else if (m.role === "toolResult") {
237
+ if (Array.isArray(m.content)) {
238
+ for (const p of m.content) {
239
+ if (p?.type === "text") toolResultTokensRaw += estimateTokens(p.text ?? "");
240
+ }
241
+ }
242
+ } else if (m.role === "bashExecution") {
243
+ toolCallTokensRaw += estimateTokens(m.command ?? "");
244
+ toolResultTokensRaw += estimateTokens(m.output ?? "");
245
+ }
246
+ } else if (entry.type === "branch_summary" || entry.type === "compaction") {
247
+ msgTokensRaw += estimateTokens((entry as any).summary ?? "");
248
+ }
249
+ }
250
+
251
+ const totalRaw = systemRaw + toolDefsRaw + msgTokensRaw + toolCallTokensRaw + toolResultTokensRaw;
252
+ const ratio = totalRaw > 0 ? usage.tokens / totalRaw : 1;
253
+
254
+ const sys = Math.round(systemRaw * ratio);
255
+ const tools = Math.round(toolDefsRaw * ratio);
256
+ const msgs = Math.round(msgTokensRaw * ratio);
257
+ const toolCalls = Math.round((toolCallTokensRaw + toolResultTokensRaw) * ratio);
258
+ const accounted = sys + tools + msgs + toolCalls;
259
+
260
+ return {
261
+ total: usage.tokens,
262
+ contextWindow: usage.contextWindow,
263
+ percent: usage.percent ?? (usage.tokens / usage.contextWindow) * 100,
264
+ systemPrompt: sys,
265
+ systemTools: tools,
266
+ toolCalls,
267
+ messages: msgs,
268
+ other: Math.max(0, usage.tokens - accounted),
269
+ };
270
+ }
271
+
272
+ /** Overlay options shared across all tabs. */
160
273
  const OVERLAY_OPTIONS = {
161
274
  overlay: true,
162
275
  overlayOptions: {
163
276
  anchor: "center" as const,
164
277
  width: "90%" as const,
165
278
  minWidth: 60,
166
- maxHeight: "80%" as const,
279
+ maxHeight: "90%" as const,
167
280
  },
168
281
  };
169
282
 
170
283
  // ── Extension ──────────────────────────────────────────────────────────────────
171
284
 
172
285
  export default function contextViewerExtension(pi: ExtensionAPI): void {
173
- // ── /system-prompt-data ──
174
- pi.registerCommand("system-prompt-data", {
175
- description: "Show the full system prompt in a scrollable popup",
286
+ pi.registerCommand("context-viewer", {
287
+ description: "Inspect context usage, system prompt, tools, messages, and full LLM context in a tabbed overlay",
176
288
  handler: async (_args: string, ctx: ExtensionCommandContext) => {
177
289
  if (!ctx.hasUI) return;
178
290
 
179
- const systemPrompt = ctx.getSystemPrompt();
180
- if (!systemPrompt) {
181
- ctx.ui.notify("No system prompt available yet. Send a message first.", "warning");
182
- return;
183
- }
291
+ // ── Gather data ─────────────────────────────────────────────────────
292
+ const systemPrompt = ctx.getSystemPrompt() ?? "";
293
+ const usage = ctx.getContextUsage();
184
294
 
185
- const lineCount = systemPrompt.split("\n").length;
186
- const charCount = systemPrompt.length;
295
+ const allTools = pi.getAllTools();
296
+ const activeToolNames = pi.getActiveTools();
297
+ const activeToolDefs = allTools.filter((t) => activeToolNames.includes(t.name));
187
298
 
188
- await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
189
- const displayLines = buildNumberedLines(systemPrompt, theme);
190
- return new ScrollableOverlay({
191
- title: "System Prompt",
192
- subtitle: `${charCount.toLocaleString()} chars · ${lineCount} lines`,
193
- rawText: systemPrompt,
194
- displayLines,
195
- theme,
196
- done,
197
- });
198
- }, OVERLAY_OPTIONS);
199
- },
200
- });
299
+ const branch = ctx.sessionManager.getBranch();
300
+ const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
201
301
 
202
- // ── /total-context-data ──
203
- pi.registerCommand("total-context-data", {
204
- description: "Show the complete LLM context (system prompt + all messages)",
205
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
206
- if (!ctx.hasUI) return;
302
+ const breakdown = buildTokenBreakdown(systemPrompt, activeToolDefs, branch, usage);
303
+ const toolsText = buildToolsText(activeToolDefs);
304
+ const fullText = buildTotalContextText(systemPrompt, context, usage, ctx.model);
207
305
 
208
- const systemPrompt = ctx.getSystemPrompt() ?? "";
209
- const context = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId());
210
- const fullText = buildTotalContextText(systemPrompt, context, ctx.getContextUsage(), ctx.model);
306
+ // ── Subtitle ────────────────────────────────────────────────────────
307
+ const subtitle =
308
+ usage?.tokens != null && usage.contextWindow != null
309
+ ? `${formatTokens(usage.tokens)} / ${formatTokens(usage.contextWindow)} (${(usage.percent ?? (usage.tokens / usage.contextWindow) * 100).toFixed(1)}%)`
310
+ : "no usage data yet";
211
311
 
312
+ // ── Build and open the overlay ──────────────────────────────────────
212
313
  await ctx.ui.custom<void>((_tui, theme, _keybindings, done) => {
213
- const displayLines = buildNumberedLines(fullText, theme);
214
- return new ScrollableOverlay({
215
- title: "Total Context Data",
216
- subtitle: `${fullText.length.toLocaleString()} chars · ${displayLines.length} lines`,
217
- rawText: fullText,
218
- displayLines,
314
+ // Build messages text for the Messages tab
315
+ const messagesLines: string[] = [];
316
+ if (context.messages.length > 0) {
317
+ for (let i = 0; i < context.messages.length; i++) {
318
+ messagesLines.push(...formatMessageForDisplay(context.messages[i]!, i));
319
+ }
320
+ } else {
321
+ messagesLines.push("(no messages yet)");
322
+ }
323
+ const messagesText = messagesLines.join("\n");
324
+
325
+ const tabs = [
326
+ new StatsTabContent(breakdown, theme),
327
+ new ScrollableTabContent(
328
+ { rawText: systemPrompt, displayLines: buildNumberedLines(systemPrompt, theme), theme },
329
+ "System",
330
+ ),
331
+ new ScrollableTabContent(
332
+ { rawText: toolsText, displayLines: buildNumberedLines(toolsText, theme), theme },
333
+ "Tools",
334
+ ),
335
+ new ScrollableTabContent(
336
+ { rawText: messagesText, displayLines: buildNumberedLines(messagesText, theme), theme },
337
+ "Messages",
338
+ ),
339
+ new ScrollableTabContent(
340
+ { rawText: fullText, displayLines: buildNumberedLines(fullText, theme), theme },
341
+ "Full",
342
+ ),
343
+ ];
344
+
345
+ return new TabbedOverlay({
346
+ title: "Context Viewer",
347
+ subtitle,
348
+ tabs,
219
349
  theme,
220
350
  done,
221
351
  });
@@ -0,0 +1,281 @@
1
+ /**
2
+ * ScrollableTabContent — scrollable text viewer for use inside TabbedOverlay.
3
+ *
4
+ * Same scroll/search/copy logic as ScrollableOverlay, but without an outer frame.
5
+ * Renders only the content lines; the outer frame is handled by TabbedOverlay.
6
+ */
7
+
8
+ import { copyToClipboard as copyTextToClipboard, type Theme } from "@earendil-works/pi-coding-agent";
9
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
10
+ import type { TabContent } from "./tabbed-overlay.js";
11
+
12
+ export interface ScrollableTabContentOptions {
13
+ /** The raw text to display (used for search & clipboard) */
14
+ rawText: string;
15
+ /** The styled display lines (with ANSI formatting, line numbers, etc.) */
16
+ displayLines: string[];
17
+ /** Active theme */
18
+ theme: Theme;
19
+ }
20
+
21
+ export class ScrollableTabContent implements TabContent {
22
+ private scrollOffset = 0;
23
+
24
+ // Search state
25
+ private searchMode = false;
26
+ private searchQuery = "";
27
+ private searchMatches: number[] = [];
28
+ private currentMatchIndex = -1;
29
+ private copyFlash = false;
30
+
31
+ constructor(
32
+ private opts: ScrollableTabContentOptions,
33
+ public readonly name: string = "",
34
+ ) {}
35
+
36
+ /**
37
+ * Returns the above-content line (search bar or match info),
38
+ * or null if a plain border separator should be rendered.
39
+ */
40
+ getAboveContentLine(_innerWidth: number): string | null {
41
+ const th = this.opts.theme;
42
+ if (this.searchMode) {
43
+ return ` ${th.fg("accent", "/")} ${this.searchQuery}${th.fg("dim", "▏")}`;
44
+ }
45
+ if (this.searchMatches.length > 0) {
46
+ return ` ${th.fg("accent", "/")} ${th.fg("text", this.searchQuery)} ${th.fg("dim", "—")} ${th.fg("accent", `${this.currentMatchIndex + 1}/${this.searchMatches.length}`)}`;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ getFooterLeft(): string {
52
+ const th = this.opts.theme;
53
+ const total = this.opts.displayLines.length;
54
+ const maxScroll = Math.max(0, total - 1);
55
+ const visibleEnd = Math.min(this.scrollOffset + 1, total);
56
+
57
+ const scrollPercent =
58
+ total === 0
59
+ ? "All"
60
+ : this.scrollOffset === 0
61
+ ? "Top"
62
+ : this.scrollOffset >= maxScroll
63
+ ? "Bot"
64
+ : `${Math.round(((this.scrollOffset + 1) / total) * 100)}%`;
65
+
66
+ let left = `${visibleEnd}/${total} [${scrollPercent}]`;
67
+ if (this.copyFlash) {
68
+ left += th.fg("success", " ✓ Copied!");
69
+ }
70
+ return left;
71
+ }
72
+
73
+ readonly footerHints = "↑↓ scroll · / search · n/N next · y copy";
74
+
75
+ /**
76
+ * Handle keyboard input. Returns true if the key was consumed (prevents outer
77
+ * overlay from acting on it — e.g. so Escape exits search mode instead of
78
+ * closing the overlay).
79
+ */
80
+ handleInput(data: string): boolean {
81
+ if (this.searchMode) {
82
+ if (matchesKey(data, Key.escape)) {
83
+ this.searchMode = false;
84
+ this.searchQuery = "";
85
+ return true;
86
+ }
87
+ if (matchesKey(data, Key.enter)) {
88
+ if (this.searchQuery.length > 0) {
89
+ this.findMatches();
90
+ if (this.searchMatches.length > 0) {
91
+ this.currentMatchIndex = 0;
92
+ this.scrollToMatch();
93
+ }
94
+ }
95
+ this.searchMode = false;
96
+ return true;
97
+ }
98
+ if (matchesKey(data, Key.backspace)) {
99
+ this.searchQuery = this.searchQuery.slice(0, -1);
100
+ this.findMatches();
101
+ if (this.searchMatches.length > 0) {
102
+ this.currentMatchIndex = 0;
103
+ this.scrollToMatch();
104
+ }
105
+ return true;
106
+ }
107
+ if (data.length === 1 && data.charCodeAt(0) >= 32) {
108
+ this.searchQuery += data;
109
+ this.findMatches();
110
+ if (this.searchMatches.length > 0) {
111
+ this.currentMatchIndex = 0;
112
+ this.scrollToMatch();
113
+ }
114
+ return true;
115
+ }
116
+ return true; // swallow all other keys in search mode
117
+ }
118
+
119
+ // Normal mode — handle scroll/search/copy, NOT q/Escape (those go to TabbedOverlay)
120
+ if (matchesKey(data, Key.down) || data === "j") {
121
+ this.scrollDown(1);
122
+ return true;
123
+ }
124
+ if (matchesKey(data, Key.up) || data === "k") {
125
+ this.scrollUp(1);
126
+ return true;
127
+ }
128
+ if (matchesKey(data, Key.home) || data === "g") {
129
+ this.scrollOffset = 0;
130
+ return true;
131
+ }
132
+ if (matchesKey(data, Key.end) || data === "G") {
133
+ this.scrollToBottom();
134
+ return true;
135
+ }
136
+ if (matchesKey(data, Key.pageDown) || matchesKey(data, Key.ctrl("f"))) {
137
+ this.scrollDown(28);
138
+ return true;
139
+ }
140
+ if (matchesKey(data, Key.pageUp) || matchesKey(data, Key.ctrl("b"))) {
141
+ this.scrollUp(28);
142
+ return true;
143
+ }
144
+ if (matchesKey(data, Key.ctrl("d"))) {
145
+ this.scrollDown(14);
146
+ return true;
147
+ }
148
+ if (matchesKey(data, Key.ctrl("u"))) {
149
+ this.scrollUp(14);
150
+ return true;
151
+ }
152
+ if (data === "/") {
153
+ this.searchMode = true;
154
+ this.searchQuery = "";
155
+ this.searchMatches = [];
156
+ this.currentMatchIndex = -1;
157
+ return true;
158
+ }
159
+ if (data === "n") {
160
+ this.nextMatch();
161
+ return true;
162
+ }
163
+ if (data === "N") {
164
+ this.prevMatch();
165
+ return true;
166
+ }
167
+ if (data === "y") {
168
+ void this.copyToClipboard();
169
+ return true;
170
+ }
171
+
172
+ return false; // not consumed — let TabbedOverlay handle it
173
+ }
174
+
175
+ renderContent(innerWidth: number, height: number): string[] {
176
+ const th = this.opts.theme;
177
+ const lines: string[] = [];
178
+ const total = this.opts.displayLines.length;
179
+
180
+ // Clamp scroll offset
181
+ const maxScroll = Math.max(0, total - height);
182
+ this.scrollOffset = Math.min(this.scrollOffset, maxScroll);
183
+ this.scrollOffset = Math.max(0, this.scrollOffset);
184
+
185
+ for (let i = 0; i < height; i++) {
186
+ const lineIdx = this.scrollOffset + i;
187
+ if (lineIdx < total) {
188
+ let line = this.opts.displayLines[lineIdx]!;
189
+
190
+ const isCurrentMatch =
191
+ this.searchMatches.length > 0 &&
192
+ this.currentMatchIndex >= 0 &&
193
+ this.searchMatches[this.currentMatchIndex] === lineIdx;
194
+ const isOtherMatch =
195
+ this.searchMatches.length > 0 && this.searchMatches.includes(lineIdx) && !isCurrentMatch;
196
+
197
+ if (isCurrentMatch) {
198
+ line = th.bg("selectedBg", truncateToWidth(line, innerWidth));
199
+ } else if (isOtherMatch) {
200
+ line = th.fg("warning", truncateToWidth(line, innerWidth));
201
+ } else {
202
+ line = truncateToWidth(line, innerWidth);
203
+ }
204
+
205
+ lines.push(line);
206
+ } else {
207
+ lines.push(th.fg("dim", "~"));
208
+ }
209
+ }
210
+
211
+ return lines;
212
+ }
213
+
214
+ invalidate(): void {
215
+ // No cached state
216
+ }
217
+
218
+ // ── Private helpers ────────────────────────────────────────────────────────
219
+
220
+ private scrollDown(amount: number): void {
221
+ const maxOffset = Math.max(0, this.opts.displayLines.length - 1);
222
+ this.scrollOffset = Math.min(this.scrollOffset + amount, maxOffset);
223
+ }
224
+
225
+ private scrollUp(amount: number): void {
226
+ this.scrollOffset = Math.max(0, this.scrollOffset - amount);
227
+ }
228
+
229
+ private scrollToBottom(): void {
230
+ this.scrollOffset = Math.max(0, this.opts.displayLines.length - 1);
231
+ }
232
+
233
+ private findMatches(): void {
234
+ const query = this.searchQuery.toLowerCase();
235
+ const rawLines = this.opts.rawText.split("\n");
236
+ this.searchMatches = [];
237
+ if (query.length === 0) {
238
+ this.currentMatchIndex = -1;
239
+ return;
240
+ }
241
+ for (let i = 0; i < rawLines.length; i++) {
242
+ if (rawLines[i]!.toLowerCase().includes(query)) {
243
+ this.searchMatches.push(i);
244
+ }
245
+ }
246
+ }
247
+
248
+ private scrollToMatch(): void {
249
+ if (this.currentMatchIndex >= 0 && this.currentMatchIndex < this.searchMatches.length) {
250
+ const targetLine = this.searchMatches[this.currentMatchIndex]!;
251
+ const visibleLines = 28; // approximate; TabbedOverlay uses CONTENT_HEIGHT
252
+ if (targetLine < this.scrollOffset || targetLine >= this.scrollOffset + visibleLines) {
253
+ this.scrollOffset = Math.max(0, targetLine - Math.floor(visibleLines / 3));
254
+ }
255
+ }
256
+ }
257
+
258
+ private nextMatch(): void {
259
+ if (this.searchMatches.length === 0) return;
260
+ this.currentMatchIndex = (this.currentMatchIndex + 1) % this.searchMatches.length;
261
+ this.scrollToMatch();
262
+ }
263
+
264
+ private prevMatch(): void {
265
+ if (this.searchMatches.length === 0) return;
266
+ this.currentMatchIndex = (this.currentMatchIndex - 1 + this.searchMatches.length) % this.searchMatches.length;
267
+ this.scrollToMatch();
268
+ }
269
+
270
+ private async copyToClipboard(): Promise<void> {
271
+ this.copyFlash = true;
272
+ try {
273
+ await copyTextToClipboard(this.opts.rawText);
274
+ } catch {
275
+ // Silently fail if clipboard tools aren't available
276
+ }
277
+ setTimeout(() => {
278
+ this.copyFlash = false;
279
+ }, 1500);
280
+ }
281
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * StatsTabContent — token distribution grid + category breakdown table.
3
+ *
4
+ * Adapts the visual dashboard from pi-context (github.com/ttttmr/pi-context)
5
+ * for use inside TabbedOverlay. Shows a colored 10×5 block grid on the left and
6
+ * a per-category token breakdown on the right.
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
+ export interface ContextTokenBreakdown {
19
+ total: number;
20
+ contextWindow: number;
21
+ percent: number;
22
+ systemPrompt: number;
23
+ systemTools: number;
24
+ toolCalls: number;
25
+ messages: number;
26
+ other: number;
27
+ }
28
+
29
+ interface Category {
30
+ label: string;
31
+ value: number;
32
+ color: string;
33
+ }
34
+
35
+ export class StatsTabContent implements TabContent {
36
+ readonly name = "Stats";
37
+ readonly footerHints = "";
38
+
39
+ constructor(
40
+ private breakdown: ContextTokenBreakdown | null,
41
+ private theme: Theme,
42
+ ) {}
43
+
44
+ /** Stats view has no interactive search bar — always use border separator. */
45
+ getAboveContentLine(_innerWidth: number): string | null {
46
+ return null;
47
+ }
48
+
49
+ getFooterLeft(): string {
50
+ if (!this.breakdown) return "";
51
+ const { total, contextWindow, percent } = this.breakdown;
52
+ return `${formatTokens(total)} / ${formatTokens(contextWindow)} (${percent.toFixed(1)}%)`;
53
+ }
54
+
55
+ /** Stats view has no keyboard interactions. */
56
+ handleInput(_data: string): boolean {
57
+ return false;
58
+ }
59
+
60
+ invalidate(): void {}
61
+
62
+ renderContent(_innerWidth: number, height: number): string[] {
63
+ const th = this.theme;
64
+
65
+ if (!this.breakdown) {
66
+ const lines: string[] = [
67
+ "",
68
+ ` ${th.fg("warning", "No context usage data available.")}`,
69
+ ` ${th.fg("dim", "Send a message first, then re-open /context-viewer.")}`,
70
+ ];
71
+ while (lines.length < height) lines.push("");
72
+ return lines;
73
+ }
74
+
75
+ const { total, contextWindow, percent, systemPrompt, systemTools, toolCalls, messages, other } = this.breakdown;
76
+
77
+ const categories: Category[] = [
78
+ { label: "System Prompt", value: systemPrompt, color: "muted" },
79
+ { label: "System Tools", value: systemTools, color: "dim" },
80
+ { label: "Tool Calls", value: toolCalls, color: "success" },
81
+ { label: "Messages", value: messages, color: "accent" },
82
+ ];
83
+
84
+ if (other > 10) {
85
+ categories.push({ label: "Other", value: other, color: "dim" });
86
+ }
87
+
88
+ const available = Math.max(0, contextWindow - total);
89
+
90
+ // ── Build grid blocks ────────────────────────────────────────────────────
91
+ const blocks: { color: string; filled: boolean }[] = [];
92
+ for (const cat of categories) {
93
+ let count = Math.round((cat.value / contextWindow) * TOTAL_BLOCKS);
94
+ if (count === 0 && cat.value > 0) count = 1;
95
+ for (let i = 0; i < count && blocks.length < TOTAL_BLOCKS; i++) {
96
+ blocks.push({ color: cat.color, filled: true });
97
+ }
98
+ }
99
+ while (blocks.length < TOTAL_BLOCKS) {
100
+ blocks.push({ color: "borderMuted", filled: false });
101
+ }
102
+
103
+ // ── Render grid rows ─────────────────────────────────────────────────────
104
+ const gridLines: string[] = [];
105
+ for (let r = 0; r < GRID_HEIGHT; r++) {
106
+ let row = "";
107
+ for (let c = 0; c < GRID_WIDTH; c++) {
108
+ const b = blocks[r * GRID_WIDTH + c]!;
109
+ row += th.fg(b.color as Parameters<typeof th.fg>[0], b.filled ? "■" : "□");
110
+ if (c < GRID_WIDTH - 1) row += " ";
111
+ }
112
+ gridLines.push(row);
113
+ }
114
+
115
+ // ── Build legend ─────────────────────────────────────────────────────────
116
+ const LABEL_W = 14;
117
+ const TOKEN_W = 7;
118
+
119
+ const legendLines: string[] = [];
120
+
121
+ // Total usage line (bold, no icon)
122
+ legendLines.push(
123
+ ` ${th.bold(th.fg("text", "Total Usage".padEnd(LABEL_W + 2)))} ` +
124
+ `${th.fg("accent", formatTokens(total).padStart(TOKEN_W))} ` +
125
+ `${th.fg("dim", `(${percent.toFixed(1).padStart(5)}%)`)}`,
126
+ );
127
+ legendLines.push(""); // blank separator before categories
128
+
129
+ // Per-category lines
130
+ for (const cat of categories) {
131
+ const pct = ((cat.value / contextWindow) * 100).toFixed(1);
132
+ legendLines.push(
133
+ `${th.fg(cat.color as Parameters<typeof th.fg>[0], "■")} ` +
134
+ `${th.fg("text", cat.label.padEnd(LABEL_W))} ` +
135
+ `${th.fg("accent", formatTokens(cat.value).padStart(TOKEN_W))} ` +
136
+ `${th.fg("dim", `(${pct.padStart(5)}%)`)}`,
137
+ );
138
+ }
139
+
140
+ // Available line
141
+ const availPct = ((available / contextWindow) * 100).toFixed(1);
142
+ legendLines.push(
143
+ `${th.fg("borderMuted" as Parameters<typeof th.fg>[0], "□")} ` +
144
+ `${th.fg("dim", "Available".padEnd(LABEL_W))} ` +
145
+ `${th.fg("dim", formatTokens(available).padStart(TOKEN_W))} ` +
146
+ `${th.fg("dim", `(${availPct.padStart(5)}%)`)}`,
147
+ );
148
+
149
+ // ── Combine grid + legend side by side ───────────────────────────────────
150
+ // Grid visible width: GRID_WIDTH * 2 - 1 (each "■ " or "□ " = 2, minus trailing space)
151
+ const GRID_VIS_W = GRID_WIDTH * 2 - 1;
152
+ const maxRows = Math.max(gridLines.length, legendLines.length);
153
+
154
+ const combined: string[] = [];
155
+ combined.push(""); // top padding
156
+
157
+ for (let i = 0; i < maxRows; i++) {
158
+ const leftRaw = gridLines[i] ?? "";
159
+ const leftVisW = visibleWidth(leftRaw);
160
+ const pad = " ".repeat(Math.max(0, GRID_VIS_W - leftVisW));
161
+ const right = legendLines[i] ?? "";
162
+ combined.push(` ${leftRaw}${pad} ${right}`);
163
+ }
164
+
165
+ combined.push(""); // bottom padding
166
+
167
+ // Fill or truncate to content height
168
+ const result: string[] = [];
169
+ for (let i = 0; i < height; i++) {
170
+ result.push(combined[i] ?? "");
171
+ }
172
+ return result;
173
+ }
174
+ }
@@ -0,0 +1,173 @@
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, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
+
16
+ /** Number of lines shown in the content area of each tab. */
17
+ export const CONTENT_HEIGHT = 28;
18
+
19
+ /**
20
+ * Interface that each tab must implement.
21
+ *
22
+ * Key contract: `handleInput` returns `true` if the key was consumed (so the
23
+ * outer overlay won't act on it). This lets content tabs swallow Escape when
24
+ * in search mode rather than closing the overlay.
25
+ */
26
+ export interface TabContent {
27
+ readonly name: string;
28
+
29
+ /**
30
+ * Returns a styled string to render between the tab bar and the content area,
31
+ * or `null` to render a plain border separator.
32
+ * Used by scrollable tabs for the live search / match-info row.
33
+ */
34
+ getAboveContentLine(innerWidth: number): string | null;
35
+
36
+ /** Render exactly `height` lines of content. */
37
+ renderContent(innerWidth: number, height: number): string[];
38
+
39
+ /** Left portion of the footer row (e.g. scroll position, copy flash). */
40
+ getFooterLeft(): string;
41
+
42
+ /** Hint items shown in the footer (right side). Omit Tab / q hints — they are added by TabbedOverlay. */
43
+ readonly footerHints: string;
44
+
45
+ /**
46
+ * Handle a keyboard event. Return `true` if consumed (prevents TabbedOverlay
47
+ * from acting on the key), `false` to let the outer overlay handle it.
48
+ */
49
+ handleInput(data: string): boolean;
50
+
51
+ /** Reset any cached rendering state. */
52
+ invalidate(): void;
53
+ }
54
+
55
+ export interface TabbedOverlayOptions {
56
+ /** Title text shown in the top header row. */
57
+ title: string;
58
+ /** Subtitle / stats text shown dimmed after the title. */
59
+ subtitle: string;
60
+ /** Ordered list of tab views. */
61
+ tabs: TabContent[];
62
+ /** Active theme. */
63
+ theme: Theme;
64
+ /** Called when the user closes the overlay (Escape / q). */
65
+ done: () => void;
66
+ }
67
+
68
+ export class TabbedOverlay {
69
+ private activeTabIndex = 0;
70
+
71
+ constructor(private opts: TabbedOverlayOptions) {}
72
+
73
+ private get activeTab(): TabContent {
74
+ return this.opts.tabs[this.activeTabIndex]!;
75
+ }
76
+
77
+ handleInput(data: string): void {
78
+ // Tab / Shift+Tab always switch tabs (not delegated to content).
79
+ if (matchesKey(data, Key.tab)) {
80
+ this.activeTabIndex = (this.activeTabIndex + 1) % this.opts.tabs.length;
81
+ return;
82
+ }
83
+ if (matchesKey(data, Key.shift("tab"))) {
84
+ this.activeTabIndex = (this.activeTabIndex - 1 + this.opts.tabs.length) % this.opts.tabs.length;
85
+ return;
86
+ }
87
+
88
+ // Delegate to the active tab first. If it consumes the key, stop.
89
+ const consumed = this.activeTab.handleInput(data);
90
+ if (consumed) return;
91
+
92
+ // Outer-level: close the overlay.
93
+ if (matchesKey(data, Key.escape) || data === "q") {
94
+ this.opts.done();
95
+ }
96
+ }
97
+
98
+ render(width: number): string[] {
99
+ const th = this.opts.theme;
100
+ const innerW = width - 2;
101
+ const lines: string[] = [];
102
+
103
+ // ANSI-aware padding: pad styled string to `len` visible columns.
104
+ const pad = (s: string, len: number) => s + " ".repeat(Math.max(0, len - visibleWidth(s)));
105
+
106
+ const row = (content: string) => th.fg("border", "│") + pad(content, innerW) + th.fg("border", "│");
107
+ const borderTop = th.fg("border", `╭${"─".repeat(innerW)}╮`);
108
+ const borderSep = th.fg("border", `├${"─".repeat(innerW)}┤`);
109
+ const borderBottom = th.fg("border", `╰${"─".repeat(innerW)}╯`);
110
+
111
+ // ── Top border ──────────────────────────────────────────────────────────
112
+ lines.push(borderTop);
113
+
114
+ // ── Title row ───────────────────────────────────────────────────────────
115
+ const title = ` ${th.fg("accent", th.bold(this.opts.title))} ${th.fg("dim", `(${this.opts.subtitle})`)}`;
116
+ lines.push(row(title));
117
+
118
+ // ── Tab bar ─────────────────────────────────────────────────────────────
119
+ let tabBar = " ";
120
+ for (let i = 0; i < this.opts.tabs.length; i++) {
121
+ const tab = this.opts.tabs[i]!;
122
+ if (i === this.activeTabIndex) {
123
+ tabBar += th.fg("accent", th.bold(`[${tab.name}]`));
124
+ } else {
125
+ tabBar += th.fg("dim", `[${tab.name}]`);
126
+ }
127
+ if (i < this.opts.tabs.length - 1) tabBar += " ";
128
+ }
129
+ lines.push(row(tabBar));
130
+
131
+ // ── Above-content row (search bar or border) ─────────────────────────────
132
+ const aboveLine = this.activeTab.getAboveContentLine(innerW);
133
+ if (aboveLine !== null) {
134
+ lines.push(row(` ${aboveLine}`));
135
+ } else {
136
+ lines.push(borderSep);
137
+ }
138
+
139
+ // ── Content area ─────────────────────────────────────────────────────────
140
+ const contentLines = this.activeTab.renderContent(innerW, CONTENT_HEIGHT);
141
+ for (let i = 0; i < CONTENT_HEIGHT; i++) {
142
+ if (i < contentLines.length) {
143
+ lines.push(row(truncateToWidth(contentLines[i]!, innerW)));
144
+ } else {
145
+ lines.push(row(th.fg("dim", "~")));
146
+ }
147
+ }
148
+
149
+ // ── Footer ───────────────────────────────────────────────────────────────
150
+ lines.push(borderSep);
151
+
152
+ const footerLeft = this.activeTab.getFooterLeft();
153
+ const tabHints = this.opts.tabs.length > 1 ? "Tab switch" : "";
154
+ const activeHints = this.activeTab.footerHints;
155
+ const hintParts = [tabHints, activeHints, "q close"].filter(Boolean);
156
+ const footerHintsText = th.fg("dim", hintParts.join(" · "));
157
+
158
+ let footerContent = footerHintsText;
159
+ if (footerLeft) {
160
+ footerContent = th.fg("dim", ` ${footerLeft} `) + footerHintsText;
161
+ }
162
+ lines.push(row(footerContent));
163
+ lines.push(borderBottom);
164
+
165
+ return lines;
166
+ }
167
+
168
+ invalidate(): void {
169
+ for (const tab of this.opts.tabs) {
170
+ tab.invalidate();
171
+ }
172
+ }
173
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Shared utility helpers for edb-context-viewer.
3
+ */
4
+
5
+ /** Format a token count as a human-readable string (e.g. 45k, 1.2M). */
6
+ export const formatTokens = (n: number | null | undefined): string => {
7
+ if (n == null) return "N/A";
8
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
9
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
10
+ return n.toString();
11
+ };