@mrclrchtr/supi-context 1.3.1 → 1.5.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 (22) hide show
  1. package/README.md +39 -24
  2. package/node_modules/@mrclrchtr/supi-core/README.md +52 -41
  3. package/node_modules/@mrclrchtr/supi-core/package.json +1 -1
  4. package/node_modules/@mrclrchtr/supi-core/src/api.ts +15 -13
  5. package/node_modules/@mrclrchtr/supi-core/src/{config-settings.ts → config/config-settings.ts} +2 -2
  6. package/node_modules/@mrclrchtr/supi-core/src/{context-provider-registry.ts → context/context-provider-registry.ts} +1 -1
  7. package/node_modules/@mrclrchtr/supi-core/src/extension.ts +1 -1
  8. package/node_modules/@mrclrchtr/supi-core/src/index.ts +15 -13
  9. package/node_modules/@mrclrchtr/supi-core/src/path-utils.ts +40 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +42 -10
  11. package/node_modules/@mrclrchtr/supi-core/src/{settings-registry.ts → settings/settings-registry.ts} +1 -1
  12. package/package.json +2 -2
  13. package/src/analysis.ts +223 -19
  14. package/src/context.ts +4 -3
  15. package/src/format.ts +527 -116
  16. package/src/prompt-inference.ts +37 -13
  17. package/src/renderer.ts +30 -13
  18. /package/node_modules/@mrclrchtr/supi-core/src/{config.ts → config/config.ts} +0 -0
  19. /package/node_modules/@mrclrchtr/supi-core/src/{context-messages.ts → context/context-messages.ts} +0 -0
  20. /package/node_modules/@mrclrchtr/supi-core/src/{context-tag.ts → context/context-tag.ts} +0 -0
  21. /package/node_modules/@mrclrchtr/supi-core/src/{settings-command.ts → settings/settings-command.ts} +0 -0
  22. /package/node_modules/@mrclrchtr/supi-core/src/{settings-ui.ts → settings/settings-ui.ts} +0 -0
package/src/format.ts CHANGED
@@ -1,11 +1,10 @@
1
+ // biome-ignore-all lint/nursery/noExcessiveLinesPerFile: format file is inherently large
2
+
1
3
  import type { Theme } from "@earendil-works/pi-coding-agent";
4
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
5
  import type { ContextAnalysis } from "./analysis.ts";
3
6
  import { formatTokens, pluralize } from "./utils.ts";
4
7
 
5
- const GRID_COLS = 20;
6
- const GRID_ROWS = 5;
7
- const GRID_BLOCKS = GRID_COLS * GRID_ROWS;
8
-
9
8
  type CategoryKey =
10
9
  | "systemPrompt"
11
10
  | "userMessages"
@@ -54,227 +53,639 @@ function padRight(text: string, width: number): string {
54
53
  return text.padEnd(width, " ");
55
54
  }
56
55
 
