@mrclrchtr/supi-context 2.6.1 → 2.7.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/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(
@@ -15,13 +15,27 @@ import {
15
15
  } from "./format-helpers.ts";
16
16
  import { formatTokens } from "./utils.ts";
17
17
 
18
+ function formatPercentage(value: number | null): string {
19
+ return value === null ? "?" : `${value.toFixed(1)}%`;
20
+ }
21
+
18
22
  export function renderSummary(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
19
- const used = analysis.totalTokens ?? 0;
20
23
  const health = theme.fg(healthColor(analysis), "●");
21
24
  const usage =
22
- analysis.contextWindow > 0
23
- ? `${formatTokens(used)} / ${formatTokens(analysis.contextWindow)} tokens (${pct(used, analysis.contextWindow)})`
24
- : `${formatTokens(used)} tokens`;
25
+ analysis.contextWindow !== null
26
+ ? `${formatTokens(analysis.usedTokens)} / ${formatTokens(analysis.contextWindow)} tokens (${formatPercentage(analysis.usagePercent)} usage)`
27
+ : `${formatTokens(analysis.usedTokens)} tokens`;
28
+ const capacityParts: string[] = [];
29
+
30
+ if (analysis.headroomTokens !== null) {
31
+ capacityParts.push(`Headroom ${formatTokens(analysis.headroomTokens)}`);
32
+ }
33
+ if (analysis.compactionEnabled) {
34
+ capacityParts.push(`Compaction reserve ${formatTokens(analysis.reserveTokens)}`);
35
+ }
36
+ if (analysis.pressurePercent !== null) {
37
+ capacityParts.push(`Pressure ${formatPercentage(analysis.pressurePercent)}`);
38
+ }
25
39
 
26
40
  const lines = [
27
41
  truncateToWidth(
@@ -30,6 +44,9 @@ export function renderSummary(analysis: ContextAnalysis, theme: Theme, width: nu
30
44
  ),
31
45
  ];
32
46
 
47
+ if (capacityParts.length > 0) {
48
+ lines.push(...wrapReportText(theme.fg("dim", capacityParts.join(" · ")), width));
49
+ }
33
50
  if (analysis.approximationNote) {
34
51
  lines.push(...wrapReportText(theme.fg("warning", analysis.approximationNote), width));
35
52
  }
@@ -38,16 +55,16 @@ export function renderSummary(analysis: ContextAnalysis, theme: Theme, width: nu
38
55
  }
39
56
 
40
57
  export function renderUsageBar(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
41
- if (analysis.contextWindow <= 0) {
58
+ if (analysis.contextWindow === null) {
42
59
  return [theme.fg("dim", "No model selected — usage bar unavailable")];
43
60
  }
44
61
 
45
- const percentLabel = pct(analysis.totalTokens ?? 0, analysis.contextWindow);
62
+ const percentLabel = formatPercentage(analysis.usagePercent);
46
63
  const barWidth = Math.max(12, Math.min(48, width - visibleWidth(percentLabel) - 3));
47
64
  const values = [
48
65
  ...CATEGORY_ORDER.map((key) => analysis.categories[key]),
49
- analysis.categories.autocompactBuffer,
50
- analysis.categories.freeSpace,
66
+ analysis.reserveTokens,
67
+ analysis.headroomTokens ?? 0,
51
68
  ];
52
69
  const counts = allocateBlocks(values, barWidth);
53
70
 
@@ -85,11 +102,11 @@ export function renderUsageBar(analysis: ContextAnalysis, theme: Theme, width: n
85
102
  if (analysis.categories[key] <= 0) continue;
86
103
  legendParts.push(`${theme.fg(CATEGORY_COLORS[key], "●")} ${CATEGORY_LABELS[key]}`);
87
104
  }
88
- if (analysis.categories.autocompactBuffer > 0) {
89
- legendParts.push(`${theme.fg("warning", "▒")} Autocompact buffer`);
105
+ if (analysis.reserveTokens > 0) {
106
+ legendParts.push(`${theme.fg("warning", "▒")} Compaction reserve`);
90
107
  }
91
- if (analysis.categories.freeSpace > 0) {
92
- legendParts.push(`${theme.fg("dim", "░")} Free space`);
108
+ if (analysis.headroomTokens !== null && analysis.headroomTokens > 0) {
109
+ legendParts.push(`${theme.fg("dim", "░")} Headroom`);
93
110
  }
94
111
 
95
112
  return [barLine, ...wrapReportText(legendParts.join(theme.fg("dim", " • ")), width)];
@@ -107,31 +124,20 @@ export function renderCategoryBreakdown(
107
124
  label: string;
108
125
  color: Parameters<Theme["fg"]>[0];
109
126
  tokens: number;
110
- }> = [
111
- ...CATEGORY_ORDER.map((key) => ({
112
- label: CATEGORY_LABELS[key],
113
- color: CATEGORY_COLORS[key],
114
- tokens: analysis.categories[key],
115
- })),
116
- {
117
- label: "Autocompact buffer",
118
- color: "warning",
119
- tokens: analysis.categories.autocompactBuffer,
120
- },
121
- {
122
- label: "Free space",
123
- color: "dim",
124
- tokens: analysis.categories.freeSpace,
125
- },
126
- ];
127
+ }> = CATEGORY_ORDER.map((key) => ({
128
+ label: CATEGORY_LABELS[key],
129
+ color: CATEGORY_COLORS[key],
130
+ tokens: analysis.categories[key],
131
+ }));
127
132
 
128
133
  const labelWidth = Math.max(18, Math.min(22, width - 22));
134
+ const total = analysis.contextWindow ?? 0;
129
135
  for (const row of rows) {
130
- if (row.tokens <= 0 && row.label !== "Free space") continue;
136
+ if (row.tokens <= 0) continue;
131
137
  const bullet = theme.fg(row.color, "●");
132
138
  const label = padRight(row.label, labelWidth);
133
139
  const tokens = padLeft(formatTokens(row.tokens), 8);
134
- const percentage = padLeft(pct(row.tokens, analysis.contextWindow), 7);
140
+ const percentage = padLeft(pct(row.tokens, total), 7);
135
141
  lines.push(truncateToWidth(` ${bullet} ${label} ${tokens} ${percentage}`, width));
136
142
  }
137
143
 
package/src/format.ts CHANGED
@@ -18,10 +18,14 @@ import {
18
18
  renderUsageBar,
19
19
  } from "./format-summary.ts";
20
20
 
21
+ /** Presentation mode for a Context Usage Report. */
22
+ export type ContextReportMode = "preview" | "full";
23
+
21
24
  export function formatContextReport(
22
25
  analysis: ContextAnalysis,
23
26
  theme: Theme,
24
27
  width = 200,
28
+ mode: ContextReportMode = "preview",
25
29
  ): string[] {
26
30
  const safeWidth = clampReportWidth(width);
27
31
  const lines: string[] = [];
@@ -41,8 +45,8 @@ export function formatContextReport(
41
45
  renderContextFilesSection(analysis, theme, safeWidth),
42
46
  renderInjectedFilesSection(analysis, theme, safeWidth),
43
47
  renderSkillsSection(analysis, theme, safeWidth),
44
- renderGuidelinesSection(analysis, theme, safeWidth),
45
- renderToolDefinitionsSection(analysis, theme, safeWidth),
48
+ renderGuidelinesSection(analysis, theme, safeWidth, mode === "full"),
49
+ renderToolDefinitionsSection(analysis, theme, safeWidth, mode === "full"),
46
50
  renderCompactionNote(analysis, theme, safeWidth),
47
51
  renderProviderSections(analysis, theme, safeWidth),
48
52
  ].filter((section) => section.length > 0);
@@ -1,8 +1,8 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { ContextAnalysis } from "./analysis.ts";
3
- import { formatContextReport } from "./format.ts";
3
+ import { type ContextReportMode, formatContextReport } from "./format.ts";
4
4
 
5
- /** Width-aware rendered context report shared by message and tool renderers. */
5
+ /** Width-aware Context Usage Report shared by custom-entry and tool renderers. */
6
6
  export class ContextReportComponent {
7
7
  private cachedWidth?: number;
8
8
  private cachedLines?: string[];
@@ -10,6 +10,7 @@ export class ContextReportComponent {
10
10
  constructor(
11
11
  private readonly analysis: ContextAnalysis,
12
12
  private readonly theme: Theme,
13
+ private readonly mode: ContextReportMode = "preview",
13
14
  ) {}
14
15
 
15
16
  render(width: number): string[] {
@@ -17,7 +18,7 @@ export class ContextReportComponent {
17
18
  return this.cachedLines;
18
19
  }
19
20
 
20
- const lines = formatContextReport(this.analysis, this.theme, width);
21
+ const lines = formatContextReport(this.analysis, this.theme, width, this.mode);
21
22
  this.cachedWidth = width;
22
23
  this.cachedLines = lines;
23
24
  return lines;
@@ -0,0 +1,75 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import type { ContextPressureSnapshot } from "./capacity.ts";
4
+ import { healthColor } from "./format-helpers.ts";
5
+ import { formatTokens } from "./utils.ts";
6
+
7
+ function formatPercentage(value: number | null): string {
8
+ return value === null ? "unavailable" : `${value.toFixed(1)}%`;
9
+ }
10
+
11
+ function formatTokenValue(value: number | null): string {
12
+ return value === null ? "unavailable" : formatTokens(value);
13
+ }
14
+
15
+ /**
16
+ * Width-safe TUI renderer for the small Context Pressure Snapshot.
17
+ *
18
+ * The collapsed view is intentionally one dense line. Expansion exposes every
19
+ * snapshot field without falling back to the raw JSON returned to the agent.
20
+ */
21
+ export class ContextPressureComponent implements Component {
22
+ constructor(
23
+ private readonly snapshot: ContextPressureSnapshot,
24
+ private readonly theme: Theme,
25
+ private readonly expanded: boolean,
26
+ ) {}
27
+
28
+ render(width: number): string[] {
29
+ return this.expanded ? this.renderExpanded(width) : [this.renderCollapsed(width)];
30
+ }
31
+
32
+ invalidate(): void {}
33
+
34
+ private renderCollapsed(width: number): string {
35
+ const usage =
36
+ this.snapshot.contextWindow === null
37
+ ? `${formatTokens(this.snapshot.usedTokens)} used`
38
+ : `${formatTokens(this.snapshot.usedTokens)}/${formatTokens(this.snapshot.contextWindow)} used`;
39
+ const parts = [
40
+ `${this.theme.fg(healthColor(this.snapshot), "●")} ${this.theme.fg("text", usage)}`,
41
+ `${this.theme.fg("dim", "headroom")} ${this.theme.fg("muted", formatTokenValue(this.snapshot.headroomTokens))}`,
42
+ `${this.theme.fg("dim", "pressure")} ${this.theme.fg("muted", formatPercentage(this.snapshot.pressurePercent))}`,
43
+ this.theme.fg("muted", this.snapshot.modelName),
44
+ ];
45
+
46
+ return truncateToWidth(parts.join(` ${this.theme.fg("dim", "·")} `), width);
47
+ }
48
+
49
+ private renderExpanded(width: number): string[] {
50
+ const values: Array<[string, string]> = [
51
+ ["Model", this.snapshot.modelName],
52
+ ["Context window", formatTokenValue(this.snapshot.contextWindow)],
53
+ ["Used", formatTokens(this.snapshot.usedTokens)],
54
+ ["Usage", formatPercentage(this.snapshot.usagePercent)],
55
+ ["Auto-compaction", this.snapshot.compactionEnabled ? "enabled" : "disabled"],
56
+ ["Compaction reserve", formatTokens(this.snapshot.reserveTokens)],
57
+ ["Headroom", formatTokenValue(this.snapshot.headroomTokens)],
58
+ ["Pressure", formatPercentage(this.snapshot.pressurePercent)],
59
+ ["Compacted", this.snapshot.compacted ? "yes" : "no"],
60
+ ];
61
+ const labelWidth = Math.max(8, Math.min(18, width - 12));
62
+ const lines = values.map(([label, value]) =>
63
+ truncateToWidth(
64
+ `${this.theme.fg("dim", `${label.padEnd(labelWidth)} `)}${this.theme.fg("text", value)}`,
65
+ width,
66
+ ),
67
+ );
68
+
69
+ if (this.snapshot.approximationNote) {
70
+ lines.push(truncateToWidth(this.theme.fg("warning", this.snapshot.approximationNote), width));
71
+ }
72
+
73
+ return lines;
74
+ }
75
+ }
@@ -1,10 +1,13 @@
1
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "@earendil-works/pi-coding-agent";
2
+
1
3
  // Prompt guidance and tool description for the supi_context agent tool.
2
4
 
3
5
  export const toolDescription =
4
- "Report current PI context usage: token breakdown, context window, compaction, injected files, guideline sources, tool definitions, and provider sections.";
6
+ "Report current context capacity. Omit mode for a concise, constant-shape pressure snapshot; use mode: full only when diagnostic attribution is needed. Full output is compact JSON and is replaced with a temporary-file envelope above " +
7
+ `${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}.`;
5
8
 
6
9
  export const promptSnippet =
7
- "supi_context — context usage report (token breakdown, context window)";
10
+ "supi_context — concise context-pressure snapshot (mode: full for diagnostics)";
8
11
 
9
12
  export const promptGuidelines = [
10
13
  "Use supi_context before large operations or when context usage is near the limit.",
@@ -0,0 +1,51 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import {
5
+ DEFAULT_MAX_BYTES,
6
+ DEFAULT_MAX_LINES,
7
+ truncateHead,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { ContextAnalysis } from "../analysis.ts";
10
+
11
+ interface TruncatedOutputEnvelope {
12
+ truncated: true;
13
+ fullOutputPath: string;
14
+ totalLines: number;
15
+ totalBytes: number;
16
+ maxLines: number;
17
+ maxBytes: number;
18
+ }
19
+
20
+ /**
21
+ * Serialize a diagnostic report without ever returning invalid partial JSON.
22
+ *
23
+ * When Pi's normal tool-output limits would truncate the report, the complete
24
+ * compact JSON is stored in a temporary file and the agent receives a small,
25
+ * valid JSON envelope pointing to it instead.
26
+ */
27
+ export async function serializeFullContextAnalysis(analysis: ContextAnalysis): Promise<string> {
28
+ const fullJson = JSON.stringify(analysis);
29
+ const truncation = truncateHead(fullJson, {
30
+ maxLines: DEFAULT_MAX_LINES,
31
+ maxBytes: DEFAULT_MAX_BYTES,
32
+ });
33
+
34
+ if (!truncation.truncated) {
35
+ return fullJson;
36
+ }
37
+
38
+ const outputDir = await mkdtemp(join(tmpdir(), "supi-context-"));
39
+ const fullOutputPath = join(outputDir, "context-usage-report.json");
40
+ await writeFile(fullOutputPath, fullJson, "utf8");
41
+
42
+ const envelope: TruncatedOutputEnvelope = {
43
+ truncated: true,
44
+ fullOutputPath,
45
+ totalLines: truncation.totalLines,
46
+ totalBytes: truncation.totalBytes,
47
+ maxLines: DEFAULT_MAX_LINES,
48
+ maxBytes: DEFAULT_MAX_BYTES,
49
+ };
50
+ return JSON.stringify(envelope);
51
+ }
@@ -1,14 +1,23 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Text } from "@earendil-works/pi-tui";
3
3
  import type { ContextAnalysis } from "../analysis.ts";
4
- import { healthColor, pct } from "../format-helpers.ts";
4
+ import { type ContextPressureSnapshot, createContextPressureSnapshot } from "../capacity.ts";
5
5
  import { ContextReportComponent } from "../report-component.ts";
6
- import { formatTokens, pluralize } from "../utils.ts";
6
+ import { ContextPressureComponent } from "../snapshot-component.ts";
7
7
 
8
- export interface ContextToolDetails {
8
+ export interface ContextToolConciseDetails {
9
+ mode: "concise";
10
+ snapshot: ContextPressureSnapshot;
11
+ }
12
+
13
+ export interface ContextToolFullDetails {
14
+ mode: "full";
9
15
  analysis: ContextAnalysis;
10
16
  }
11
17
 
18
+ /** TUI data mirrors the mode-specific data returned to the agent. */
19
+ export type ContextToolDetails = ContextToolConciseDetails | ContextToolFullDetails;
20
+
12
21
  interface ContextToolResult {
13
22
  content: Array<{ type: string; text?: string }>;
14
23
  details?: unknown;
@@ -20,8 +29,15 @@ interface ResultOptions {
20
29
  isPartial: boolean;
21
30
  }
22
31
 
23
- export function renderContextToolCall(_args: unknown, theme: Theme): Text {
24
- const content = `${theme.fg("toolTitle", "supi_context")} ${theme.fg("muted", "current session")}`;
32
+ export function renderContextToolCall(args: unknown, theme: Theme): Text {
33
+ const mode =
34
+ args &&
35
+ typeof args === "object" &&
36
+ "mode" in args &&
37
+ (args as { mode?: string }).mode === "full"
38
+ ? "full"
39
+ : "concise";
40
+ const content = `${theme.fg("toolTitle", "supi_context")} ${theme.fg("muted", mode)}`;
25
41
  return new Text(content, 0, 0);
26
42
  }
27
43
 
@@ -29,7 +45,7 @@ export function renderContextToolResult(
29
45
  result: ContextToolResult | undefined,
30
46
  options: ResultOptions,
31
47
  theme: Theme,
32
- ): Text | ContextReportComponent {
48
+ ): Text | ContextPressureComponent | ContextReportComponent {
33
49
  if (options.isPartial) {
34
50
  return new Text(theme.fg("warning", "Analyzing context…"), 0, 0);
35
51
  }
@@ -38,53 +54,37 @@ export function renderContextToolResult(
38
54
  return new Text(theme.fg("error", "supi_context failed"), 0, 0);
39
55
  }
40
56
 
41
- const analysis = extractAnalysis(result?.details);
42
- if (!analysis) {
57
+ const details = extractDetails(result?.details);
58
+ if (!details) {
43
59
  return new Text(theme.fg("dim", "No context analysis data"), 0, 0);
44
60
  }
45
61
 
62
+ if (details.mode === "concise") {
63
+ return new ContextPressureComponent(details.snapshot, theme, options.expanded);
64
+ }
65
+
46
66
  if (options.expanded) {
47
- return new ContextReportComponent(analysis, theme);
67
+ return new ContextReportComponent(details.analysis, theme, "full");
48
68
  }
49
69
 
50
- return new Text(formatCollapsedSummary(analysis, theme), 0, 0);
70
+ return new ContextPressureComponent(
71
+ createContextPressureSnapshot(details.analysis.modelName, details.analysis),
72
+ theme,
73
+ false,
74
+ );
51
75
  }
52
76
 
53
- function extractAnalysis(details: unknown): ContextAnalysis | undefined {
54
- if (!details || typeof details !== "object" || !("analysis" in details)) {
77
+ function extractDetails(details: unknown): ContextToolDetails | undefined {
78
+ if (!details || typeof details !== "object" || !("mode" in details)) {
55
79
  return undefined;
56
80
  }
57
- return (details as ContextToolDetails).analysis;
58
- }
59
-
60
- function formatCollapsedSummary(analysis: ContextAnalysis, theme: Theme): string {
61
- const used = analysis.totalTokens ?? 0;
62
- const usage =
63
- analysis.contextWindow > 0
64
- ? `${formatTokens(used)} / ${formatTokens(analysis.contextWindow)} (${pct(used, analysis.contextWindow)})`
65
- : `${formatTokens(used)} tokens`;
66
- const dot = theme.fg("dim", "·");
67
- const parts = [
68
- `${theme.fg(healthColor(analysis), "●")} ${theme.fg("dim", "usage")} ${theme.fg("text", theme.bold(usage))}`,
69
- ];
70
-
71
- if (analysis.contextWindow > 0) {
72
- parts.push(
73
- `${theme.fg("dim", "free")} ${theme.fg("muted", formatTokens(analysis.categories.freeSpace))}`,
74
- );
75
- }
76
81
 
77
- if (analysis.compaction) {
78
- parts.push(
79
- `${theme.fg("dim", "compacted")} ${theme.fg("muted", pluralize(analysis.compaction.summarizedTurns, "turn", "turns"))}`,
80
- );
82
+ const mode = (details as { mode?: unknown }).mode;
83
+ if (mode === "concise" && "snapshot" in details) {
84
+ return details as ContextToolConciseDetails;
81
85
  }
82
-
83
- parts.push(`${theme.fg("dim", "model")} ${theme.fg("muted", analysis.modelName)}`);
84
-
85
- if (!analysis.approximationNote) {
86
- return parts.join(` ${dot} `);
86
+ if (mode === "full" && "analysis" in details) {
87
+ return details as ContextToolFullDetails;
87
88
  }
88
-
89
- return `${parts.join(` ${dot} `)}\n${theme.fg("warning", analysis.approximationNote)}`;
89
+ return undefined;
90
90
  }