@pi9/context 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chase Cummings
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,40 @@
1
+ # @pi9/context
2
+
3
+ Pi extension that registers `/context` — a scrollable breakdown of how your current context window is being used (model, fixed-scale usage graph, compaction reserve, conversation stats, memory files, tools, and skills).
4
+
5
+ ![The context report showing a token-usage graph and detailed breakdown](media/context-report.png)
6
+
7
+ ## Usage
8
+
9
+ In an interactive pi session:
10
+
11
+ ```text
12
+ /context
13
+ ```
14
+
15
+ The report opens as an inline custom view in the main UI flow. Scroll with **↑/↓** or **j/k**, page with **PgUp/PgDn** or **u/d**, jump with **Home/End**, and close with **q** or **Esc** (Enter also closes).
16
+
17
+ In modes without extension UI support, the command shows a short warning only.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ pi install npm:@pi9/context
23
+ ```
24
+
25
+ For local development, load `packages/context/src/index.ts` as an extension.
26
+
27
+ ## Development
28
+
29
+ ```bash
30
+ npm run typecheck
31
+ npm test
32
+ ```
33
+
34
+ ### Manual verification
35
+
36
+ To verify the TUI end-to-end:
37
+
38
+ 1. Start pi with this extension in a terminal at least 80 columns wide.
39
+ 2. Run `/context` on a session with several messages so the report exceeds the viewport.
40
+ 3. Confirm keyboard scrolling, help text, the 1K-token-per-character graph, section token totals, and **Esc** closes the inline view without injecting report text into the chat.
Binary file
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@pi9/context",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension to show a breakdown of your current context usage.",
5
+ "license": "MIT",
6
+ "author": "Chase Cummings <chaseecummings@gmail.com>",
7
+ "type": "module",
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/Chase-C/pi9_subagent.git",
13
+ "directory": "packages/context"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/Chase-C/pi9_subagent/issues"
17
+ },
18
+ "homepage": "https://github.com/Chase-C/pi9_subagent/tree/main/packages/context#readme",
19
+ "files": [
20
+ "src",
21
+ "media",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "keywords": [
26
+ "pi",
27
+ "pi-extension",
28
+ "pi-package",
29
+ "pi9",
30
+ "context"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22.0.0"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
42
+ "release": "node ../../scripts/release.mjs context",
43
+ "release:patch": "node ../../scripts/release.mjs context patch",
44
+ "release:minor": "node ../../scripts/release.mjs context minor",
45
+ "release:major": "node ../../scripts/release.mjs context major",
46
+ "prepublishOnly": "npm run typecheck && npm test"
47
+ },
48
+ "pi": {
49
+ "extensions": [
50
+ "./src/index.ts"
51
+ ],
52
+ "skills": [],
53
+ "prompts": []
54
+ },
55
+ "peerDependencies": {
56
+ "@earendil-works/pi-ai": "*",
57
+ "@earendil-works/pi-coding-agent": "*",
58
+ "@earendil-works/pi-tui": "*"
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-ai": "^0.80.6",
62
+ "@earendil-works/pi-coding-agent": "^0.80.6",
63
+ "@earendil-works/pi-tui": "^0.80.6",
64
+ "typescript": "~6.0.3",
65
+ "vitest": "^4.1.6"
66
+ }
67
+ }
@@ -0,0 +1,359 @@
1
+ import { readFileSync } from "node:fs";
2
+ import {
3
+ buildSessionContext,
4
+ estimateTokens as estimateMessageTokens,
5
+ getAgentDir,
6
+ SettingsManager,
7
+ type BuildSystemPromptOptions,
8
+ type ExtensionAPI,
9
+ type ExtensionCommandContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import type {
12
+ CompactionDetails,
13
+ ContextReport,
14
+ ConversationDetails,
15
+ ConversationStats,
16
+ ConversationTurn,
17
+ MemoryDetails,
18
+ ModelDetails,
19
+ SkillDetails,
20
+ ToolDetails,
21
+ ToolSource,
22
+ } from "./types.js";
23
+
24
+ type AgentMessage = Parameters<typeof estimateMessageTokens>[0];
25
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
26
+
27
+ export function buildContextReport(
28
+ pi: ExtensionAPI,
29
+ ctx: ExtensionCommandContext,
30
+ compaction = collectCompactionDetails(ctx),
31
+ ): ContextReport | undefined {
32
+ const usage = ctx.getContextUsage();
33
+ if (!usage) {
34
+ return undefined;
35
+ }
36
+
37
+ const systemPrompt = ctx.getSystemPrompt() ?? "";
38
+ const promptOptions = ctx.getSystemPromptOptions();
39
+ const model: ModelDetails = {
40
+ provider: ctx.model?.provider ?? "Unknown Provider",
41
+ id: ctx.model?.id ?? "unknown-model",
42
+ name: ctx.model?.name ?? ctx.model?.id ?? "unknown-model",
43
+ thinking: pi.getThinkingLevel(),
44
+ contextWindow: usage.contextWindow ?? ctx.model?.contextWindow,
45
+ };
46
+
47
+ return {
48
+ kind: "conversation",
49
+ model,
50
+ usage,
51
+ compaction,
52
+ promptTokens: estimateTokens(systemPrompt),
53
+ tools: collectToolDetails(pi, promptOptions, systemPrompt),
54
+ skills: collectSkillDetails(pi, promptOptions, systemPrompt),
55
+ memory: collectMemoryDetails(promptOptions, systemPrompt),
56
+ snapshot: { capturedAt: Date.now() },
57
+ conversation: collectConversationDetails(ctx),
58
+ };
59
+ }
60
+
61
+ export function collectCompactionDetails(ctx: ExtensionCommandContext): CompactionDetails {
62
+ const settings = SettingsManager.create(ctx.cwd, getAgentDir(), {
63
+ projectTrusted: ctx.isProjectTrusted(),
64
+ }).getCompactionSettings();
65
+ return {
66
+ enabled: settings.enabled,
67
+ reserveTokens: settings.reserveTokens,
68
+ };
69
+ }
70
+
71
+ export function estimateTokens(text: unknown): number {
72
+ if (typeof text === "string") {
73
+ return Math.ceil(text.length / 4);
74
+ }
75
+ return Math.ceil(JSON.stringify(text ?? "").length / 4);
76
+ }
77
+
78
+ export function collectSkillDetails(
79
+ pi: ExtensionAPI,
80
+ promptOptions?: BuildSystemPromptOptions,
81
+ systemPrompt?: string,
82
+ ): SkillDetails[] {
83
+ const details: SkillDetails[] = [];
84
+ if (promptOptions?.selectedTools && !promptOptions.selectedTools.includes("read")) {
85
+ return details;
86
+ }
87
+
88
+ const skills = promptOptions
89
+ ? (promptOptions.skills ?? []).filter((skill) => !skill.disableModelInvocation)
90
+ : pi.getCommands().filter((cmd) => cmd.source === "skill");
91
+
92
+ const visited = new Set<string>();
93
+ skills.forEach((skill) => {
94
+ const key = `${skill.name}-${skill.sourceInfo.scope}`;
95
+ if (visited.has(key)) {
96
+ return;
97
+ }
98
+ visited.add(key);
99
+
100
+ let content = "";
101
+ const path = "filePath" in skill && typeof skill.filePath === "string"
102
+ ? skill.filePath
103
+ : skill.sourceInfo.path;
104
+ try {
105
+ content = readFileSync(path, "utf-8");
106
+ } catch {
107
+ // Missing skill files should not make /context fail.
108
+ }
109
+
110
+ const promptEntry = [
111
+ " <skill>",
112
+ ` <name>${escapeXml(skill.name)}</name>`,
113
+ ` <description>${escapeXml(skill.description ?? "")}</description>`,
114
+ ` <location>${escapeXml(path)}</location>`,
115
+ " </skill>",
116
+ ].join("\n");
117
+ if (systemPrompt !== undefined && !systemPrompt.includes(promptEntry)) return;
118
+
119
+ details.push({
120
+ name: skill.name,
121
+ descTokens: estimateTokens(promptEntry),
122
+ bodyTokens: estimateTokens(content),
123
+ scope: skill.sourceInfo.scope === "project" ? "project" : "user",
124
+ });
125
+ });
126
+
127
+ details.sort((a, b) => b.descTokens - a.descTokens || a.name.localeCompare(b.name));
128
+ return details;
129
+ }
130
+
131
+ export function collectToolDetails(
132
+ pi: ExtensionAPI,
133
+ promptOptions?: BuildSystemPromptOptions,
134
+ systemPrompt?: string,
135
+ ): ToolDetails[] {
136
+ const active = pi.getActiveTools();
137
+ const tools = new Map(pi.getAllTools().map((tool) => [tool.name, tool]));
138
+ const promptParts = collectToolPromptParts(tools, active, promptOptions, systemPrompt);
139
+ const details: ToolDetails[] = [];
140
+
141
+ for (const tool of tools.values()) {
142
+ const source = tool.sourceInfo.source;
143
+ let detailSource: ToolSource;
144
+ if (source === "builtin" || /^<builtin:/i.test(tool.sourceInfo.path)) {
145
+ detailSource = { kind: "builtin" };
146
+ } else if (/^mcp_/i.test(tool.name) || /mcp/i.test(source)) {
147
+ detailSource = { kind: "mcp", name: source };
148
+ } else {
149
+ detailSource = { kind: "extension", name: source };
150
+ }
151
+
152
+ const definitionTokens = estimateTokens({
153
+ name: tool.name,
154
+ description: tool.description,
155
+ parameters: tool.parameters,
156
+ });
157
+ const parts = promptParts.get(tool.name) ?? [];
158
+ const promptTokens = parts.length > 0 ? estimateTokens(parts.join("\n")) : 0;
159
+ details.push({
160
+ name: tool.name,
161
+ tokens: definitionTokens + promptTokens,
162
+ definitionTokens,
163
+ promptTokens,
164
+ source: detailSource,
165
+ active: active.includes(tool.name),
166
+ });
167
+ }
168
+
169
+ details.sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
170
+ return details;
171
+ }
172
+
173
+ function collectToolPromptParts(
174
+ tools: Map<string, ReturnType<ExtensionAPI["getAllTools"]>[number]>,
175
+ active: string[],
176
+ promptOptions?: BuildSystemPromptOptions,
177
+ systemPrompt?: string,
178
+ ): Map<string, string[]> {
179
+ const parts = new Map<string, string[]>();
180
+ if (!promptOptions || promptOptions.customPrompt) return parts;
181
+
182
+ const activeNames = promptOptions.selectedTools ?? active;
183
+ const append = (name: string, text: string): void => {
184
+ if (systemPrompt !== undefined && !systemPrompt.includes(text)) return;
185
+ const current = parts.get(name) ?? [];
186
+ current.push(text);
187
+ parts.set(name, current);
188
+ };
189
+
190
+ for (const name of activeNames) {
191
+ const snippet = promptOptions.toolSnippets?.[name];
192
+ if (snippet) append(name, `- ${name}: ${snippet}`);
193
+ }
194
+
195
+ const attributedGuidelines = new Set<string>();
196
+ if (
197
+ activeNames.includes("bash") &&
198
+ !activeNames.some((name) => name === "grep" || name === "find" || name === "ls")
199
+ ) {
200
+ const guideline = "Use bash for file operations like ls, rg, find";
201
+ append("bash", `- ${guideline}`);
202
+ attributedGuidelines.add(guideline);
203
+ }
204
+
205
+ const includedGuidelines = new Set(
206
+ (promptOptions.promptGuidelines ?? [])
207
+ .map((guideline) => guideline.trim())
208
+ .filter((guideline) => guideline.length > 0),
209
+ );
210
+ for (const name of activeNames) {
211
+ const tool = tools.get(name);
212
+ if (!tool) continue;
213
+
214
+ const toolGuidelines = new Set(
215
+ (tool.promptGuidelines ?? [])
216
+ .map((guideline) => guideline.trim())
217
+ .filter((guideline) => guideline.length > 0),
218
+ );
219
+ for (const guideline of toolGuidelines) {
220
+ if (!includedGuidelines.has(guideline) || attributedGuidelines.has(guideline)) continue;
221
+ append(name, `- ${guideline}`);
222
+ attributedGuidelines.add(guideline);
223
+ }
224
+ }
225
+
226
+ return parts;
227
+ }
228
+
229
+ export function collectMemoryDetails(
230
+ promptOptions?: BuildSystemPromptOptions,
231
+ systemPrompt?: string,
232
+ ): MemoryDetails[] {
233
+ const files = promptOptions?.contextFiles ?? [];
234
+ const details = files.flatMap((file): MemoryDetails[] => {
235
+ const promptEntry = `<project_instructions path="${file.path}">\n${file.content}\n</project_instructions>\n\n`;
236
+ if (systemPrompt !== undefined && !systemPrompt.includes(promptEntry)) return [];
237
+ return [{ path: file.path, tokens: estimateTokens(promptEntry) }];
238
+ });
239
+
240
+ details.sort((a, b) => b.tokens - a.tokens || a.path.localeCompare(b.path));
241
+ return details;
242
+ }
243
+
244
+ export function collectConversationDetails(ctx: ExtensionCommandContext): ConversationDetails {
245
+ const branch = ctx.sessionManager.getBranch();
246
+ const messages = buildSessionContext(branch).messages;
247
+ const stats: ConversationStats = {
248
+ userMessages: 0,
249
+ assistantMessages: 0,
250
+ toolResults: 0,
251
+ toolCalls: 0,
252
+ thinkingBlocks: 0,
253
+ imageBlocks: 0,
254
+ compactions: branch.filter((entry) => entry.type === "compaction").length,
255
+ };
256
+ const history: ConversationTurn[] = [];
257
+ let tokens = 0;
258
+
259
+ for (const message of messages) {
260
+ const messageTokens = estimateMessageTokens(message);
261
+ tokens += messageTokens;
262
+
263
+ switch (message.role) {
264
+ case "user":
265
+ stats.userMessages += 1;
266
+ stats.imageBlocks += countImageBlocks(message.content);
267
+ history.push({ kind: "user", tokens: messageTokens });
268
+ break;
269
+ case "assistant":
270
+ stats.assistantMessages += 1;
271
+ addAssistantTurns(message, history, stats, messageTokens);
272
+ break;
273
+ case "toolResult":
274
+ stats.toolResults += 1;
275
+ stats.imageBlocks += countImageBlocks(message.content);
276
+ history.push({
277
+ kind: "tool-result",
278
+ tool: message.toolName ?? "unknown",
279
+ tokens: messageTokens,
280
+ callId: message.toolCallId,
281
+ isError: message.isError,
282
+ });
283
+ break;
284
+ case "custom":
285
+ stats.imageBlocks += countImageBlocks(message.content);
286
+ history.push({ kind: "custom", tokens: messageTokens });
287
+ break;
288
+ default:
289
+ history.push({ kind: "custom", tokens: messageTokens });
290
+ break;
291
+ }
292
+ }
293
+
294
+ return { stats, history, tokens };
295
+ }
296
+
297
+ function addAssistantTurns(
298
+ message: AssistantMessage,
299
+ history: ConversationTurn[],
300
+ stats: ConversationStats,
301
+ fallbackTokens: number,
302
+ ): void {
303
+ if (!Array.isArray(message.content)) {
304
+ history.push({ kind: "assistant", tokens: fallbackTokens });
305
+ return;
306
+ }
307
+
308
+ let emitted = false;
309
+ for (const block of message.content) {
310
+ if (!block || typeof block !== "object" || !("type" in block)) {
311
+ continue;
312
+ }
313
+
314
+ if (block.type === "text") {
315
+ history.push({ kind: "assistant", tokens: estimateTokens("text" in block ? block.text : "") });
316
+ emitted = true;
317
+ } else if (block.type === "thinking") {
318
+ stats.thinkingBlocks += 1;
319
+ history.push({ kind: "thinking", tokens: estimateTokens("thinking" in block ? block.thinking : "") });
320
+ emitted = true;
321
+ } else if (block.type === "toolCall") {
322
+ stats.toolCalls += 1;
323
+ history.push({
324
+ kind: "tool-call",
325
+ tool: "name" in block && typeof block.name === "string" ? block.name : "unknown",
326
+ tokens: estimateTokens(block),
327
+ callId: "id" in block && typeof block.id === "string" ? block.id : undefined,
328
+ });
329
+ emitted = true;
330
+ }
331
+ }
332
+
333
+ if (!emitted) {
334
+ history.push({ kind: "assistant", tokens: fallbackTokens });
335
+ }
336
+ }
337
+
338
+ function escapeXml(value: string): string {
339
+ return value
340
+ .replace(/&/g, "&amp;")
341
+ .replace(/</g, "&lt;")
342
+ .replace(/>/g, "&gt;")
343
+ .replace(/\"/g, "&quot;")
344
+ .replace(/'/g, "&apos;");
345
+ }
346
+
347
+ function countImageBlocks(content: unknown): number {
348
+ if (!Array.isArray(content)) {
349
+ return 0;
350
+ }
351
+
352
+ let count = 0;
353
+ for (const block of content) {
354
+ if (block && typeof block === "object" && "type" in block && block.type === "image") {
355
+ count += 1;
356
+ }
357
+ }
358
+ return count;
359
+ }
@@ -0,0 +1,541 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import type { Component, TUI } from "@earendil-works/pi-tui";
3
+ import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import type { ContextReport, ToolSource } from "./types.js";
5
+
6
+ export const CONTEXT_REPORT_HELP = "↑↓/jk scroll · PgUp/PgDn or u/d page · Home/End · q/Esc close";
7
+
8
+ const CHROME_LINES = 4;
9
+ const VIEW_HEIGHT_FRACTION = 0.9;
10
+ const GRAPH_CELL_TOKENS = 1_000;
11
+ const GRAPH_CELL_GAP = " ";
12
+ const GRAPH_GAP = 4;
13
+ const SUMMARY_MIN_WIDTH = 28;
14
+ const GRAPH_STYLE = {
15
+ prompt: { glyph: "●", color: "muted" },
16
+ tools: { glyph: "◆", color: "warning" },
17
+ memory: { glyph: "■", color: "error" },
18
+ skills: { glyph: "✦", color: "success" },
19
+ conversation: { glyph: "◉", color: "accent" },
20
+ other: { glyph: "◇", color: "dim" },
21
+ free: { glyph: "○", color: "borderMuted" },
22
+ compaction: { glyph: "▲", color: "warning" },
23
+ unknown: { glyph: "?", color: "dim" },
24
+ } as const satisfies Record<string, { glyph: string; color: ThemeColor }>;
25
+
26
+ type TreeBranch = "├" | "└";
27
+
28
+ interface ContextReportComponentOptions {
29
+ theme: Theme;
30
+ tui: TUI;
31
+ onClose: () => void;
32
+ }
33
+
34
+ interface GraphCategory {
35
+ label: string;
36
+ tokens: number;
37
+ glyph: string;
38
+ color: ThemeColor;
39
+ }
40
+
41
+ interface DetailItem {
42
+ label: string;
43
+ tokens: number;
44
+ meta?: string;
45
+ }
46
+
47
+ export function createContextReportComponent(
48
+ report: ContextReport,
49
+ { theme, tui, onClose }: ContextReportComponentOptions,
50
+ ): Component {
51
+ let scrollOffset = 0;
52
+ let lastRenderWidth = 80;
53
+ let cachedWidth = -1;
54
+ let cachedLines: string[] = [];
55
+
56
+ const syncLines = (width: number): readonly string[] => {
57
+ const contentWidth = reportContentWidth(width);
58
+ if (contentWidth !== cachedWidth) {
59
+ cachedWidth = contentWidth;
60
+ cachedLines = formatContextReportLines(report, theme, contentWidth);
61
+ }
62
+ return cachedLines;
63
+ };
64
+
65
+ return {
66
+ invalidate() {
67
+ cachedWidth = -1;
68
+ cachedLines = [];
69
+ },
70
+
71
+ handleInput(data: string) {
72
+ const viewport = contentViewportLines(tui.terminal.rows);
73
+ const total = syncLines(lastRenderWidth).length;
74
+
75
+ if (
76
+ matchesKey(data, "escape") ||
77
+ matchesKey(data, "q") ||
78
+ matchesKey(data, "enter") ||
79
+ matchesKey(data, "ctrl+c")
80
+ ) {
81
+ onClose();
82
+ return;
83
+ }
84
+
85
+ let next = scrollOffset;
86
+ if (matchesKey(data, "up") || matchesKey(data, "k")) {
87
+ next = clampScrollOffset(scrollOffset - 1, total, viewport);
88
+ } else if (matchesKey(data, "down") || matchesKey(data, "j")) {
89
+ next = clampScrollOffset(scrollOffset + 1, total, viewport);
90
+ } else if (matchesKey(data, "pageUp") || matchesKey(data, "u")) {
91
+ next = clampScrollOffset(scrollOffset - viewport, total, viewport);
92
+ } else if (matchesKey(data, "pageDown") || matchesKey(data, "d")) {
93
+ next = clampScrollOffset(scrollOffset + viewport, total, viewport);
94
+ } else if (matchesKey(data, "home")) {
95
+ next = 0;
96
+ } else if (matchesKey(data, "end")) {
97
+ next = maxScrollOffset(total, viewport);
98
+ } else {
99
+ return;
100
+ }
101
+
102
+ if (next !== scrollOffset) {
103
+ scrollOffset = next;
104
+ tui.requestRender();
105
+ }
106
+ },
107
+
108
+ render(width: number): string[] {
109
+ lastRenderWidth = width;
110
+ const displayLines = syncLines(width);
111
+ const viewport = contentViewportLines(tui.terminal.rows);
112
+ scrollOffset = clampScrollOffset(scrollOffset, displayLines.length, viewport);
113
+
114
+ if (width < 4) {
115
+ return displayLines
116
+ .slice(scrollOffset, scrollOffset + viewport)
117
+ .map((line) => truncateToWidth(line, Math.max(1, width), ""));
118
+ }
119
+
120
+ const innerWidth = width - 2;
121
+ const border = (text: string) => theme.fg("border", text);
122
+ const padLine = (text: string) => truncateToWidth(text, innerWidth, "...", true);
123
+ const title = truncateToWidth(" Context Report ", innerWidth);
124
+ const titlePad = Math.max(0, innerWidth - visibleWidth(title));
125
+ const result = [
126
+ border("╭") + theme.fg("accent", title) + border(`${"─".repeat(titlePad)}╮`),
127
+ border("│") + padLine(` ${theme.fg("dim", CONTEXT_REPORT_HELP)}`) + border("│"),
128
+ ];
129
+
130
+ const visible = displayLines.slice(scrollOffset, scrollOffset + viewport);
131
+ for (const line of visible) {
132
+ result.push(border("│") + padLine(` ${line}`) + border("│"));
133
+ }
134
+ for (let index = visible.length; index < viewport; index += 1) {
135
+ result.push(border("│") + padLine("") + border("│"));
136
+ }
137
+
138
+ const maxOffset = maxScrollOffset(displayLines.length, viewport);
139
+ const scrollHint = maxOffset > 0
140
+ ? theme.fg("dim", ` ${scrollOffset + 1}-${scrollOffset + visible.length} of ${displayLines.length}`)
141
+ : "";
142
+ result.push(border("│") + padLine(scrollHint) + border("│"));
143
+ result.push(border(`╰${"─".repeat(innerWidth)}╯`));
144
+
145
+ for (const line of result) {
146
+ if (visibleWidth(line) > width) {
147
+ throw new Error(`Rendered line exceeds width ${width}: ${visibleWidth(line)} cols`);
148
+ }
149
+ }
150
+
151
+ return result;
152
+ },
153
+ };
154
+ }
155
+
156
+ export function contentViewportLines(terminalRows: number): number {
157
+ const viewRows = Math.floor(terminalRows * VIEW_HEIGHT_FRACTION);
158
+ return Math.max(1, viewRows - CHROME_LINES);
159
+ }
160
+
161
+ export function maxScrollOffset(lineCount: number, viewportLines: number): number {
162
+ return Math.max(0, lineCount - viewportLines);
163
+ }
164
+
165
+ export function clampScrollOffset(offset: number, lineCount: number, viewportLines: number): number {
166
+ return Math.max(0, Math.min(offset, maxScrollOffset(lineCount, viewportLines)));
167
+ }
168
+
169
+ export function formatContextReportLines(report: ContextReport, theme: Theme, width = 80): string[] {
170
+ const lines = [
171
+ theme.fg("customMessageLabel", theme.bold("Context Usage")),
172
+ "",
173
+ ...formatUsageOverview(report, theme, width),
174
+ ];
175
+
176
+ if (report.kind === "conversation") {
177
+ pushSection(lines, formatConversationSection(report, theme));
178
+ pushSection(lines, formatDetailSection(
179
+ "Memory files (estimated)",
180
+ report.memory.map((file) => ({ label: file.path, tokens: file.tokens })),
181
+ theme,
182
+ ));
183
+ }
184
+
185
+ pushSection(lines, formatToolsSection(report, theme));
186
+ pushSection(lines, formatDetailSection(
187
+ "Skills (estimated)",
188
+ report.skills.map((skill) => ({
189
+ label: skill.name.replace(/^skill:/, ""),
190
+ tokens: skill.descTokens,
191
+ meta: `full ${formatTokens(skill.bodyTokens)} · ${skill.scope}`,
192
+ })),
193
+ theme,
194
+ ));
195
+
196
+ return lines;
197
+ }
198
+
199
+ function reportContentWidth(width: number): number {
200
+ return Math.max(1, width - 4);
201
+ }
202
+
203
+ function formatUsageOverview(report: ContextReport, theme: Theme, width: number): string[] {
204
+ const contextWindow = knownTokenValue(report.usage.contextWindow) ?? 0;
205
+ const currentTotal = knownTokenValue(report.usage.tokens);
206
+ const usedCategories = buildGraphCategories(report, currentTotal);
207
+ const knownTokens = sum(usedCategories.map((category) => category.tokens));
208
+ const configuredReserve = report.compaction.enabled
209
+ ? knownTokenValue(report.compaction.reserveTokens) ?? 0
210
+ : 0;
211
+ const occupiedTokens = currentTotal ?? knownTokens;
212
+ const unoccupiedTokens = contextWindow > 0 ? Math.max(0, contextWindow - occupiedTokens) : 0;
213
+ const compactionTokens = Math.min(configuredReserve, unoccupiedTokens);
214
+ const freeTokens = currentTotal !== null ? Math.max(0, unoccupiedTokens - compactionTokens) : 0;
215
+ const unknownTokens = currentTotal === null ? Math.max(0, unoccupiedTokens - compactionTokens) : 0;
216
+ const graphCategories = [
217
+ ...usedCategories,
218
+ ...(freeTokens > 0 ? [{ label: "Free space", tokens: freeTokens, ...GRAPH_STYLE.free }] : []),
219
+ ...(unknownTokens > 0 ? [{ label: "Unknown capacity", tokens: unknownTokens, ...GRAPH_STYLE.unknown }] : []),
220
+ ...(compactionTokens > 0 ? [{ label: "Compaction reserve", tokens: compactionTokens, ...GRAPH_STYLE.compaction }] : []),
221
+ ];
222
+ const totalGraphTokens = contextWindow > 0
223
+ ? contextWindow
224
+ : Math.max(GRAPH_CELL_TOKENS, sum(graphCategories.map((category) => category.tokens)));
225
+ const visibleGraphCategories = graphCategories.length > 0
226
+ ? graphCategories
227
+ : [{ label: "Free space", tokens: totalGraphTokens, ...GRAPH_STYLE.free }];
228
+ const graphLines = formatGraphGrid(visibleGraphCategories, totalGraphTokens, graphColumnCount(width), theme);
229
+ const summaryLines = formatUsageSummary(
230
+ report,
231
+ usedCategories,
232
+ freeTokens,
233
+ unknownTokens,
234
+ configuredReserve,
235
+ compactionTokens,
236
+ theme,
237
+ );
238
+
239
+ if (!shouldRenderSideBySide(width, graphLines)) {
240
+ return [
241
+ ...graphLines,
242
+ "",
243
+ ...summaryLines,
244
+ ];
245
+ }
246
+
247
+ const graphWidth = visibleWidth(graphLines[0] ?? "");
248
+ const lines: string[] = [];
249
+ const rows = Math.max(graphLines.length, summaryLines.length);
250
+ for (let index = 0; index < rows; index += 1) {
251
+ lines.push(`${padAnsi(graphLines[index] ?? "", graphWidth)}${" ".repeat(GRAPH_GAP)}${summaryLines[index] ?? ""}`.trimEnd());
252
+ }
253
+ return lines;
254
+ }
255
+
256
+ function formatUsageSummary(
257
+ report: ContextReport,
258
+ categories: GraphCategory[],
259
+ freeTokens: number,
260
+ unknownTokens: number,
261
+ configuredReserve: number,
262
+ compactionTokens: number,
263
+ theme: Theme,
264
+ ): string[] {
265
+ const contextWindow = knownTokenValue(report.usage.contextWindow) ?? 0;
266
+ const currentTotal = knownTokenValue(report.usage.tokens);
267
+ const modelLabel = report.model.name || report.model.id || "unknown model";
268
+ const modelParts = [`${report.model.provider}/${report.model.id}`];
269
+ if (report.model.thinking) modelParts.push(`thinking ${report.model.thinking}`);
270
+
271
+ const lines = [
272
+ `${theme.fg("text", theme.bold(modelLabel))}${contextWindow > 0 ? theme.fg("muted", ` (${formatTokens(contextWindow)} context)`) : ""}`,
273
+ theme.fg("muted", modelParts.join(" · ")),
274
+ `${theme.fg("accent", formatTokens(currentTotal))}${contextWindow > 0 ? `/${formatTokens(contextWindow)}` : ""} tokens (${formatPercent(report.usage.percent)})`,
275
+ theme.fg("muted", `1 char = ${formatTokens(GRAPH_CELL_TOKENS)} tokens`),
276
+ "",
277
+ heading(theme, "Estimated breakdown", currentTotal),
278
+ ];
279
+
280
+ for (const category of categories) {
281
+ lines.push(`${theme.fg(category.color, category.glyph)} ${category.label}: ${formatTokens(category.tokens)}${formatCategoryPercent(category.tokens, contextWindow)}`);
282
+ }
283
+ if (freeTokens > 0) {
284
+ lines.push(`${theme.fg(GRAPH_STYLE.free.color, GRAPH_STYLE.free.glyph)} Free space: ${formatTokens(freeTokens)}${formatCategoryPercent(freeTokens, contextWindow)}`);
285
+ }
286
+ if (unknownTokens > 0) {
287
+ lines.push(`${theme.fg(GRAPH_STYLE.unknown.color, GRAPH_STYLE.unknown.glyph)} Unknown capacity: ${formatTokens(unknownTokens)}${formatCategoryPercent(unknownTokens, contextWindow)}`);
288
+ }
289
+ if (configuredReserve > 0) {
290
+ const remaining = compactionTokens < configuredReserve
291
+ ? theme.fg("dim", ` · ${formatTokens(compactionTokens)} unoccupied`)
292
+ : "";
293
+ lines.push(`${theme.fg(GRAPH_STYLE.compaction.color, GRAPH_STYLE.compaction.glyph)} Compaction reserve: ${formatTokens(configuredReserve)}${formatCategoryPercent(configuredReserve, contextWindow)}${remaining}`);
294
+ }
295
+
296
+ return lines;
297
+ }
298
+
299
+ function buildGraphCategories(report: ContextReport, currentTotal: number | null): GraphCategory[] {
300
+ const activeTools = report.tools.filter((tool) => tool.active);
301
+ const toolTokens = sum(activeTools.map((tool) => tool.tokens));
302
+ const toolPromptTokens = sum(activeTools.map((tool) => tool.promptTokens));
303
+ const skillTokens = sum(report.skills.map((skill) => skill.descTokens));
304
+ const memoryTokens = report.kind === "conversation" ? sum(report.memory.map((file) => file.tokens)) : 0;
305
+ const conversationTokens = report.kind === "conversation" ? report.conversation.tokens : 0;
306
+ const systemPromptTokens = Math.max(
307
+ 0,
308
+ report.promptTokens - toolPromptTokens - skillTokens - memoryTokens,
309
+ );
310
+ const categories = [
311
+ { label: "System prompt", tokens: systemPromptTokens, ...GRAPH_STYLE.prompt },
312
+ { label: "Tools", tokens: toolTokens, ...GRAPH_STYLE.tools },
313
+ { label: "Memory files", tokens: memoryTokens, ...GRAPH_STYLE.memory },
314
+ { label: "Skills", tokens: skillTokens, ...GRAPH_STYLE.skills },
315
+ { label: "Conversation", tokens: conversationTokens, ...GRAPH_STYLE.conversation },
316
+ ].filter((category) => category.tokens > 0);
317
+ const total = sum(categories.map((category) => category.tokens));
318
+
319
+ if (total === 0 || currentTotal === null) return categories;
320
+ if (total > currentTotal) return scaleCategories(categories, currentTotal);
321
+ if (total < currentTotal) {
322
+ return [...categories, { label: "Other", tokens: currentTotal - total, ...GRAPH_STYLE.other }];
323
+ }
324
+ return categories;
325
+ }
326
+
327
+ function scaleCategories(categories: GraphCategory[], targetTokens: number): GraphCategory[] {
328
+ const total = sum(categories.map((category) => category.tokens));
329
+ if (total <= 0 || targetTokens <= 0) return [];
330
+
331
+ const ratio = targetTokens / total;
332
+ const scaled = categories.map((category) => ({
333
+ ...category,
334
+ tokens: Math.max(0, Math.round(category.tokens * ratio)),
335
+ }));
336
+ let delta = targetTokens - sum(scaled.map((category) => category.tokens));
337
+ const candidates = [...scaled].sort((a, b) => b.tokens - a.tokens);
338
+
339
+ if (delta > 0 && candidates[0]) {
340
+ candidates[0].tokens += delta;
341
+ delta = 0;
342
+ }
343
+ for (const category of candidates) {
344
+ if (delta >= 0) break;
345
+ const removed = Math.min(category.tokens, -delta);
346
+ category.tokens -= removed;
347
+ delta += removed;
348
+ }
349
+
350
+ return scaled.filter((category) => category.tokens > 0);
351
+ }
352
+
353
+ function formatGraphGrid(
354
+ categories: GraphCategory[],
355
+ totalTokens: number,
356
+ columns: number,
357
+ theme: Theme,
358
+ ): string[] {
359
+ const cellCount = Math.max(1, Math.ceil(totalTokens / GRAPH_CELL_TOKENS));
360
+ const cells = allocateGraphCells(categories, cellCount);
361
+ const lines: string[] = [];
362
+
363
+ for (let start = 0; start < cellCount; start += columns) {
364
+ lines.push(cells
365
+ .slice(start, start + columns)
366
+ .map((category) => theme.fg(category.color, category.glyph))
367
+ .join(GRAPH_CELL_GAP));
368
+ }
369
+
370
+ return lines;
371
+ }
372
+
373
+ function allocateGraphCells(categories: GraphCategory[], cellCount: number): GraphCategory[] {
374
+ const ranges: Array<GraphCategory & { start: number; end: number }> = [];
375
+ let cursor = 0;
376
+ for (const category of categories) {
377
+ const tokens = Math.max(0, category.tokens);
378
+ if (tokens <= 0) continue;
379
+ ranges.push({ ...category, tokens, start: cursor, end: cursor + tokens });
380
+ cursor += tokens;
381
+ }
382
+
383
+ const fallback = ranges[ranges.length - 1] ?? { label: "Free space", tokens: 0, start: 0, end: 0, ...GRAPH_STYLE.free };
384
+ const cells: GraphCategory[] = [];
385
+ for (let index = 0; index < cellCount; index += 1) {
386
+ const start = index * GRAPH_CELL_TOKENS;
387
+ const end = start + GRAPH_CELL_TOKENS;
388
+ let best = fallback;
389
+ let bestOverlap = 0;
390
+
391
+ for (const range of ranges) {
392
+ const overlap = Math.max(0, Math.min(end, range.end) - Math.max(start, range.start));
393
+ if (overlap > bestOverlap) {
394
+ best = range;
395
+ bestOverlap = overlap;
396
+ }
397
+ }
398
+
399
+ cells.push(best);
400
+ }
401
+ return cells;
402
+ }
403
+
404
+ function graphColumnCount(width: number): number {
405
+ const target = width >= 112 ? 36 : width >= 88 ? 32 : width >= 68 ? 24 : 20;
406
+ const sideBySideWidth = width - GRAPH_GAP - SUMMARY_MIN_WIDTH;
407
+ const maxWidth = sideBySideWidth >= 10 ? sideBySideWidth : width;
408
+ return Math.max(1, Math.min(target, maxGraphColumnsForWidth(maxWidth)));
409
+ }
410
+
411
+ function maxGraphColumnsForWidth(width: number): number {
412
+ const gapWidth = visibleWidth(GRAPH_CELL_GAP);
413
+ return Math.max(1, Math.floor((Math.max(1, width) + gapWidth) / (1 + gapWidth)));
414
+ }
415
+
416
+ function shouldRenderSideBySide(width: number, graphLines: readonly string[]): boolean {
417
+ const graphWidth = visibleWidth(graphLines[0] ?? "");
418
+ return width - graphWidth - GRAPH_GAP >= SUMMARY_MIN_WIDTH;
419
+ }
420
+
421
+ function padAnsi(text: string, width: number): string {
422
+ const clipped = truncateToWidth(text, Math.max(0, width), "");
423
+ return `${clipped}${" ".repeat(Math.max(0, width - visibleWidth(clipped)))}`;
424
+ }
425
+
426
+ function knownTokenValue(value: number | null | undefined): number | null {
427
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : null;
428
+ }
429
+
430
+ function formatConversationSection(report: Extract<ContextReport, { kind: "conversation" }>, theme: Theme): string[] {
431
+ const stats = report.conversation.stats;
432
+ return [
433
+ heading(theme, "Conversation (estimated)", report.conversation.tokens),
434
+ `${branch(theme, "├")}messages: user ${stats.userMessages} · assistant ${stats.assistantMessages} · tool results ${stats.toolResults}`,
435
+ `${branch(theme, "├")}blocks: tool calls ${stats.toolCalls} · thinking ${stats.thinkingBlocks} · images ${stats.imageBlocks}`,
436
+ `${branch(theme, "├")}compactions: ${stats.compactions}`,
437
+ `${branch(theme, "└")}message tokens: ${theme.fg("accent", formatTokens(report.conversation.tokens))}`,
438
+ ];
439
+ }
440
+
441
+ function formatToolsSection(report: ContextReport, theme: Theme): string[] {
442
+ if (report.tools.length === 0) return [];
443
+
444
+ const callCounts = report.kind === "conversation"
445
+ ? collectToolCallCounts(report.conversation.history)
446
+ : new Map<string, number>();
447
+ const groups: Array<{ title: string; kind: ToolSource["kind"] }> = [
448
+ { title: "Built-in tools", kind: "builtin" },
449
+ { title: "MCP tools", kind: "mcp" },
450
+ { title: "Extension tools", kind: "extension" },
451
+ ];
452
+ const activeTokens = sum(report.tools.filter((tool) => tool.active).map((tool) => tool.tokens));
453
+ const lines = [heading(theme, "Tools (estimated)", activeTokens)];
454
+
455
+ for (const group of groups) {
456
+ const tools = report.tools.filter((tool) => tool.source.kind === group.kind);
457
+ if (tools.length === 0) continue;
458
+
459
+ const groupTokens = sum(tools.filter((tool) => tool.active).map((tool) => tool.tokens));
460
+ lines.push(`${theme.fg("muted", " ")}${theme.fg("text", theme.bold(group.title))}${theme.fg("dim", ` · ${formatTokens(groupTokens)} tokens`)}`);
461
+ tools.forEach((tool, index) => {
462
+ const tree = index === tools.length - 1 ? "└" : "├";
463
+ const meta = `${tool.active ? "active" : "inactive"} · ${formatToolSource(tool.source)} · ${formatCallCount(callCounts.get(tool.name) ?? 0)}`;
464
+ lines.push(`${theme.fg("muted", ` ${tree}─ `)}${theme.fg("text", tool.name)}: ${theme.fg("accent", formatTokens(tool.tokens))} tokens${theme.fg("dim", ` · ${meta}`)}`);
465
+ });
466
+ }
467
+
468
+ return lines;
469
+ }
470
+
471
+ function formatDetailSection(
472
+ title: string,
473
+ items: DetailItem[],
474
+ theme: Theme,
475
+ ): string[] {
476
+ if (items.length === 0) return [];
477
+
478
+ const lines = [heading(theme, title, sum(items.map((item) => item.tokens)))];
479
+ items.forEach((item, index) => {
480
+ const meta = item.meta ? theme.fg("dim", ` · ${item.meta}`) : "";
481
+ lines.push(`${branch(theme, index === items.length - 1 ? "└" : "├")}${theme.fg("text", item.label)}: ${theme.fg("accent", formatTokens(item.tokens))} tokens${meta}`);
482
+ });
483
+
484
+ return lines;
485
+ }
486
+
487
+ function formatToolSource(source: ToolSource): string {
488
+ if (source.kind === "builtin") return "builtin";
489
+ return `${source.kind}:${source.name}`;
490
+ }
491
+
492
+ function formatCallCount(count: number): string {
493
+ return count === 1 ? "1 call" : `${count.toLocaleString()} calls`;
494
+ }
495
+
496
+ function collectToolCallCounts(history: Extract<ContextReport, { kind: "conversation" }>["conversation"]["history"]): Map<string, number> {
497
+ const counts = new Map<string, number>();
498
+ for (const turn of history) {
499
+ if (turn.kind === "tool-call") {
500
+ counts.set(turn.tool, (counts.get(turn.tool) ?? 0) + 1);
501
+ }
502
+ }
503
+ return counts;
504
+ }
505
+
506
+ function pushSection(lines: string[], section: string[]): void {
507
+ if (section.length === 0) return;
508
+ if (lines.length > 0) lines.push("");
509
+ lines.push(...section);
510
+ }
511
+
512
+ function heading(theme: Theme, text: string, tokens?: number | null): string {
513
+ const total = tokens === undefined ? "" : theme.fg("dim", ` · ${formatTokens(tokens)} tokens`);
514
+ return theme.fg("customMessageLabel", theme.bold(text)) + total;
515
+ }
516
+
517
+ function branch(theme: Theme, branch: TreeBranch): string {
518
+ return theme.fg("muted", ` ${branch}─ `);
519
+ }
520
+
521
+ function formatTokens(value: number | null | undefined): string {
522
+ if (value === null || value === undefined) return "unknown";
523
+
524
+ const tokens = Math.max(0, Math.round(value));
525
+ const trim = (input: number) => input.toFixed(input >= 10 ? 0 : 1).replace(/\.0$/, "");
526
+ if (tokens >= 1_000_000) return `${trim(tokens / 1_000_000)}M`;
527
+ if (tokens >= 1_000) return `${trim(tokens / 1_000)}K`;
528
+ return tokens.toLocaleString();
529
+ }
530
+
531
+ function formatPercent(value: number | null | undefined): string {
532
+ return value === null || value === undefined ? "unknown" : `${value.toFixed(1)}%`;
533
+ }
534
+
535
+ function formatCategoryPercent(tokens: number | null, contextWindow: number): string {
536
+ return tokens === null || contextWindow <= 0 ? "" : ` — ${((tokens / contextWindow) * 100).toFixed(1)}%`;
537
+ }
538
+
539
+ function sum(values: readonly number[]): number {
540
+ return values.reduce((total, value) => total + value, 0);
541
+ }
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { buildContextReport } from "./builders.js";
3
+ import { createContextReportComponent } from "./component.js";
4
+ import type { ContextReport } from "./types.js";
5
+
6
+ async function showContextReport(ctx: ExtensionCommandContext, report: ContextReport): Promise<void> {
7
+ if (ctx.mode !== "tui") {
8
+ ctx.ui.notify("/context requires interactive mode", "warning");
9
+ return;
10
+ }
11
+
12
+ await ctx.ui.custom<void>((tui, theme, _kb, done) =>
13
+ createContextReportComponent(report, {
14
+ theme,
15
+ tui,
16
+ onClose: () => done(undefined),
17
+ }),
18
+ );
19
+ }
20
+
21
+ export default function contextExtension(pi: ExtensionAPI): void {
22
+ pi.registerCommand("context", {
23
+ description: "Show context window usage and conversation stats",
24
+ handler: async (_args, ctx) => {
25
+ const report = buildContextReport(pi, ctx);
26
+ if (!report) {
27
+ ctx.ui.notify("Context usage is unavailable for the current model/session", "warning");
28
+ return;
29
+ }
30
+
31
+ await showContextReport(ctx, report);
32
+ },
33
+ });
34
+ }
package/src/types.ts ADDED
@@ -0,0 +1,105 @@
1
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
2
+ import type { ContextUsage } from "@earendil-works/pi-coding-agent";
3
+
4
+ export type ContextReport = ConversationContextReport | StaticContextReport;
5
+
6
+ export interface ConversationContextReport {
7
+ kind: "conversation";
8
+ model: ModelDetails;
9
+ usage: ContextUsage;
10
+ compaction: CompactionDetails;
11
+ promptTokens: number;
12
+ tools: ToolDetails[];
13
+ skills: SkillDetails[];
14
+ memory: MemoryDetails[];
15
+ snapshot: SnapshotDetails;
16
+ conversation: ConversationDetails;
17
+ }
18
+
19
+ export interface StaticContextReport {
20
+ kind: "static";
21
+ model: ModelDetails;
22
+ usage: ContextUsage;
23
+ compaction: CompactionDetails;
24
+ promptTokens: number;
25
+ tools: ToolDetails[];
26
+ skills: SkillDetails[];
27
+ }
28
+
29
+ export interface CompactionDetails {
30
+ enabled: boolean;
31
+ reserveTokens: number;
32
+ }
33
+
34
+ export interface ModelDetails {
35
+ provider: string;
36
+ id: string;
37
+ name: string;
38
+ thinking?: ModelThinkingLevel;
39
+ contextWindow?: number;
40
+ }
41
+
42
+ export interface ToolDetails {
43
+ name: string;
44
+ tokens: number;
45
+ definitionTokens: number;
46
+ promptTokens: number;
47
+ source: ToolSource;
48
+ active: boolean;
49
+ }
50
+
51
+ export type ToolSource =
52
+ | { kind: "builtin" }
53
+ | { kind: "extension"; name: string }
54
+ | { kind: "mcp"; name: string };
55
+
56
+ export interface SkillDetails {
57
+ name: string;
58
+ descTokens: number;
59
+ bodyTokens: number;
60
+ scope: "user" | "project";
61
+ }
62
+
63
+ export interface MemoryDetails {
64
+ path: string;
65
+ tokens: number;
66
+ }
67
+
68
+ export interface SnapshotDetails {
69
+ capturedAt: number;
70
+ }
71
+
72
+ export interface ConversationDetails {
73
+ stats: ConversationStats;
74
+ history: ConversationTurn[];
75
+ tokens: number;
76
+ }
77
+
78
+ export interface ConversationStats {
79
+ userMessages: number;
80
+ assistantMessages: number;
81
+ toolResults: number;
82
+ toolCalls: number;
83
+ thinkingBlocks: number;
84
+ imageBlocks: number;
85
+ compactions: number;
86
+ }
87
+
88
+ export type ConversationTurn =
89
+ | {
90
+ kind: "user" | "assistant" | "thinking" | "bash" | "custom" | "compact" | "branch";
91
+ tokens: number;
92
+ }
93
+ | {
94
+ kind: "tool-call";
95
+ tool: string;
96
+ tokens: number;
97
+ callId?: string;
98
+ }
99
+ | {
100
+ kind: "tool-result";
101
+ tool: string;
102
+ tokens: number;
103
+ callId?: string;
104
+ isError?: boolean;
105
+ };