@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.
- package/CHANGELOG.md +56 -0
- package/package.json +7 -15
- package/scripts/build-binary.ts +1 -1
- package/src/cli/update-cli.ts +25 -1
- package/src/config/model-registry.ts +21 -19
- package/src/config/settings-schema.ts +14 -19
- package/src/discovery/claude-plugins.ts +28 -3
- package/src/edit/modes/atom.lark +7 -5
- package/src/edit/modes/atom.ts +510 -73
- package/src/edit/modes/hashline.ts +172 -91
- package/src/extensibility/extensions/runner.ts +34 -1
- package/src/extensibility/extensions/types.ts +8 -0
- package/src/lsp/client.ts +27 -35
- 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/memories/index.ts +5 -0
- package/src/modes/components/settings-defs.ts +1 -1
- package/src/modes/controllers/command-controller.ts +17 -0
- package/src/modes/controllers/input-controller.ts +7 -1
- package/src/modes/controllers/selector-controller.ts +2 -2
- package/src/modes/interactive-mode.ts +57 -26
- package/src/modes/theme/theme.ts +10 -1
- package/src/modes/types.ts +5 -3
- package/src/modes/utils/context-usage.ts +294 -0
- package/src/modes/utils/ui-helpers.ts +19 -6
- package/src/prompts/system/auto-continue.md +1 -0
- package/src/prompts/tools/atom.md +99 -44
- package/src/prompts/tools/exit-plan-mode.md +5 -39
- package/src/prompts/tools/github.md +3 -3
- 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/sdk.ts +13 -2
- package/src/session/agent-session.ts +175 -79
- package/src/session/compaction/compaction.ts +35 -22
- package/src/session/session-dump-format.ts +1 -0
- package/src/session/session-manager.ts +19 -2
- package/src/slash-commands/builtin-registry.ts +12 -5
- package/src/tools/bash.ts +9 -4
- package/src/tools/debug.ts +57 -70
- package/src/tools/gh.ts +267 -119
- 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/utils/git.ts +61 -2
- package/src/web/search/providers/searxng.ts +71 -13
- 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
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}`);
|
package/src/memories/index.ts
CHANGED
|
@@ -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: "
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
535
|
+
disableLoopMode(): void {
|
|
521
536
|
const wasEnabled = this.loopModeEnabled;
|
|
522
537
|
this.loopModeEnabled = false;
|
|
523
538
|
this.loopPrompt = undefined;
|
|
524
|
-
|
|
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
|
|
543
|
+
if (wasEnabled) {
|
|
532
544
|
this.showStatus("Loop mode disabled.");
|
|
533
545
|
}
|
|
534
546
|
}
|
|
535
547
|
|
|
536
|
-
|
|
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 =
|
|
564
|
+
this.loopPrompt = undefined;
|
|
548
565
|
this.statusLine.setLoopModeStatus({ enabled: true });
|
|
549
566
|
this.updateEditorTopBorder();
|
|
550
567
|
this.ui.requestRender();
|
|
551
|
-
this.showStatus(
|
|
552
|
-
|
|
553
|
-
|
|
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();
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
package/src/modes/types.ts
CHANGED
|
@@ -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(
|
|
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
|
+
}
|