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

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 (58) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/package.json +7 -15
  3. package/scripts/build-binary.ts +1 -1
  4. package/src/cli/update-cli.ts +25 -1
  5. package/src/config/model-registry.ts +21 -19
  6. package/src/config/settings-schema.ts +14 -19
  7. package/src/discovery/claude-plugins.ts +28 -3
  8. package/src/edit/modes/atom.lark +7 -5
  9. package/src/edit/modes/atom.ts +510 -73
  10. package/src/edit/modes/hashline.ts +172 -91
  11. package/src/extensibility/extensions/runner.ts +34 -1
  12. package/src/extensibility/extensions/types.ts +8 -0
  13. package/src/lsp/client.ts +27 -35
  14. package/src/lsp/index.ts +2 -4
  15. package/src/lsp/render.ts +0 -3
  16. package/src/lsp/types.ts +1 -4
  17. package/src/lsp/utils.ts +18 -14
  18. package/src/memories/index.ts +5 -0
  19. package/src/modes/components/settings-defs.ts +1 -1
  20. package/src/modes/controllers/command-controller.ts +17 -0
  21. package/src/modes/controllers/input-controller.ts +7 -1
  22. package/src/modes/controllers/selector-controller.ts +2 -2
  23. package/src/modes/interactive-mode.ts +57 -26
  24. package/src/modes/theme/theme.ts +10 -1
  25. package/src/modes/types.ts +5 -3
  26. package/src/modes/utils/context-usage.ts +294 -0
  27. package/src/modes/utils/ui-helpers.ts +19 -6
  28. package/src/prompts/system/auto-continue.md +1 -0
  29. package/src/prompts/tools/atom.md +99 -44
  30. package/src/prompts/tools/exit-plan-mode.md +5 -39
  31. package/src/prompts/tools/github.md +3 -3
  32. package/src/prompts/tools/lsp.md +2 -3
  33. package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
  34. package/src/prompts/tools/task.md +34 -147
  35. package/src/prompts/tools/todo-write.md +22 -64
  36. package/src/sdk.ts +13 -2
  37. package/src/session/agent-session.ts +175 -79
  38. package/src/session/compaction/compaction.ts +35 -22
  39. package/src/session/session-dump-format.ts +1 -0
  40. package/src/session/session-manager.ts +19 -2
  41. package/src/slash-commands/builtin-registry.ts +12 -5
  42. package/src/tools/bash.ts +9 -4
  43. package/src/tools/debug.ts +57 -70
  44. package/src/tools/gh.ts +267 -119
  45. package/src/tools/index.ts +7 -7
  46. package/src/tools/{run-command → recipe}/index.ts +19 -19
  47. package/src/tools/recipe/render.ts +19 -0
  48. package/src/tools/{run-command → recipe}/runner.ts +28 -7
  49. package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
  50. package/src/tools/renderers.ts +2 -2
  51. package/src/utils/git.ts +61 -2
  52. package/src/web/search/providers/searxng.ts +71 -13
  53. package/src/tools/run-command/render.ts +0 -18
  54. /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
  55. /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
  56. /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
  57. /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
  58. /package/src/tools/{run-command → recipe}/runners/task.ts +0 -0
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}`);
@@ -277,6 +277,11 @@ async function runPhase1(options: {
277
277
  });
278
278
 
279
279
  if (result.kind === "failed") {
280
+ logger.error("Memory phase1 stage1 job failed", {
281
+ threadId: claim.threadId,
282
+ rolloutPath: claim.rolloutPath,
283
+ reason: result.reason,
284
+ });
280
285
  markStage1Failed(db, {
281
286
  threadId: claim.threadId,
282
287
  ownershipToken: claim.ownershipToken,
@@ -348,7 +348,7 @@ const OPTION_PROVIDERS: Partial<Record<SettingPath, OptionProvider>> = {
348
348
  { value: "kagi", label: "Kagi", description: "Requires KAGI_API_KEY and Kagi Search API beta access" },
349
349
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
350
350
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
351
- { value: "searxng", label: "SearXNG", description: "Self-hosted metasearch; set searxng.endpoint" },
351
+ { value: "searxng", label: "SearXNG", description: "Requires searxng.endpoint" },
352
352
  ],
353
353
  "providers.image": [
354
354
  {
@@ -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) {
@@ -659,9 +659,9 @@ export class SelectorController {
659
659
  return;
660
660
  }
661
661
 
662
- // Update UI
662
+ // Update UI — pass the context built by navigateTree to skip a second O(N) walk.
663
663
  this.ctx.chatContainer.clear();
664
- this.ctx.renderInitialMessages();
664
+ this.ctx.renderInitialMessages(result.sessionContext);
665
665
  await this.ctx.reloadTodos();
666
666
  if (result.editorText && !this.ctx.editor.getText().trim()) {
667
667
  this.ctx.editor.setText(result.editorText);
@@ -14,7 +14,17 @@ import {
14
14
  type UsageReport,
15
15
  } from "@oh-my-pi/pi-ai";
16
16
  import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
17
- import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
17
+ import {
18
+ Container,
19
+ clearRenderCache,
20
+ Loader,
21
+ Markdown,
22
+ ProcessTerminal,
23
+ Spacer,
24
+ Text,
25
+ TUI,
26
+ visibleWidth,
27
+ } from "@oh-my-pi/pi-tui";
18
28
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
19
29
  import chalk from "chalk";
20
30
  import { KeybindingsManager } from "../config/keybindings";
@@ -442,6 +452,7 @@ export class InteractiveMode implements InteractiveModeContext {
442
452
 
443
453
  // Set up theme file watcher
444
454
  onThemeChange(() => {
455
+ clearRenderCache();
445
456
  this.ui.invalidate();
446
457
  this.updateEditorBorderColor();
447
458
  this.ui.requestRender();
@@ -492,10 +503,7 @@ export class InteractiveMode implements InteractiveModeContext {
492
503
  }
493
504
 
494
505
  #scheduleLoopAutoSubmit(): void {
495
- if (this.#loopAutoSubmitTimer) {
496
- clearTimeout(this.#loopAutoSubmitTimer);
497
- this.#loopAutoSubmitTimer = undefined;
498
- }
506
+ this.#cancelLoopAutoSubmit();
499
507
  if (!this.loopModeEnabled || !this.loopPrompt) return;
500
508
  const prompt = this.loopPrompt;
501
509
  const loopAction = settings.get("loop.mode");
@@ -507,6 +515,13 @@ export class InteractiveMode implements InteractiveModeContext {
507
515
  }, 800);
508
516
  }
509
517
 
518
+ #cancelLoopAutoSubmit(): void {
519
+ if (this.#loopAutoSubmitTimer) {
520
+ clearTimeout(this.#loopAutoSubmitTimer);
521
+ this.#loopAutoSubmitTimer = undefined;
522
+ }
523
+ }
524
+
510
525
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
511
526
  if (action === "compact") {
512
527
  await this.handleCompactCommand();
@@ -517,43 +532,42 @@ export class InteractiveMode implements InteractiveModeContext {
517
532
  this.onInputCallback(this.startPendingSubmission({ text: prompt }));
518
533
  }
519
534
 
520
- disableLoopMode(options?: { silent?: boolean }): void {
535
+ disableLoopMode(): void {
521
536
  const wasEnabled = this.loopModeEnabled;
522
537
  this.loopModeEnabled = false;
523
538
  this.loopPrompt = undefined;
524
- if (this.#loopAutoSubmitTimer) {
525
- clearTimeout(this.#loopAutoSubmitTimer);
526
- this.#loopAutoSubmitTimer = undefined;
527
- }
539
+ this.#cancelLoopAutoSubmit();
528
540
  this.statusLine.setLoopModeStatus(undefined);
529
541
  this.updateEditorTopBorder();
530
542
  this.ui.requestRender();
531
- if (wasEnabled && !options?.silent) {
543
+ if (wasEnabled) {
532
544
  this.showStatus("Loop mode disabled.");
533
545
  }
534
546
  }
535
547
 
536
- async handleLoopCommand(prompt?: string): Promise<void> {
548
+ /**
549
+ * Pause the loop without exiting it: drops the captured prompt and any
550
+ * pending auto-resubmit. Loop mode stays enabled — the next prompt the
551
+ * user submits becomes the new loop prompt and resumes iteration.
552
+ */
553
+ pauseLoop(): void {
554
+ this.loopPrompt = undefined;
555
+ this.#cancelLoopAutoSubmit();
556
+ }
557
+
558
+ async handleLoopCommand(): Promise<void> {
537
559
  if (this.loopModeEnabled) {
538
560
  this.disableLoopMode();
539
561
  return;
540
562
  }
541
- const trimmed = prompt?.trim();
542
- if (!trimmed) {
543
- this.showError("Usage: /loop <prompt>");
544
- return;
545
- }
546
563
  this.loopModeEnabled = true;
547
- this.loopPrompt = trimmed;
564
+ this.loopPrompt = undefined;
548
565
  this.statusLine.setLoopModeStatus({ enabled: true });
549
566
  this.updateEditorTopBorder();
550
567
  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
- }
568
+ this.showStatus(
569
+ "Loop mode enabled. Your next prompt will repeat after each turn. Esc cancels the current iteration; /loop again to disable.",
570
+ );
557
571
  }
558
572
 
559
573
  startPendingSubmission(input: { text: string; images?: ImageContent[] }): SubmittedUserInput {
@@ -864,6 +878,19 @@ export class InteractiveMode implements InteractiveModeContext {
864
878
  } else {
865
879
  await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
866
880
  }
881
+ // If #applyPlanModeModel queued a deferred switch to the plan-role model
882
+ // (because the session was streaming on entry), drop it now: we are
883
+ // leaving plan mode, so flushing it on the next agent_end would land the
884
+ // session on the plan-role model after the user has exited plan mode
885
+ // (issue #816). Only clear when the pending target matches the plan-role
886
+ // model — leave any unrelated user-queued switch intact.
887
+ const pending = this.#pendingModelSwitch;
888
+ if (pending) {
889
+ const planResolution = this.session.resolveRoleModelWithThinking("plan");
890
+ if (planResolution.model && modelsAreEqual(pending.model, planResolution.model)) {
891
+ this.#pendingModelSwitch = undefined;
892
+ }
893
+ }
867
894
  }
868
895
  this.session.setPlanModeState(undefined);
869
896
  this.planModeEnabled = false;
@@ -1331,8 +1358,8 @@ export class InteractiveMode implements InteractiveModeContext {
1331
1358
  this.#uiHelpers.renderSessionContext(sessionContext, options);
1332
1359
  }
1333
1360
 
1334
- renderInitialMessages(): void {
1335
- this.#uiHelpers.renderInitialMessages();
1361
+ renderInitialMessages(prebuiltContext?: SessionContext): void {
1362
+ this.#uiHelpers.renderInitialMessages(prebuiltContext);
1336
1363
  }
1337
1364
 
1338
1365
  getUserMessageText(message: Message): string {
@@ -1396,6 +1423,10 @@ export class InteractiveMode implements InteractiveModeContext {
1396
1423
  this.#commandController.handleToolsCommand();
1397
1424
  }
1398
1425
 
1426
+ handleContextCommand(): void {
1427
+ this.#commandController.handleContextCommand();
1428
+ }
1429
+
1399
1430
  #prepareSessionSwitch(): void {
1400
1431
  this.#btwController.dispose();
1401
1432
  this.#extensionUiController.clearExtensionTerminalInputListeners();
@@ -2328,8 +2328,14 @@ export function getSymbolTheme(): SymbolTheme {
2328
2328
  };
2329
2329
  }
2330
2330
 
2331
+ let _markdownTheme: MarkdownTheme | undefined;
2332
+ let _markdownThemeRef: Theme | undefined;
2333
+
2331
2334
  export function getMarkdownTheme(): MarkdownTheme {
2332
- return {
2335
+ if (_markdownTheme !== undefined && _markdownThemeRef === theme) {
2336
+ return _markdownTheme;
2337
+ }
2338
+ const markdownTheme: MarkdownTheme = {
2333
2339
  heading: (text: string) => theme.fg("mdHeading", text),
2334
2340
  link: (text: string) => theme.fg("mdLink", text),
2335
2341
  linkUrl: (text: string) => theme.fg("mdLinkUrl", text),
@@ -2355,6 +2361,9 @@ export function getMarkdownTheme(): MarkdownTheme {
2355
2361
  }
2356
2362
  },
2357
2363
  };
2364
+ _markdownTheme = markdownTheme;
2365
+ _markdownThemeRef = theme;
2366
+ return markdownTheme;
2358
2367
  }
2359
2368
 
2360
2369
  export function getSelectListTheme(): SelectListTheme {
@@ -159,7 +159,7 @@ export interface InteractiveModeContext {
159
159
  sessionContext: SessionContext,
160
160
  options?: { updateFooter?: boolean; populateHistory?: boolean },
161
161
  ): void;
162
- renderInitialMessages(): void;
162
+ renderInitialMessages(prebuiltContext?: SessionContext): void;
163
163
  getUserMessageText(message: Message): string;
164
164
  findLastAssistantMessage(): AssistantMessage | undefined;
165
165
  extractAssistantText(message: AssistantMessage): string;
@@ -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
+ }