@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 +17 -0
- package/README.md +32 -12
- package/package.json +1 -1
- package/src/index.ts +175 -45
- package/src/scrollable-tab-content.ts +281 -0
- package/src/stats-tab-content.ts +174 -0
- package/src/tabbed-overlay.ts +173 -0
- package/src/utils.ts +11 -0
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.
|
|
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
|
-
|
|
6
|
-
- **`/total-context-data`** — complete LLM context (system prompt + all messages + usage stats)
|
|
5
|
+
## Command
|
|
7
6
|
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
22
|
-
|
|
|
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
|
|
51
|
+
| `Escape` / `q` | Close overlay |
|
|
32
52
|
|
|
33
53
|
## Install
|
|
34
54
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* edb-context-viewer
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Single command for inspecting the full LLM context in a tabbed overlay:
|
|
5
5
|
*
|
|
6
|
-
* /
|
|
7
|
-
*
|
|
6
|
+
* /context-viewer — 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)
|
|
8
12
|
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
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: "
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
return;
|
|
183
|
-
}
|
|
291
|
+
// ── Gather data ─────────────────────────────────────────────────────
|
|
292
|
+
const systemPrompt = ctx.getSystemPrompt() ?? "";
|
|
293
|
+
const usage = ctx.getContextUsage();
|
|
184
294
|
|
|
185
|
-
const
|
|
186
|
-
const
|
|
295
|
+
const allTools = pi.getAllTools();
|
|
296
|
+
const activeToolNames = pi.getActiveTools();
|
|
297
|
+
const activeToolDefs = allTools.filter((t) => activeToolNames.includes(t.name));
|
|
187
298
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
+
};
|