57
- function allocateGridBlocks(segments: number[]): number[] {
58
- const total = segments.reduce((sum, value) => sum + value, 0);
59
- if (total <= 0) {
60
- return segments.map(() => 0);
56
+ function sum(values: number[]): number {
57
+ return values.reduce((total, value) => total + value, 0);
58
+ }
59
+
60
+ function allocateBlocks(values: number[], totalBlocks: number): number[] {
61
+ const total = sum(values);
62
+ if (total <= 0 || totalBlocks <= 0) {
63
+ return values.map(() => 0);
61
64
  }
62
65
 
63
- const exact = segments.map((value) => (value / total) * GRID_BLOCKS);
66
+ const exact = values.map((value) => (value / total) * totalBlocks);
64
67
  const counts = exact.map((value) => Math.floor(value));
65
- const remaining = GRID_BLOCKS - counts.reduce((sum, value) => sum + value, 0);
68
+ const remaining = totalBlocks - sum(counts);
66
69
 
67
70
  const byRemainder = exact
68
71
  .map((value, index) => ({ index, remainder: value - counts[index] }))
69
72
  .sort((a, b) => b.remainder - a.remainder);
70
73
 
71
- for (let i = 0; i < remaining; i++) {
74
+ for (let i = 0; i < remaining; i += 1) {
72
75
  counts[byRemainder[i]?.index ?? 0] += 1;
73
76
  }
74
77
 
75
78
  return counts;
76
79
  }
77
80
 
78
- function renderGrid(analysis: ContextAnalysis, theme: Theme): string[] {
79
- const { contextWindow, categories } = analysis;
80
- if (contextWindow <= 0) {
81
- return [theme.fg("dim", "No model selected — grid unavailable")];
81
+ function healthColor(analysis: ContextAnalysis): Parameters<Theme["fg"]>["0"] {
82
+ if (analysis.contextWindow <= 0) return "dim";
83
+ const reserved = analysis.totalTokens ?? 0;
84
+ const pressure =
85
+ ((reserved + analysis.categories.autocompactBuffer) / analysis.contextWindow) * 100;
86
+ if (pressure >= 90) return "error";
87
+ if (pressure >= 70) return "warning";
88
+ return "success";
89
+ }
90
+
91
+ function sectionHeader(title: string, meta: string | null, theme: Theme, width: number): string {
92
+ const left = theme.fg("text", title);
93
+ const content = meta ? `${left}${theme.fg("dim", ` ${meta}`)}` : left;
94
+ return truncateToWidth(content, width);
95
+ }
96
+
97
+ function renderSummary(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
98
+ const used = analysis.totalTokens ?? 0;
99
+ const health = theme.fg(healthColor(analysis), "●");
100
+ const usage =
101
+ analysis.contextWindow > 0
102
+ ? `${formatTokens(used)} / ${formatTokens(analysis.contextWindow)} tokens (${pct(used, analysis.contextWindow)})`
103
+ : `${formatTokens(used)} tokens`;
104
+
105
+ const lines = [
106
+ truncateToWidth(
107
+ `${health} ${theme.fg("text", analysis.modelName)}${theme.fg("dim", ` · ${usage}`)}`,
108
+ width,
109
+ ),
110
+ ];
111
+
112
+ if (analysis.approximationNote) {
113
+ lines.push(...wrapTextWithAnsi(theme.fg("warning", analysis.approximationNote), width));
114
+ }
115
+
116
+ return lines;
117
+ }
118
+
119
+ function renderUsageBar(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
120
+ if (analysis.contextWindow <= 0) {
121
+ return [theme.fg("dim", "No model selected — usage bar unavailable")];
82
122
  }
83
123
 
124
+ const percentLabel = pct(analysis.totalTokens ?? 0, analysis.contextWindow);
125
+ const barWidth = Math.max(12, Math.min(48, width - visibleWidth(percentLabel) - 3));
126
+ const values = [
127
+ ...CATEGORY_ORDER.map((key) => analysis.categories[key]),
128
+ analysis.categories.autocompactBuffer,
129
+ analysis.categories.freeSpace,
130
+ ];
131
+ const counts = allocateBlocks(values, barWidth);
132
+
84
133
  const segments = [
85
- ...CATEGORY_ORDER.map((key) => ({
134
+ ...CATEGORY_ORDER.map((key, index) => ({
86
135
  color: CATEGORY_COLORS[key],
87
- tokens: categories[key],
88
136
  block: "█",
137
+ count: counts[index] ?? 0,
89
138
  })),
90
- { color: "dim" as Parameters<Theme["fg"]>["0"], tokens: categories.freeSpace, block: "░" },
91
139
  {
92
140
  color: "warning" as Parameters<Theme["fg"]>["0"],
93
- tokens: categories.autocompactBuffer,
141
+ block: "▒",
142
+ count: counts[CATEGORY_ORDER.length] ?? 0,
143
+ },
144
+ {
145
+ color: "dim" as Parameters<Theme["fg"]>["0"],
94
146
  block: "░",
147
+ count: counts[CATEGORY_ORDER.length + 1] ?? 0,
95
148
  },
96
149
  ];
97
150
 
98
- const counts = allocateGridBlocks(segments.map((segment) => segment.tokens));
99
- const blocks: string[] = [];
100
- for (const [index, segment] of segments.entries()) {
101
- for (let i = 0; i < (counts[index] ?? 0); i++) {
102
- blocks.push(theme.fg(segment.color, segment.block));
103
- }
104
- }
105
-
106
- const gridLines: string[] = [];
107
- for (let row = 0; row < GRID_ROWS; row++) {
108
- const start = row * GRID_COLS;
109
- const line = blocks.slice(start, start + GRID_COLS).join("");
110
- gridLines.push(line);
111
- }
151
+ const bar = segments
152
+ .map((segment) =>
153
+ segment.count > 0 ? theme.fg(segment.color, segment.block.repeat(segment.count)) : "",
154
+ )
155
+ .join("");
112
156
 
113
- // Model info on the right
114
- const infoLines = [
115
- theme.fg("text", analysis.modelName),
116
- theme.fg("dim", `${formatTokens(contextWindow)} context window`),
117
- theme.fg(
118
- "text",
119
- `${formatTokens(analysis.totalTokens ?? 0)} used (${pct(analysis.totalTokens ?? 0, contextWindow)})`,
120
- ),
121
- ];
157
+ const barLine = truncateToWidth(
158
+ `${theme.fg("dim", "[")}${bar}${theme.fg("dim", "]")} ${theme.fg("text", percentLabel)}`,
159
+ width,
160
+ );
122
161
 
123
- if (analysis.approximationNote) {
124
- infoLines.push(theme.fg("warning", analysis.approximationNote));
162
+ const legendParts: string[] = [];
163
+ for (const key of CATEGORY_ORDER) {
164
+ if (analysis.categories[key] <= 0) continue;
165
+ legendParts.push(`${theme.fg(CATEGORY_COLORS[key], "●")} ${CATEGORY_LABELS[key]}`);
125
166
  }
126
-
127
- const combined: string[] = [];
128
- for (let i = 0; i < GRID_ROWS; i++) {
129
- const left = gridLines[i] ?? "";
130
- const right = infoLines[i] ?? "";
131
- combined.push(`${left} ${right}`);
167
+ if (analysis.categories.autocompactBuffer > 0) {
168
+ legendParts.push(`${theme.fg("warning", "▒")} Autocompact buffer`);
132
169
  }
133
- // Append any remaining info lines if grid is shorter (shouldn't happen with 5 rows)
134
- for (let i = GRID_ROWS; i < infoLines.length; i++) {
135
- combined.push(`${" ".repeat(GRID_COLS + 2)}${infoLines[i]}`);
170
+ if (analysis.categories.freeSpace > 0) {
171
+ legendParts.push(`${theme.fg("dim", "░")} Free space`);
136
172
  }
137
173
 
138
- return combined;
174
+ return [barLine, ...wrapTextWithAnsi(legendParts.join(theme.fg("dim", " • ")), width)];
139
175
  }
140
176
 
141
- function renderCategoryBreakdown(analysis: ContextAnalysis, theme: Theme): string[] {
177
+ function renderCategoryBreakdown(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
142
178
  const lines: string[] = [];
143
- lines.push(theme.fg("accent", "Usage by category"));
179
+ lines.push(sectionHeader("Usage by category", null, theme, width));
144
180
 
145
- const { contextWindow, categories } = analysis;
146
- const allCategories: Array<{
147
- key: CategoryKey | "autocompactBuffer" | "freeSpace";
181
+ const rows: Array<{
148
182
  label: string;
149
183
  color: Parameters<Theme["fg"]>["0"];
150
184
  tokens: number;
151
185
  }> = [
152
186
  ...CATEGORY_ORDER.map((key) => ({
153
- key,
154
187
  label: CATEGORY_LABELS[key],
155
188
  color: CATEGORY_COLORS[key],
156
- tokens: categories[key],
189
+ tokens: analysis.categories[key],
157
190
  })),
158
191
  {
159
- key: "autocompactBuffer",
160
192
  label: "Autocompact buffer",
161
193
  color: "warning" as Parameters<Theme["fg"]>["0"],
162
- tokens: categories.autocompactBuffer,
194
+ tokens: analysis.categories.autocompactBuffer,
163
195
  },
164
196
  {
165
- key: "freeSpace",
166
197
  label: "Free space",
167
198
  color: "dim" as Parameters<Theme["fg"]>["0"],
168
- tokens: categories.freeSpace,
199
+ tokens: analysis.categories.freeSpace,
169
200
  },
170
201
  ];
171
202
 
172
- for (const cat of allCategories) {
173
- if (cat.tokens <= 0 && cat.key !== "freeSpace") continue;
174
- const label = padRight(cat.label, 20);
175
- const tokens = padLeft(formatTokens(cat.tokens), 8);
176
- const percentage = padLeft(pct(cat.tokens, contextWindow), 7);
177
- lines.push(`${theme.fg(cat.color, "●")} ${label} ${tokens} ${percentage}`);
203
+ const labelWidth = Math.max(18, Math.min(22, width - 22));
204
+ for (const row of rows) {
205
+ if (row.tokens <= 0 && row.label !== "Free space") continue;
206
+ const bullet = theme.fg(row.color, "●");
207
+ const label = padRight(row.label, labelWidth);
208
+ const tokens = padLeft(formatTokens(row.tokens), 8);
209
+ const percentage = padLeft(pct(row.tokens, analysis.contextWindow), 7);
210
+ lines.push(truncateToWidth(` ${bullet} ${label} ${tokens} ${percentage}`, width));
178
211
  }
179
212
 
180
213
  return lines;
181
214
  }
182
215
 
183
- function renderContextFilesSection(analysis: ContextAnalysis, theme: Theme): string[] {
184
- const files = analysis.systemPromptBreakdown.contextFiles;
185
- if (files.length === 0) return [];
216
+ function renderCompositionGuidelineSubRows(
217
+ sources: Array<{ source: string; tokens: number }>,
218
+ opts: { subLabelWidth: number; total: number; theme: Theme; width: number },
219
+ ): string[] {
220
+ const lines: string[] = [];
221
+ for (const item of sources) {
222
+ const label =
223
+ item.source === "default" ? "default" : item.source === "other" ? "extensions" : item.source;
224
+ lines.push(
225
+ truncateToWidth(
226
+ ` ${opts.theme.fg("dim", padRight(label, opts.subLabelWidth))} ${padLeft(formatTokens(item.tokens), 8)} ${padLeft(pct(item.tokens, opts.total), 7)}`,
227
+ opts.width,
228
+ ),
229
+ );
230
+ }
231
+ return lines;
232
+ }
186
233
 
234
+ function renderCompositionSnippetSubRows(
235
+ details: Array<{ name: string; tokens: number }>,
236
+ subLabelWidth: number,
237
+ theme: Theme,
238
+ width: number,
239
+ ): string[] {
187
240
  const lines: string[] = [];
188
- lines.push("");
189
- lines.push(theme.fg("accent", "Context Files (system prompt)"));
190
- for (const f of files) {
191
- lines.push(` ${theme.fg("text", f.path)} ${theme.fg("dim", formatTokens(f.tokens))}`);
241
+ for (const item of details) {
242
+ lines.push(
243
+ truncateToWidth(
244
+ ` ${theme.fg("dim", padRight(item.name, subLabelWidth))} ${padLeft(formatTokens(item.tokens), 8)}`,
245
+ width,
246
+ ),
247
+ );
192
248
  }
193
249
  return lines;
194
250
  }
195
251
 
196
- function renderInjectedFilesSection(analysis: ContextAnalysis, theme: Theme): string[] {
197
- const files = analysis.injectedFiles;
198
- if (files.length === 0) return [];
252
+ function renderSystemPromptComposition(
253
+ analysis: ContextAnalysis,
254
+ theme: Theme,
255
+ width: number,
256
+ ): string[] {
257
+ const breakdown = analysis.systemPromptBreakdown;
258
+ const instructionFileTokens = sum(breakdown.instructionFiles.map((f) => f.tokens));
259
+ const contextFileTokens = sum(breakdown.contextFiles.map((f) => f.tokens));
260
+ const skillTokens = sum(breakdown.skills.map((s) => s.tokens));
261
+
262
+ // Use sum of all breakdown components as the denominator so percentages
263
+ // stay internally consistent even when the system prompt token count has
264
+ // been scaled to actual model usage.
265
+ const total =
266
+ breakdown.base +
267
+ instructionFileTokens +
268
+ contextFileTokens +
269
+ skillTokens +
270
+ breakdown.guidelines +
271
+ breakdown.toolSnippets +
272
+ breakdown.appendText;
199
273
 
200
274
  const lines: string[] = [];
201
- lines.push("");
202
- lines.push(theme.fg("accent", "Context Files (injected · supi-claude-md)"));
203
- for (const f of files) {
275
+ lines.push(
276
+ sectionHeader(
277
+ "System prompt composition",
278
+ total > 0 ? `${formatTokens(total)} tokens` : null,
279
+ theme,
280
+ width,
281
+ ),
282
+ );
283
+
284
+ const labelWidth = Math.max(18, Math.min(22, width - 22));
285
+ const subLabelWidth = labelWidth;
286
+
287
+ const rows = [
288
+ { label: "Base", color: "accent" as Parameters<Theme["fg"]>["0"], tokens: breakdown.base },
289
+ {
290
+ label: "Instruction files",
291
+ color: "text" as Parameters<Theme["fg"]>["0"],
292
+ tokens: instructionFileTokens,
293
+ },
294
+ {
295
+ label: "Context files",
296
+ color: "text" as Parameters<Theme["fg"]>["0"],
297
+ tokens: contextFileTokens,
298
+ },
299
+ { label: "Skills", color: "text" as Parameters<Theme["fg"]>["0"], tokens: skillTokens },
300
+ {
301
+ label: "Guidelines",
302
+ color: "text" as Parameters<Theme["fg"]>["0"],
303
+ tokens: breakdown.guidelines,
304
+ },
305
+ {
306
+ label: "Tool snippets",
307
+ color: "text" as Parameters<Theme["fg"]>["0"],
308
+ tokens: breakdown.toolSnippets,
309
+ },
310
+ {
311
+ label: "Append text",
312
+ color: "text" as Parameters<Theme["fg"]>["0"],
313
+ tokens: breakdown.appendText,
314
+ },
315
+ ];
316
+
317
+ for (const row of rows) {
318
+ if (row.tokens <= 0) continue;
319
+ const bullet = theme.fg(row.color, "●");
320
+ const label = padRight(row.label, labelWidth);
321
+ const tokens = padLeft(formatTokens(row.tokens), 8);
322
+ const percentage = padLeft(pct(row.tokens, total), 7);
323
+ lines.push(truncateToWidth(` ${bullet} ${label} ${tokens} ${percentage}`, width));
324
+
325
+ if (row.label === "Guidelines" && breakdown.guidelineSources.length > 0) {
326
+ lines.push(
327
+ ...renderCompositionGuidelineSubRows(breakdown.guidelineSources, {
328
+ subLabelWidth,
329
+ total,
330
+ theme,
331
+ width,
332
+ }),
333
+ );
334
+ }
335
+
336
+ if (row.label === "Tool snippets" && breakdown.toolSnippetDetails.length > 0) {
337
+ lines.push(
338
+ ...renderCompositionSnippetSubRows(
339
+ breakdown.toolSnippetDetails,
340
+ subLabelWidth,
341
+ theme,
342
+ width,
343
+ ),
344
+ );
345
+ }
346
+ }
347
+
348
+ return lines;
349
+ }
350
+
351
+ interface FileSectionOptions {
352
+ title: string;
353
+ subtitle: string;
354
+ files: Array<{ path: string; tokens: number; lines: number; extra?: string }>;
355
+ total: number;
356
+ theme: Theme;
357
+ width: number;
358
+ }
359
+
360
+ function renderFileSection(options: FileSectionOptions): string[] {
361
+ const { title, subtitle, files, total, theme, width } = options;
362
+ if (files.length === 0) return [];
363
+
364
+ const sorted = [...files].sort((a, b) => b.tokens - a.tokens);
365
+ const totalTokens = sum(sorted.map((file) => file.tokens));
366
+ const header = sectionHeader(
367
+ title,
368
+ `${pluralize(sorted.length, "file", "files")}, ${formatTokens(totalTokens)} tokens${subtitle ? ` · ${subtitle}` : ""}`,
369
+ theme,
370
+ width,
371
+ );
372
+
373
+ const tokenWidth = 8;
374
+ const lineWidth = 10;
375
+ const pctWidth = 7;
376
+ const extraWidth = sorted.some((file) => file.extra) ? 10 : 0;
377
+ const reserved =
378
+ 2 + 2 + lineWidth + 2 + tokenWidth + 2 + pctWidth + (extraWidth ? 2 + extraWidth : 0);
379
+ const pathWidth = Math.max(16, width - reserved);
380
+
381
+ const lines = [header];
382
+ for (const file of sorted) {
383
+ const path = padRight(truncateToWidth(file.path, pathWidth), pathWidth);
384
+ const lineCol = padLeft(`${file.lines} lines`, lineWidth);
385
+ const tokenCol = padLeft(formatTokens(file.tokens), tokenWidth);
386
+ const pctCol = padLeft(pct(file.tokens, total), pctWidth);
387
+ const extra = extraWidth ? ` ${theme.fg("dim", padRight(file.extra ?? "", extraWidth))}` : "";
204
388
  lines.push(
205
- ` ${theme.fg("text", f.file)} ${theme.fg("dim", formatTokens(f.tokens))} ${theme.fg("dim", `turn ${f.turn}`)}`,
389
+ ` ${theme.fg("text", path)} ${theme.fg("dim", lineCol)} ${theme.fg("dim", tokenCol)}${extra} ${theme.fg("dim", pctCol)}`,
206
390
  );
207
391
  }
392
+
208
393
  return lines;
209
394
  }
210
395
 
211
- function renderSkillsSection(analysis: ContextAnalysis, theme: Theme): string[] {
396
+ function renderInstructionFilesSection(
397
+ analysis: ContextAnalysis,
398
+ theme: Theme,
399
+ width: number,
400
+ ): string[] {
401
+ return renderFileSection({
402
+ title: "Instruction Files (AGENTS.md / CLAUDE.md)",
403
+ subtitle: "share of system prompt",
404
+ files: analysis.systemPromptBreakdown.instructionFiles.map((file) => ({
405
+ path: file.path,
406
+ tokens: file.tokens,
407
+ lines: file.lines,
408
+ extra: file.origin,
409
+ })),
410
+ total: analysis.categories.systemPrompt,
411
+ theme,
412
+ width,
413
+ });
414
+ }
415
+
416
+ function renderContextFilesSection(
417
+ analysis: ContextAnalysis,
418
+ theme: Theme,
419
+ width: number,
420
+ ): string[] {
421
+ return renderFileSection({
422
+ title: "Context Files (system prompt)",
423
+ subtitle: "share of system prompt",
424
+ files: analysis.systemPromptBreakdown.contextFiles.map((file) => ({
425
+ path: file.path,
426
+ tokens: file.tokens,
427
+ lines: file.lines,
428
+ })),
429
+ total: analysis.categories.systemPrompt,
430
+ theme,
431
+ width,
432
+ });
433
+ }
434
+
435
+ function renderInjectedFilesSection(
436
+ analysis: ContextAnalysis,
437
+ theme: Theme,
438
+ width: number,
439
+ ): string[] {
440
+ return renderFileSection({
441
+ title: "Context Files (injected · supi-claude-md)",
442
+ subtitle: "share of full context",
443
+ files: analysis.injectedFiles.map((file) => ({
444
+ path: file.file,
445
+ tokens: file.tokens,
446
+ lines: file.lines,
447
+ extra: `turn ${file.turn}`,
448
+ })),
449
+ total: analysis.totalTokens ?? 0,
450
+ theme,
451
+ width,
452
+ });
453
+ }
454
+
455
+ function renderSkillsSection(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
212
456
  const lines: string[] = [];
213
- lines.push("");
214
- lines.push(theme.fg("accent", `Skills (${analysis.skills.length})`));
457
+ const total = sum(analysis.skills.map((skill) => skill.tokens));
458
+ lines.push(
459
+ sectionHeader(
460
+ `Skills (${analysis.skills.length})`,
461
+ total > 0 ? `${formatTokens(total)} tokens` : null,
462
+ theme,
463
+ width,
464
+ ),
465
+ );
215
466
 
216
467
  if (analysis.skills.length === 0) {
217
- lines.push(theme.fg("dim", " Send a message to see skill details"));
468
+ lines.push(truncateToWidth(theme.fg("dim", " Send a message to see skill details"), width));
218
469
  return lines;
219
470
  }
220
471
 
221
- for (const s of analysis.skills) {
222
- lines.push(` ${theme.fg("text", s.name)} ${theme.fg("dim", formatTokens(s.tokens))}`);
472
+ const skillNameWidth =
473
+ analysis.skills.length > 0 ? Math.max(...analysis.skills.map((s) => s.name.length)) : 0;
474
+ for (const skill of analysis.skills) {
475
+ lines.push(
476
+ truncateToWidth(
477
+ ` ${theme.fg("text", padRight(skill.name, skillNameWidth))} ${theme.fg("dim", padLeft(formatTokens(skill.tokens), 8))}`,
478
+ width,
479
+ ),
480
+ );
223
481
  }
482
+
224
483
  return lines;
225
484
  }
226
485
 
227
- function renderGuidelinesAndTools(analysis: ContextAnalysis, theme: Theme): string[] {
486
+ function renderSourceSummaryBar(
487
+ gs: Array<{ source: string; bulletCount: number }>,
488
+ _theme: Theme,
489
+ _width: number,
490
+ ): string | null {
491
+ if (gs.length === 0) return null;
492
+ const parts: string[] = [];
493
+ for (const s of gs) {
494
+ const label =
495
+ s.source === "default" ? "default" : s.source === "other" ? "extensions" : s.source;
496
+ parts.push(`${pluralize(s.bulletCount, "bullet", "bullets")} from ${label}`);
497
+ }
498
+ return parts.join(" · ");
499
+ }
500
+
501
+ function renderBulletLines(
502
+ bullets: string[],
503
+ full: boolean,
504
+ theme: Theme,
505
+ width: number,
506
+ ): string[] {
507
+ const previewLimit = full ? bullets.length : Math.min(6, bullets.length);
508
+ const lines: string[] = [];
509
+ for (let i = 0; i < previewLimit; i += 1) {
510
+ const rawText = bullets[i] ?? "";
511
+ const previewText = full || rawText.length <= 90 ? rawText : `${rawText.slice(0, 90)}…`;
512
+ const bullet = `${theme.fg("dim", "•")} ${theme.fg("text", previewText)}`;
513
+ if (full) {
514
+ lines.push(...wrapTextWithAnsi(bullet, Math.max(1, width - 2)).map((line) => ` ${line}`));
515
+ } else {
516
+ lines.push(truncateToWidth(` ${bullet}`, width));
517
+ }
518
+ }
519
+ if (!full && bullets.length > previewLimit) {
520
+ lines.push(
521
+ truncateToWidth(
522
+ ` ${theme.fg("dim", `… and ${bullets.length - previewLimit} more — run /supi-context full`)}`,
523
+ width,
524
+ ),
525
+ );
526
+ }
527
+ return lines;
528
+ }
529
+
530
+ function renderGuidelinesSection(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
531
+ const sourceSummary = renderSourceSummaryBar(analysis.guidelineSources, theme, width);
532
+
533
+ const lines = [
534
+ sectionHeader(
535
+ `Guidelines (${pluralize(analysis.guidelineBullets.length, "bullet", "bullets")})`,
536
+ `${formatTokens(analysis.guidelines)} tokens`,
537
+ theme,
538
+ width,
539
+ ),
540
+ ];
541
+
542
+ if (sourceSummary) {
543
+ lines.push(truncateToWidth(` ${theme.fg("dim", sourceSummary)}`, width));
544
+ }
545
+
546
+ const bullets = analysis.guidelineBullets;
547
+ if (bullets.length === 0) {
548
+ return lines;
549
+ }
550
+
551
+ lines.push(...renderBulletLines(bullets, analysis.full, theme, width));
552
+ return lines;
553
+ }
554
+
555
+ function renderToolDefinitionsSection(
556
+ analysis: ContextAnalysis,
557
+ theme: Theme,
558
+ width: number,
559
+ ): string[] {
560
+ const tools = [...analysis.toolDefinitions.tools].sort((a, b) => b.tokens - a.tokens);
561
+ if (tools.length === 0) return [];
562
+
563
+ const hasSnippetDetails = analysis.toolSnippetDetails.length > 0;
564
+
228
565
  const lines: string[] = [];
229
- lines.push("");
230
- lines.push(
231
- `${theme.fg("text", "Guidelines")} ${theme.fg("dim", formatTokens(analysis.guidelines))}`,
232
- );
233
566
  lines.push(
234
- `${theme.fg("text", `Tool Definitions (${analysis.toolDefinitions.count} active)`)} ${theme.fg("dim", formatTokens(analysis.toolDefinitions.tokens))}`,
567
+ sectionHeader(
568
+ `Tool Definitions (${tools.length} active)`,
569
+ `${formatTokens(analysis.toolDefinitions.tokens)} def tokens${hasSnippetDetails ? ` + ${formatTokens(sum(analysis.toolSnippetDetails.map((s) => s.tokens)))} snippet` : ""}`,
570
+ theme,
571
+ width,
572
+ ),
235
573
  );
574
+
575
+ const previewLimit = analysis.full ? tools.length : Math.min(5, tools.length);
576
+ const nameWidth = Math.max(12, Math.min(18, Math.max(...tools.map((tool) => tool.name.length))));
577
+ const defTokenWidth = 8;
578
+ const snippetTokenWidth = hasSnippetDetails ? 10 : 0;
579
+ const reserved =
580
+ 2 + nameWidth + 2 + defTokenWidth + 2 + (snippetTokenWidth ? snippetTokenWidth + 2 : 0);
581
+ const descWidth = Math.max(12, width - reserved);
582
+
583
+ for (let i = 0; i < previewLimit; i += 1) {
584
+ const tool = tools[i];
585
+ const name = padRight(tool.name, nameWidth);
586
+ const previewDescription =
587
+ analysis.full || tool.description.length <= 50
588
+ ? tool.description
589
+ : `${tool.description.slice(0, 50)}…`;
590
+ const description = truncateToWidth(previewDescription, descWidth);
591
+ const defTokens = padLeft(formatTokens(tool.tokens), defTokenWidth);
592
+ const snippet = analysis.toolSnippetDetails.find((s) => s.name === tool.name);
593
+ const snippetCol = snippet
594
+ ? ` ${theme.fg("dim", padLeft(`+${formatTokens(snippet.tokens)}snip`, snippetTokenWidth))}`
595
+ : "";
596
+ lines.push(
597
+ truncateToWidth(
598
+ ` ${theme.fg("text", name)} ${theme.fg("dim", description)} ${theme.fg("dim", defTokens)}${snippetCol}`,
599
+ width,
600
+ ),
601
+ );
602
+ }
603
+
604
+ if (!analysis.full && tools.length > previewLimit) {
605
+ lines.push(
606
+ truncateToWidth(
607
+ ` ${theme.fg("dim", `… and ${tools.length - previewLimit} more — run /supi-context full`)}`,
608
+ width,
609
+ ),
610
+ );
611
+ }
612
+
613
+ // Add a legend row for snippet column if any tools had snippets
614
+ if (hasSnippetDetails) {
615
+ const snippetTotal = sum(analysis.toolSnippetDetails.map((s) => s.tokens));
616
+ lines.push(
617
+ truncateToWidth(
618
+ ` ${theme.fg("dim", `→ total tool snippet tokens: ${formatTokens(snippetTotal)}`)}`,
619
+ width,
620
+ ),
621
+ );
622
+ }
623
+
236
624
  return lines;
237
625
  }
238
626
 
239
- function renderCompactionNote(analysis: ContextAnalysis, theme: Theme): string[] {
627
+ function renderCompactionNote(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
240
628
  if (!analysis.compaction) return [];
241
629
  return [
242
- "",
243
- theme.fg(
244
- "dim",
245
- `↳ ${pluralize(analysis.compaction.summarizedTurns, "older turn", "older turns")} summarized (compaction)`,
630
+ truncateToWidth(
631
+ theme.fg(
632
+ "dim",
633
+ `↳ ${pluralize(analysis.compaction.summarizedTurns, "older turn", "older turns")} summarized (compaction)`,
634
+ ),
635
+ width,
246
636
  ),
247
637
  ];
248
638
  }
249
639
 
250
- function renderProviderSections(analysis: ContextAnalysis, theme: Theme): string[] {
640
+ function renderProviderSections(analysis: ContextAnalysis, theme: Theme, width: number): string[] {
251
641
  if (analysis.providerSections.length === 0) return [];
252
642
 
253
643
  const lines: string[] = [];
254
644
  for (const section of analysis.providerSections) {
255
- lines.push("");
256
- lines.push(theme.fg("accent", section.label));
645
+ lines.push(sectionHeader(section.label, null, theme, width));
257
646
  for (const [key, value] of Object.entries(section.data)) {
258
- lines.push(` ${theme.fg("text", key)}: ${theme.fg("dim", String(value))}`);
647
+ const label = theme.fg("text", key);
648
+ const content = theme.fg("dim", String(value));
649
+ lines.push(truncateToWidth(` ${label}: ${content}`, width));
259
650
  }
260
651
  }
652
+
261
653
  return lines;
262
654
  }
263
655
 
264
- export function formatContextReport(analysis: ContextAnalysis, theme: Theme): string[] {
656
+ export function formatContextReport(
657
+ analysis: ContextAnalysis,
658
+ theme: Theme,
659
+ width = 200,
660
+ ): string[] {
661
+ const safeWidth = Math.max(24, width);
265
662
  const lines: string[] = [];
266
663
 
267
- lines.push(theme.fg("accent", "◆ Context Usage"));
664
+ lines.push(truncateToWidth(theme.fg("accent", "◆ Context Usage"), safeWidth));
268
665
  lines.push("");
269
- lines.push(...renderGrid(analysis, theme));
666
+ lines.push(...renderSummary(analysis, theme, safeWidth));
270
667
  lines.push("");
271
- lines.push(...renderCategoryBreakdown(analysis, theme));
272
- lines.push(...renderContextFilesSection(analysis, theme));
273
- lines.push(...renderInjectedFilesSection(analysis, theme));
274
- lines.push(...renderSkillsSection(analysis, theme));
275
- lines.push(...renderGuidelinesAndTools(analysis, theme));
276
- lines.push(...renderCompactionNote(analysis, theme));
277
- lines.push(...renderProviderSections(analysis, theme));
668
+ lines.push(...renderUsageBar(analysis, theme, safeWidth));
669
+ lines.push("");
670
+ lines.push(...renderCategoryBreakdown(analysis, theme, safeWidth));
671
+ lines.push("");
672
+ lines.push(...renderSystemPromptComposition(analysis, theme, safeWidth));
673
+
674
+ const sections = [
675
+ renderInstructionFilesSection(analysis, theme, safeWidth),
676
+ renderContextFilesSection(analysis, theme, safeWidth),
677
+ renderInjectedFilesSection(analysis, theme, safeWidth),
678
+ renderSkillsSection(analysis, theme, safeWidth),
679
+ renderGuidelinesSection(analysis, theme, safeWidth),
680
+ renderToolDefinitionsSection(analysis, theme, safeWidth),
681
+ renderCompactionNote(analysis, theme, safeWidth),
682
+ renderProviderSections(analysis, theme, safeWidth),
683
+ ].filter((section) => section.length > 0);
684
+
685
+ for (const section of sections) {
686
+ lines.push("");
687
+ lines.push(...section);
688
+ }
278
689
 
279
690
  return lines;
280
691
  }