shariq-pi-extensions 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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +94 -0
  3. package/THIRD_PARTY_NOTICES.md +11 -0
  4. package/docs/ARCHITECTURE.md +54 -0
  5. package/docs/DEVELOPMENT.md +88 -0
  6. package/docs/EXTENSIONS.md +89 -0
  7. package/extensions/antigravity-provider/LICENSE +21 -0
  8. package/extensions/antigravity-provider/README.md +23 -0
  9. package/extensions/antigravity-provider/antigravity/cloud-code-assist.ts +519 -0
  10. package/extensions/antigravity-provider/antigravity/index.ts +67 -0
  11. package/extensions/antigravity-provider/antigravity/models.ts +184 -0
  12. package/extensions/antigravity-provider/antigravity/oauth.ts +499 -0
  13. package/extensions/antigravity-provider/index.ts +3 -0
  14. package/extensions/ask-user/README.md +21 -0
  15. package/extensions/ask-user/index.ts +417 -0
  16. package/extensions/background-terminals/README.md +43 -0
  17. package/extensions/background-terminals/index.ts +400 -0
  18. package/extensions/background-terminals/src/manager.ts +480 -0
  19. package/extensions/background-terminals/src/output-buffer.ts +110 -0
  20. package/extensions/background-terminals/src/presentation.ts +74 -0
  21. package/extensions/background-terminals/src/types.ts +47 -0
  22. package/extensions/background-terminals/src/ui.ts +478 -0
  23. package/extensions/context-usage/LICENSE +21 -0
  24. package/extensions/context-usage/README.md +20 -0
  25. package/extensions/context-usage/index.ts +2006 -0
  26. package/extensions/context-usage/lib/ansi.ts +60 -0
  27. package/extensions/context-usage/lib/boundary.ts +27 -0
  28. package/extensions/context-usage/lib/chat.ts +190 -0
  29. package/extensions/context-usage/lib/config.ts +30 -0
  30. package/extensions/context-usage/lib/fmt.ts +59 -0
  31. package/extensions/context-usage/lib/style.ts +260 -0
  32. package/extensions/copy-all/README.md +15 -0
  33. package/extensions/copy-all/index.ts +72 -0
  34. package/extensions/factory-provider/API_KEYS.md +17 -0
  35. package/extensions/factory-provider/AUTH.md +15 -0
  36. package/extensions/factory-provider/README.md +90 -0
  37. package/extensions/factory-provider/REFRESH_MODELS.md +185 -0
  38. package/extensions/factory-provider/UPDATING.md +97 -0
  39. package/extensions/factory-provider/factory/api-keys.ts +407 -0
  40. package/extensions/factory-provider/factory/auth.ts +132 -0
  41. package/extensions/factory-provider/factory/constants.ts +15 -0
  42. package/extensions/factory-provider/factory/dashboard.ts +363 -0
  43. package/extensions/factory-provider/factory/droid.ts +84 -0
  44. package/extensions/factory-provider/factory/limits.ts +342 -0
  45. package/extensions/factory-provider/factory/models.ts +283 -0
  46. package/extensions/factory-provider/factory/responses.ts +331 -0
  47. package/extensions/factory-provider/factory/warm-theme.ts +53 -0
  48. package/extensions/factory-provider/factory/websocket.ts +130 -0
  49. package/extensions/factory-provider/index.ts +204 -0
  50. package/extensions/factory-provider/scripts/audit-reasoning-efforts.py +84 -0
  51. package/extensions/firecrawl-web/README.md +31 -0
  52. package/extensions/firecrawl-web/auth.ts +92 -0
  53. package/extensions/firecrawl-web/client.ts +238 -0
  54. package/extensions/firecrawl-web/index.ts +96 -0
  55. package/extensions/firecrawl-web/output.ts +125 -0
  56. package/extensions/git-info/README.md +25 -0
  57. package/extensions/git-info/index.ts +162 -0
  58. package/extensions/git-info/src/git.ts +206 -0
  59. package/extensions/git-info/src/ui.ts +291 -0
  60. package/extensions/goal/README.md +44 -0
  61. package/extensions/goal/constants.ts +4 -0
  62. package/extensions/goal/extension.ts +1064 -0
  63. package/extensions/goal/index.ts +2 -0
  64. package/extensions/goal/types.ts +56 -0
  65. package/extensions/goal/ui.ts +334 -0
  66. package/extensions/orchestration/README.md +58 -0
  67. package/extensions/orchestration/dashboard.ts +192 -0
  68. package/extensions/orchestration/engine.ts +706 -0
  69. package/extensions/orchestration/index.ts +243 -0
  70. package/extensions/orchestration/prompts.ts +124 -0
  71. package/extensions/orchestration/protocol.ts +116 -0
  72. package/extensions/orchestration/settings-ui.ts +41 -0
  73. package/extensions/orchestration/settings.ts +59 -0
  74. package/extensions/orchestration/storage.ts +49 -0
  75. package/extensions/orchestration/types.ts +105 -0
  76. package/extensions/performance-status/README.md +5 -0
  77. package/extensions/performance-status/index.ts +171 -0
  78. package/extensions/pi-memory/README.md +53 -0
  79. package/extensions/pi-memory/index.ts +326 -0
  80. package/extensions/pi-memory/src/config.ts +75 -0
  81. package/extensions/pi-memory/src/database.ts +617 -0
  82. package/extensions/pi-memory/src/extraction.ts +178 -0
  83. package/extensions/pi-memory/src/project.ts +80 -0
  84. package/extensions/pi-memory/src/projection.ts +59 -0
  85. package/extensions/pi-memory/src/redaction.ts +60 -0
  86. package/extensions/pi-memory/src/retrieval.ts +30 -0
  87. package/extensions/pi-memory/src/service.ts +194 -0
  88. package/extensions/pi-memory/src/session.ts +146 -0
  89. package/extensions/pi-memory/src/types.ts +82 -0
  90. package/extensions/shared/README.md +18 -0
  91. package/extensions/shared/activity-status.ts +31 -0
  92. package/extensions/shared/child-session.ts +150 -0
  93. package/extensions/shared/context-utilization.ts +47 -0
  94. package/extensions/shared/dashboard-state.ts +99 -0
  95. package/extensions/shared/tool-call-timeout.ts +104 -0
  96. package/extensions/shared/tui-dashboard.ts +139 -0
  97. package/extensions/shell-shortcuts/README.md +17 -0
  98. package/extensions/shell-shortcuts/features/exit-alias.ts +10 -0
  99. package/extensions/shell-shortcuts/index.ts +6 -0
  100. package/extensions/subagents/README.md +89 -0
  101. package/extensions/subagents/index.ts +1386 -0
  102. package/extensions/subagents/src/backend.ts +69 -0
  103. package/extensions/subagents/src/backends/pi.ts +781 -0
  104. package/extensions/subagents/src/catalog.ts +85 -0
  105. package/extensions/subagents/src/config.ts +241 -0
  106. package/extensions/subagents/src/context.ts +57 -0
  107. package/extensions/subagents/src/coordinator.ts +64 -0
  108. package/extensions/subagents/src/domain.ts +294 -0
  109. package/extensions/subagents/src/format.ts +74 -0
  110. package/extensions/subagents/src/manager.ts +813 -0
  111. package/extensions/subagents/src/prompt.ts +94 -0
  112. package/extensions/subagents/src/result-delivery.ts +20 -0
  113. package/extensions/subagents/src/runtime.ts +48 -0
  114. package/extensions/subagents/src/ui/takeover.ts +695 -0
  115. package/extensions/subagents/src/ui/transcript.ts +182 -0
  116. package/extensions/subagents/src/worktree.ts +175 -0
  117. package/extensions/web-fetch/README.md +13 -0
  118. package/extensions/web-fetch/index.ts +441 -0
  119. package/package.json +117 -0
  120. package/skills/background-terminals/SKILL.md +42 -0
  121. package/skills/orchestration/SKILL.md +33 -0
  122. package/skills/subagents/SKILL.md +89 -0
  123. package/themes/ember-warm-dark.json +85 -0
