@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.
- package/CHANGELOG.md +22 -0
- package/package.json +7 -7
- package/src/config/settings-schema.ts +3 -3
- package/src/edit/modes/atom.lark +7 -5
- package/src/edit/modes/atom.ts +462 -56
- package/src/edit/modes/hashline.ts +21 -1
- package/src/lsp/index.ts +2 -4
- package/src/lsp/render.ts +0 -3
- package/src/lsp/types.ts +1 -4
- package/src/lsp/utils.ts +18 -14
- package/src/modes/controllers/command-controller.ts +17 -0
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/interactive-mode.ts +30 -23
- package/src/modes/types.ts +4 -2
- package/src/modes/utils/context-usage.ts +294 -0
- package/src/prompts/tools/atom.md +99 -44
- package/src/prompts/tools/exit-plan-mode.md +5 -39
- package/src/prompts/tools/lsp.md +2 -3
- package/src/prompts/tools/{run-command.md → recipe.md} +1 -1
- package/src/prompts/tools/task.md +34 -147
- package/src/prompts/tools/todo-write.md +22 -64
- package/src/session/compaction/compaction.ts +35 -22
- package/src/session/session-dump-format.ts +1 -0
- package/src/slash-commands/builtin-registry.ts +12 -5
- package/src/tools/debug.ts +57 -70
- package/src/tools/index.ts +7 -7
- package/src/tools/{run-command → recipe}/index.ts +19 -19
- package/src/tools/recipe/render.ts +19 -0
- package/src/tools/{run-command → recipe}/runner.ts +28 -7
- package/src/tools/{run-command → recipe}/runners/pkg.ts +23 -53
- package/src/tools/renderers.ts +2 -2
- package/src/tools/run-command/render.ts +0 -18
- /package/src/tools/{run-command → recipe}/runners/cargo.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/index.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/just.ts +0 -0
- /package/src/tools/{run-command → recipe}/runners/make.ts +0 -0
- /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 =
|
|
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,
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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 (!
|
|
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 (
|
|
629
|
+
if (occurrence > fallbackIndexes.length) {
|
|
626
630
|
throw new Error(
|
|
627
|
-
`Symbol "${symbol}" occurrence ${
|
|
631
|
+
`Symbol "${symbol}" occurrence ${occurrence} is out of bounds on line ${lineNumber} (found ${fallbackIndexes.length})`,
|
|
628
632
|
);
|
|
629
633
|
}
|
|
630
|
-
return fallbackIndexes[
|
|
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.
|
|
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
|
-
|
|
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(
|
|
524
|
+
disableLoopMode(): void {
|
|
521
525
|
const wasEnabled = this.loopModeEnabled;
|
|
522
526
|
this.loopModeEnabled = false;
|
|
523
527
|
this.loopPrompt = undefined;
|
|
524
|
-
|
|
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
|
|
532
|
+
if (wasEnabled) {
|
|
532
533
|
this.showStatus("Loop mode disabled.");
|
|
533
534
|
}
|
|
534
535
|
}
|
|
535
536
|
|
|
536
|
-
|
|
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 =
|
|
553
|
+
this.loopPrompt = undefined;
|
|
548
554
|
this.statusLine.setLoopModeStatus({ enabled: true });
|
|
549
555
|
this.updateEditorTopBorder();
|
|
550
556
|
this.ui.requestRender();
|
|
551
|
-
this.showStatus(
|
|
552
|
-
|
|
553
|
-
|
|
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();
|
package/src/modes/types.ts
CHANGED
|
@@ -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(
|
|
240
|
-
disableLoopMode(
|
|
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
|
+
}
|