@mrclrchtr/supi-context 2.6.1 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/analysis.ts CHANGED
@@ -5,13 +5,18 @@ import {
5
5
  buildSessionContext,
6
6
  type ExtensionAPI,
7
7
  type ExtensionContext,
8
- type estimateTokens,
8
+ estimateTokens,
9
9
  formatSkillsForPrompt,
10
10
  getLatestCompactionEntry,
11
11
  SettingsManager,
12
12
  } from "@earendil-works/pi-coding-agent";
13
13
  import { getRegisteredContextProviders } from "@mrclrchtr/supi-core/context";
14
14
 
15
+ import {
16
+ analyzeContextCapacity,
17
+ type ContextPressureSnapshot,
18
+ createContextPressureSnapshot,
19
+ } from "./capacity.ts";
15
20
  import { deriveOptionsFromSystemPrompt, extractGuidelinesSection } from "./prompt-inference.ts";
16
21
 
17
22
  type AgentMessage = Parameters<typeof estimateTokens>[0];
@@ -69,17 +74,9 @@ export interface ContextProviderSection {
69
74
  data: Record<string, string | number>;
70
75
  }
71
76
 
72
- export interface ContextAnalysis {
73
- modelName: string;
74
- contextWindow: number;
75
- totalTokens: number | null;
77
+ export interface ContextAnalysis extends ContextPressureSnapshot {
76
78
  scaled: boolean;
77
- approximationNote: string | null;
78
- full: boolean;
79
- categories: CategoryTokens & {
80
- autocompactBuffer: number;
81
- freeSpace: number;
82
- };
79
+ categories: CategoryTokens;
83
80
  systemPromptBreakdown: {
84
81
  base: number;
85
82
  instructionFiles: ContextFileInfo[];
@@ -98,7 +95,6 @@ export interface ContextAnalysis {
98
95
  guidelineSources: GuidelineSourceInfo[];
99
96
  toolSnippetDetails: ToolSnippetInfo[];
100
97
  toolDefinitions: { count: number; tokens: number; tools: ToolInfo[] };
101
- compaction: { summarizedTurns: number } | null;
102
98
  providerSections: ContextProviderSection[];
103
99
  }
104
100
 
@@ -106,39 +102,6 @@ export function estimateTextTokens(text: string): number {
106
102
  return Math.ceil(text.length / 4);
107
103
  }
108
104
 
109
- function estimateGenericContent(content: unknown): number {
110
- if (typeof content === "string") {
111
- return estimateTextTokens(content);
112
- }
113
- if (Array.isArray(content)) {
114
- let chars = 0;
115
- for (const block of content as Array<{ type?: string; text?: string }>) {
116
- if (block.type === "text" && block.text) {
117
- chars += block.text.length;
118
- }
119
- }
120
- return Math.ceil(chars / 4);
121
- }
122
- return 0;
123
- }
124
-
125
- function estimateUserMessage(msg: Extract<AgentMessage, { role: "user" }>): number {
126
- const content = msg.content;
127
- if (typeof content === "string") {
128
- return estimateTextTokens(content);
129
- }
130
- if (Array.isArray(content)) {
131
- let chars = 0;
132
- for (const block of content) {
133
- if (block.type === "text" && block.text) {
134
- chars += block.text.length;
135
- }
136
- }
137
- return Math.ceil(chars / 4);
138
- }
139
- return 0;
140
- }
141
-
142
105
  function estimateAssistantMessage(msg: Extract<AgentMessage, { role: "assistant" }>): {
143
106
  text: number;
144
107
  toolCalls: number;
@@ -169,7 +132,7 @@ function estimateMessageByCategory(msg: AgentMessage): {
169
132
  } {
170
133
  if (msg.role === "user") {
171
134
  return {
172
- user: estimateUserMessage(msg),
135
+ user: estimateTokens(msg),
173
136
  assistantText: 0,
174
137
  toolCalls: 0,
175
138
  toolResult: 0,
@@ -185,7 +148,7 @@ function estimateMessageByCategory(msg: AgentMessage): {
185
148
  user: 0,
186
149
  assistantText: 0,
187
150
  toolCalls: 0,
188
- toolResult: estimateGenericContent(msg.content),
151
+ toolResult: estimateTokens(msg),
189
152
  other: 0,
190
153
  };
191
154
  }
@@ -194,10 +157,14 @@ function estimateMessageByCategory(msg: AgentMessage): {
194
157
  assistantText: 0,
195
158
  toolCalls: 0,
196
159
  toolResult: 0,
197
- other: estimateGenericContent((msg as unknown as Record<string, unknown>).content),
160
+ other: estimateTokens(msg),
198
161
  };
199
162
  }
200
163
 
164
+ function estimateMessageTokens(msg: AgentMessage): number {
165
+ return estimateTokens(msg);
166
+ }
167
+
201
168
  function computeMessageCategories(messages: AgentMessage[]): CategoryTokens {
202
169
  const categories: CategoryTokens = {
203
170
  systemPrompt: 0,
@@ -220,6 +187,26 @@ function computeMessageCategories(messages: AgentMessage[]): CategoryTokens {
220
187
  return categories;
221
188
  }
222
189
 
190
+ interface ScalingResult {
191
+ categories: CategoryTokens;
192
+ scaled: boolean;
193
+ approximationNote: string | null;
194
+ usedTokens: number;
195
+ }
196
+
197
+ type CurrentContextUsage = ReturnType<ExtensionContext["getContextUsage"]>;
198
+
199
+ function hasMeasuredTokens(tokens: number | null | undefined): tokens is number {
200
+ return typeof tokens === "number" && tokens > 0;
201
+ }
202
+
203
+ function getApproximationNote(contextUsage: CurrentContextUsage): string | null {
204
+ if (contextUsage === undefined) return "Approximate (no usage data available)";
205
+ return hasMeasuredTokens(contextUsage.tokens)
206
+ ? null
207
+ : "Token count pending — send a message to refresh";
208
+ }
209
+
223
210
  function applyScaling(
224
211
  categories: CategoryTokens,
225
212
  actualTokens: number | null,
@@ -227,20 +214,13 @@ function applyScaling(
227
214
  contextUsage:
228
215
  | { tokens: number | null; contextWindow: number; percent: number | null }
229
216
  | undefined,
230
- ): {
231
- categories: CategoryTokens;
232
- scaled: boolean;
233
- approximationNote: string | null;
234
- totalTokens: number;
235
- } {
217
+ ): ScalingResult {
236
218
  let scaled = false;
237
- let approximationNote: string | null = null;
238
- const hasActualTotal = actualTokens !== null && actualTokens > 0;
239
- const totalTokens = hasActualTotal ? actualTokens : rawTotal;
219
+ const hasActualTotal = hasMeasuredTokens(actualTokens);
220
+ const usedTokens = hasActualTotal ? actualTokens : rawTotal;
221
+ const approximationNote = getApproximationNote(contextUsage);
240
222
 
241
- if (contextUsage === undefined) {
242
- approximationNote = "Approximate (no usage data available)";
243
- } else if (hasActualTotal && rawTotal > 0) {
223
+ if (hasActualTotal && rawTotal > 0) {
244
224
  const scale = actualTokens / rawTotal;
245
225
  categories.systemPrompt = Math.round(categories.systemPrompt * scale);
246
226
  categories.userMessages = Math.round(categories.userMessages * scale);
@@ -249,11 +229,9 @@ function applyScaling(
249
229
  categories.toolResults = Math.round(categories.toolResults * scale);
250
230
  categories.other = Math.round(categories.other * scale);
251
231
  scaled = true;
252
- } else if (actualTokens === null || actualTokens === 0) {
253
- approximationNote = "Token count pending — send a message to refresh";
254
232
  }
255
233
 
256
- return { categories, scaled, approximationNote, totalTokens };
234
+ return { categories, scaled, approximationNote, usedTokens };
257
235
  }
258
236
 
259
237
  /**
@@ -508,18 +486,10 @@ function computeToolDefinitions(pi: ExtensionAPI): {
508
486
  };
509
487
  }
510
488
 
511
- function detectCompaction(branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>): {
512
- summarizedTurns: number;
513
- } | null {
514
- const compactionEntry = getLatestCompactionEntry(branch);
515
- if (!compactionEntry) return null;
516
-
517
- const index = branch.findIndex((e) => e.id === compactionEntry.id);
518
- const messagesBefore = branch
519
- .slice(0, Math.max(0, index))
520
- .filter((e) => e.type === "message").length;
521
- const summarizedTurns = Math.floor(messagesBefore / 2);
522
- return { summarizedTurns };
489
+ function hasCompactionOnActiveBranch(
490
+ branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>,
491
+ ): boolean {
492
+ return getLatestCompactionEntry(branch) !== null;
523
493
  }
524
494
 
525
495
  export function extractInjectedContextFiles(messages: AgentMessage[]): InjectedFileInfo[] {
@@ -556,22 +526,93 @@ export function extractInjectedContextFiles(messages: AgentMessage[]): InjectedF
556
526
  return Array.from(seen.values()).sort((a, b) => a.turn - b.turn || a.file.localeCompare(b.file));
557
527
  }
558
528
 
559
- export function analyzeContext(
529
+ interface ContextFallback {
530
+ messages: AgentMessage[];
531
+ systemPromptText: string;
532
+ }
533
+
534
+ interface CapacityObservation {
535
+ branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>;
536
+ contextUsage: CurrentContextUsage;
537
+ snapshot: ContextPressureSnapshot;
538
+ fallback?: ContextFallback;
539
+ }
540
+
541
+ interface ContextObservation extends ContextFallback {
542
+ scaling: ScalingResult;
543
+ snapshot: ContextPressureSnapshot;
544
+ }
545
+
546
+ function estimateContextTokens(fallback: ContextFallback): number {
547
+ return (
548
+ estimateTextTokens(fallback.systemPromptText) +
549
+ fallback.messages.reduce((total, message) => total + estimateMessageTokens(message), 0)
550
+ );
551
+ }
552
+
553
+ function collectContextFallback(
560
554
  ctx: ExtensionContext,
561
- pi: ExtensionAPI,
562
- cachedOptions: BuildSystemPromptOptions | undefined,
563
- full = false,
564
- ): ContextAnalysis {
555
+ branch: ReturnType<ExtensionContext["sessionManager"]["getBranch"]>,
556
+ ): ContextFallback {
557
+ return {
558
+ messages: buildSessionContext(branch).messages,
559
+ systemPromptText: ctx.getSystemPrompt(),
560
+ };
561
+ }
562
+
563
+ /**
564
+ * Observe the small shared capacity seam. It only walks messages when Pi has
565
+ * no measured usage total and an aggregate estimate is genuinely necessary.
566
+ */
567
+ function observeCapacity(ctx: ExtensionContext): CapacityObservation {
565
568
  const branch = ctx.sessionManager.getBranch();
566
- const apiView = buildSessionContext(branch);
567
569
  const contextUsage = ctx.getContextUsage();
568
- const contextWindow = contextUsage?.contextWindow ?? 0;
569
- const actualTokens = contextUsage?.tokens ?? null;
570
+ let fallback: ContextFallback | undefined;
571
+ let usedTokens: number;
572
+ if (hasMeasuredTokens(contextUsage?.tokens)) {
573
+ usedTokens = contextUsage.tokens;
574
+ } else {
575
+ fallback = collectContextFallback(ctx, branch);
576
+ usedTokens = estimateContextTokens(fallback);
577
+ }
578
+ const settings = SettingsManager.create(ctx.cwd, undefined, {
579
+ projectTrusted: ctx.isProjectTrusted(),
580
+ });
581
+ const capacity = analyzeContextCapacity({
582
+ contextWindow: contextUsage?.contextWindow ?? null,
583
+ usedTokens,
584
+ compactionEnabled: settings.getCompactionEnabled(),
585
+ configuredReserveTokens: settings.getCompactionReserveTokens(),
586
+ compacted: hasCompactionOnActiveBranch(branch),
587
+ approximationNote: getApproximationNote(contextUsage),
588
+ });
570
589
 
571
- const systemPromptText = ctx.getSystemPrompt();
572
- const categories = computeMessageCategories(apiView.messages);
573
- categories.systemPrompt = estimateTextTokens(systemPromptText);
590
+ return {
591
+ branch,
592
+ contextUsage,
593
+ fallback,
594
+ snapshot: createContextPressureSnapshot(
595
+ ctx.model?.name ?? ctx.model?.id ?? "No model selected",
596
+ capacity,
597
+ ),
598
+ };
599
+ }
600
+
601
+ /** Return a constant-shape Context Pressure Snapshot without diagnostic attribution. */
602
+ export function analyzeContextPressure(ctx: ExtensionContext): ContextPressureSnapshot {
603
+ return observeCapacity(ctx).snapshot;
604
+ }
574
605
 
606
+ /** Compose a full Context Usage Report from shared capacity and diagnostic attribution. */
607
+ export function analyzeContext(
608
+ ctx: ExtensionContext,
609
+ pi: ExtensionAPI,
610
+ cachedOptions: BuildSystemPromptOptions | undefined,
611
+ ): ContextAnalysis {
612
+ const capacity = observeCapacity(ctx);
613
+ const fallback = capacity.fallback ?? collectContextFallback(ctx, capacity.branch);
614
+ const categories = computeMessageCategories(fallback.messages);
615
+ categories.systemPrompt = estimateTextTokens(fallback.systemPromptText);
575
616
  const rawTotal =
576
617
  categories.systemPrompt +
577
618
  categories.userMessages +
@@ -579,45 +620,33 @@ export function analyzeContext(
579
620
  categories.toolCalls +
580
621
  categories.toolResults +
581
622
  categories.other;
582
-
583
- const scaling = applyScaling(categories, actualTokens, rawTotal, contextUsage);
584
-
585
- const autocompactBuffer =
586
- contextWindow > 0 ? SettingsManager.create(ctx.cwd).getCompactionReserveTokens() : 0;
587
- const used =
588
- scaling.categories.systemPrompt +
589
- scaling.categories.userMessages +
590
- scaling.categories.assistantMessages +
591
- scaling.categories.toolCalls +
592
- scaling.categories.toolResults +
593
- scaling.categories.other;
594
- const freeSpace = Math.max(0, contextWindow - used - autocompactBuffer);
595
-
623
+ const observation: ContextObservation = {
624
+ ...fallback,
625
+ scaling: applyScaling(
626
+ categories,
627
+ capacity.contextUsage?.tokens ?? null,
628
+ rawTotal,
629
+ capacity.contextUsage,
630
+ ),
631
+ snapshot: capacity.snapshot,
632
+ };
596
633
  const promptOptions = deriveOptionsFromSystemPrompt(ctx, cachedOptions);
597
634
  const breakdown = computeSystemPromptBreakdown(
598
635
  promptOptions,
599
- systemPromptText,
600
- scaling.categories.systemPrompt,
636
+ observation.systemPromptText,
637
+ observation.scaling.categories.systemPrompt,
601
638
  ctx.cwd,
602
639
  );
603
- const injectedFiles = extractInjectedContextFiles(apiView.messages);
640
+ const injectedFiles = extractInjectedContextFiles(observation.messages);
604
641
  const toolDefinitions = computeToolDefinitions(pi);
605
- const compaction = detectCompaction(branch);
606
-
607
- const guidelineBullets = extractGuidelineBullets(extractGuidelinesSection(systemPromptText));
642
+ const guidelineBullets = extractGuidelineBullets(
643
+ extractGuidelinesSection(observation.systemPromptText),
644
+ );
608
645
 
609
646
  return {
610
- modelName: ctx.model?.name ?? ctx.model?.id ?? "No model selected",
611
- contextWindow,
612
- totalTokens: scaling.totalTokens,
613
- scaled: scaling.scaled,
614
- approximationNote: scaling.approximationNote,
615
- full,
616
- categories: {
617
- ...scaling.categories,
618
- autocompactBuffer,
619
- freeSpace,
620
- },
647
+ ...observation.snapshot,
648
+ scaled: observation.scaling.scaled,
649
+ categories: observation.scaling.categories,
621
650
  systemPromptBreakdown: breakdown,
622
651
  injectedFiles,
623
652
  skills: breakdown.skills,
@@ -626,7 +655,6 @@ export function analyzeContext(
626
655
  guidelineSources: breakdown.guidelineSources,
627
656
  toolSnippetDetails: breakdown.toolSnippetDetails,
628
657
  toolDefinitions,
629
- compaction,
630
658
  providerSections: collectProviderData(),
631
659
  };
632
660
  }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * A small, point-in-time capacity reading without diagnostic attribution.
3
+ */
4
+ export interface ContextCapacity {
5
+ contextWindow: number | null;
6
+ usedTokens: number;
7
+ usagePercent: number | null;
8
+ compactionEnabled: boolean;
9
+ reserveTokens: number;
10
+ headroomTokens: number | null;
11
+ pressurePercent: number | null;
12
+ compacted: boolean;
13
+ approximationNote: string | null;
14
+ }
15
+
16
+ /** Agent-facing, constant-shape reading of current context pressure. */
17
+ export interface ContextPressureSnapshot extends ContextCapacity {
18
+ modelName: string;
19
+ }
20
+
21
+ /** Inputs that the shared capacity analysis derives from session state. */
22
+ export interface ContextCapacityInput {
23
+ contextWindow: number | null;
24
+ usedTokens: number;
25
+ compactionEnabled: boolean;
26
+ configuredReserveTokens: number;
27
+ compacted: boolean;
28
+ approximationNote: string | null;
29
+ }
30
+
31
+ function roundPercentage(value: number): number {
32
+ return Math.round(value * 10) / 10;
33
+ }
34
+
35
+ /**
36
+ * Derive reserve-adjusted capacity without inspecting diagnostic inventories.
37
+ *
38
+ * The configured reserve affects capacity only while auto-compaction is enabled.
39
+ * This lets report and snapshot callers share exactly the same Active Context
40
+ * Limit, Headroom, and Pressure Percentage semantics.
41
+ */
42
+ export function analyzeContextCapacity(input: ContextCapacityInput): ContextCapacity {
43
+ const contextWindow = input.contextWindow && input.contextWindow > 0 ? input.contextWindow : null;
44
+ const reserveTokens = input.compactionEnabled ? Math.max(0, input.configuredReserveTokens) : 0;
45
+ const activeLimit = contextWindow === null ? null : contextWindow - reserveTokens;
46
+ const hasUsableActiveLimit = activeLimit !== null && activeLimit > 0;
47
+
48
+ return {
49
+ contextWindow,
50
+ usedTokens: input.usedTokens,
51
+ usagePercent:
52
+ contextWindow === null ? null : roundPercentage((input.usedTokens / contextWindow) * 100),
53
+ compactionEnabled: input.compactionEnabled,
54
+ reserveTokens,
55
+ headroomTokens: activeLimit === null ? null : Math.max(0, activeLimit - input.usedTokens),
56
+ pressurePercent: hasUsableActiveLimit
57
+ ? roundPercentage((input.usedTokens / activeLimit) * 100)
58
+ : null,
59
+ compacted: input.compacted,
60
+ approximationNote: input.approximationNote,
61
+ };
62
+ }
63
+
64
+ /** Create the exact agent-facing Context Pressure Snapshot shape. */
65
+ export function createContextPressureSnapshot(
66
+ modelName: string,
67
+ capacity: ContextCapacity,
68
+ ): ContextPressureSnapshot {
69
+ return {
70
+ modelName,
71
+ contextWindow: capacity.contextWindow,
72
+ usedTokens: capacity.usedTokens,
73
+ usagePercent: capacity.usagePercent,
74
+ compactionEnabled: capacity.compactionEnabled,
75
+ reserveTokens: capacity.reserveTokens,
76
+ headroomTokens: capacity.headroomTokens,
77
+ pressurePercent: capacity.pressurePercent,
78
+ compacted: capacity.compacted,
79
+ approximationNote: capacity.approximationNote,
80
+ };
81
+ }
package/src/context.ts CHANGED
@@ -1,49 +1,53 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
1
2
  import type { BuildSystemPromptOptions, ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
3
  import { Type } from "typebox";
3
- import { analyzeContext } from "./analysis.ts";
4
+ import { analyzeContext, analyzeContextPressure } from "./analysis.ts";
4
5
  import { loadContextConfig } from "./config.ts";
5
- import { registerContextRenderer } from "./renderer.ts";
6
+ import { type ContextReportEntryData, registerContextEntryRenderer } from "./entry-renderer.ts";
6
7
  import { registerContextSettings } from "./settings-registration.ts";
7
8
  import { promptGuidelines, promptSnippet, toolDescription } from "./tool/guidance.ts";
9
+ import { serializeFullContextAnalysis } from "./tool/output.ts";
8
10
  import {
9
11
  type ContextToolDetails,
10
12
  renderContextToolCall,
11
13
  renderContextToolResult,
12
14
  } from "./tool/render.ts";
13
- import { formatTokens } from "./utils.ts";
15
+
16
+ const contextToolParameters = Type.Object({
17
+ mode: Type.Optional(
18
+ StringEnum(["concise", "full"] as const, {
19
+ description: "Omit for concise capacity data, or use full for the diagnostic report.",
20
+ }),
21
+ ),
22
+ });
14
23
 
15
24
  export default function contextExtension(pi: ExtensionAPI) {
16
25
  let cachedOptions: BuildSystemPromptOptions | undefined;
26
+ let commandRegistered = false;
17
27
 
18
- // Register settings synchronously during factory
28
+ // Register settings synchronously during factory.
19
29
  registerContextSettings(pi);
30
+ registerContextEntryRenderer(pi);
20
31
 
21
32
  pi.on("before_agent_start", async (event) => {
22
33
  cachedOptions = event.systemPromptOptions;
23
34
  });
24
35
 
25
- pi.on("session_start", async () => {
36
+ pi.on("session_start", async (_event, ctx) => {
26
37
  cachedOptions = undefined;
27
- });
38
+ if (ctx.mode !== "tui" || commandRegistered) return;
28
39
 
29
- pi.registerCommand("supi-context", {
30
- description: "Show detailed context usage. Pass 'full' to show all guideline bullets.",
31
- handler: async (args, ctx) => {
32
- const full = args.trim() === "full";
33
- const analysis = analyzeContext(ctx, pi, cachedOptions, full);
34
- const shortContent = `${formatTokens(analysis.totalTokens ?? 0)} / ${formatTokens(analysis.contextWindow)} tokens`;
35
-
36
- pi.sendMessage({
37
- customType: "supi-context",
38
- content: shortContent,
39
- display: true,
40
- details: { analysis },
41
- });
42
- },
40
+ commandRegistered = true;
41
+ pi.registerCommand("supi-context", {
42
+ description: "Show detailed context usage. Pass 'full' to show all guideline bullets.",
43
+ handler: async (args, commandCtx) => {
44
+ const mode = args.trim() === "full" ? "full" : "preview";
45
+ const analysis = analyzeContext(commandCtx, pi, cachedOptions);
46
+ pi.appendEntry<ContextReportEntryData>("supi-context", { mode, analysis });
47
+ },
48
+ });
43
49
  });
44
50
 
45
- registerContextRenderer(pi);
46
-
47
51
  // ── supi_context agent tool (gated on config) ────────────
48
52
 
49
53
  if (loadContextConfig(process.cwd()).agentToolEnabled) {
@@ -52,16 +56,24 @@ export default function contextExtension(pi: ExtensionAPI) {
52
56
  label: "Context Usage",
53
57
  description: toolDescription,
54
58
  promptSnippet,
55
- parameters: Type.Object({}),
59
+ parameters: contextToolParameters,
56
60
  promptGuidelines,
57
61
  renderCall: renderContextToolCall,
58
62
  renderResult: renderContextToolResult,
59
63
  // biome-ignore lint/complexity/useMaxParams: pi tool execute signature
60
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
61
- const analysis = analyzeContext(ctx, pi, cachedOptions, true);
64
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
65
+ if (params.mode !== "full") {
66
+ const snapshot = analyzeContextPressure(ctx);
67
+ return {
68
+ content: [{ type: "text", text: JSON.stringify(snapshot) }],
69
+ details: { mode: "concise", snapshot } satisfies ContextToolDetails,
70
+ };
71
+ }
72
+
73
+ const analysis = analyzeContext(ctx, pi, cachedOptions);
62
74
  return {
63
- content: [{ type: "text", text: JSON.stringify(analysis, null, 2) }],
64
- details: { analysis } satisfies ContextToolDetails,
75
+ content: [{ type: "text", text: await serializeFullContextAnalysis(analysis) }],
76
+ details: { mode: "full", analysis } satisfies ContextToolDetails,
65
77
  };
66
78
  },
67
79
  });
@@ -0,0 +1,23 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import type { ContextAnalysis } from "./analysis.ts";
4
+ import type { ContextReportMode } from "./format.ts";
5
+ import { ContextReportComponent } from "./report-component.ts";
6
+
7
+ /** Durable, TUI-only payload appended by the `/supi-context` command. */
8
+ export interface ContextReportEntryData {
9
+ analysis: ContextAnalysis;
10
+ mode: ContextReportMode;
11
+ }
12
+
13
+ /** Register the TUI renderer for new Context Usage Report custom entries. */
14
+ export function registerContextEntryRenderer(pi: ExtensionAPI): void {
15
+ pi.registerEntryRenderer<ContextReportEntryData>("supi-context", (entry, _options, theme) => {
16
+ const data = entry.data;
17
+ if (!data) {
18
+ return new Text(theme.fg("dim", "No context analysis data"), 1, 0);
19
+ }
20
+
21
+ return new ContextReportComponent(data.analysis, theme, data.mode);
22
+ });
23
+ }
@@ -74,11 +74,9 @@ export function allocateBlocks(values: number[], totalBlocks: number): number[]
74
74
  return counts;
75
75
  }
76
76
 
77
- export function healthColor(analysis: ContextAnalysis): ReportColor {
78
- if (analysis.contextWindow <= 0) return "dim";
79
- const reserved = analysis.totalTokens ?? 0;
80
- const pressure =
81
- ((reserved + analysis.categories.autocompactBuffer) / analysis.contextWindow) * 100;
77
+ export function healthColor(analysis: Pick<ContextAnalysis, "pressurePercent">): ReportColor {
78
+ const pressure = analysis.pressurePercent;
79
+ if (pressure === null) return "dim";
82
80
  if (pressure >= 90) return "error";
83
81
  if (pressure >= 70) return "warning";
84
82
  return "success";
@@ -112,7 +112,7 @@ export function renderInjectedFilesSection(
112
112
  lines: file.lines,
113
113
  extra: `turn ${file.turn}`,
114
114
  })),
115
- total: analysis.totalTokens ?? 0,
115
+ total: analysis.usedTokens,
116
116
  theme,
117
117
  width,
118
118
  });
@@ -196,6 +196,7 @@ export function renderGuidelinesSection(
196
196
  analysis: ContextAnalysis,
197
197
  theme: Theme,
198
198
  width: number,
199
+ full: boolean,
199
200
  ): string[] {
200
201
  const sourceSummary = renderSourceSummaryBar(analysis.guidelineSources);
201
202
 
@@ -217,7 +218,7 @@ export function renderGuidelinesSection(
217
218
  return lines;
218
219
  }
219
220
 
220
- lines.push(...renderBulletLines(bullets, analysis.full, theme, width));
221
+ lines.push(...renderBulletLines(bullets, full, theme, width));
221
222
  return lines;
222
223
  }
223
224
 
@@ -225,6 +226,7 @@ export function renderToolDefinitionsSection(
225
226
  analysis: ContextAnalysis,
226
227
  theme: Theme,
227
228
  width: number,
229
+ full: boolean,
228
230
  ): string[] {
229
231
  const tools = [...analysis.toolDefinitions.tools].sort((a, b) => b.tokens - a.tokens);
230
232
  if (tools.length === 0) return [];
@@ -241,7 +243,7 @@ export function renderToolDefinitionsSection(
241
243
  ),
242
244
  );
243
245
 
244
- const previewLimit = analysis.full ? tools.length : Math.min(5, tools.length);
246
+ const previewLimit = full ? tools.length : Math.min(5, tools.length);
245
247
  const nameWidth = Math.max(12, Math.min(18, Math.max(...tools.map((tool) => tool.name.length))));
246
248
  const defTokenWidth = 8;
247
249
  const snippetTokenWidth = hasSnippetDetails ? 10 : 0;
@@ -253,7 +255,7 @@ export function renderToolDefinitionsSection(
253
255
  const tool = tools[i];
254
256
  const name = padRight(tool.name, nameWidth);
255
257
  const previewDescription =
256
- analysis.full || tool.description.length <= 50
258
+ full || tool.description.length <= 50
257
259
  ? tool.description
258
260
  : `${tool.description.slice(0, 50)}…`;
259
261
  const description = truncateToWidth(previewDescription, descWidth);
@@ -270,7 +272,7 @@ export function renderToolDefinitionsSection(
270
272
  );
271
273
  }
272
274
 
273
- if (!analysis.full && tools.length > previewLimit) {
275
+ if (!full && tools.length > previewLimit) {
274
276
  lines.push(
275
277
  formatOverflowHint(tools.length - previewLimit, theme, width, {
276
278
  hint: "run /supi-context full",
@@ -293,14 +295,8 @@ export function renderCompactionNote(
293
295
  theme: Theme,
294
296
  width: number,
295
297
  ): string[] {
296
- if (!analysis.compaction) return [];
297
- return [
298
- formatDimLine(
299
- `↳ ${pluralize(analysis.compaction.summarizedTurns, "older turn", "older turns")} summarized (compaction)`,
300
- theme,
301
- width,
302
- ),
303
- ];
298
+ if (!analysis.compacted) return [];
299
+ return [formatDimLine("↳ Compaction present on the active branch", theme, width)];
304
300
  }
305
301
 
306
302
  export function renderProviderSections(