@oh-my-pi/pi-coding-agent 14.5.8 → 14.5.9

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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +7 -7
  3. package/src/config/settings-schema.ts +3 -3
  4. package/src/edit/modes/atom.lark +7 -5
  5. package/src/edit/modes/atom.ts +462 -56
  6. package/src/edit/modes/hashline.ts +21 -1
  7. package/src/lsp/index.ts +2 -4
  8. package/src/lsp/render.ts +0 -3
  9. package/src/lsp/types.ts +1 -4
  10. package/src/lsp/utils.ts +18 -14
  11. package/src/modes/controllers/command-controller.ts +17 -0
  12. package/src/modes/controllers/input-controller.ts +7 -1
  13. package/src/modes/interactive-mode.ts +30 -23
  14. package/src/modes/types.ts +4 -2
  15. package/src/modes/utils/context-usage.ts +294 -0
  16. package/src/prompts/tools/atom.md +99 -44
  17. package/src/prompts/tools/exit-plan-mode.md +5 -39
  18. package/src/prompts/tools/lsp.md +2 -3
  19. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  20. package/src/prompts/tools/task.md +34 -147
  21. package/src/prompts/tools/todo-write.md +22 -64
  22. package/src/session/compaction/compaction.ts +35 -22
  23. package/src/session/session-dump-format.ts +1 -0
  24. package/src/slash-commands/builtin-registry.ts +12 -5
  25. package/src/tools/debug.ts +57 -70
  26. package/src/tools/index.ts +7 -7
  27. package/src/tools/{run-command → recipe}/index.ts +19 -19
  28. package/src/tools/recipe/render.ts +19 -0
  29. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  30. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  31. package/src/tools/renderers.ts +2 -2
  32. package/src/tools/run-command/render.ts +0 -18
  33. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  34. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  35. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  36. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  37. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
@@ -605,6 +605,26 @@ export class HashlineMismatchError extends Error {
605
605
  `Edit rejected: ${mismatches.length} line${mismatches.length > 1 ? "s have" : " has"} changed since the last read (marked *).`,
606
606
  "The edit was NOT applied, please use the updated file content shown below, and issue another edit tool-call.",
607
607
  );
608
+
609
+ // Content-based recovery hint: the two-letter hash is weak, so a
610
+ // unique match elsewhere is only a candidate. Keep this advisory; never
611
+ // silently retarget stale edits based on a whole-file hash-only match.
612
+ const hints: string[] = [];
613
+ for (const m of mismatches) {
614
+ const matches: number[] = [];
615
+ for (let line = 1; line <= fileLines.length; line++) {
616
+ if (computeLineHash(line, fileLines[line - 1]) === m.expected) matches.push(line);
617
+ if (matches.length > 1) break;
618
+ }
619
+ if (matches.length === 1 && matches[0] !== m.line) {
620
+ hints.push(` ${m.line}${m.expected} → ${matches[0]}${m.expected}`);
621
+ }
622
+ }
623
+ if (hints.length > 0) {
624
+ lines.push("Hash-only shifted candidate; verify content/context before using:");
625
+ lines.push(...hints);
626
+ }
627
+
608
628
  lines.push("");
609
629
 
610
630
  let prevLine = -1;