@@ -0,0 +1,2006 @@
1
+ import type { Component } from "@earendil-works/pi-tui";
2
+ import type { ContextUsage, ExtensionAPI, ExtensionContext, SessionEntry, Theme, ToolInfo } from "@earendil-works/pi-coding-agent";
3
+ import { buildSessionContext, convertToLlm, getAgentDir, keyText } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
5
+ import { homedir } from "node:os";
6
+ import { delimiter, join } from "node:path";
7
+ import { stripAnsi } from "./lib/ansi.ts";
8
+ import { isJsonObject, positiveNumberValue, stringValue, type JsonValue } from "./lib/boundary.ts";
9
+ import { findContainerBy, isResourceRow, RESOURCE_HEADER_RE, type ContainerLike } from "./lib/chat.ts";
10
+ import { configPaths, expandHomePath, readJsonConfig } from "./lib/config.ts";
11
+ import { compactCount } from "./lib/fmt.ts";
12
+ import { ELLIPSIS, GLYPH, SEP, ink, panelHeader } from "./lib/style.ts";
13
+
14
+ type ViewMode = "summary" | "compact" | "expanded";
15
+
16
+ type SkillSummary = {
17
+ name: string;
18
+ description: string;
19
+ location: string;
20
+ chars: number;
21
+ tokens: number;
22
+ };
23
+
24
+ type ToolSummary = {
25
+ name: string;
26
+ description: string;
27
+ source: string;
28
+ schema: unknown;
29
+ promptGuidelines: string[];
30
+ };
31
+
32
+ type ScanRow = {
33
+ name: string;
34
+ tokens?: number;
35
+ desc?: string;
36
+ inactive?: boolean;
37
+ };
38
+
39
+ type ToolField = {
40
+ name: string;
41
+ type: string;
42
+ required: boolean;
43
+ description: string;
44
+ depth: number;
45
+ };
46
+
47
+ type ToolExpanded = {
48
+ name: string;
49
+ tokens: number;
50
+ source: string;
51
+ description: string;
52
+ fields: ToolField[];
53
+ };
54
+
55
+ type ExpandedContent =
56
+ | { kind: "text"; note?: string; attribution?: string; preview?: string[] }
57
+ | { kind: "skills"; note?: string; rows: ScanRow[] }
58
+ | { kind: "tools"; notes: string[]; tools: ToolExpanded[] };
59
+
60
+ type PrefixSection = {
61
+ id: string;
62
+ title: string;
63
+ content: string;
64
+ /** Dim suffix after the char count, e.g. "÷ 2.6" or "÷ 2.6 · Anthropic tool payload". */
65
+ detail: string;
66
+ /** Tools only: formula-derived tokens replacing the ch ÷ denominator estimate. */
67
+ effectiveTokens?: number;
68
+ /** Tools only: minified-payload size, when content.length is not the counted chars. */
69
+ rawChars?: number;
70
+ denominator: number;
71
+ compactRows?: ScanRow[];
72
+ expanded: ExpandedContent;
73
+ };
74
+
75
+ type ModelSummary = {
76
+ provider: string;
77
+ id: string;
78
+ api: string;
79
+ };
80
+
81
+ type HeuristicProfile = Partial<Pick<ResolvedHeuristic, "label" | "textDenominator" | "sessionDenominator" | "toolDenominator" | "toolNumerator">>;
82
+
83
+ type HeuristicRule = HeuristicProfile & {
84
+ profile?: string;
85
+ match?: {
86
+ provider?: string;
87
+ model?: string;
88
+ id?: string;
89
+ api?: string;
90
+ };
91
+ };
92
+
93
+ type ContextUsageConfig = {
94
+ profiles?: Record<string, HeuristicProfile>;
95
+ defaults?: Partial<Pick<ResolvedHeuristic, "textDenominator" | "sessionDenominator" | "toolDenominator" | "toolNumerator">> & { profile?: string };
96
+ rules?: HeuristicRule[];
97
+ };
98
+
99
+ type ResolvedHeuristic = {
100
+ label: string;
101
+ source: string;
102
+ textDenominator: number;
103
+ sessionDenominator: number;
104
+ toolDenominator: number;
105
+ toolNumerator: string;
106
+ };
107
+
108
+ type BuiltInHeuristicRule = {
109
+ label: string;
110
+ providerIncludes: string[];
111
+ apiEquals: string[];
112
+ modelRegex?: RegExp;
113
+ textDenominator: number;
114
+ sessionDenominator: number;
115
+ toolDenominator: number;
116
+ toolNumerator: string;
117
+ };
118
+
119
+ type ToolNumeratorResult = {
120
+ label: string;
121
+ content: string;
122
+ chars: number;
123
+ /** Present only for the openai-cookbook formula; ratio shapes divide chars instead. */
124
+ tokens?: number;
125
+ };
126
+
127
+ type ToolDisplayEstimate = {
128
+ tokens: number;
129
+ chars: number;
130
+ };
131
+
132
+ type SessionBreakdown = {
133
+ thinkingChars: number;
134
+ toolOutputChars: number;
135
+ messageChars: number;
136
+ messageCount: number;
137
+ };
138
+
139
+ /** The slice of pi's ReadonlySessionManager the session walk needs. */
140
+ type SessionSource = {
141
+ getEntries(): SessionEntry[];
142
+ getLeafId(): string | null;
143
+ };
144
+
145
+ type PrefixSnapshot = {
146
+ signature: string;
147
+ sections: PrefixSection[];
148
+ tools: ToolSummary[];
149
+ heuristic: ResolvedHeuristic;
150
+ model?: ModelSummary;
151
+ session?: SessionBreakdown;
152
+ contextUsage?: ContextUsage;
153
+ };
154
+
155
+ type ContextUsageTui = {
156
+ children?: unknown[];
157
+ requestRender?: (force?: boolean) => void;
158
+ };
159
+
160
+ type ContextUsageGlobal = typeof globalThis & {
161
+ __piContextUsageTui?: ContextUsageTui;
162
+ __piContextUsageChat?: ContainerLike;
163
+ __piContextUsageBlock?: StartupContextComponent;
164
+ __piContextUsageMode?: ViewMode;
165
+ __piContextUsageInstallTimer?: ReturnType<typeof setTimeout>;
166
+ __piContextUsageModel?: ModelSummary;
167
+ };
168
+
169
+ const g = globalThis as ContextUsageGlobal;
170
+
171
+ const PROJECT_CONTEXT_RE = /\n?<project_context>\n\n[\s\S]*?\n<\/project_context>\n?/;
172
+ const PROJECT_INSTRUCTIONS_RE = /<project_instructions path="([^"]*)">\n([\s\S]*?)\n<\/project_instructions>/g;
173
+ const AVAILABLE_SKILLS_RE = /\n\nThe following skills provide specialized instructions for specific tasks\.[\s\S]*?<available_skills>[\s\S]*?<\/available_skills>/;
174
+ const SKILL_RE = /<skill>\s*<name>([\s\S]*?)<\/name>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<location>([\s\S]*?)<\/location>\s*<\/skill>/g;
175
+ const DEFAULT_MODE: ViewMode = "summary";
176
+ const OPENAI_TOOL_TEXT_FRAGMENT_DENOMINATOR = 6.6;
177
+
178
+ // The family accent (design language §3): theme-derived, used sparingly — the panel
179
+ // brand, token figures, total rows, and the carried part of the context bar.
180
+ function accent(theme: Theme | undefined, text: string): string {
181
+ return ink(theme, "accent", text);
182
+ }
183
+
184
+ type TokenLabelLayout = { unitWidth: number; fieldWidth: number };
185
+
186
+ function tokenIntegerWidth(tokens: number): number {
187
+ return compactCount(tokens).split(".", 1)[0].length;
188
+ }
189
+
190
+ function estimatedTokenLabel(tokens: number, layout: TokenLabelLayout = tokenLabelLayout([tokens])): string {
191
+ const leftPad = " ".repeat(Math.max(0, layout.unitWidth - tokenIntegerWidth(tokens)));
192
+ return `${leftPad}~${compactCount(tokens)}`;
193
+ }
194
+
195
+ function estimatedTokenField(tokens: number, layout: TokenLabelLayout): string {
196
+ return estimatedTokenLabel(tokens, layout).padEnd(layout.fieldWidth, " ");
197
+ }
198
+
199
+ function exactTokenLabel(tokens: number, layout: TokenLabelLayout = tokenLabelLayout([tokens])): string {
200
+ const leftPad = " ".repeat(Math.max(0, layout.unitWidth - tokenIntegerWidth(tokens)) + 1);
201
+ return `${leftPad}${compactCount(tokens)}`;
202
+ }
203
+
204
+ function tokenLabelLayout(tokens: number[]): TokenLabelLayout {
205
+ const unitWidth = Math.max(0, ...tokens.map(tokenIntegerWidth));
206
+ const rawLabels = tokens.map((token) => {
207
+ const leftPad = " ".repeat(Math.max(0, unitWidth - tokenIntegerWidth(token)));
208
+ return `${leftPad}~${compactCount(token)}`;
209
+ });
210
+ return { unitWidth, fieldWidth: Math.max(0, ...rawLabels.map((label) => label.length)) };
211
+ }
212
+
213
+ function formatPercent(value: number | null): string | undefined {
214
+ if (value === null || !Number.isFinite(value)) return undefined;
215
+ return `${value.toFixed(1)}%`;
216
+ }
217
+
218
+ function cleanDenominator(value: unknown, fallback = 4): number {
219
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
220
+ }
221
+
222
+ // Denominators are sanitized once, at heuristic resolution (applyHeuristicPatch); by
223
+ // the time one reaches a count it is a trusted positive number.
224
+ function estimateCharsAsTokens(chars: number, denominator: number): number {
225
+ return Math.ceil(chars / denominator);
226
+ }
227
+
228
+ function formatDenominator(value: number): string {
229
+ return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "");
230
+ }
231
+
232
+ // --- the family number grammar: `~0.5k tokens (1.2k ch ÷ 2.6)` -------------------------
233
+
234
+ function ratioDetail(denominator: number): string {
235
+ return `÷ ${formatDenominator(denominator)}`;
236
+ }
237
+
238
+ function countDetail(chars: number, detail?: string): string {
239
+ return `(${compactCount(chars)} ch${detail ? ` ${detail}` : ""})`;
240
+ }
241
+
242
+ function inlineCount(chars: number, denominator: number): string {
243
+ return `~${compactCount(estimateCharsAsTokens(chars, denominator))} tokens ${countDetail(chars, ratioDetail(denominator))}`;
244
+ }
245
+
246
+ function compactPath(filePath: string): string {
247
+ const home = homedir();
248
+ if (filePath === join(getAgentDir(), "AGENTS.md")) return "Global AGENTS.md";
249
+ if (filePath.startsWith(`${home}/`)) return `~/${filePath.slice(home.length + 1)}`;
250
+ return filePath;
251
+ }
252
+
253
+ function tildeAll(text: string): string {
254
+ return text.split(`${homedir()}/`).join("~/");
255
+ }
256
+
257
+ function middleTruncatePath(text: string, width: number): string {
258
+ if (text.length <= width) return text;
259
+ if (width <= 3) return "…";
260
+ const keep = width - 1;
261
+ let tail = Math.min(Math.floor(keep * 0.5), 28);
262
+ const slashIndex = text.lastIndexOf("/");
263
+ if (slashIndex >= 0 && text.length - slashIndex <= Math.floor(keep * 0.6)) {
264
+ tail = text.length - slashIndex;
265
+ }
266
+ const head = Math.max(1, keep - tail);
267
+ return `${text.slice(0, head)}…${text.slice(text.length - tail)}`;
268
+ }
269
+
270
+ function unescapeXml(value: string): string {
271
+ return value
272
+ .replace(/&apos;/g, "'")
273
+ .replace(/&quot;/g, '"')
274
+ .replace(/&gt;/g, ">")
275
+ .replace(/&lt;/g, "<")
276
+ .replace(/&amp;/g, "&");
277
+ }
278
+
279
+ function safeJson(value: unknown): string {
280
+ try {
281
+ return JSON.stringify(value, null, 2) ?? "undefined";
282
+ } catch (error) {
283
+ return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`;
284
+ }
285
+ }
286
+
287
+ function safeMinifiedJson(value: unknown): string {
288
+ try {
289
+ return JSON.stringify(value) ?? "undefined";
290
+ } catch (error) {
291
+ return `[unserializable: ${error instanceof Error ? error.message : String(error)}]`;
292
+ }
293
+ }
294
+
295
+ function normalizeBlankLines(text: string): string {
296
+ return text.replace(/\n{3,}/g, "\n\n").trim();
297
+ }
298
+
299
+ function singleLine(text: string, max = 140): string {
300
+ const normalized = text.replace(/\s+/g, " ").trim();
301
+ if (normalized.length <= max) return normalized;
302
+ return `${normalized.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
303
+ }
304
+
305
+ function firstMeaningfulLines(text: string, maxLines: number): string[] {
306
+ return text
307
+ .split("\n")
308
+ .map((line) => line.trim())
309
+ .filter(Boolean)
310
+ .slice(0, maxLines);
311
+ }
312
+
313
+ function getPromptRemainder(systemPrompt: string): string {
314
+ return normalizeBlankLines(
315
+ systemPrompt.replace(PROJECT_CONTEXT_RE, "\n").replace(AVAILABLE_SKILLS_RE, "\n"),
316
+ );
317
+ }
318
+
319
+ function parseSkills(content: string, denominator: number): SkillSummary[] {
320
+ return [...content.matchAll(SKILL_RE)].map((m) => {
321
+ const chars = (m[0] ?? "").length;
322
+ return {
323
+ name: unescapeXml((m[1] ?? "").trim()),
324
+ description: unescapeXml((m[2] ?? "").trim()),
325
+ location: unescapeXml((m[3] ?? "").trim()),
326
+ chars,
327
+ tokens: estimateCharsAsTokens(chars, denominator),
328
+ };
329
+ });
330
+ }
331
+
332
+ function parseContextSections(systemPrompt: string, denominator: number): PrefixSection[] {
333
+ const sections: PrefixSection[] = [];
334
+ for (const match of systemPrompt.matchAll(PROJECT_INSTRUCTIONS_RE)) {
335
+ const [, rawPath, content] = match;
336
+ const filePath = rawPath ?? "";
337
+ const title = compactPath(filePath);
338
+ const body = content ?? "";
339
+ const preview = firstMeaningfulLines(body, 8).map((line) => singleLine(line, 150));
340
+ sections.push({
341
+ id: `context:${filePath}`,
342
+ title,
343
+ content: body,
344
+ denominator,
345
+ detail: ratioDetail(denominator),
346
+ expanded: {
347
+ kind: "text",
348
+ note: `${tildeAll(filePath)} · preview only`,
349
+ preview: preview.length > 0 ? preview : ["(no non-empty lines)"],
350
+ },
351
+ });
352
+ }
353
+ return sections;
354
+ }
355
+
356
+ type RuntimeAdditions = { chars: number; snippetCount: number; guidelineCount: number };
357
+
358
+ // Issue #9: the runtime system prompt is assembled from pi's base prompt plus tool- and
359
+ // extension-provided instructions (the "Available tools" snippet lines and deduplicated
360
+ // promptGuidelines). Attribute the part we can verify: count only text that is actually
361
+ // present in the prompt remainder, deduplicating guidelines the same way pi does, so the
362
+ // number is evidence-based rather than a guess from tool metadata.
363
+ function detectRuntimeAdditions(promptRemainder: string, tools: ToolSummary[]): RuntimeAdditions {
364
+ let chars = 0;
365
+ let snippetCount = 0;
366
+ let guidelineCount = 0;
367
+ const seenGuidelines = new Set<string>();
368
+ for (const tool of tools) {
369
+ const escapedName = tool.name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
370
+ const snippetMatch = promptRemainder.match(new RegExp(`^- ${escapedName}: .+$`, "m"));
371
+ if (snippetMatch) {
372
+ chars += snippetMatch[0].length + 1; // +1 for the newline the line occupies
373
+ snippetCount += 1;
374
+ }
375
+ for (const guideline of tool.promptGuidelines) {
376
+ const normalized = guideline.trim();
377
+ if (normalized.length === 0 || seenGuidelines.has(normalized)) continue;
378
+ seenGuidelines.add(normalized);
379
+ if (promptRemainder.includes(normalized)) {
380
+ chars += normalized.length + 1;
381
+ guidelineCount += 1;
382
+ }
383
+ }
384
+ }
385
+ return { chars, snippetCount, guidelineCount };
386
+ }
387
+
388
+ function runtimeAdditionsAttribution(additions: RuntimeAdditions, denominator: number): string | undefined {
389
+ if (additions.chars === 0) return undefined;
390
+ const tokens = estimateCharsAsTokens(additions.chars, denominator);
391
+ const parts: string[] = [];
392
+ if (additions.snippetCount > 0) parts.push(`${additions.snippetCount} tool snippet${additions.snippetCount === 1 ? "" : "s"}`);
393
+ if (additions.guidelineCount > 0) parts.push(`${additions.guidelineCount} guideline${additions.guidelineCount === 1 ? "" : "s"}`);
394
+ return `of which tool/extension instructions: ~${compactCount(tokens)} tokens (${parts.join(", ")}) · already counted in this row`;
395
+ }
396
+
397
+ function buildSkillsSection(systemPrompt: string, denominator: number): { section?: PrefixSection; skills: SkillSummary[] } {
398
+ const match = systemPrompt.match(AVAILABLE_SKILLS_RE);
399
+ if (!match) return { skills: [] };
400
+ const content = match[0].trim();
401
+ const skills = parseSkills(content, denominator);
402
+ const sortedSkills = [...skills].sort((a, b) => b.tokens - a.tokens || a.name.localeCompare(b.name));
403
+ const scanRows = sortedSkills.map((skill) => ({ name: skill.name, tokens: skill.tokens, desc: skill.description }));
404
+ const wrapperChars = Math.max(0, content.length - skills.reduce((sum, skill) => sum + skill.chars, 0));
405
+ const wrapperNote = wrapperChars > 0
406
+ ? `list wrapper/markup ${inlineCount(wrapperChars, denominator)}`
407
+ : undefined;
408
+ return {
409
+ skills,
410
+ section: {
411
+ id: "skills",
412
+ title: `Skill frontmatter (${skills.length})`,
413
+ content,
414
+ denominator,
415
+ detail: ratioDetail(denominator),
416
+ compactRows: scanRows,
417
+ expanded: { kind: "skills", note: wrapperNote, rows: scanRows },
418
+ },
419
+ };
420
+ }
421
+
422
+ // Provenance short form (design language §8): the local defining path *is* the
423
+ // audit trail, and the origin URL / package ref / `top-level` decorations duplicate
424
+ // it, so the label is `scope · path` (falling back to the loader source when no path
425
+ // exists) and builtins collapse to one word. Pi keeps the full SourceInfo.
426
+ function sourceInfoLabel(tool: ToolInfo): string {
427
+ const sourceInfo = tool.sourceInfo;
428
+ if (sourceInfo.source === "builtin") return "builtin";
429
+ const where = sourceInfo.path ?? sourceInfo.source;
430
+ return [sourceInfo.scope, where].filter(Boolean).join(SEP) || "unknown";
431
+ }
432
+
433
+ function summarizeTool(tool: ToolInfo): ToolSummary {
434
+ return {
435
+ name: tool.name,
436
+ description: tool.description.trim() || "(no description)",
437
+ source: sourceInfoLabel(tool),
438
+ schema: tool.parameters,
439
+ promptGuidelines: tool.promptGuidelines ?? [],
440
+ };
441
+ }
442
+
443
+ // pi does not re-export pi-ai's Model type; ctx.model carries it.
444
+ type PiModel = NonNullable<ExtensionContext["model"]>;
445
+
446
+ function toModelSummary(model: PiModel | undefined): ModelSummary | undefined {
447
+ return model ? { provider: model.provider, id: model.id, api: model.api } : undefined;
448
+ }
449
+
450
+ function modelLabel(model?: ModelSummary): string {
451
+ return model ? `${model.provider}/${model.id}` : "unknown model";
452
+ }
453
+
454
+ function mergeContextUsageConfig(base: ContextUsageConfig, next?: ContextUsageConfig): ContextUsageConfig {
455
+ if (!next) return base;
456
+ return {
457
+ ...base,
458
+ ...next,
459
+ defaults: { ...(base.defaults ?? {}), ...(next.defaults ?? {}) },
460
+ profiles: { ...(base.profiles ?? {}), ...(next.profiles ?? {}) },
461
+ rules: [...(base.rules ?? []), ...(Array.isArray(next.rules) ? next.rules : [])],
462
+ };
463
+ }
464
+
465
+ function parseHeuristicProfile(value: unknown): HeuristicProfile {
466
+ if (!isJsonObject(value)) return {};
467
+ const profile: HeuristicProfile = {};
468
+ const label = stringValue(value.label);
469
+ const textDenominator = positiveNumberValue(value.textDenominator);
470
+ const sessionDenominator = positiveNumberValue(value.sessionDenominator);
471
+ const toolDenominator = positiveNumberValue(value.toolDenominator);
472
+ const toolNumerator = stringValue(value.toolNumerator);
473
+ if (label) profile.label = label;
474
+ if (textDenominator) profile.textDenominator = textDenominator;
475
+ if (sessionDenominator) profile.sessionDenominator = sessionDenominator;
476
+ if (toolDenominator) profile.toolDenominator = toolDenominator;
477
+ if (toolNumerator) profile.toolNumerator = toolNumerator;
478
+ return profile;
479
+ }
480
+
481
+ function parseHeuristicRule(value: unknown): HeuristicRule | undefined {
482
+ if (!isJsonObject(value)) return undefined;
483
+ const rule: HeuristicRule = parseHeuristicProfile(value);
484
+ const profile = stringValue(value.profile);
485
+ if (profile) rule.profile = profile;
486
+ if (isJsonObject(value.match)) {
487
+ const match: NonNullable<HeuristicRule["match"]> = {};
488
+ const provider = stringValue(value.match.provider);
489
+ const model = stringValue(value.match.model);
490
+ const id = stringValue(value.match.id);
491
+ const api = stringValue(value.match.api);
492
+ if (provider) match.provider = provider;
493
+ if (model) match.model = model;
494
+ if (id) match.id = id;
495
+ if (api) match.api = api;
496
+ if (Object.keys(match).length > 0) rule.match = match;
497
+ }
498
+ return Object.keys(rule).length > 0 ? rule : undefined;
499
+ }
500
+
501
+ function parseContextUsageConfig(value: unknown): ContextUsageConfig {
502
+ if (!isJsonObject(value)) return {};
503
+ const config: ContextUsageConfig = {};
504
+ if (isJsonObject(value.defaults)) {
505
+ const defaults: NonNullable<ContextUsageConfig["defaults"]> = parseHeuristicProfile(value.defaults);
506
+ const profile = stringValue(value.defaults.profile);
507
+ if (profile) defaults.profile = profile;
508
+ if (Object.keys(defaults).length > 0) config.defaults = defaults;
509
+ }
510
+ if (isJsonObject(value.profiles)) {
511
+ const profiles: Record<string, HeuristicProfile> = {};
512
+ for (const [name, entry] of Object.entries(value.profiles)) {
513
+ const profile = parseHeuristicProfile(entry);
514
+ if (Object.keys(profile).length > 0) profiles[name] = profile;
515
+ }
516
+ if (Object.keys(profiles).length > 0) config.profiles = profiles;
517
+ }
518
+ if (Array.isArray(value.rules)) {
519
+ const rules = value.rules.map(parseHeuristicRule).filter((rule): rule is HeuristicRule => !!rule);
520
+ if (rules.length > 0) config.rules = rules;
521
+ }
522
+ return config;
523
+ }
524
+
525
+ function splitConfigPaths(value: string | undefined, separator = delimiter): string[] {
526
+ return (value ?? "").split(separator).map((entry) => expandHomePath(entry.trim())).filter(Boolean);
527
+ }
528
+
529
+ function loadContextUsageConfig(cwd: string): ContextUsageConfig {
530
+ const paths = [...configPaths("pi-context-usage", cwd), ...splitConfigPaths(process.env.PI_CONTEXT_USAGE_CONFIG)];
531
+ return paths.reduce<ContextUsageConfig>(
532
+ (config, filePath) => mergeContextUsageConfig(config, readJsonConfig(filePath, parseContextUsageConfig)),
533
+ {},
534
+ );
535
+ }
536
+
537
+ function globToRegex(pattern: string): RegExp {
538
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
539
+ return new RegExp(`^${escaped}$`, "i");
540
+ }
541
+
542
+ function matchesPattern(value: string, pattern?: string): boolean {
543
+ if (!pattern) return true;
544
+ if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) {
545
+ const end = pattern.lastIndexOf("/");
546
+ try {
547
+ return new RegExp(pattern.slice(1, end), pattern.slice(end + 1) || undefined).test(value);
548
+ } catch {
549
+ return false;
550
+ }
551
+ }
552
+ if (pattern.includes("*") || pattern.includes("?")) return globToRegex(pattern).test(value);
553
+ return value.toLowerCase() === pattern.toLowerCase();
554
+ }
555
+
556
+ function ruleMatchesModel(rule: HeuristicRule, model?: ModelSummary): boolean {
557
+ const match = rule.match;
558
+ if (!match) return true;
559
+ const provider = model?.provider ?? "";
560
+ const id = model?.id ?? "";
561
+ const api = model?.api ?? "";
562
+ return matchesPattern(provider, match.provider)
563
+ && matchesPattern(id, match.model ?? match.id)
564
+ && matchesPattern(api, match.api);
565
+ }
566
+
567
+ function defaultHeuristic(): ResolvedHeuristic {
568
+ return {
569
+ label: "fallback chars/4",
570
+ source: "fallback",
571
+ textDenominator: 4,
572
+ sessionDenominator: 4,
573
+ toolDenominator: 4,
574
+ toolNumerator: "openai-responses",
575
+ };
576
+ }
577
+
578
+ function applyHeuristicPatch(base: ResolvedHeuristic, patch: HeuristicProfile | Partial<ResolvedHeuristic> | undefined, source: string): ResolvedHeuristic {
579
+ const normalized: Partial<ResolvedHeuristic> = patch ? {
580
+ label: patch.label,
581
+ textDenominator: patch.textDenominator,
582
+ sessionDenominator: patch.sessionDenominator,
583
+ toolDenominator: patch.toolDenominator,
584
+ toolNumerator: patch.toolNumerator,
585
+ } : {};
586
+ return {
587
+ ...base,
588
+ ...normalized,
589
+ // An absent label in a patch must not clobber the base label: unknown providers
590
+ // otherwise reach renderHeader with label undefined and crash methodologyHint.
591
+ label: normalized.label ?? base.label,
592
+ source,
593
+ textDenominator: cleanDenominator(normalized.textDenominator, base.textDenominator),
594
+ sessionDenominator: cleanDenominator(normalized.sessionDenominator, base.sessionDenominator),
595
+ toolDenominator: cleanDenominator(normalized.toolDenominator, base.toolDenominator),
596
+ toolNumerator: normalized.toolNumerator ?? base.toolNumerator,
597
+ };
598
+ }
599
+
600
+ const BUILT_IN_HEURISTIC_RULES: BuiltInHeuristicRule[] = [
601
+ {
602
+ label: "Claude 4.7+ heuristic",
603
+ providerIncludes: ["anthropic"],
604
+ apiEquals: ["anthropic-messages"],
605
+ modelRegex: /claude.*4[-.]?[78]|4[-.]?[78].*claude/,
606
+ textDenominator: 2.6,
607
+ sessionDenominator: 2.6,
608
+ toolDenominator: 2.6,
609
+ toolNumerator: "anthropic",
610
+ },
611
+ {
612
+ label: "Claude 4.5/4.6 heuristic",
613
+ providerIncludes: ["anthropic"],
614
+ apiEquals: ["anthropic-messages"],
615
+ modelRegex: /claude.*4[-.]?[56]|4[-.]?[56].*claude/,
616
+ textDenominator: 3.8,
617
+ sessionDenominator: 3.5,
618
+ toolDenominator: 3.3,
619
+ toolNumerator: "anthropic",
620
+ },
621
+ {
622
+ label: "Anthropic heuristic",
623
+ providerIncludes: ["anthropic"],
624
+ apiEquals: ["anthropic-messages"],
625
+ textDenominator: 3.5,
626
+ sessionDenominator: 3.5,
627
+ toolDenominator: 3.3,
628
+ toolNumerator: "anthropic",
629
+ },
630
+ {
631
+ label: "OpenAI-Codex heuristic",
632
+ providerIncludes: ["openai-codex"],
633
+ apiEquals: ["openai-codex-responses"],
634
+ textDenominator: 4,
635
+ sessionDenominator: 4,
636
+ toolDenominator: 5.5,
637
+ toolNumerator: "openai-cookbook",
638
+ },
639
+ {
640
+ label: "OpenAI Responses heuristic",
641
+ providerIncludes: ["openai"],
642
+ apiEquals: ["openai-responses", "azure-openai-responses"],
643
+ textDenominator: 4,
644
+ sessionDenominator: 4,
645
+ toolDenominator: 5.5,
646
+ toolNumerator: "openai-responses",
647
+ },
648
+ {
649
+ label: "OpenAI-chat-style heuristic",
650
+ providerIncludes: ["mistral"],
651
+ apiEquals: ["openai-completions", "mistral-conversations"],
652
+ textDenominator: 4,
653
+ sessionDenominator: 4,
654
+ toolDenominator: 5.5,
655
+ toolNumerator: "openai-chat",
656
+ },
657
+ {
658
+ label: "Gemini/Vertex heuristic",
659
+ providerIncludes: ["google", "gemini"],
660
+ apiEquals: ["google-generative-ai", "google-vertex"],
661
+ textDenominator: 4,
662
+ sessionDenominator: 4,
663
+ toolDenominator: 4,
664
+ toolNumerator: "gemini",
665
+ },
666
+ {
667
+ label: "Bedrock heuristic",
668
+ providerIncludes: ["bedrock"],
669
+ apiEquals: ["bedrock-converse-stream"],
670
+ textDenominator: 4,
671
+ sessionDenominator: 4,
672
+ toolDenominator: 4,
673
+ toolNumerator: "bedrock",
674
+ },
675
+ ];
676
+
677
+ function builtInRuleMatches(rule: BuiltInHeuristicRule, model: ModelSummary): boolean {
678
+ const provider = model.provider.toLowerCase();
679
+ const api = model.api.toLowerCase();
680
+ const providerOrApiMatches = rule.providerIncludes.some((entry) => provider.includes(entry))
681
+ || rule.apiEquals.includes(api);
682
+ const modelMatches = rule.modelRegex ? rule.modelRegex.test(model.id.toLowerCase()) : true;
683
+ return providerOrApiMatches && modelMatches;
684
+ }
685
+
686
+ function builtInHeuristicForModel(model?: ModelSummary): Partial<ResolvedHeuristic> | undefined {
687
+ if (!model) return undefined;
688
+ const rule = BUILT_IN_HEURISTIC_RULES.find((candidate) => builtInRuleMatches(candidate, model));
689
+ if (!rule) return undefined;
690
+ return {
691
+ label: rule.label,
692
+ textDenominator: rule.textDenominator,
693
+ sessionDenominator: rule.sessionDenominator,
694
+ toolDenominator: rule.toolDenominator,
695
+ toolNumerator: rule.toolNumerator,
696
+ };
697
+ }
698
+
699
+ // Heuristic resolution is one flat candidate list merged left to right with a single
700
+ // patch function: fallback < defaults.profile < defaults < built-in model rule <
701
+ // matching config rules (each optionally pulling in a named profile first).
702
+ function resolveHeuristic(model: ModelSummary | undefined, config: ContextUsageConfig): ResolvedHeuristic {
703
+ const candidates: Array<{ patch: HeuristicProfile | Partial<ResolvedHeuristic> | undefined; source: string }> = [];
704
+ const defaults = config.defaults ?? {};
705
+ if (defaults.profile && config.profiles?.[defaults.profile]) {
706
+ candidates.push({ patch: config.profiles[defaults.profile], source: `profile:${defaults.profile}` });
707
+ }
708
+ candidates.push({ patch: defaults, source: "configured defaults" });
709
+ const builtIn = builtInHeuristicForModel(model);
710
+ if (builtIn) candidates.push({ patch: builtIn, source: builtIn.label ?? "provider-aware heuristic" });
711
+ for (const rule of config.rules ?? []) {
712
+ if (!ruleMatchesModel(rule, model)) continue;
713
+ if (rule.profile && config.profiles?.[rule.profile]) {
714
+ candidates.push({ patch: config.profiles[rule.profile], source: `profile:${rule.profile}` });
715
+ }
716
+ candidates.push({ patch: rule, source: rule.label ?? (rule.profile ? `rule:${rule.profile}` : "custom rule") });
717
+ }
718
+ return candidates.reduce(
719
+ (heuristic, { patch, source }) => applyHeuristicPatch(heuristic, patch, source),
720
+ defaultHeuristic(),
721
+ );
722
+ }
723
+
724
+ function openAIResponsesToolPayload(tool: ToolSummary): unknown {
725
+ return {
726
+ type: "function",
727
+ name: tool.name,
728
+ description: tool.description,
729
+ parameters: tool.schema,
730
+ strict: null,
731
+ };
732
+ }
733
+
734
+ function openAIChatToolPayload(tool: ToolSummary): unknown {
735
+ return {
736
+ type: "function",
737
+ function: {
738
+ name: tool.name,
739
+ description: tool.description,
740
+ parameters: tool.schema,
741
+ strict: null,
742
+ },
743
+ };
744
+ }
745
+
746
+ function anthropicToolPayload(tool: ToolSummary): unknown {
747
+ return {
748
+ name: tool.name,
749
+ description: tool.description,
750
+ input_schema: tool.schema,
751
+ };
752
+ }
753
+
754
+ function geminiToolPayload(tools: ToolSummary[]): unknown {
755
+ return {
756
+ functionDeclarations: tools.map((tool) => ({
757
+ name: tool.name,
758
+ description: tool.description,
759
+ parametersJsonSchema: tool.schema,
760
+ })),
761
+ };
762
+ }
763
+
764
+ function bedrockToolPayload(tool: ToolSummary): unknown {
765
+ return {
766
+ toolSpec: {
767
+ name: tool.name,
768
+ description: tool.description,
769
+ inputSchema: { json: tool.schema },
770
+ },
771
+ };
772
+ }
773
+
774
+ function rawToolPayload(tool: ToolSummary): unknown {
775
+ return {
776
+ name: tool.name,
777
+ description: tool.description,
778
+ parameters: tool.schema,
779
+ promptGuidelines: tool.promptGuidelines,
780
+ };
781
+ }
782
+
783
+ function toolPayloadForShape(tool: ToolSummary, shape: string): unknown {
784
+ switch (shape) {
785
+ case "openai-chat":
786
+ case "openai-completions":
787
+ case "mistral":
788
+ return openAIChatToolPayload(tool);
789
+ case "anthropic":
790
+ return anthropicToolPayload(tool);
791
+ case "gemini":
792
+ case "google":
793
+ case "vertex":
794
+ return { name: tool.name, description: tool.description, parametersJsonSchema: tool.schema };
795
+ case "bedrock":
796
+ return bedrockToolPayload(tool);
797
+ case "raw-schema":
798
+ return rawToolPayload(tool);
799
+ default:
800
+ return openAIResponsesToolPayload(tool);
801
+ }
802
+ }
803
+
804
+ function aggregateToolPayloadForShape(tools: ToolSummary[], shape: string): unknown {
805
+ if (shape === "gemini" || shape === "google" || shape === "vertex") return geminiToolPayload(tools);
806
+ return tools.map((tool) => toolPayloadForShape(tool, shape));
807
+ }
808
+
809
+ function toolPayloadLabel(shape: string): string {
810
+ switch (shape) {
811
+ case "openai-responses":
812
+ case "openai-codex-responses":
813
+ return "OpenAI Responses tool payload";
814
+ case "openai-chat":
815
+ case "openai-completions":
816
+ case "mistral":
817
+ return "OpenAI Chat tool payload";
818
+ case "anthropic":
819
+ return "Anthropic tool payload";
820
+ case "gemini":
821
+ case "google":
822
+ case "vertex":
823
+ return "Gemini/Vertex tool payload";
824
+ case "bedrock":
825
+ return "Bedrock tool payload";
826
+ case "raw-schema":
827
+ return "Raw tool schema payload";
828
+ default:
829
+ return `Unknown tool shape ${shape}; OpenAI Responses fallback`;
830
+ }
831
+ }
832
+
833
+ function buildToolNumerator(tools: ToolSummary[], heuristic: ResolvedHeuristic): ToolNumeratorResult {
834
+ const shape = heuristic.toolNumerator;
835
+ if (shape === "openai-cookbook") {
836
+ const content = safeMinifiedJson(tools.map(openAIResponsesToolPayload));
837
+ return {
838
+ label: "OpenAI-style local formula",
839
+ content,
840
+ chars: content.length,
841
+ tokens: estimateOpenAIFunctionToolTokens(tools),
842
+ };
843
+ }
844
+ const content = safeMinifiedJson(aggregateToolPayloadForShape(tools, shape));
845
+ return {
846
+ label: toolPayloadLabel(shape),
847
+ content,
848
+ chars: content.length,
849
+ };
850
+ }
851
+
852
+ function trimFinalPeriod(text: string): string {
853
+ return text.endsWith(".") ? text.slice(0, -1) : text;
854
+ }
855
+
856
+ function getSchemaProperties(schema: unknown): Record<string, unknown> {
857
+ if (!isJsonObject(schema) || !isJsonObject(schema.properties)) return {};
858
+ return schema.properties;
859
+ }
860
+
861
+ function schemaPropertyType(property: unknown): string {
862
+ if (!isJsonObject(property)) return "object";
863
+ if (typeof property.type === "string") return property.type;
864
+ if (Array.isArray(property.type)) return property.type.filter((entry): entry is string => typeof entry === "string").join("|");
865
+ if (property.anyOf) return "anyOf";
866
+ if (property.oneOf) return "oneOf";
867
+ if (property.allOf) return "allOf";
868
+ return "object";
869
+ }
870
+
871
+ function schemaPropertyDescription(property: unknown): string {
872
+ if (!isJsonObject(property)) return "";
873
+ return typeof property.description === "string" ? trimFinalPeriod(property.description) : "";
874
+ }
875
+
876
+ function schemaPropertyEnum(property: unknown): JsonValue[] {
877
+ if (!isJsonObject(property) || !Array.isArray(property.enum)) return [];
878
+ return property.enum;
879
+ }
880
+
881
+ function schemaArrayItemProperties(property: unknown): Record<string, unknown> {
882
+ if (!isJsonObject(property)) return {};
883
+ return getSchemaProperties(property.items);
884
+ }
885
+
886
+ function getSchemaRequired(schema: unknown): string[] {
887
+ if (!isJsonObject(schema) || !Array.isArray(schema.required)) return [];
888
+ return schema.required.filter((entry): entry is string => typeof entry === "string");
889
+ }
890
+
891
+ function arrayItemsSchema(property: unknown): unknown {
892
+ return isJsonObject(property) ? property.items : undefined;
893
+ }
894
+
895
+ function collectToolFields(name: string, property: unknown, depth: number, required: boolean, out: ToolField[], maxDepth = 3): void {
896
+ out.push({
897
+ name,
898
+ type: schemaPropertyType(property),
899
+ required,
900
+ description: schemaPropertyDescription(property),
901
+ depth,
902
+ });
903
+ if (depth >= maxDepth) return;
904
+ const nested = getSchemaProperties(property);
905
+ if (Object.keys(nested).length > 0) {
906
+ const requiredKeys = new Set(getSchemaRequired(property));
907
+ for (const [childName, childProperty] of Object.entries(nested)) {
908
+ collectToolFields(childName, childProperty, depth + 1, requiredKeys.has(childName), out, maxDepth);
909
+ }
910
+ }
911
+ const itemProperties = schemaArrayItemProperties(property);
912
+ if (Object.keys(itemProperties).length > 0) {
913
+ const requiredKeys = new Set(getSchemaRequired(arrayItemsSchema(property)));
914
+ for (const [childName, childProperty] of Object.entries(itemProperties)) {
915
+ collectToolFields(childName, childProperty, depth + 1, requiredKeys.has(childName), out, maxDepth);
916
+ }
917
+ }
918
+ }
919
+
920
+ function buildToolFields(schema: unknown): ToolField[] {
921
+ const fields: ToolField[] = [];
922
+ const requiredKeys = new Set(getSchemaRequired(schema));
923
+ for (const [name, property] of Object.entries(getSchemaProperties(schema))) {
924
+ collectToolFields(name, property, 0, requiredKeys.has(name), fields);
925
+ }
926
+ return fields;
927
+ }
928
+
929
+ function estimateOpenAIToolTextTokens(text: string): number {
930
+ return estimateCharsAsTokens(text.length, OPENAI_TOOL_TEXT_FRAGMENT_DENOMINATOR);
931
+ }
932
+
933
+ function estimateOpenAIToolDefinitionTokens(tool: ToolSummary): number {
934
+ let tokens = 7;
935
+ tokens += estimateOpenAIToolTextTokens(`${tool.name}:${trimFinalPeriod(tool.description)}`);
936
+ const propertyEntries = Object.entries(getSchemaProperties(tool.schema));
937
+ if (propertyEntries.length > 0) tokens += 3;
938
+ for (const [propertyName, property] of propertyEntries) tokens += estimateOpenAIPropertyTokens(propertyName, property);
939
+ return tokens;
940
+ }
941
+
942
+ function estimateOpenAIPropertyTokens(propertyName: string, property: unknown): number {
943
+ const propInit = 3;
944
+ const propKey = 3;
945
+ const enumInit = -3;
946
+ const enumItem = 3;
947
+
948
+ let tokens = propKey;
949
+ const enumValues = schemaPropertyEnum(property);
950
+ if (enumValues.length > 0) {
951
+ tokens += enumInit;
952
+ for (const enumValue of enumValues) tokens += enumItem + estimateOpenAIToolTextTokens(String(enumValue));
953
+ }
954
+ tokens += estimateOpenAIToolTextTokens(`${propertyName}:${schemaPropertyType(property)}:${schemaPropertyDescription(property)}`);
955
+
956
+ const nestedEntries = Object.entries(getSchemaProperties(property));
957
+ if (nestedEntries.length > 0) {
958
+ tokens += propInit;
959
+ for (const [nestedName, nestedProperty] of nestedEntries) tokens += estimateOpenAIPropertyTokens(nestedName, nestedProperty);
960
+ }
961
+
962
+ const itemEntries = Object.entries(schemaArrayItemProperties(property));
963
+ if (itemEntries.length > 0) {
964
+ tokens += propInit;
965
+ for (const [itemName, itemProperty] of itemEntries) tokens += estimateOpenAIPropertyTokens(itemName, itemProperty);
966
+ }
967
+
968
+ return tokens;
969
+ }
970
+
971
+ function estimateOpenAIFunctionToolTokens(tools: ToolSummary[]): number {
972
+ // OpenAI's public token-counting docs say exact tool counts need the Responses
973
+ // input-token endpoint. For no-API-call startup estimates, use the older
974
+ // cookbook/tiktoken-style schema-summary formula: model-specific constants plus
975
+ // name/description/property summaries, not raw schema JSON. Current public
976
+ // tiktoken maps GPT-5 and GPT-4o families to o200k_base, so use the GPT-4o/GPT-5
977
+ // family constants. A synthetic schema ablation found chars/6.6 over these schema
978
+ // text fragments, plus recursive nested property counting, beats raw schema-char
979
+ // denominators on held-out mixed schemas while remaining dependency-free.
980
+ let tokens = 0;
981
+ for (const tool of tools) tokens += estimateOpenAIToolDefinitionTokens(tool);
982
+ if (tools.length > 0) tokens += 12;
983
+ return tokens;
984
+ }
985
+
986
+ function buildToolDisplayEstimate(tool: ToolSummary, heuristic: ResolvedHeuristic): ToolDisplayEstimate {
987
+ const shape = heuristic.toolNumerator;
988
+ const chars = safeMinifiedJson(toolPayloadForShape(tool, shape)).length;
989
+ if (shape === "openai-cookbook") {
990
+ return { tokens: estimateOpenAIToolDefinitionTokens(tool), chars };
991
+ }
992
+ return { tokens: estimateCharsAsTokens(chars, heuristic.toolDenominator), chars };
993
+ }
994
+
995
+ function buildToolsSection(pi: ExtensionAPI, heuristic: ResolvedHeuristic): { section?: PrefixSection; tools: ToolSummary[] } {
996
+ const activeNames = new Set(pi.getActiveTools());
997
+ const allTools = pi.getAllTools();
998
+ const activeToolInfos = allTools.filter((tool) => activeNames.has(tool.name));
999
+ const inactiveTools = allTools
1000
+ .filter((tool) => !activeNames.has(tool.name))
1001
+ .map(summarizeTool)
1002
+ .sort((a, b) => a.name.localeCompare(b.name));
1003
+ const tools = activeToolInfos.map(summarizeTool);
1004
+ if (tools.length === 0) return { tools };
1005
+
1006
+ const numerator = buildToolNumerator(tools, heuristic);
1007
+ const denominator = heuristic.toolDenominator;
1008
+ const effectiveTokens = numerator.tokens ?? estimateCharsAsTokens(numerator.chars, denominator);
1009
+ const sectionDetail = typeof numerator.tokens === "number"
1010
+ ? `· OpenAI formula · schema text ${ratioDetail(OPENAI_TOOL_TEXT_FRAGMENT_DENOMINATOR)}`
1011
+ : `${ratioDetail(denominator)} · ${numerator.label}`;
1012
+ const toolEstimates = tools.map((tool) => ({ tool, estimate: buildToolDisplayEstimate(tool, heuristic) }));
1013
+ const sortedEstimates = [...toolEstimates].sort((a, b) => b.estimate.tokens - a.estimate.tokens || a.tool.name.localeCompare(b.tool.name));
1014
+ const compactToolRows: ScanRow[] = [
1015
+ ...sortedEstimates.map(({ tool, estimate }) => ({ name: tool.name, tokens: estimate.tokens, desc: tool.description })),
1016
+ ...inactiveTools.map((tool) => ({ name: tool.name, desc: `(inactive) ${tool.description}`, inactive: true })),
1017
+ ];
1018
+ const expandedTools: ToolExpanded[] = sortedEstimates.map(({ tool, estimate }) => ({
1019
+ name: tool.name,
1020
+ tokens: estimate.tokens,
1021
+ source: tool.source,
1022
+ description: tool.description,
1023
+ fields: buildToolFields(tool.schema),
1024
+ }));
1025
+ const notes = typeof numerator.tokens === "number"
1026
+ ? [
1027
+ "formula +7/fn +3/prop-section +3/prop -3/enum +3/enum-item +12 once · nested counted recursively",
1028
+ `counted on the minified provider payload (${compactCount(numerator.chars)} ch); tree below is the readable view of it`,
1029
+ ]
1030
+ : [
1031
+ `counts use ${numerator.label} at ch ${ratioDetail(denominator)} over the minified provider payload (${compactCount(numerator.chars)} ch); the tree below is the readable view`,
1032
+ ];
1033
+ return {
1034
+ tools,
1035
+ section: {
1036
+ id: "tools",
1037
+ title: `Tools (${tools.length}/${allTools.length} active)`,
1038
+ content: numerator.content,
1039
+ effectiveTokens,
1040
+ rawChars: numerator.chars,
1041
+ denominator,
1042
+ detail: sectionDetail,
1043
+ compactRows: compactToolRows,
1044
+ expanded: { kind: "tools", notes, tools: expandedTools },
1045
+ },
1046
+ };
1047
+ }
1048
+
1049
+ function countTextContent(content: unknown): number {
1050
+ if (typeof content === "string") return content.length;
1051
+ if (!Array.isArray(content)) return 0;
1052
+ return content.reduce((sum, block) => {
1053
+ if (!block || typeof block !== "object") return sum;
1054
+ const typed = block as { type?: string; text?: string; data?: string; mimeType?: string };
1055
+ if (typed.type === "text") return sum + (typed.text ?? "").length;
1056
+ if (typed.type === "image") return sum + `[image:${typed.mimeType ?? "unknown"}:${typed.data?.length ?? 0} chars]`.length;
1057
+ return sum;
1058
+ }, 0);
1059
+ }
1060
+
1061
+ function countToolCallContent(block: unknown): number {
1062
+ if (!block || typeof block !== "object") return 0;
1063
+ const toolCall = block as { id?: string; name?: string; arguments?: unknown };
1064
+ return safeJson({ id: toolCall.id, name: toolCall.name, arguments: toolCall.arguments }).length;
1065
+ }
1066
+
1067
+ function countReasoningPayload(value: unknown): number {
1068
+ if (!value) return 0;
1069
+ if (typeof value !== "string") return safeJson(value).length;
1070
+ try {
1071
+ return safeJson(JSON.parse(value)).length;
1072
+ } catch {
1073
+ return value.length;
1074
+ }
1075
+ }
1076
+
1077
+ function buildSessionBreakdown(sessionManager?: SessionSource): SessionBreakdown | undefined {
1078
+ if (!sessionManager) return undefined;
1079
+ // Session entries are arbitrary historical content; a malformed session should cost
1080
+ // the session rows, not the whole panel.
1081
+ try {
1082
+ const { messages } = buildSessionContext(sessionManager.getEntries(), sessionManager.getLeafId());
1083
+ if (messages.length === 0) return undefined;
1084
+
1085
+ const breakdown: SessionBreakdown = {
1086
+ thinkingChars: 0,
1087
+ toolOutputChars: 0,
1088
+ messageChars: 0,
1089
+ messageCount: messages.length,
1090
+ };
1091
+
1092
+ for (const message of convertToLlm(messages)) {
1093
+ if (message.role === "toolResult") {
1094
+ breakdown.toolOutputChars += countTextContent(message.content);
1095
+ continue;
1096
+ }
1097
+ if (message.role === "assistant") {
1098
+ for (const block of message.content) {
1099
+ if (block.type === "thinking") {
1100
+ // OpenAI/Codex sends encrypted reasoning items back as context when
1101
+ // a signature is present; the visible thinking summary itself is not
1102
+ // replayed. For providers without signatures, fall back to text.
1103
+ breakdown.thinkingChars += block.thinkingSignature
1104
+ ? countReasoningPayload(block.thinkingSignature)
1105
+ : (block.thinking ?? "").length;
1106
+ } else if (block.type === "toolCall") {
1107
+ breakdown.messageChars += countToolCallContent(block);
1108
+ } else {
1109
+ breakdown.messageChars += countTextContent([block]);
1110
+ }
1111
+ }
1112
+ continue;
1113
+ }
1114
+ breakdown.messageChars += countTextContent(message.content);
1115
+ }
1116
+
1117
+ return breakdown;
1118
+ } catch {
1119
+ return undefined;
1120
+ }
1121
+ }
1122
+
1123
+ function sessionChars(session: SessionBreakdown): number {
1124
+ return session.thinkingChars + session.toolOutputChars + session.messageChars;
1125
+ }
1126
+
1127
+ // Only for walking the foreign TUI component tree, whose objects we do not control.
1128
+ // Snapshot building deliberately has no such guards: if pi's session callbacks throw
1129
+ // (resume race), render()'s catch shows the honest "unavailable" line instead of a
1130
+ // quietly zeroed panel.
1131
+ function safely<T>(fn: () => T, fallback: T): T {
1132
+ try {
1133
+ return fn();
1134
+ } catch {
1135
+ return fallback;
1136
+ }
1137
+ }
1138
+
1139
+ // May throw while pi is still wiring a resumed session; StartupContextComponent.render()
1140
+ // catches, renders the "unavailable" line, and recovers on the next snapshot.
1141
+ function buildSnapshot(
1142
+ pi: ExtensionAPI,
1143
+ getSystemPrompt: () => string,
1144
+ sessionManager?: SessionSource,
1145
+ getContextUsage?: () => ContextUsage | undefined,
1146
+ getModel?: () => ModelSummary | undefined,
1147
+ config: ContextUsageConfig = {},
1148
+ ): PrefixSnapshot {
1149
+ const systemPrompt = getSystemPrompt();
1150
+ const model = getModel?.();
1151
+ const heuristic = resolveHeuristic(model, config);
1152
+ const textDenominator = heuristic.textDenominator;
1153
+ const promptRemainder = getPromptRemainder(systemPrompt);
1154
+ const systemPreview = firstMeaningfulLines(promptRemainder, 6).map((line) => singleLine(line));
1155
+
1156
+ // Tools are resolved before the system section so the runtime prompt row can attribute
1157
+ // the tool/extension instructions embedded in it (issue #9).
1158
+ const { section: toolsSection, tools } = buildToolsSection(pi, heuristic);
1159
+ const runtimeAdditions = detectRuntimeAdditions(promptRemainder, tools);
1160
+
1161
+ const sections: PrefixSection[] = [
1162
+ {
1163
+ id: "system", // id is config/signature API — stays "system" even though the title changed
1164
+ title: "Runtime system prompt",
1165
+ content: promptRemainder,
1166
+ denominator: textDenominator,
1167
+ detail: ratioDetail(textDenominator),
1168
+ expanded: {
1169
+ kind: "text",
1170
+ note: "assembled at runtime: pi base prompt + tool/extension instructions · preview only",
1171
+ attribution: runtimeAdditionsAttribution(runtimeAdditions, textDenominator),
1172
+ preview: systemPreview.length > 0 ? systemPreview : ["(no non-empty lines)"],
1173
+ },
1174
+ },
1175
+ ...parseContextSections(systemPrompt, textDenominator),
1176
+ ];
1177
+
1178
+ const { section: skillsSection } = buildSkillsSection(systemPrompt, textDenominator);
1179
+ if (skillsSection) sections.push(skillsSection);
1180
+ if (toolsSection) sections.push(toolsSection);
1181
+
1182
+ const session = buildSessionBreakdown(sessionManager);
1183
+ const contextUsage = getContextUsage?.();
1184
+
1185
+ const signature = [
1186
+ systemPrompt.length,
1187
+ model ? `${model.provider}:${model.id}:${model.api}` : "no-model",
1188
+ `${heuristic.label}:${heuristic.textDenominator}:${heuristic.sessionDenominator}:${heuristic.toolDenominator}:${heuristic.toolNumerator}`,
1189
+ JSON.stringify(config),
1190
+ pi.getActiveTools().join(","),
1191
+ pi.getAllTools().map((tool) => `${tool.name}:${tool.description.length}`).join(","),
1192
+ session ? `${session.thinkingChars}:${session.toolOutputChars}:${session.messageChars}:${session.messageCount}` : "no-session",
1193
+ contextUsage ? `${contextUsage.tokens}:${contextUsage.contextWindow}:${contextUsage.percent}` : "no-usage",
1194
+ ].join("|");
1195
+
1196
+ return { signature, sections, tools, heuristic, model, session, contextUsage };
1197
+ }
1198
+
1199
+ function sectionTokens(section: PrefixSection): number {
1200
+ return section.effectiveTokens ?? estimateCharsAsTokens(section.content.length, section.denominator);
1201
+ }
1202
+
1203
+ function sectionChars(section: PrefixSection): number {
1204
+ return section.rawChars ?? section.content.length;
1205
+ }
1206
+
1207
+ function totalTokens(snapshot: PrefixSnapshot): number {
1208
+ return snapshot.sections.reduce((sum, section) => sum + sectionTokens(section), 0);
1209
+ }
1210
+
1211
+ function totalChars(snapshot: PrefixSnapshot): number {
1212
+ return snapshot.sections.reduce((sum, section) => sum + sectionChars(section), 0);
1213
+ }
1214
+
1215
+ function nextMode(mode: ViewMode): ViewMode {
1216
+ return mode === "summary" ? "compact" : mode === "compact" ? "expanded" : "summary";
1217
+ }
1218
+
1219
+ function padLabel(label: string, width = 42): string {
1220
+ // Overlong labels truncate rather than overflow: the token column is a column, and a
1221
+ // single 40-char title must not shift it (the … keeps the loss visible).
1222
+ const fitted = label.length >= width ? `${label.slice(0, Math.max(0, width - 2))}${ELLIPSIS}` : label;
1223
+ return fitted.padEnd(width, " ");
1224
+ }
1225
+
1226
+ // Methodology is stated here, once, in the dim hint line (design language §5) — data
1227
+ // rows carry only raw sizes. When the session or tool method deviates from the text
1228
+ // ratio, say so here (tool tokens may come from the OpenAI formula or a different
1229
+ // denominator); the expanded view stays the per-section audit trail.
1230
+ function methodologyHint(heuristic: ResolvedHeuristic): string {
1231
+ const sessionPart = heuristic.sessionDenominator !== heuristic.textDenominator
1232
+ ? `${SEP}session ${ratioDetail(heuristic.sessionDenominator)}`
1233
+ : "";
1234
+ const toolsPart = heuristic.toolNumerator === "openai-cookbook"
1235
+ ? `${SEP}tools: OpenAI formula`
1236
+ : heuristic.toolDenominator !== heuristic.textDenominator
1237
+ ? `${SEP}tools ${ratioDetail(heuristic.toolDenominator)}`
1238
+ : "";
1239
+ return `counts ch ${ratioDetail(heuristic.textDenominator)}${sessionPart}${toolsPart} (${heuristic.label})`;
1240
+ }
1241
+
1242
+ function renderHeader(snapshot: PrefixSnapshot, mode: ViewMode, theme: Theme): string[] {
1243
+ const ctrlO = keyText("app.tools.expand") || "Ctrl+O";
1244
+ return panelHeader(theme, "Context Usage", {
1245
+ modes: ["summary", "compact", "expanded"],
1246
+ active: mode,
1247
+ hint: `${ctrlO}: cycle view${SEP}model ${modelLabel(snapshot.model)}${SEP}${methodologyHint(snapshot.heuristic)}`,
1248
+ });
1249
+ }
1250
+
1251
+ // One renderer for every label/tokens/detail row — section rows, session rows, and
1252
+ // totals all flow through here, so alignment and grammar can never diverge.
1253
+ type MetricRow = {
1254
+ label: string;
1255
+ tokens: number;
1256
+ /** pi-reported numbers render without the ~ estimate marker. */
1257
+ exact?: boolean;
1258
+ /** total rows: accent + bold. */
1259
+ emphasis?: boolean;
1260
+ /** dim suffix, parens included, e.g. "(1.2k ch)" or "(residual)". */
1261
+ detail?: string;
1262
+ /** summary section rows open with the family ▸ glyph (design language §1). */
1263
+ section?: boolean;
1264
+ };
1265
+
1266
+ function renderMetricRow(row: MetricRow, theme: Theme, layout?: TokenLabelLayout): string {
1267
+ const tokenText = `${row.exact ? exactTokenLabel(row.tokens, layout) : estimatedTokenLabel(row.tokens, layout)} tokens`;
1268
+ if (row.emphasis) {
1269
+ return ` ${accent(theme, theme.bold(`${padLabel(row.label)}${tokenText}`))}${row.detail ? ` ${theme.fg("dim", row.detail)}` : ""}`;
1270
+ }
1271
+ const lead = row.section ? `${accent(theme, GLYPH.section)} ` : "";
1272
+ const labelWidth = row.section ? 40 : 42; // glyph + space keep the token column aligned
1273
+ return ` ${lead}${theme.fg("muted", padLabel(row.label, labelWidth))}${theme.fg("dim", `${tokenText}${row.detail ? ` ${row.detail}` : ""}`)}`;
1274
+ }
1275
+
1276
+ function joinLeftRight(left: string, right: string, width: number, gap = 2): string {
1277
+ if (!right) return left;
1278
+ const used = stripAnsi(left).length + stripAnsi(right).length;
1279
+ return `${left}${" ".repeat(Math.max(gap, width - used))}${right}`;
1280
+ }
1281
+
1282
+ function wrapPlainText(text: string, width: number, maxLines: number): string[] {
1283
+ const max = Math.max(16, width);
1284
+ const normalized = text.replace(/\s+/g, " ").trim();
1285
+ if (!normalized) return [];
1286
+ const wrapped = wrapTextWithAnsi(normalized, max);
1287
+ if (wrapped.length <= maxLines) return wrapped;
1288
+ const capped = wrapped.slice(0, maxLines);
1289
+ const last = capped[maxLines - 1] ?? "";
1290
+ // Reserve a column for the ellipsis so the capped line still fits `width`.
1291
+ capped[maxLines - 1] = last.length >= max ? `${last.slice(0, max - 1)}…` : `${last}…`;
1292
+ return capped;
1293
+ }
1294
+
1295
+ function renderExpandedSectionHeader(section: PrefixSection, theme: Theme, width: number): string {
1296
+ const tokens = sectionTokens(section);
1297
+ const chars = sectionChars(section);
1298
+ const left = ` ${theme.bold(section.title)} ${accent(theme, `${estimatedTokenLabel(tokens)} tokens`)}`;
1299
+ const right = theme.fg("dim", `${compactCount(chars)} ch ${section.detail}`);
1300
+ return joinLeftRight(left, right, Math.max(40, width));
1301
+ }
1302
+
1303
+ function renderExpandedNote(note: string, theme: Theme): string {
1304
+ return ` ${theme.fg("dim", note)}`;
1305
+ }
1306
+
1307
+ function renderExpandedPreview(lines: string[], theme: Theme, width: number): string[] {
1308
+ const max = Math.max(24, width - 4);
1309
+ return lines.map((line) => ` ${theme.fg("dim", singleLine(line, max))}`);
1310
+ }
1311
+
1312
+ type FieldColumns = { nameCol: number; typeCol: number; hasRequired: boolean };
1313
+
1314
+ const fieldIndent = (depth: number) => 6 + depth * 2;
1315
+
1316
+ // Column layout across *all* fields in the tools block (design language §8): each
1317
+ // tool used to size its own columns, so the section read as a stack of differently
1318
+ // ragged mini-tables instead of one aligned table.
1319
+ function toolFieldColumns(fields: ToolField[]): FieldColumns {
1320
+ return {
1321
+ nameCol: Math.min(30, Math.max(0, ...fields.map((field) => fieldIndent(field.depth) + field.name.length))),
1322
+ typeCol: Math.min(10, Math.max(0, ...fields.map((field) => field.type.length))),
1323
+ hasRequired: fields.some((field) => field.required),
1324
+ };
1325
+ }
1326
+
1327
+ function renderToolFieldRows(fields: ToolField[], theme: Theme, width: number, columns?: FieldColumns): string[] {
1328
+ if (fields.length === 0) return [` ${theme.fg("dim", "(no parameters)")}`];
1329
+ const { nameCol, typeCol, hasRequired } = columns ?? toolFieldColumns(fields);
1330
+ return fields.map((field) => {
1331
+ const rawName = `${" ".repeat(fieldIndent(field.depth))}${field.name}`;
1332
+ const namePart = rawName.length > nameCol ? `${rawName.slice(0, nameCol - 1)}…` : rawName.padEnd(nameCol, " ");
1333
+ const typePart = field.type.length > typeCol ? `${field.type.slice(0, typeCol - 1)}…` : field.type.padEnd(typeCol, " ");
1334
+ const reqPart = hasRequired ? (field.required ? "required" : " ") : "";
1335
+ const prefixWidth = namePart.length + 2 + typePart.length + (hasRequired ? 2 + reqPart.length : 0);
1336
+ const descWidth = Math.max(16, width - prefixWidth - 3);
1337
+ const desc = field.description ? singleLine(field.description, descWidth) : "";
1338
+ return `${theme.fg("text", namePart)} ${theme.fg("muted", typePart)}${hasRequired ? ` ${theme.fg("dim", reqPart)}` : ""}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
1339
+ });
1340
+ }
1341
+
1342
+ // Tool entry header (design language §8): the name is the L0 anchor — bold — with
1343
+ // the shortened provenance beside it at L3-dim, so a column of tools scans by name
1344
+ // while the audit trail stays present without competing. Tokens keep the accent,
1345
+ // right-aligned. When width runs out the provenance gives way before the name does.
1346
+ function renderExpandedToolHeader(tool: ToolExpanded, tokenLayout: TokenLabelLayout, theme: Theme, width: number): string {
1347
+ const maxWidth = Math.max(40, width);
1348
+ const indent = " ";
1349
+ const gap = 2;
1350
+ const token = estimatedTokenField(tool.tokens, tokenLayout);
1351
+ const labelWidth = Math.max(12, maxWidth - indent.length - gap - tokenLayout.fieldWidth);
1352
+ const name = tool.name.length > labelWidth ? middleTruncatePath(tool.name, labelWidth) : tool.name;
1353
+ const sourceRoom = labelWidth - name.length - SEP.length;
1354
+ const source = tool.source && sourceRoom >= 8 ? middleTruncatePath(tildeAll(tool.source), sourceRoom) : "";
1355
+ const plainLabel = source ? `${name}${SEP}${source}` : name;
1356
+ const used = indent.length + plainLabel.length + tokenLayout.fieldWidth;
1357
+ const styled = `${theme.fg("text", theme.bold(name))}${source ? theme.fg("dim", `${SEP}${source}`) : ""}`;
1358
+ return `${indent}${styled}${" ".repeat(Math.max(gap, maxWidth - used))}${accent(theme, token)}`;
1359
+ }
1360
+
1361
+ function renderExpandedToolsBlock(content: { notes: string[]; tools: ToolExpanded[] }, theme: Theme, width: number): string[] {
1362
+ const out: string[] = [];
1363
+ const tokenLayout = tokenLabelLayout(content.tools.map((tool) => tool.tokens));
1364
+ const allFields = content.tools.flatMap((tool) => tool.fields);
1365
+ const columns = allFields.length > 0 ? toolFieldColumns(allFields) : undefined;
1366
+ for (const note of content.notes) {
1367
+ for (const line of wrapPlainText(note, Math.max(24, width - 4), 4)) out.push(` ${theme.fg("dim", line)}`);
1368
+ }
1369
+ for (const tool of content.tools) {
1370
+ out.push("");
1371
+ out.push(renderExpandedToolHeader(tool, tokenLayout, theme, width));
1372
+ if (tool.description && tool.description !== "(no description)") {
1373
+ for (const line of wrapPlainText(tool.description, Math.max(24, width - 6), 3)) {
1374
+ out.push(` ${theme.fg("dim", line)}`);
1375
+ }
1376
+ }
1377
+ out.push(...renderToolFieldRows(tool.fields, theme, width, columns));
1378
+ }
1379
+ return out;
1380
+ }
1381
+
1382
+ type SessionEstimate = {
1383
+ totalTokens: number;
1384
+ totalSource: "pi" | "heuristic";
1385
+ toolOutputTokens: number;
1386
+ messageTokens: number;
1387
+ otherTokens: number;
1388
+ denominator: number;
1389
+ };
1390
+
1391
+ function buildSessionEstimate(snapshot: PrefixSnapshot): SessionEstimate | undefined {
1392
+ if (!snapshot.session) return undefined;
1393
+ const denominator = snapshot.heuristic.sessionDenominator;
1394
+ const toolOutputTokens = estimateCharsAsTokens(snapshot.session.toolOutputChars, denominator);
1395
+ const messageTokens = estimateCharsAsTokens(snapshot.session.messageChars, denominator);
1396
+ const heuristicTotal = estimateCharsAsTokens(sessionChars(snapshot.session), denominator);
1397
+ const harnessTokens = totalTokens(snapshot);
1398
+ const piSessionTokens = snapshot.contextUsage?.tokens === null || snapshot.contextUsage?.tokens === undefined
1399
+ ? undefined
1400
+ : Math.max(0, Math.round(snapshot.contextUsage.tokens - harnessTokens));
1401
+ const totalTokensValue = piSessionTokens ?? heuristicTotal;
1402
+ const otherTokens = Math.max(0, Math.round(totalTokensValue - toolOutputTokens - messageTokens));
1403
+ return {
1404
+ totalTokens: totalTokensValue,
1405
+ totalSource: piSessionTokens === undefined ? "heuristic" : "pi",
1406
+ toolOutputTokens,
1407
+ messageTokens,
1408
+ otherTokens,
1409
+ denominator,
1410
+ };
1411
+ }
1412
+
1413
+ // --- proportion (design language §8): "of what" — shares of the context window --------
1414
+
1415
+ /**
1416
+ * Integer percent of the context window; `<1%` rather than a dishonest `0%`. Shares
1417
+ * derived from estimated token counts carry the ~ marker — a wrong harness estimate
1418
+ * must not masquerade as an exact share (only Total request is pi-exact).
1419
+ */
1420
+ function ctxShareLabel(tokens: number, usage: ContextUsage | undefined, options: { estimate?: boolean } = {}): string | undefined {
1421
+ if (!usage || usage.tokens === null || usage.contextWindow <= 0) return undefined;
1422
+ const percent = (tokens / usage.contextWindow) * 100;
1423
+ if (!Number.isFinite(percent) || percent < 0) return undefined;
1424
+ const rounded = Math.round(percent);
1425
+ if (rounded === 0 && tokens > 0) return "<1% ctx";
1426
+ return `${options.estimate ? "~" : ""}${rounded}% ctx`;
1427
+ }
1428
+
1429
+ // The window is a budget label, not a measurement: 200k, not 200.0k.
1430
+ function contextWindowLabel(tokens: number): string {
1431
+ return compactCount(tokens).replace(/\.0(k|M)$/, "$1");
1432
+ }
1433
+
1434
+ function harnessDetail(snapshot: PrefixSnapshot): string {
1435
+ const share = ctxShareLabel(totalTokens(snapshot), snapshot.contextUsage, { estimate: true });
1436
+ return countDetail(totalChars(snapshot), share ? `· ${share}` : undefined);
1437
+ }
1438
+
1439
+ // One stacked bar under Total request: the carried part (harness + session) in accent,
1440
+ // free window dim — the half-second "how full am I?" answer.
1441
+ function renderContextBar(snapshot: PrefixSnapshot, estimate: SessionEstimate, theme: Theme, width: number): string[] {
1442
+ const usage = snapshot.contextUsage;
1443
+ if (!usage || usage.tokens === null || usage.contextWindow <= 0) return [];
1444
+ const free = Math.max(0, usage.contextWindow - usage.tokens);
1445
+ const legend =
1446
+ `harness ~${compactCount(totalTokens(snapshot))}${SEP}session ~${compactCount(estimate.totalTokens)}${SEP}free ${compactCount(free)}`;
1447
+ const room = Math.max(0, width - 4 - legend.length - 2);
1448
+ const sameLine = room >= 12;
1449
+ const barWidth = Math.min(28, Math.max(12, sameLine ? room : width - 4));
1450
+ const carried = Math.min(1, Math.max(0, usage.tokens / usage.contextWindow));
1451
+ const filled = Math.min(barWidth, Math.max(usage.tokens > 0 ? 1 : 0, Math.round(carried * barWidth)));
1452
+ const bar = `${accent(theme, "█".repeat(filled))}${theme.fg("dim", "▒".repeat(barWidth - filled))}`;
1453
+ return sameLine
1454
+ ? [` ${bar} ${theme.fg("dim", legend)}`]
1455
+ : [` ${bar}`, ` ${theme.fg("dim", legend)}`];
1456
+ }
1457
+
1458
+ function renderSessionRows(snapshot: PrefixSnapshot, theme: Theme, width: number, layout?: TokenLabelLayout): string[] {
1459
+ const estimate = buildSessionEstimate(snapshot);
1460
+ if (!snapshot.session || !estimate) return [];
1461
+ const sessionShare = ctxShareLabel(estimate.totalTokens, snapshot.contextUsage, { estimate: true });
1462
+ const provenance = estimate.totalSource === "pi" ? "Pi current - harness" : "heuristic fallback";
1463
+ const rows = [
1464
+ "",
1465
+ renderMetricRow({ label: "Tool outputs", tokens: estimate.toolOutputTokens, detail: countDetail(snapshot.session.toolOutputChars) }, theme, layout),
1466
+ renderMetricRow({ label: "Messages", tokens: estimate.messageTokens, detail: countDetail(snapshot.session.messageChars) }, theme, layout),
1467
+ renderMetricRow({ label: "Other / reasoning", tokens: estimate.otherTokens, detail: "(residual)" }, theme, layout),
1468
+ renderMetricRow({
1469
+ label: "Total session",
1470
+ tokens: estimate.totalTokens,
1471
+ emphasis: true,
1472
+ detail: sessionShare ? `(${sessionShare} · ${provenance})` : `(${provenance})`,
1473
+ }, theme, layout),
1474
+ ];
1475
+ const usage = snapshot.contextUsage;
1476
+ if (usage && usage.tokens !== null) {
1477
+ const percent = formatPercent(usage.percent);
1478
+ const window = usage.contextWindow > 0 ? contextWindowLabel(usage.contextWindow) : undefined;
1479
+ rows.push(renderMetricRow({
1480
+ label: "Total request",
1481
+ tokens: usage.tokens,
1482
+ exact: true,
1483
+ emphasis: true,
1484
+ detail: percent && window ? `(${percent} / ${window} ctx)` : "(Pi usage)",
1485
+ }, theme, layout));
1486
+ rows.push(...renderContextBar(snapshot, estimate, theme, width));
1487
+ }
1488
+ return rows;
1489
+ }
1490
+
1491
+ function summaryTokenLayout(snapshot: PrefixSnapshot): TokenLabelLayout {
1492
+ const values = [...snapshot.sections.map(sectionTokens), totalTokens(snapshot)];
1493
+ const sessionEstimate = buildSessionEstimate(snapshot);
1494
+ if (sessionEstimate) values.push(
1495
+ sessionEstimate.totalTokens,
1496
+ sessionEstimate.toolOutputTokens,
1497
+ sessionEstimate.messageTokens,
1498
+ sessionEstimate.otherTokens,
1499
+ );
1500
+ if (typeof snapshot.contextUsage?.tokens === "number") values.push(snapshot.contextUsage.tokens);
1501
+ return tokenLabelLayout(values);
1502
+ }
1503
+
1504
+ function summarySectionLabel(title: string) {
1505
+ if (/runtime system/i.test(title)) return "System";
1506
+ if (/agents/i.test(title)) return "Rules";
1507
+ if (/skill/i.test(title)) return "Skills";
1508
+ if (/tools/i.test(title)) return "Tools";
1509
+ return title.replace(/\s*\(.+\)$/, "").split(/\s+/)[0] || "Other";
1510
+ }
1511
+
1512
+ function contextSummaryFrame(theme: Theme, title: string, rows: string[], width: number) {
1513
+ const safeWidth = Math.max(8, width);
1514
+ const inner = Math.max(0, safeWidth - 2);
1515
+ const label = truncateToWidth(` ${title} `, Math.max(0, inner - 1), "…");
1516
+ const top = theme.fg("border", "╭") +
1517
+ accent(theme, label) +
1518
+ theme.fg("border", "─".repeat(Math.max(0, inner - stripAnsi(label).length)) + "╮");
1519
+ const body = rows.map((row) => {
1520
+ const clipped = truncateToWidth(row, inner, "…");
1521
+ return theme.fg("border", "│") +
1522
+ clipped +
1523
+ " ".repeat(Math.max(0, inner - stripAnsi(clipped).length)) +
1524
+ theme.fg("border", "│");
1525
+ });
1526
+ return [top, ...body, theme.fg("border", `╰${"─".repeat(inner)}╯`)];
1527
+ }
1528
+
1529
+ function renderSummary(snapshot: PrefixSnapshot, theme: Theme, width = 80): string[] {
1530
+ const usage = snapshot.contextUsage;
1531
+ const harness = totalTokens(snapshot);
1532
+ const request = usage?.tokens;
1533
+ const window = usage?.contextWindow;
1534
+ const percent = formatPercent(usage?.percent ?? null);
1535
+ const budgetParts = [
1536
+ `Harness ~${compactCount(harness)}`,
1537
+ typeof request === "number" ? `Request ${compactCount(request)}` : undefined,
1538
+ window && window > 0 ? `/ ${contextWindowLabel(window)}` : undefined,
1539
+ percent ? `· ${percent}` : undefined,
1540
+ ].filter(Boolean).join(" ");
1541
+ const breakdown = snapshot.sections
1542
+ .map((section) => `${summarySectionLabel(section.title)} ~${compactCount(sectionTokens(section))}`)
1543
+ .join(SEP);
1544
+ const ctrlO = keyText("app.tools.expand") || "Ctrl+O";
1545
+ const rows = [
1546
+ ` ${theme.fg("text", budgetParts)}`,
1547
+ ` ${theme.fg("muted", breakdown || "No harness sections detected")}`,
1548
+ ` ${theme.fg("dim", `${modelLabel(snapshot.model)}${SEP}${ctrlO} details`)}`,
1549
+ ];
1550
+ return ["", ...contextSummaryFrame(theme, "CONTEXT BUDGET", rows, width), ""];
1551
+ }
1552
+
1553
+ type CompactLayout = { labelWidth: number; tokenLayout: TokenLabelLayout };
1554
+
1555
+ function compactLabel(label: string, width: number): string {
1556
+ if (label.length > width) return `${label.slice(0, Math.max(0, width - 1))}…`;
1557
+ return label.padEnd(width, " ");
1558
+ }
1559
+
1560
+ function numericRowTokens(rows: ScanRow[]): number[] {
1561
+ return rows.flatMap((row) => typeof row.tokens === "number" ? [row.tokens] : []);
1562
+ }
1563
+
1564
+ function compactLayout(snapshot: PrefixSnapshot): CompactLayout {
1565
+ const rows = snapshot.sections.flatMap((section) => section.compactRows ?? []);
1566
+ const labels = [
1567
+ ...snapshot.sections.map((section) => section.title),
1568
+ ...rows.map((row) => row.name),
1569
+ "Total harness",
1570
+ ];
1571
+ const labelWidth = Math.min(26, Math.max(0, ...labels.map((label) => label.length)));
1572
+ const tokenLayout = tokenLabelLayout([
1573
+ ...snapshot.sections.map(sectionTokens),
1574
+ ...numericRowTokens(rows),
1575
+ totalTokens(snapshot),
1576
+ ]);
1577
+ return { labelWidth, tokenLayout };
1578
+ }
1579
+
1580
+ function inactiveTokenField(layout: TokenLabelLayout): string {
1581
+ return "-".padStart(Math.max(1, layout.unitWidth + 1)).padEnd(Math.max(layout.fieldWidth, layout.unitWidth + 1, 1), " ");
1582
+ }
1583
+
1584
+ function renderScanRows(rows: ScanRow[], theme: Theme, width: number, layout?: CompactLayout): string[] {
1585
+ const labelWidth = layout?.labelWidth ?? Math.min(26, Math.max(...rows.map((row) => row.name.length)));
1586
+ const tokenLayout = layout?.tokenLayout ?? tokenLabelLayout(numericRowTokens(rows));
1587
+ const tokenWidth = Math.max(tokenLayout.fieldWidth, tokenLayout.unitWidth + 1, 1);
1588
+ const effectiveTokenLayout = { ...tokenLayout, fieldWidth: tokenWidth };
1589
+ const descWidth = Math.max(24, width - (4 + labelWidth + 2 + tokenWidth + 2));
1590
+ return rows.map((row) => {
1591
+ const name = compactLabel(row.name, labelWidth);
1592
+ const desc = row.desc ? singleLine(row.desc, descWidth) : "";
1593
+ const token = typeof row.tokens === "number"
1594
+ ? estimatedTokenField(row.tokens, effectiveTokenLayout)
1595
+ : inactiveTokenField(effectiveTokenLayout);
1596
+ if (row.inactive) {
1597
+ return theme.fg("dim", ` ${name} ${token}${desc ? ` ${desc}` : ""}`);
1598
+ }
1599
+ return ` ${theme.fg("text", name)} ${accent(theme, token)}${desc ? ` ${theme.fg("dim", desc)}` : ""}`;
1600
+ });
1601
+ }
1602
+
1603
+ function renderCompactTotalRow(snapshot: PrefixSnapshot, theme: Theme, layout: CompactLayout): string {
1604
+ const label = compactLabel("Total harness", layout.labelWidth + 2);
1605
+ const token = `${estimatedTokenLabel(totalTokens(snapshot), layout.tokenLayout)} tokens`;
1606
+ return ` ${accent(theme, theme.bold(`${label} ${token}`))} ${theme.fg("dim", harnessDetail(snapshot))}`;
1607
+ }
1608
+
1609
+ function renderCompact(snapshot: PrefixSnapshot, theme: Theme, width: number): string[] {
1610
+ const lines = renderHeader(snapshot, "compact", theme);
1611
+ const layout = compactLayout(snapshot);
1612
+ for (const section of snapshot.sections) {
1613
+ const title = compactLabel(section.title, layout.labelWidth);
1614
+ const counts = `${estimatedTokenLabel(sectionTokens(section), layout.tokenLayout)} tokens ${countDetail(sectionChars(section))}`;
1615
+ lines.push("", ` ${accent(theme, GLYPH.section)} ${theme.bold(title)} ${theme.fg("dim", counts)}`);
1616
+ if (section.compactRows && section.compactRows.length > 0) {
1617
+ lines.push(...renderScanRows(section.compactRows, theme, width, layout));
1618
+ }
1619
+ }
1620
+ const sessionTokenLayout = summaryTokenLayout(snapshot);
1621
+ lines.push("", renderCompactTotalRow(snapshot, theme, layout), ...renderSessionRows(snapshot, theme, width, sessionTokenLayout));
1622
+ lines.push(""); // panel tail spacer (design language §8)
1623
+ return lines;
1624
+ }
1625
+
1626
+ function renderExpanded(snapshot: PrefixSnapshot, theme: Theme, width: number): string[] {
1627
+ const lines = renderHeader(snapshot, "expanded", theme);
1628
+ const layout = summaryTokenLayout(snapshot);
1629
+ lines.push(
1630
+ renderMetricRow({ label: "Total harness", tokens: totalTokens(snapshot), emphasis: true, detail: harnessDetail(snapshot) }, theme, layout),
1631
+ ...renderSessionRows(snapshot, theme, width, layout),
1632
+ );
1633
+
1634
+ for (const section of snapshot.sections) {
1635
+ lines.push("", renderExpandedSectionHeader(section, theme, width));
1636
+ const expanded = section.expanded;
1637
+ if (expanded.kind === "tools") {
1638
+ lines.push(...renderExpandedToolsBlock(expanded, theme, width));
1639
+ continue;
1640
+ }
1641
+ if (expanded.note) lines.push(renderExpandedNote(expanded.note, theme));
1642
+ if (expanded.kind === "text" && expanded.attribution) lines.push(renderExpandedNote(expanded.attribution, theme));
1643
+ if (expanded.kind === "skills") {
1644
+ lines.push("", ...renderScanRows(expanded.rows, theme, width));
1645
+ } else if (expanded.preview && expanded.preview.length > 0) {
1646
+ lines.push("", ...renderExpandedPreview(expanded.preview, theme, width));
1647
+ }
1648
+ }
1649
+
1650
+ lines.push(""); // panel tail spacer (design language §8)
1651
+ return lines;
1652
+ }
1653
+
1654
+ function wrapLines(lines: string[], width: number): string[] {
1655
+ const maxWidth = Math.max(24, width);
1656
+ const out: string[] = [];
1657
+ for (const rawLine of lines) {
1658
+ if (rawLine.length === 0) {
1659
+ out.push("");
1660
+ continue;
1661
+ }
1662
+ const wrapped = wrapTextWithAnsi(rawLine, maxWidth);
1663
+ if (wrapped.length === 0) out.push("");
1664
+ else out.push(...wrapped.map((line) => truncateToWidth(line, maxWidth, "…")));
1665
+ }
1666
+ return out;
1667
+ }
1668
+
1669
+ class StartupContextComponent implements Component {
1670
+ readonly __piContextUsageBlock = true;
1671
+ private cachedSignature?: string;
1672
+ private cachedMode?: ViewMode;
1673
+ private cachedWidth?: number;
1674
+ private cachedLines?: string[];
1675
+
1676
+ // No TS parameter properties: keep the source compatible with Node's strip-only
1677
+ // type stripping so the zero-dependency test harness can import this file directly.
1678
+ private readonly snapshot: () => PrefixSnapshot;
1679
+ private readonly getTheme: () => Theme;
1680
+ private mode: ViewMode;
1681
+
1682
+ constructor(snapshot: () => PrefixSnapshot, getTheme: () => Theme, mode: ViewMode) {
1683
+ this.snapshot = snapshot;
1684
+ this.getTheme = getTheme;
1685
+ this.mode = mode;
1686
+ }
1687
+
1688
+ getMode(): ViewMode {
1689
+ return this.mode;
1690
+ }
1691
+
1692
+ setMode(mode: ViewMode): void {
1693
+ if (this.mode !== mode) {
1694
+ this.mode = mode;
1695
+ g.__piContextUsageMode = mode;
1696
+ this.invalidate();
1697
+ }
1698
+ }
1699
+
1700
+ setExpanded(_expanded: boolean): void {
1701
+ // Pi's native Ctrl+O path toggles one global boolean and calls setExpanded()
1702
+ // on every expandable chat component exactly once per toggle. Use that as
1703
+ // the single source of truth for cycling; do not also listen to raw terminal
1704
+ // input, or one keypress can advance twice.
1705
+ this.cycleMode();
1706
+ }
1707
+
1708
+ cycleMode(): ViewMode {
1709
+ this.setMode(nextMode(this.mode));
1710
+ return this.mode;
1711
+ }
1712
+
1713
+ render(width: number): string[] {
1714
+ try {
1715
+ const snapshot = this.snapshot();
1716
+ if (
1717
+ this.cachedLines &&
1718
+ this.cachedSignature === snapshot.signature &&
1719
+ this.cachedMode === this.mode &&
1720
+ this.cachedWidth === width
1721
+ ) {
1722
+ return this.cachedLines;
1723
+ }
1724
+
1725
+ const theme = this.getTheme();
1726
+ const body = this.mode === "summary"
1727
+ ? renderSummary(snapshot, theme, width)
1728
+ : this.mode === "compact"
1729
+ ? renderCompact(snapshot, theme, width)
1730
+ : renderExpanded(snapshot, theme, width);
1731
+
1732
+ this.cachedSignature = snapshot.signature;
1733
+ this.cachedMode = this.mode;
1734
+ this.cachedWidth = width;
1735
+ this.cachedLines = wrapLines(body, Math.max(20, width));
1736
+ return this.cachedLines;
1737
+ } catch {
1738
+ this.cachedSignature = "context-usage-unavailable";
1739
+ this.cachedMode = this.mode;
1740
+ this.cachedWidth = width;
1741
+ this.cachedLines = wrapLines([
1742
+ "",
1743
+ `${accent(undefined, "[Context Usage]")} unavailable while Pi finishes resuming this session`,
1744
+ ], Math.max(20, width));
1745
+ return this.cachedLines;
1746
+ }
1747
+ }
1748
+
1749
+ invalidate(): void {
1750
+ this.cachedSignature = undefined;
1751
+ this.cachedMode = undefined;
1752
+ this.cachedWidth = undefined;
1753
+ this.cachedLines = undefined;
1754
+ }
1755
+ }
1756
+
1757
+ function renderPlain(component: Component, width = 120): string {
1758
+ try {
1759
+ return stripAnsi(component.render(width).join("\n"));
1760
+ } catch {
1761
+ return "";
1762
+ }
1763
+ }
1764
+
1765
+ function isPrefixBlock(component: unknown): component is StartupContextComponent {
1766
+ return !!component && typeof component === "object" && (component as { __piContextUsageBlock?: boolean }).__piContextUsageBlock === true;
1767
+ }
1768
+
1769
+ // _lib/chat.ts isResourceRow, minus the panel itself: the [Context Usage] block renders
1770
+ // arbitrary text the fuzzy [Section] regex must never re-anchor on.
1771
+ function isResourceComponent(component: unknown): boolean {
1772
+ return !isPrefixBlock(component) && isResourceRow(component);
1773
+ }
1774
+
1775
+ function isBlankComponent(component: Component): boolean {
1776
+ const text = renderPlain(component, 80);
1777
+ return text.trim().length === 0;
1778
+ }
1779
+
1780
+ function findResourceChatContainer(node: unknown): ContainerLike | undefined {
1781
+ return findContainerBy(node, (children) => children.some((child) => isResourceComponent(child)));
1782
+ }
1783
+
1784
+ function removeExistingPrefixBlocks(chat: ContainerLike): void {
1785
+ if (!Array.isArray(chat.children)) return;
1786
+ chat.children = chat.children.filter((child) => !isPrefixBlock(child));
1787
+ }
1788
+
1789
+ function insertionIndexAfterResourceList(chat: ContainerLike): number {
1790
+ if (!Array.isArray(chat.children)) return -1;
1791
+ let index = -1;
1792
+ for (let i = 0; i < chat.children.length; i++) {
1793
+ if (!isResourceComponent(chat.children[i])) continue;
1794
+ index = i;
1795
+ if (i + 1 < chat.children.length && isBlankComponent(chat.children[i + 1] as Component)) index = i + 1;
1796
+ }
1797
+ return index;
1798
+ }
1799
+
1800
+ function isContextBlockInstalled(block: StartupContextComponent): boolean {
1801
+ const chat = g.__piContextUsageChat;
1802
+ return Array.isArray(chat?.children) && chat.children.includes(block);
1803
+ }
1804
+
1805
+ function installContextBlock(block: StartupContextComponent): boolean {
1806
+ const tui = g.__piContextUsageTui;
1807
+ const chat = findResourceChatContainer(tui) ?? g.__piContextUsageChat;
1808
+ if (!chat || !Array.isArray(chat.children)) return false;
1809
+
1810
+ g.__piContextUsageChat = chat;
1811
+ if (chat.children.includes(block)) return true;
1812
+
1813
+ removeExistingPrefixBlocks(chat);
1814
+ const insertAfter = insertionIndexAfterResourceList(chat);
1815
+ const insertAt = insertAfter >= 0 ? insertAfter + 1 : 0;
1816
+ chat.children.splice(insertAt, 0, block);
1817
+ tui?.requestRender?.(true);
1818
+ return true;
1819
+ }
1820
+
1821
+ function scheduleInstall(block: StartupContextComponent): void {
1822
+ if (g.__piContextUsageInstallTimer) clearTimeout(g.__piContextUsageInstallTimer);
1823
+ let attempts = 0;
1824
+ const attempt = () => {
1825
+ attempts++;
1826
+ if (safely(() => installContextBlock(block), false)) return;
1827
+ if (attempts < 30) {
1828
+ g.__piContextUsageInstallTimer = setTimeout(attempt, 50);
1829
+ }
1830
+ };
1831
+ g.__piContextUsageInstallTimer = setTimeout(attempt, 0);
1832
+ }
1833
+
1834
+ function setMode(mode: ViewMode): void {
1835
+ g.__piContextUsageMode = mode;
1836
+ g.__piContextUsageBlock?.setMode(mode);
1837
+ g.__piContextUsageTui?.requestRender?.(true);
1838
+ }
1839
+
1840
+ // Test-only surface. Named exports are runtime-inert under Pi's jiti loader; this object
1841
+ // exists for the repo test suites (see docs/testing.md) and is not a stable public API.
1842
+ export const internals = {
1843
+ // system-prompt parsing
1844
+ PROJECT_CONTEXT_RE,
1845
+ PROJECT_INSTRUCTIONS_RE,
1846
+ AVAILABLE_SKILLS_RE,
1847
+ SKILL_RE,
1848
+ RESOURCE_HEADER_RE,
1849
+ getPromptRemainder,
1850
+ parseSkills,
1851
+ parseContextSections,
1852
+ buildSkillsSection,
1853
+ // heuristic resolution
1854
+ parseContextUsageConfig,
1855
+ splitConfigPaths,
1856
+ cleanDenominator,
1857
+ resolveHeuristic,
1858
+ // provider payload shaping
1859
+ toolPayloadForShape,
1860
+ aggregateToolPayloadForShape,
1861
+ buildToolNumerator,
1862
+ buildToolDisplayEstimate,
1863
+ // OpenAI cookbook-style formula
1864
+ estimateOpenAIToolDefinitionTokens,
1865
+ estimateOpenAIFunctionToolTokens,
1866
+ // session accounting
1867
+ buildSessionEstimate,
1868
+ // token label layout
1869
+ tokenLabelLayout,
1870
+ estimatedTokenLabel,
1871
+ estimatedTokenField,
1872
+ exactTokenLabel,
1873
+ renderMetricRow,
1874
+ // proportion (design language §8)
1875
+ ctxShareLabel,
1876
+ contextWindowLabel,
1877
+ methodologyHint,
1878
+ // runtime-addition attribution (issue #9)
1879
+ detectRuntimeAdditions,
1880
+ runtimeAdditionsAttribution,
1881
+ // snapshot + renderers
1882
+ buildSnapshot,
1883
+ totalTokens,
1884
+ renderSummary,
1885
+ renderCompact,
1886
+ renderExpanded,
1887
+ stripAnsi,
1888
+ };
1889
+
1890
+ export type {
1891
+ PrefixSnapshot,
1892
+ PrefixSection,
1893
+ ToolSummary,
1894
+ ModelSummary,
1895
+ ContextUsageConfig,
1896
+ ResolvedHeuristic,
1897
+ SessionBreakdown,
1898
+ };
1899
+
1900
+ export default function piContextUsage(pi: ExtensionAPI) {
1901
+ // Snapshot building is expensive (system-prompt regex parse, JSON serialization of
1902
+ // every tool schema, a full session walk) while rendering is frequent. Rebuild only
1903
+ // after a context-changing event or a short staleness window — never per render frame.
1904
+ const SNAPSHOT_TTL_MS = 5_000;
1905
+ const cache: { value?: PrefixSnapshot; builtAt: number; dirty: boolean } = { builtAt: 0, dirty: true };
1906
+ const markDirty = () => {
1907
+ cache.dirty = true;
1908
+ };
1909
+
1910
+ pi.on("message_end", async () => markDirty());
1911
+ pi.on("session_compact", async () => markDirty());
1912
+
1913
+ pi.on("session_start", async (_event, ctx) => {
1914
+ if (!ctx.hasUI) return;
1915
+
1916
+ // Restore Pi's normal header; this extension now renders below Pi's loaded-resource list.
1917
+ ctx.ui.setHeader(undefined);
1918
+
1919
+ const currentMode = g.__piContextUsageMode ?? DEFAULT_MODE;
1920
+ const config = loadContextUsageConfig(ctx.cwd);
1921
+ g.__piContextUsageModel = toModelSummary(ctx.model);
1922
+ markDirty();
1923
+ const block = new StartupContextComponent(
1924
+ () => {
1925
+ const now = Date.now();
1926
+ if (!cache.value || cache.dirty || now - cache.builtAt > SNAPSHOT_TTL_MS) {
1927
+ cache.value = buildSnapshot(
1928
+ pi,
1929
+ () => ctx.getSystemPrompt(),
1930
+ ctx.sessionManager,
1931
+ () => ctx.getContextUsage(),
1932
+ () => g.__piContextUsageModel ?? toModelSummary(ctx.model),
1933
+ config,
1934
+ );
1935
+ cache.builtAt = now;
1936
+ cache.dirty = false;
1937
+ }
1938
+ return cache.value;
1939
+ },
1940
+ () => ctx.ui.theme,
1941
+ currentMode,
1942
+ );
1943
+ g.__piContextUsageBlock = block;
1944
+
1945
+ ctx.ui.setWidget("__pi_context_usage_capture", (tui: ContextUsageTui) => {
1946
+ g.__piContextUsageTui = tui;
1947
+ return {
1948
+ render: () => {
1949
+ const activeBlock = g.__piContextUsageBlock;
1950
+ // Ctrl+T rebuilds Pi's chat transcript to toggle thinking visibility,
1951
+ // which can drop startup-only chat children. Keep this zero-line widget
1952
+ // mounted so it can quietly reinsert the estimator after such rebuilds.
1953
+ if (activeBlock && !isContextBlockInstalled(activeBlock)) scheduleInstall(activeBlock);
1954
+ return [] as string[];
1955
+ },
1956
+ invalidate: () => {},
1957
+ };
1958
+ });
1959
+
1960
+ scheduleInstall(block);
1961
+
1962
+ });
1963
+
1964
+ pi.on("model_select", async (event, ctx) => {
1965
+ g.__piContextUsageModel = toModelSummary(event.model);
1966
+ markDirty();
1967
+ g.__piContextUsageBlock?.invalidate();
1968
+ if (ctx.hasUI) g.__piContextUsageTui?.requestRender?.(true);
1969
+ });
1970
+
1971
+ pi.on("session_shutdown", async (_event, ctx) => {
1972
+ if (g.__piContextUsageInstallTimer) clearTimeout(g.__piContextUsageInstallTimer);
1973
+ g.__piContextUsageInstallTimer = undefined;
1974
+ const block = g.__piContextUsageBlock;
1975
+ const chat = g.__piContextUsageChat;
1976
+ if (block && chat) {
1977
+ const index = chat.children.indexOf(block);
1978
+ if (index >= 0) chat.children.splice(index, 1);
1979
+ }
1980
+ if (ctx.hasUI) {
1981
+ ctx.ui.setWidget("__pi_context_usage_capture", undefined);
1982
+ g.__piContextUsageTui?.requestRender?.(true);
1983
+ }
1984
+ g.__piContextUsageBlock = undefined;
1985
+ g.__piContextUsageChat = undefined;
1986
+ g.__piContextUsageTui = undefined;
1987
+ g.__piContextUsageModel = undefined;
1988
+ cache.value = undefined;
1989
+ cache.dirty = true;
1990
+ cache.builtAt = 0;
1991
+ });
1992
+
1993
+ pi.registerCommand("context-usage", {
1994
+ description: "Show or switch the startup [Context Usage] view (summary, compact, expanded)",
1995
+ handler: async (args, ctx) => {
1996
+ if (!ctx.hasUI) return;
1997
+ const requested = args.trim().toLowerCase() as ViewMode | "";
1998
+ const mode: ViewMode = requested === "summary" || requested === "compact" || requested === "expanded"
1999
+ ? requested
2000
+ : nextMode(g.__piContextUsageMode ?? g.__piContextUsageBlock?.getMode() ?? DEFAULT_MODE);
2001
+ setMode(mode);
2002
+ if (g.__piContextUsageBlock) scheduleInstall(g.__piContextUsageBlock);
2003
+ ctx.ui.notify(`[Context Usage] view: ${mode}`, "info");
2004
+ },
2005
+ });
2006
+ }