@@ -650,7 +670,7 @@ export function validateLineRef(ref: { line: number; hash: string }, fileLines:
650
670
  /**
651
671
  * Default search window for {@link tryRebaseAnchor} (lines on each side of the requested anchor).
652
672
  */
653
- export const ANCHOR_REBASE_WINDOW = 2;
673
+ export const ANCHOR_REBASE_WINDOW = 5;
654
674
 
655
675
  /**
656
676
  * Look for the requested hash within ±`window` lines of `anchor.line`.
package/src/lsp/index.ts CHANGED
@@ -1136,7 +1136,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1136
1136
  _onUpdate?: AgentToolUpdateCallback<LspToolDetails>,
1137
1137
  _context?: AgentToolContext,
1138
1138
  ): Promise<AgentToolResult<LspToolDetails>> {
1139
- const { action, file, line, symbol, occurrence, query, new_name, apply, timeout } = params;
1139
+ const { action, file, line, symbol, query, new_name, apply, timeout } = params;
1140
1140
  const timeoutSec = clampTimeout("lsp", timeout);
1141
1141
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
1142
1142
  signal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -1449,9 +1449,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1449
1449
 
1450
1450
  const uri = targetFile ? fileToUri(targetFile) : "";
1451
1451
  const resolvedLine = line ?? 1;
1452
- const resolvedCharacter = targetFile
1453
- ? await resolveSymbolColumn(targetFile, resolvedLine, symbol, occurrence)
1454
- : 0;
1452
+ const resolvedCharacter = targetFile ? await resolveSymbolColumn(targetFile, resolvedLine, symbol) : 0;
1455
1453
  const position = { line: resolvedLine - 1, character: resolvedCharacter };
1456
1454
 
1457
1455
  let output: string;
package/src/lsp/render.ts CHANGED
@@ -131,9 +131,6 @@ export function renderResult(
131
131
  }
132
132
  if (request?.symbol) {
133
133
  requestLines.push(theme.fg("dim", `symbol: ${sanitizeInlineText(request.symbol)}`));
134
- if (request.occurrence !== undefined) {
135
- requestLines.push(theme.fg("dim", `occurrence: ${request.occurrence}`));
136
- }
137
134
  }
138
135
  if (request?.query) requestLines.push(theme.fg("dim", `query: ${request.query}`));
139
136
  if (request?.new_name) requestLines.push(theme.fg("dim", `new name: ${request.new_name}`));
package/src/lsp/types.ts CHANGED
@@ -25,10 +25,7 @@ export const lspSchema = Type.Object({
25
25
  ),
26
26
  file: Type.Optional(Type.String({ description: "File path" })),
27
27
  line: Type.Optional(Type.Number({ description: "Line number (1-indexed)" })),
28
- symbol: Type.Optional(
29
- Type.String({ description: "Symbol/substring to locate on the line (used to compute column)" }),
30
- ),
31
- occurrence: Type.Optional(Type.Number({ description: "Symbol occurrence on line (1-indexed, default: 1)" })),
28
+ symbol: Type.Optional(Type.String({ description: "Symbol/substring to locate on the line" })),
32
29
  query: Type.Optional(Type.String({ description: "Search query or SSR pattern" })),
33
30
  new_name: Type.Optional(Type.String({ description: "New name for rename" })),
34
31
  apply: Type.Optional(Type.Boolean({ description: "Apply edits (default: true)" })),
package/src/lsp/utils.ts CHANGED
@@ -596,38 +596,42 @@ function findSymbolMatchIndexes(lineText: string, symbol: string, caseInsensitiv
596
596
  return indexes;
597
597
  }
598
598
 
599
- function normalizeOccurrence(occurrence?: number): number {
600
- if (occurrence === undefined || !Number.isFinite(occurrence)) return 1;
601
- return Math.max(1, Math.trunc(occurrence));
599
+ /**
600
+ * Parses a symbol spec of the form `name` or `name#N` where N is the 1-indexed
601
+ * occurrence on the target line. Returns `name` and `occurrence` (default 1).
602
+ *
603
+ * Greedy match on `.+` so `#name#2` parses as symbol=`#name` (TS private field)
604
+ * with occurrence 2. Specs without a trailing `#\d+` are treated as literal.
605
+ */
606
+ function parseSymbolSpec(spec: string): { symbol: string; occurrence: number } {
607
+ const match = spec.match(/^(.+)#(\d+)$/);
608
+ if (!match) return { symbol: spec, occurrence: 1 };
609
+ const occurrence = Math.max(1, Number.parseInt(match[2], 10));
610
+ return { symbol: match[1], occurrence };
602
611
  }
603
612
 
604
- export async function resolveSymbolColumn(
605
- filePath: string,
606
- line: number,
607
- symbol?: string,
608
- occurrence?: number,
609
- ): Promise<number> {
613
+ export async function resolveSymbolColumn(filePath: string, line: number, symbolSpec?: string): Promise<number> {
610
614
  const lineNumber = Math.max(1, line);
611
- const matchOccurrence = normalizeOccurrence(occurrence);
612
615
  try {
613
616
  const fileText = await Bun.file(filePath).text();
614
617
  const lines = fileText.split("\n");
615
618
  const targetLine = lines[lineNumber - 1] ?? "";
616
- if (!symbol) {
619
+ if (!symbolSpec) {
617
620
  return firstNonWhitespaceColumn(targetLine);
618
621
  }
619
622
 
623
+ const { symbol, occurrence } = parseSymbolSpec(symbolSpec);
620
624
  const exactIndexes = findSymbolMatchIndexes(targetLine, symbol);
621
625
  const fallbackIndexes = exactIndexes.length > 0 ? exactIndexes : findSymbolMatchIndexes(targetLine, symbol, true);
622
626
  if (fallbackIndexes.length === 0) {
623
627
  throw new Error(`Symbol "${symbol}" not found on line ${lineNumber}`);
624
628
  }
625
- if (matchOccurrence > fallbackIndexes.length) {
629
+ if (occurrence > fallbackIndexes.length) {
626
630
  throw new Error(
627
- `Symbol "${symbol}" occurrence ${matchOccurrence} is out of bounds on line ${lineNumber} (found ${fallbackIndexes.length})`,
631
+ `Symbol "${symbol}" occurrence ${occurrence} is out of bounds on line ${lineNumber} (found ${fallbackIndexes.length})`,
628
632
  );
629
633
  }
630
- return fallbackIndexes[matchOccurrence - 1];
634
+ return fallbackIndexes[occurrence - 1];
631
635
  } catch (error) {
632
636
  if (isEnoent(error)) {
633
637
  throw new Error(`File not found: ${filePath}`);
@@ -24,6 +24,7 @@ import { DynamicBorder } from "../../modes/components/dynamic-border";
24
24
  import { PythonExecutionComponent } from "../../modes/components/python-execution";
25
25
  import { getMarkdownTheme, getSymbolTheme, theme } from "../../modes/theme/theme";
26
26
  import type { InteractiveModeContext } from "../../modes/types";
27
+ import { computeContextBreakdown, renderContextUsage } from "../../modes/utils/context-usage";
27
28
  import { buildHotkeysMarkdown } from "../../modes/utils/hotkeys-markdown";
28
29
  import { buildToolsMarkdown } from "../../modes/utils/tools-markdown";
29
30
  import type { AsyncJobSnapshotItem } from "../../session/agent-session";
@@ -529,6 +530,22 @@ export class CommandController {
529
530
  showMarkdownPanel(this.ctx, "Available Tools", tools);
530
531
  }
531
532
 
533
+ handleContextCommand(): void {
534
+ const breakdown = computeContextBreakdown(this.ctx.session);
535
+ if (breakdown.contextWindow <= 0) {
536
+ this.ctx.showWarning("Context usage is unavailable: no model is selected for this session.");
537
+ return;
538
+ }
539
+ const output = renderContextUsage(breakdown, theme);
540
+ this.ctx.chatContainer.addChild(new Spacer(1));
541
+ this.ctx.chatContainer.addChild(new DynamicBorder());
542
+ this.ctx.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Context Usage")), 1, 0));
543
+ this.ctx.chatContainer.addChild(new Spacer(1));
544
+ this.ctx.chatContainer.addChild(new Text(output, 1, 0));
545
+ this.ctx.chatContainer.addChild(new DynamicBorder());
546
+ this.ctx.ui.requestRender();
547
+ }
548
+
532
549
  async handleMemoryCommand(text: string): Promise<void> {
533
550
  const argumentText = text.slice(7).trim();
534
551
  const action = argumentText.split(/\s+/, 1)[0]?.toLowerCase() || "view";
@@ -45,7 +45,7 @@ export class InputController {
45
45
  );
46
46
  this.ctx.editor.onEscape = () => {
47
47
  if (this.ctx.loopModeEnabled) {
48
- this.ctx.disableLoopMode();
48
+ this.ctx.pauseLoop();
49
49
  if (this.ctx.session.isStreaming) {
50
50
  void this.ctx.session.abort();
51
51
  } else {
@@ -317,6 +317,12 @@ export class InputController {
317
317
  }
318
318
  }
319
319
 
320
+ // While loop mode is on, every user-typed prompt becomes the new loop
321
+ // prompt that auto-resubmits after each yield.
322
+ if (this.ctx.loopModeEnabled) {
323
+ this.ctx.loopPrompt = text;
324
+ }
325
+
320
326
  // Queue input during compaction
321
327
  if (this.ctx.session.isCompacting) {
322
328
  if (this.ctx.pendingImages.length > 0) {
@@ -492,10 +492,7 @@ export class InteractiveMode implements InteractiveModeContext {
492
492
  }
493
493
 
494
494
  #scheduleLoopAutoSubmit(): void {
495
- if (this.#loopAutoSubmitTimer) {
496
- clearTimeout(this.#loopAutoSubmitTimer);
497
- this.#loopAutoSubmitTimer = undefined;
498
- }
495
+ this.#cancelLoopAutoSubmit();
499
496
  if (!this.loopModeEnabled || !this.loopPrompt) return;
500
497
  const prompt = this.loopPrompt;
501
498
  const loopAction = settings.get("loop.mode");
@@ -507,6 +504,13 @@ export class InteractiveMode implements InteractiveModeContext {
507
504
  }, 800);
508
505
  }
509
506
 
507
+ #cancelLoopAutoSubmit(): void {
508
+ if (this.#loopAutoSubmitTimer) {
509
+ clearTimeout(this.#loopAutoSubmitTimer);
510
+ this.#loopAutoSubmitTimer = undefined;
511
+ }
512
+ }
513
+
510
514
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
511
515
  if (action === "compact") {
512
516
  await this.handleCompactCommand();
@@ -517,43 +521,42 @@ export class InteractiveMode implements InteractiveModeContext {
517
521
  this.onInputCallback(this.startPendingSubmission({ text: prompt }));
518
522
  }
519
523
 
520
- disableLoopMode(options?: { silent?: boolean }): void {
524
+ disableLoopMode(): void {
521
525
  const wasEnabled = this.loopModeEnabled;
522
526
  this.loopModeEnabled = false;
523
527
  this.loopPrompt = undefined;
524
- if (this.#loopAutoSubmitTimer) {
525
- clearTimeout(this.#loopAutoSubmitTimer);
526
- this.#loopAutoSubmitTimer = undefined;
527
- }
528
+ this.#cancelLoopAutoSubmit();
528
529
  this.statusLine.setLoopModeStatus(undefined);
529
530
  this.updateEditorTopBorder();
530
531
  this.ui.requestRender();
531
- if (wasEnabled && !options?.silent) {
532
+ if (wasEnabled) {
532
533
  this.showStatus("Loop mode disabled.");
533
534
  }
534
535
  }
535
536
 
536
- async handleLoopCommand(prompt?: string): Promise<void> {
537
+ /**
538
+ * Pause the loop without exiting it: drops the captured prompt and any
539
+ * pending auto-resubmit. Loop mode stays enabled — the next prompt the
540
+ * user submits becomes the new loop prompt and resumes iteration.
541
+ */
542
+ pauseLoop(): void {
543
+ this.loopPrompt = undefined;
544
+ this.#cancelLoopAutoSubmit();
545
+ }
546
+
547
+ async handleLoopCommand(): Promise<void> {
537
548
  if (this.loopModeEnabled) {
538
549
  this.disableLoopMode();
539
550
  return;
540
551
  }
541
- const trimmed = prompt?.trim();
542
- if (!trimmed) {
543
- this.showError("Usage: /loop <prompt>");
544
- return;
545
- }
546
552
  this.loopModeEnabled = true;
547
- this.loopPrompt = trimmed;
553
+ this.loopPrompt = undefined;
548
554
  this.statusLine.setLoopModeStatus({ enabled: true });
549
555
  this.updateEditorTopBorder();
550
556
  this.ui.requestRender();
551
- this.showStatus("Loop mode enabled. Esc to stop.");
552
-
553
- // Submit the first iteration immediately so the loop kicks off.
554
- if (this.onInputCallback) {
555
- this.onInputCallback(this.startPendingSubmission({ text: trimmed }));
556
- }
557
+ this.showStatus(
558
+ "Loop mode enabled. Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.",
559
+ );
557
560
  }
558
561
 
559
562
  startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
@@ -1396,6 +1399,10 @@ export class InteractiveMode implements InteractiveModeContext {
1396
1399
  this.#commandController.handleToolsCommand();
1397
1400
  }
1398
1401
 
1402
+ handleContextCommand(): void {
1403
+ this.#commandController.handleContextCommand();
1404
+ }
1405
+
1399
1406
  #prepareSessionSwitch(): void {
1400
1407
  this.#btwController.dispose();
1401
1408
  this.#extensionUiController.clearExtensionTerminalInputListeners();
@@ -181,6 +181,7 @@ export interface InteractiveModeContext {
181
181
  handleChangelogCommand(showFull?: boolean): Promise<void>;
182
182
  handleHotkeysCommand(): void;
183
183
  handleToolsCommand(): void;
184
+ handleContextCommand(): void;
184
185
  handleDumpCommand(): void;
185
186
  handleDebugTranscriptCommand(): Promise<void>;
186
187
  handleClearCommand(): Promise<void>;
@@ -236,8 +237,9 @@ export interface InteractiveModeContext {
236
237
  openExternalEditor(): void;
237
238
  registerExtensionShortcuts(): void;
238
239
  handlePlanModeCommand(initialPrompt?: string): Promise<void>;
239
- handleLoopCommand(prompt?: string): Promise<void>;
240
- disableLoopMode(options?: { silent?: boolean }): void;
240
+ handleLoopCommand(): Promise<void>;
241
+ disableLoopMode(): void;
242
+ pauseLoop(): void;
241
243
  handleExitPlanModeTool(details: ExitPlanModeDetails): Promise<void>;
242
244
 
243
245
  // Hook UI methods
@@ -0,0 +1,294 @@
1
+ import type { Model } from "@oh-my-pi/pi-ai";
2
+ import { countTokens } from "@oh-my-pi/pi-natives";
3
+ import { formatNumber } from "@oh-my-pi/pi-utils";
4
+ import type { Skill } from "../../extensibility/skills";
5
+ import type { AgentSession } from "../../session/agent-session";
6
+ import type { CompactionSettings } from "../../session/compaction";
7
+ import { effectiveReserveTokens, estimateTokens, resolveThresholdTokens } from "../../session/compaction";
8
+ import type { Tool } from "../../tools";
9
+ import type { theme as Theme } from "../theme/theme";
10
+
11
+ const GRID_COLS = 20;
12
+ const GRID_ROWS = 10;
13
+ const GRID_CELLS = GRID_COLS * GRID_ROWS;
14
+ const GRID_GUTTER = " ";
15
+
16
+ const CELL_FILLED = "⛁";
17
+ const CELL_FILLED_MESSAGES = "⛃";
18
+ const CELL_FREE = "⛶";
19
+ const CELL_BUFFER = "⛝";
20
+
21
+ type CategoryId = "systemPrompt" | "systemTools" | "skills" | "messages";
22
+
23
+ interface CategoryInfo {
24
+ id: CategoryId;
25
+ label: string;
26
+ tokens: number;
27
+ color: "accent" | "warning" | "success" | "userMessageText";
28
+ glyph: string;
29
+ }
30
+
31
+ export interface ContextBreakdown {
32
+ model: Model | undefined;
33
+ contextWindow: number;
34
+ categories: CategoryInfo[];
35
+ usedTokens: number;
36
+ autoCompactBufferTokens: number;
37
+ freeTokens: number;
38
+ }
39
+
40
+ function estimateSkillsTokens(skills: readonly Skill[]): number {
41
+ const fragments: string[] = [];
42
+ for (const skill of skills) {
43
+ // "- name: description\n" wire framing tokenizes ~identically to the
44
+ // concatenated form, so encode each piece separately and sum.
45
+ fragments.push(skill.name, skill.description);
46
+ }
47
+ return countTokens(fragments);
48
+ }
49
+
50
+ function estimateToolSchemaTokens(tools: ReadonlyArray<Pick<Tool, "name" | "description" | "parameters">>): number {
51
+ const fragments: string[] = [];
52
+ for (const tool of tools) {
53
+ fragments.push(tool.name, tool.description);
54
+ try {
55
+ fragments.push(JSON.stringify(tool.parameters ?? {}));
56
+ } catch {
57
+ // Schema may contain functions or cycles; ignore.
58
+ }
59
+ }
60
+ return countTokens(fragments);
61
+ }
62
+
63
+ function estimateMessagesTokens(session: AgentSession): number {
64
+ let total = 0;
65
+ for (const message of session.messages) {
66
+ total += estimateTokens(message);
67
+ }
68
+ return total;
69
+ }
70
+
71
+ /**
72
+ * Compute a breakdown of estimated context usage by category for the active
73
+ * session and model.
74
+ */
75
+ export function computeContextBreakdown(session: AgentSession): ContextBreakdown {
76
+ const model = session.model;
77
+ const contextWindow = model?.contextWindow ?? 0;
78
+
79
+ const skillsTokens = estimateSkillsTokens(session.skills);
80
+ const toolsTokens = estimateToolSchemaTokens(session.agent.state.tools);
81
+ const messagesTokens = estimateMessagesTokens(session);
82
+
83
+ // The rendered system prompt already contains the skill descriptions and the
84
+ // markdown tool descriptions. To present a non-overlapping breakdown:
85
+ // System prompt = total system prompt text - skills section (tool descriptions stay)
86
+ // Tools = JSON tool schema sent separately on the wire
87
+ // Skills = the skill list embedded in the system prompt
88
+ // Messages = conversation messages
89
+ const systemPromptTextTokens = countTokens(session.systemPrompt);
90
+ const systemPromptTokens = Math.max(0, systemPromptTextTokens - skillsTokens);
91
+
92
+ const categories: CategoryInfo[] = [
93
+ { id: "systemPrompt", label: "System prompt", tokens: systemPromptTokens, color: "accent", glyph: CELL_FILLED },
94
+ { id: "systemTools", label: "System tools", tokens: toolsTokens, color: "warning", glyph: CELL_FILLED },
95
+ { id: "skills", label: "Skills", tokens: skillsTokens, color: "success", glyph: CELL_FILLED },
96
+ {
97
+ id: "messages",
98
+ label: "Messages",
99
+ tokens: messagesTokens,
100
+ color: "userMessageText",
101
+ glyph: CELL_FILLED_MESSAGES,
102
+ },
103
+ ];
104
+
105
+ const usedTokens = categories.reduce((sum, c) => sum + c.tokens, 0);
106
+
107
+ let autoCompactBufferTokens = 0;
108
+ if (contextWindow > 0) {
109
+ const compactionSettings = session.settings.getGroup("compaction") as CompactionSettings;
110
+ if (compactionSettings.enabled && compactionSettings.strategy !== "off") {
111
+ const threshold = resolveThresholdTokens(contextWindow, compactionSettings);
112
+ autoCompactBufferTokens = Math.max(0, contextWindow - threshold);
113
+ } else {
114
+ autoCompactBufferTokens = 0;
115
+ }
116
+ // Even when fully disabled, fall back to a sensible reserve floor for display.
117
+ if (autoCompactBufferTokens === 0 && compactionSettings.enabled) {
118
+ autoCompactBufferTokens = effectiveReserveTokens(contextWindow, compactionSettings);
119
+ }
120
+ }
121
+ autoCompactBufferTokens = Math.min(autoCompactBufferTokens, Math.max(0, contextWindow - usedTokens));
122
+
123
+ const freeTokens = Math.max(0, contextWindow - usedTokens - autoCompactBufferTokens);
124
+
125
+ return {
126
+ model,
127
+ contextWindow,
128
+ categories,
129
+ usedTokens,
130
+ autoCompactBufferTokens,
131
+ freeTokens,
132
+ };
133
+ }
134
+
135
+ interface CellSpec {
136
+ glyph: string;
137
+ color: "accent" | "warning" | "success" | "userMessageText" | "muted" | "dim";
138
+ }
139
+
140
+ function planCells(breakdown: ContextBreakdown): CellSpec[] {
141
+ const cells: CellSpec[] = [];
142
+ const window = breakdown.contextWindow;
143
+
144
+ if (window <= 0) {
145
+ for (let i = 0; i < GRID_CELLS; i++) {
146
+ cells.push({ glyph: CELL_FREE, color: "dim" });
147
+ }
148
+ return cells;
149
+ }
150
+
151
+ const tokensPerCell = window / GRID_CELLS;
152
+
153
+ const ratioCells = (tokens: number): number => {
154
+ if (tokens <= 0) return 0;
155
+ return Math.max(1, Math.round(tokens / tokensPerCell));
156
+ };
157
+
158
+ const categoryCounts = breakdown.categories.map(category => ({
159
+ category,
160
+ count: ratioCells(category.tokens),
161
+ }));
162
+
163
+ let bufferCount = ratioCells(breakdown.autoCompactBufferTokens);
164
+
165
+ let usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
166
+
167
+ // Prevent the visualization from over-running the grid.
168
+ const maxUsable = GRID_CELLS - bufferCount;
169
+ if (usedCount > maxUsable) {
170
+ // Scale categories proportionally down to fit.
171
+ let overflow = usedCount - maxUsable;
172
+ // Trim from the largest categories first to preserve visibility for small ones.
173
+ const order = [...categoryCounts].sort((a, b) => b.count - a.count);
174
+ for (const entry of order) {
175
+ while (overflow > 0 && entry.count > 1) {
176
+ entry.count -= 1;
177
+ overflow -= 1;
178
+ }
179
+ }
180
+ usedCount = categoryCounts.reduce((sum, c) => sum + c.count, 0);
181
+ if (usedCount + bufferCount > GRID_CELLS) {
182
+ bufferCount = Math.max(0, GRID_CELLS - usedCount);
183
+ }
184
+ }
185
+
186
+ for (const { category, count } of categoryCounts) {
187
+ for (let i = 0; i < count; i++) {
188
+ cells.push({ glyph: category.glyph, color: category.color });
189
+ }
190
+ }
191
+
192
+ const freeCount = Math.max(0, GRID_CELLS - cells.length - bufferCount);
193
+ for (let i = 0; i < freeCount; i++) {
194
+ cells.push({ glyph: CELL_FREE, color: "dim" });
195
+ }
196
+ for (let i = 0; i < bufferCount; i++) {
197
+ cells.push({ glyph: CELL_BUFFER, color: "warning" });
198
+ }
199
+
200
+ // Pad to exactly GRID_CELLS in case rounding undershot.
201
+ while (cells.length < GRID_CELLS) {
202
+ cells.push({ glyph: CELL_FREE, color: "dim" });
203
+ }
204
+ return cells.slice(0, GRID_CELLS);
205
+ }
206
+
207
+ function percentString(part: number, whole: number, fractionDigits = 1): string {
208
+ if (whole <= 0) return "0%";
209
+ const pct = (part / whole) * 100;
210
+ if (pct > 0 && pct < 0.05) return "<0.1%";
211
+ return `${pct.toFixed(fractionDigits)}%`;
212
+ }
213
+
214
+ function buildLegendLines(breakdown: ContextBreakdown, theme: typeof Theme): string[] {
215
+ const lines: string[] = [];
216
+ const { model, contextWindow, categories, usedTokens, autoCompactBufferTokens, freeTokens } = breakdown;
217
+
218
+ const modelName = model?.name ?? model?.id ?? "no model";
219
+ const modelId = model?.id ?? "unknown";
220
+ const windowLabel = formatNumber(contextWindow).toLowerCase();
221
+
222
+ lines.push(theme.bold(`${modelName}`) + theme.fg("dim", ` (${windowLabel} context)`));
223
+ lines.push(theme.fg("muted", `${modelId}[${windowLabel}]`));
224
+ lines.push(
225
+ `${theme.bold(formatNumber(usedTokens))}${theme.fg("dim", `/${windowLabel} tokens`)}` +
226
+ theme.fg("muted", ` (${percentString(usedTokens, contextWindow)})`),
227
+ );
228
+ lines.push("");
229
+ lines.push(theme.fg("muted", "Estimated usage by category"));
230
+
231
+ for (const category of categories) {
232
+ const dot = theme.fg(category.color, category.glyph);
233
+ const label = category.label;
234
+ const tokens = formatNumber(category.tokens);
235
+ const pct = percentString(category.tokens, contextWindow);
236
+ lines.push(`${dot} ${label}: ${theme.bold(tokens)} ${theme.fg("dim", `tokens (${pct})`)}`);
237
+ }
238
+
239
+ const freeDot = theme.fg("dim", CELL_FREE);
240
+ lines.push(
241
+ `${freeDot} Free space: ${theme.bold(formatNumber(freeTokens))} ${theme.fg("dim", `(${percentString(freeTokens, contextWindow)})`)}`,
242
+ );
243
+
244
+ if (autoCompactBufferTokens > 0) {
245
+ const bufferDot = theme.fg("warning", CELL_BUFFER);
246
+ lines.push(
247
+ `${bufferDot} Autocompact buffer: ${theme.bold(formatNumber(autoCompactBufferTokens))} ${theme.fg(
248
+ "dim",
249
+ `tokens (${percentString(autoCompactBufferTokens, contextWindow)})`,
250
+ )}`,
251
+ );
252
+ }
253
+
254
+ return lines;
255
+ }
256
+
257
+ /**
258
+ * Render a colorful context-usage panel as ANSI text. Output is a series of
259
+ * lines pairing the grid (left) with the legend (right).
260
+ */
261
+ export function renderContextUsage(breakdown: ContextBreakdown, theme: typeof Theme): string {
262
+ if (breakdown.contextWindow <= 0) {
263
+ return theme.fg("muted", "Context usage is unavailable: no model is selected for this session.");
264
+ }
265
+
266
+ const cells = planCells(breakdown);
267
+ const legend = buildLegendLines(breakdown, theme);
268
+
269
+ const totalLines = Math.max(GRID_ROWS, legend.length);
270
+ const lines: string[] = [];
271
+
272
+ for (let row = 0; row < totalLines; row++) {
273
+ let gridSegment = "";
274
+ if (row < GRID_ROWS) {
275
+ const rowCells: string[] = [];
276
+ for (let col = 0; col < GRID_COLS; col++) {
277
+ const cell = cells[row * GRID_COLS + col];
278
+ rowCells.push(theme.fg(cell.color, cell.glyph));
279
+ }
280
+ gridSegment = rowCells.join(" ");
281
+ } else {
282
+ // Pad with blanks the same visible width as a grid row so legend lines
283
+ // past the grid stay aligned with their column.
284
+ const blank = " ".repeat(GRID_COLS * 2 - 1);
285
+ gridSegment = blank;
286
+ }
287
+
288
+ const legendSegment = legend[row] ?? "";
289
+ const line = legendSegment.length > 0 ? `${gridSegment}${GRID_GUTTER}${legendSegment}` : gridSegment;
290
+ lines.push(line);
291
+ }
292
+
293
+ return lines.join("\n");
294
+ }