@oh-my-pi/pi-coding-agent 15.5.12 → 15.5.15
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 +46 -0
- package/dist/types/config/model-registry.d.ts +1 -1
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -10
- package/dist/types/edit/file-snapshot-store.d.ts +19 -0
- package/dist/types/eval/__tests__/llm-bridge.test.d.ts +1 -0
- package/dist/types/eval/llm-bridge.d.ts +25 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +15 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/session/agent-session.d.ts +2 -0
- package/dist/types/tools/index.d.ts +0 -1
- package/package.json +8 -8
- package/src/config/model-registry.ts +89 -5
- package/src/config/models-config-schema.ts +1 -1
- package/src/config/settings-schema.ts +1 -10
- package/src/edit/file-snapshot-store.ts +34 -0
- package/src/edit/hashline/diff.ts +3 -8
- package/src/edit/renderer.ts +1 -1
- package/src/eval/__tests__/llm-bridge.test.ts +297 -0
- package/src/eval/js/shared/prelude.txt +8 -0
- package/src/eval/js/tool-bridge.ts +4 -0
- package/src/eval/llm-bridge.ts +181 -0
- package/src/eval/py/prelude.py +52 -31
- package/src/export/html/template.generated.ts +1 -1
- package/src/export/html/template.js +0 -13
- package/src/extensibility/plugins/legacy-pi-compat.ts +60 -23
- package/src/internal-urls/docs-index.generated.ts +4 -5
- package/src/main.ts +4 -0
- package/src/modes/components/model-selector.ts +119 -22
- package/src/modes/components/status-line/presets.ts +1 -0
- package/src/modes/components/status-line/segments.ts +23 -0
- package/src/modes/interactive-mode.ts +22 -87
- package/src/modes/theme/theme.ts +7 -0
- package/src/prompts/tools/eval.md +2 -0
- package/src/session/agent-session.ts +19 -0
- package/src/session/session-manager.ts +47 -0
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +6 -17
- package/src/tools/eval.ts +24 -48
- package/src/tools/index.ts +0 -4
- package/src/tools/read.ts +23 -33
- package/src/tools/renderers.ts +0 -2
- package/src/tools/search.ts +12 -21
- package/src/tools/write.ts +1 -3
- package/src/utils/file-mentions.ts +1 -3
- package/dist/types/tools/calculator.d.ts +0 -77
- package/src/prompts/tools/calculator.md +0 -10
- package/src/tools/calculator.ts +0 -541
package/src/main.ts
CHANGED
|
@@ -9,6 +9,7 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { createInterface } from "node:readline/promises";
|
|
12
|
+
import { EventLoopKeepalive } from "@oh-my-pi/pi-agent-core";
|
|
12
13
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
13
14
|
import {
|
|
14
15
|
$env,
|
|
@@ -144,6 +145,7 @@ export async function submitInteractiveInput(
|
|
|
144
145
|
}
|
|
145
146
|
|
|
146
147
|
try {
|
|
148
|
+
using _keepalive = new EventLoopKeepalive();
|
|
147
149
|
// Continue shortcuts submit an already-started empty prompt with no optimistic user message.
|
|
148
150
|
if (!input.started && !mode.markPendingSubmissionStarted(input)) {
|
|
149
151
|
return;
|
|
@@ -299,6 +301,7 @@ async function runInteractiveMode(
|
|
|
299
301
|
|
|
300
302
|
if (initialMessage !== undefined) {
|
|
301
303
|
try {
|
|
304
|
+
using _keepalive = new EventLoopKeepalive();
|
|
302
305
|
await session.prompt(initialMessage, { images: initialImages });
|
|
303
306
|
} catch (error: unknown) {
|
|
304
307
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
@@ -308,6 +311,7 @@ async function runInteractiveMode(
|
|
|
308
311
|
|
|
309
312
|
for (const message of initialMessages) {
|
|
310
313
|
try {
|
|
314
|
+
using _keepalive = new EventLoopKeepalive();
|
|
311
315
|
await session.prompt(message);
|
|
312
316
|
} catch (error: unknown) {
|
|
313
317
|
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
|
@@ -105,6 +105,8 @@ const STATIC_PROVIDER_TABS: ProviderTabState[] = [
|
|
|
105
105
|
{ id: CANONICAL_TAB, label: CANONICAL_TAB },
|
|
106
106
|
];
|
|
107
107
|
|
|
108
|
+
const MODEL_TAB_REFRESH_DEBOUNCE_MS = 120;
|
|
109
|
+
|
|
108
110
|
function formatProviderTabLabel(providerId: string): string {
|
|
109
111
|
return providerId.replace(/[-_]+/g, " ").toUpperCase();
|
|
110
112
|
}
|
|
@@ -145,6 +147,10 @@ export class ModelSelectorComponent extends Container {
|
|
|
145
147
|
// Tab state
|
|
146
148
|
#providers: ProviderTabState[] = STATIC_PROVIDER_TABS;
|
|
147
149
|
#activeTabIndex: number = 0;
|
|
150
|
+
#refreshingProviders: Set<string> = new Set();
|
|
151
|
+
#scheduledProviderRefreshes: Map<string, ReturnType<typeof setTimeout>> = new Map();
|
|
152
|
+
#refreshSpinnerFrame: number = 0;
|
|
153
|
+
#refreshSpinnerInterval?: NodeJS.Timeout;
|
|
148
154
|
|
|
149
155
|
// Context menu state
|
|
150
156
|
#isMenuOpen: boolean = false;
|
|
@@ -371,10 +377,8 @@ export class ModelSelectorComponent extends Container {
|
|
|
371
377
|
});
|
|
372
378
|
}
|
|
373
379
|
|
|
374
|
-
|
|
380
|
+
#loadModelsFromCurrentRegistryState(): void {
|
|
375
381
|
let models: ModelItem[];
|
|
376
|
-
|
|
377
|
-
// Use scoped models if provided via --models flag
|
|
378
382
|
if (this.#scopedModels.length > 0) {
|
|
379
383
|
models = this.#scopedModels.map(scoped => ({
|
|
380
384
|
kind: "provider",
|
|
@@ -384,10 +388,6 @@ export class ModelSelectorComponent extends Container {
|
|
|
384
388
|
selector: `${scoped.model.provider}/${scoped.model.id}`,
|
|
385
389
|
}));
|
|
386
390
|
} else {
|
|
387
|
-
// Reload config and cached discovery state without blocking on live provider refresh
|
|
388
|
-
await this.#modelRegistry.refresh("offline");
|
|
389
|
-
|
|
390
|
-
// Check for models.json errors
|
|
391
391
|
const loadError = this.#modelRegistry.getError();
|
|
392
392
|
if (loadError) {
|
|
393
393
|
this.#errorMessage = loadError;
|
|
@@ -395,7 +395,6 @@ export class ModelSelectorComponent extends Container {
|
|
|
395
395
|
this.#errorMessage = undefined;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
// Load available models (built-in models still work even if models.json failed)
|
|
399
398
|
try {
|
|
400
399
|
const availableModels = this.#modelRegistry.getAvailable();
|
|
401
400
|
models = availableModels.map((model: Model) => ({
|
|
@@ -415,15 +414,16 @@ export class ModelSelectorComponent extends Container {
|
|
|
415
414
|
}
|
|
416
415
|
}
|
|
417
416
|
|
|
417
|
+
const candidates = models.map(item => item.model);
|
|
418
418
|
const canonicalRecords = this.#modelRegistry.getCanonicalModels({
|
|
419
419
|
availableOnly: this.#scopedModels.length === 0,
|
|
420
|
-
candidates
|
|
420
|
+
candidates,
|
|
421
421
|
});
|
|
422
422
|
const canonicalModels = canonicalRecords
|
|
423
423
|
.map(record => {
|
|
424
424
|
const selectedModel = this.#modelRegistry.resolveCanonicalModel(record.id, {
|
|
425
425
|
availableOnly: this.#scopedModels.length === 0,
|
|
426
|
-
candidates
|
|
426
|
+
candidates,
|
|
427
427
|
});
|
|
428
428
|
if (!selectedModel) return undefined;
|
|
429
429
|
const searchText = [
|
|
@@ -457,6 +457,14 @@ export class ModelSelectorComponent extends Container {
|
|
|
457
457
|
this.#selectedIndex = Math.min(this.#selectedIndex, Math.max(0, models.length - 1));
|
|
458
458
|
}
|
|
459
459
|
|
|
460
|
+
async #loadModels(): Promise<void> {
|
|
461
|
+
if (this.#scopedModels.length === 0) {
|
|
462
|
+
// Reload config and cached discovery state without blocking on live provider refresh
|
|
463
|
+
await this.#modelRegistry.refresh("offline");
|
|
464
|
+
}
|
|
465
|
+
this.#loadModelsFromCurrentRegistryState();
|
|
466
|
+
}
|
|
467
|
+
|
|
460
468
|
#buildProviderTabs(): void {
|
|
461
469
|
const activeTabId = this.#getActiveTab().id;
|
|
462
470
|
const providerSet = new Set<string>();
|
|
@@ -475,17 +483,100 @@ export class ModelSelectorComponent extends Container {
|
|
|
475
483
|
activeIndex >= 0 ? activeIndex : Math.min(this.#activeTabIndex, this.#providers.length - 1);
|
|
476
484
|
}
|
|
477
485
|
|
|
478
|
-
|
|
486
|
+
#getActiveProviderRefreshStatusText(): string | undefined {
|
|
487
|
+
const providerId = this.#getActiveProviderId();
|
|
488
|
+
if (!providerId || !this.#refreshingProviders.has(providerId)) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const spinnerFrames = theme.spinnerFrames;
|
|
492
|
+
const spinner =
|
|
493
|
+
spinnerFrames.length > 0
|
|
494
|
+
? spinnerFrames[this.#refreshSpinnerFrame % spinnerFrames.length]
|
|
495
|
+
: theme.status.pending;
|
|
496
|
+
return theme.fg("warning", ` ${spinner} Refreshing ${formatProviderTabLabel(providerId)} in background...`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
#startRefreshSpinner(): void {
|
|
500
|
+
if (this.#refreshSpinnerInterval) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
this.#refreshSpinnerInterval = setInterval(() => {
|
|
504
|
+
const frameCount = theme.spinnerFrames.length;
|
|
505
|
+
if (frameCount > 0) {
|
|
506
|
+
this.#refreshSpinnerFrame = (this.#refreshSpinnerFrame + 1) % frameCount;
|
|
507
|
+
}
|
|
508
|
+
this.#updateTabBar();
|
|
509
|
+
this.#tui.requestRender();
|
|
510
|
+
}, 80);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
#stopRefreshSpinner(): void {
|
|
514
|
+
if (this.#refreshingProviders.size > 0) {
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (this.#refreshSpinnerInterval) {
|
|
518
|
+
clearInterval(this.#refreshSpinnerInterval);
|
|
519
|
+
this.#refreshSpinnerInterval = undefined;
|
|
520
|
+
}
|
|
521
|
+
this.#refreshSpinnerFrame = 0;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#setProviderRefreshing(providerId: string, refreshing: boolean): void {
|
|
525
|
+
if (refreshing) {
|
|
526
|
+
this.#refreshingProviders.add(providerId);
|
|
527
|
+
this.#startRefreshSpinner();
|
|
528
|
+
} else {
|
|
529
|
+
this.#refreshingProviders.delete(providerId);
|
|
530
|
+
this.#stopRefreshSpinner();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
#cancelScheduledProviderRefreshesExcept(keepProviderId?: string): void {
|
|
535
|
+
for (const [providerId, timer] of this.#scheduledProviderRefreshes) {
|
|
536
|
+
if (providerId === keepProviderId) {
|
|
537
|
+
continue;
|
|
538
|
+
}
|
|
539
|
+
clearTimeout(timer);
|
|
540
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
541
|
+
this.#setProviderRefreshing(providerId, false);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#scheduleSelectedProviderRefresh(): void {
|
|
479
546
|
const providerId = this.#getActiveProviderId();
|
|
480
547
|
if (this.#scopedModels.length > 0 || !providerId) {
|
|
481
548
|
return;
|
|
482
549
|
}
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
this.#
|
|
487
|
-
|
|
488
|
-
|
|
550
|
+
if (this.#scheduledProviderRefreshes.has(providerId) || this.#refreshingProviders.has(providerId)) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
this.#setProviderRefreshing(providerId, true);
|
|
554
|
+
const timer = setTimeout(() => {
|
|
555
|
+
this.#scheduledProviderRefreshes.delete(providerId);
|
|
556
|
+
void this.#refreshProviderInBackground(providerId);
|
|
557
|
+
}, MODEL_TAB_REFRESH_DEBOUNCE_MS);
|
|
558
|
+
this.#scheduledProviderRefreshes.set(providerId, timer);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async #refreshProviderInBackground(providerId: string): Promise<void> {
|
|
562
|
+
try {
|
|
563
|
+
await this.#modelRegistry.refreshProvider(providerId, "online");
|
|
564
|
+
// Provider refresh already updated the registry snapshot. Re-reading it
|
|
565
|
+
// here must stay purely in-memory — do not call modelRegistry.refresh()
|
|
566
|
+
// again or tab switches will pay an extra whole-registry reload after the
|
|
567
|
+
// network round-trip completes.
|
|
568
|
+
this.#loadModelsFromCurrentRegistryState();
|
|
569
|
+
this.#buildProviderTabs();
|
|
570
|
+
this.#updateTabBar();
|
|
571
|
+
this.#applyTabFilter();
|
|
572
|
+
} catch (error) {
|
|
573
|
+
this.#errorMessage = error instanceof Error ? error.message : String(error);
|
|
574
|
+
this.#updateList();
|
|
575
|
+
} finally {
|
|
576
|
+
this.#setProviderRefreshing(providerId, false);
|
|
577
|
+
this.#updateTabBar();
|
|
578
|
+
this.#tui.requestRender();
|
|
579
|
+
}
|
|
489
580
|
}
|
|
490
581
|
|
|
491
582
|
#updateTabBar(): void {
|
|
@@ -496,15 +587,21 @@ export class ModelSelectorComponent extends Container {
|
|
|
496
587
|
tabBar.onTabChange = (_tab, index) => {
|
|
497
588
|
this.#activeTabIndex = index;
|
|
498
589
|
this.#selectedIndex = 0;
|
|
590
|
+
this.#cancelScheduledProviderRefreshesExcept(this.#getActiveProviderId());
|
|
499
591
|
this.#applyTabFilter();
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
592
|
+
this.#scheduleSelectedProviderRefresh();
|
|
593
|
+
this.#updateTabBar();
|
|
594
|
+
// Let TUI's normal post-input render paint the new tab immediately.
|
|
595
|
+
// The live refresh is debounced onto a later timer so tab cycling never
|
|
596
|
+
// shares a stack frame with provider refresh work.
|
|
597
|
+
this.#tui.requestRender();
|
|
505
598
|
};
|
|
506
599
|
this.#tabBar = tabBar;
|
|
507
600
|
this.#headerContainer.addChild(tabBar);
|
|
601
|
+
const refreshStatusText = this.#getActiveProviderRefreshStatusText();
|
|
602
|
+
if (refreshStatusText) {
|
|
603
|
+
this.#headerContainer.addChild(new Text(refreshStatusText, 0, 0));
|
|
604
|
+
}
|
|
508
605
|
}
|
|
509
606
|
|
|
510
607
|
#getActiveTab(): ProviderTabState {
|
|
@@ -36,6 +36,7 @@ export const STATUS_LINE_PRESETS: Record<StatusLinePreset, PresetDef> = {
|
|
|
36
36
|
leftSegments: ["pi", "hostname", "model", "mode", "path", "git", "pr", "subagents"],
|
|
37
37
|
rightSegments: [
|
|
38
38
|
"session_name",
|
|
39
|
+
"cache_hit",
|
|
39
40
|
"token_in",
|
|
40
41
|
"token_out",
|
|
41
42
|
"token_rate",
|
|
@@ -448,6 +448,28 @@ const cacheWriteSegment: StatusLineSegment = {
|
|
|
448
448
|
},
|
|
449
449
|
};
|
|
450
450
|
|
|
451
|
+
const cacheHitSegment: StatusLineSegment = {
|
|
452
|
+
id: "cache_hit",
|
|
453
|
+
render(ctx) {
|
|
454
|
+
const { cacheRead, cacheWrite, input } = ctx.usageStats;
|
|
455
|
+
if (!cacheRead) return { content: "", visible: false };
|
|
456
|
+
|
|
457
|
+
// Hit rate = cacheRead / total prompt tokens. The prompt is the sum of
|
|
458
|
+
// cacheRead (served from cache), cacheWrite (newly cached this turn) and
|
|
459
|
+
// input (uncached). Including uncached input keeps the denominator honest
|
|
460
|
+
// for Anthropic/OpenRouter; DeepSeek reports its miss as input with
|
|
461
|
+
// cacheWrite 0, so this still yields hit/(hit+miss).
|
|
462
|
+
const total = cacheRead + cacheWrite + input;
|
|
463
|
+
|
|
464
|
+
const rate = (cacheRead / total) * 100;
|
|
465
|
+
const rateStr = rate.toFixed(2);
|
|
466
|
+
|
|
467
|
+
const parts: string[] = [theme.icon.cache];
|
|
468
|
+
parts.push(theme.fg("statusLineSpend", `${rateStr}%`));
|
|
469
|
+
return { content: parts.join(" "), visible: true };
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
|
|
451
473
|
const sessionNameSegment: StatusLineSegment = {
|
|
452
474
|
id: "session_name",
|
|
453
475
|
render(ctx) {
|
|
@@ -537,6 +559,7 @@ export const SEGMENTS: Record<StatusLineSegmentId, StatusLineSegment> = {
|
|
|
537
559
|
hostname: hostnameSegment,
|
|
538
560
|
cache_read: cacheReadSegment,
|
|
539
561
|
cache_write: cacheWriteSegment,
|
|
562
|
+
cache_hit: cacheHitSegment,
|
|
540
563
|
session_name: sessionNameSegment,
|
|
541
564
|
usage: usageSegment,
|
|
542
565
|
};
|
|
@@ -324,8 +324,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
324
324
|
#eventBus?: EventBus;
|
|
325
325
|
#eventBusUnsubscribers: Array<() => void> = [];
|
|
326
326
|
#welcomeComponent?: WelcomeComponent;
|
|
327
|
-
#todoSpinnerInterval?: NodeJS.Timeout;
|
|
328
|
-
#todoSpinnerFrame = 0;
|
|
329
327
|
#todoClosingTimeout?: NodeJS.Timeout;
|
|
330
328
|
#todoClosingState: "idle" | "playing" | "done" = "idle";
|
|
331
329
|
|
|
@@ -534,9 +532,9 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
534
532
|
this.#observerRegistry.onChange(() => {
|
|
535
533
|
this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount());
|
|
536
534
|
// Auto-checkmark todos whose matching subagent just succeeded, then
|
|
537
|
-
// re-render so the running override (
|
|
538
|
-
// is doing the work for a still-pending todo) updates as
|
|
539
|
-
// start, finish, or fail.
|
|
535
|
+
// re-render so the running override (the static "live" glyph when a
|
|
536
|
+
// subagent is doing the work for a still-pending todo) updates as
|
|
537
|
+
// subagents start, finish, or fail.
|
|
540
538
|
this.#reconcileTodosWithSubagents();
|
|
541
539
|
this.#renderTodoList();
|
|
542
540
|
this.ui.requestRender();
|
|
@@ -849,7 +847,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
849
847
|
this.#pendingSubmissionDispose = undefined;
|
|
850
848
|
}
|
|
851
849
|
this.editor.setText("");
|
|
852
|
-
this.ui.refreshNativeScrollbackIfDirty();
|
|
850
|
+
this.ui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
|
|
853
851
|
this.ensureLoadingAnimation();
|
|
854
852
|
this.ui.requestRender();
|
|
855
853
|
return submission;
|
|
@@ -960,29 +958,19 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
960
958
|
this.renderSessionContext(context);
|
|
961
959
|
}
|
|
962
960
|
|
|
963
|
-
#formatTodoLine(todo: TodoItem, prefix: string, matched: boolean
|
|
961
|
+
#formatTodoLine(todo: TodoItem, prefix: string, matched: boolean): string {
|
|
964
962
|
const checkbox = theme.checkbox;
|
|
965
963
|
const marker = formatHudNoteMarker(todo.notes?.length ?? 0);
|
|
966
|
-
const frames = theme.spinnerFrames;
|
|
967
|
-
// When the spinner is ticking, use the current animated frame; otherwise
|
|
968
|
-
// fall back to the static "running" glyph so in_progress rows still look
|
|
969
|
-
// distinct from pending rows.
|
|
970
|
-
const runningGlyph =
|
|
971
|
-
spinnerOn && frames.length > 0
|
|
972
|
-
? (frames[this.#todoSpinnerFrame % frames.length] ?? theme.status.running)
|
|
973
|
-
: theme.status.running;
|
|
974
964
|
switch (todo.status) {
|
|
975
965
|
case "completed":
|
|
976
|
-
return (
|
|
977
|
-
theme.fg("success", `${prefix}${theme.status.success} ${chalk.strikethrough(todo.content)}`) + marker
|
|
978
|
-
);
|
|
966
|
+
return theme.fg("success", `${prefix}${checkbox.checked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
979
967
|
case "in_progress":
|
|
980
|
-
return theme.fg("accent", `${prefix}${
|
|
968
|
+
return theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
981
969
|
case "abandoned":
|
|
982
970
|
return theme.fg("error", `${prefix}${checkbox.unchecked} ${chalk.strikethrough(todo.content)}`) + marker;
|
|
983
971
|
default:
|
|
984
972
|
if (matched) {
|
|
985
|
-
return theme.fg("accent", `${prefix}${
|
|
973
|
+
return theme.fg("accent", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
986
974
|
}
|
|
987
975
|
return theme.fg("dim", `${prefix}${checkbox.unchecked} ${todo.content}`) + marker;
|
|
988
976
|
}
|
|
@@ -1036,25 +1024,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1036
1024
|
this.session.setTodoPhases(next);
|
|
1037
1025
|
}
|
|
1038
1026
|
|
|
1039
|
-
#updateTodoSpinnerAnimation(needSpinner: boolean): void {
|
|
1040
|
-
if (needSpinner) {
|
|
1041
|
-
if (this.#todoSpinnerInterval) return;
|
|
1042
|
-
this.#todoSpinnerInterval = setInterval(() => {
|
|
1043
|
-
const frames = theme.spinnerFrames;
|
|
1044
|
-
if (frames.length === 0) return;
|
|
1045
|
-
this.#todoSpinnerFrame = (this.#todoSpinnerFrame + 1) % frames.length;
|
|
1046
|
-
// Rebuild the todo container so the new frame appears, then schedule
|
|
1047
|
-
// a paint. The renderer self-stops the interval once no row needs it.
|
|
1048
|
-
this.#renderTodoList();
|
|
1049
|
-
this.ui.requestRender();
|
|
1050
|
-
}, 80);
|
|
1051
|
-
} else if (this.#todoSpinnerInterval) {
|
|
1052
|
-
clearInterval(this.#todoSpinnerInterval);
|
|
1053
|
-
this.#todoSpinnerInterval = undefined;
|
|
1054
|
-
this.#todoSpinnerFrame = 0;
|
|
1055
|
-
}
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
1027
|
#getActivePhase(phases: TodoPhase[]): TodoPhase | undefined {
|
|
1059
1028
|
const nonEmpty = phases.filter(phase => phase.tasks.length > 0);
|
|
1060
1029
|
const active = nonEmpty.find(phase =>
|
|
@@ -1067,7 +1036,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1067
1036
|
this.todoContainer.clear();
|
|
1068
1037
|
const phases = this.todoPhases.filter(phase => phase.tasks.length > 0);
|
|
1069
1038
|
if (phases.length === 0) {
|
|
1070
|
-
this.#updateTodoSpinnerAnimation(false);
|
|
1071
1039
|
this.#stopTodoClosingAnimation();
|
|
1072
1040
|
this.#todoClosingState = "idle";
|
|
1073
1041
|
return;
|
|
@@ -1081,7 +1049,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1081
1049
|
phase.tasks.every(t => t.status === "completed" || t.status === "abandoned"),
|
|
1082
1050
|
);
|
|
1083
1051
|
if (allClosed) {
|
|
1084
|
-
this.#updateTodoSpinnerAnimation(false);
|
|
1085
1052
|
if (this.#todoClosingState === "done") return;
|
|
1086
1053
|
if (this.#todoClosingState === "idle") this.#startTodoClosingAnimation(phases);
|
|
1087
1054
|
return;
|
|
@@ -1095,52 +1062,23 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1095
1062
|
const lines = ["", indent + theme.bold(theme.fg("accent", "Todos"))];
|
|
1096
1063
|
|
|
1097
1064
|
const activeDescs = this.#getActiveSubagentDescriptions();
|
|
1098
|
-
//
|
|
1099
|
-
//
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
if (activeDescs.length === 0) return false;
|
|
1103
|
-
if (matchedSet.has(todo)) return true;
|
|
1104
|
-
if (todoMatchesAnyDescription(todo.content, activeDescs)) {
|
|
1105
|
-
matchedSet.add(todo);
|
|
1106
|
-
return true;
|
|
1107
|
-
}
|
|
1108
|
-
return false;
|
|
1109
|
-
};
|
|
1110
|
-
|
|
1111
|
-
// The cube animates whenever any visible open todo is "live":
|
|
1112
|
-
// (a) status is in_progress (the agent itself is working it), or
|
|
1113
|
-
// (b) a still-pending todo has a matching in-flight subagent doing
|
|
1114
|
-
// the work for it. The renderer self-stops the interval once no row
|
|
1115
|
-
// qualifies, so an orphan in_progress row at end-of-session keeps
|
|
1116
|
-
// ticking — that's the intentional "this todo is still open" signal.
|
|
1117
|
-
let needsSpinner = false;
|
|
1118
|
-
const considerForSpinner = (todo: TodoItem): void => {
|
|
1119
|
-
if (todo.status === "in_progress") {
|
|
1120
|
-
needsSpinner = true;
|
|
1121
|
-
return;
|
|
1122
|
-
}
|
|
1123
|
-
if (todo.status !== "pending") return;
|
|
1124
|
-
if (isMatched(todo)) needsSpinner = true;
|
|
1125
|
-
};
|
|
1065
|
+
// A pending todo "lights up" (accent + running glyph) when an in-flight
|
|
1066
|
+
// subagent is doing its work, matched by normalized content overlap.
|
|
1067
|
+
const isMatched = (todo: TodoItem): boolean =>
|
|
1068
|
+
activeDescs.length > 0 && todoMatchesAnyDescription(todo.content, activeDescs);
|
|
1126
1069
|
|
|
1127
1070
|
if (!this.todoExpanded) {
|
|
1128
1071
|
const activeIdx = phases.indexOf(this.#getActivePhase(phases) ?? phases[0]);
|
|
1129
1072
|
const activePhase = phases[activeIdx];
|
|
1130
|
-
if (!activePhase)
|
|
1131
|
-
this.#updateTodoSpinnerAnimation(false);
|
|
1132
|
-
return;
|
|
1133
|
-
}
|
|
1073
|
+
if (!activePhase) return;
|
|
1134
1074
|
const { visible, hiddenOpenCount } = selectStickyTodoWindow(activePhase.tasks, 5);
|
|
1135
|
-
for (const todo of visible) considerForSpinner(todo);
|
|
1136
|
-
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1137
1075
|
|
|
1138
1076
|
lines.push(
|
|
1139
1077
|
`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(activePhase.name, activeIdx + 1)}`)}`,
|
|
1140
1078
|
);
|
|
1141
1079
|
visible.forEach((todo, index) => {
|
|
1142
1080
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
1143
|
-
lines.push(this.#formatTodoLine(todo, prefix,
|
|
1081
|
+
lines.push(this.#formatTodoLine(todo, prefix, isMatched(todo)));
|
|
1144
1082
|
});
|
|
1145
1083
|
if (hiddenOpenCount > 0) {
|
|
1146
1084
|
lines.push(theme.fg("muted", `${indent} ${hook} +${hiddenOpenCount} more`));
|
|
@@ -1149,14 +1087,11 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
1149
1087
|
return;
|
|
1150
1088
|
}
|
|
1151
1089
|
|
|
1152
|
-
for (const phase of phases) for (const todo of phase.tasks) considerForSpinner(todo);
|
|
1153
|
-
this.#updateTodoSpinnerAnimation(needsSpinner);
|
|
1154
|
-
|
|
1155
1090
|
phases.forEach((phase, phaseIndex) => {
|
|
1156
1091
|
lines.push(`${indent}${theme.fg("accent", `${hook} ${formatPhaseDisplayName(phase.name, phaseIndex + 1)}`)}`);
|
|
1157
1092
|
phase.tasks.forEach((todo, index) => {
|
|
1158
1093
|
const prefix = `${indent}${index === 0 ? hook : " "} `;
|
|
1159
|
-
lines.push(this.#formatTodoLine(todo, prefix,
|
|
1094
|
+
lines.push(this.#formatTodoLine(todo, prefix, isMatched(todo)));
|
|
1160
1095
|
});
|
|
1161
1096
|
});
|
|
1162
1097
|
|
|
@@ -2166,20 +2101,21 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2166
2101
|
}
|
|
2167
2102
|
|
|
2168
2103
|
this.#renderPlanPreview(planContent, { append: true });
|
|
2104
|
+
const contextUsage = this.session.getContextUsage();
|
|
2105
|
+
const keepContextLabel =
|
|
2106
|
+
contextUsage?.percent != null
|
|
2107
|
+
? `Approve and keep context (${contextUsage.percent.toFixed(1)}%)`
|
|
2108
|
+
: "Approve and keep context";
|
|
2169
2109
|
const choice = await this.showHookSelector(
|
|
2170
2110
|
"Plan mode - next step",
|
|
2171
|
-
["Approve and execute", "Approve and compact context",
|
|
2111
|
+
["Approve and execute", "Approve and compact context", keepContextLabel, "Refine plan"],
|
|
2172
2112
|
{
|
|
2173
2113
|
helpText: this.#getPlanReviewHelpText(),
|
|
2174
2114
|
onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
|
|
2175
2115
|
},
|
|
2176
2116
|
);
|
|
2177
2117
|
|
|
2178
|
-
if (
|
|
2179
|
-
choice === "Approve and execute" ||
|
|
2180
|
-
choice === "Approve and compact context" ||
|
|
2181
|
-
choice === "Approve and keep context"
|
|
2182
|
-
) {
|
|
2118
|
+
if (choice === "Approve and execute" || choice === "Approve and compact context" || choice === keepContextLabel) {
|
|
2183
2119
|
const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
|
|
2184
2120
|
try {
|
|
2185
2121
|
const latestPlanContent = await this.#readPlanFile(planFilePath);
|
|
@@ -2265,7 +2201,6 @@ export class InteractiveMode implements InteractiveModeContext {
|
|
|
2265
2201
|
this.loadingAnimation = undefined;
|
|
2266
2202
|
}
|
|
2267
2203
|
this.#cleanupMicAnimation();
|
|
2268
|
-
this.#updateTodoSpinnerAnimation(false);
|
|
2269
2204
|
this.#cancelGoalContinuation();
|
|
2270
2205
|
if (this.#sttController) {
|
|
2271
2206
|
this.#sttController.dispose();
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -144,6 +144,7 @@ export type SymbolKey =
|
|
|
144
144
|
| "md.quoteBorder"
|
|
145
145
|
| "md.hrChar"
|
|
146
146
|
| "md.bullet"
|
|
147
|
+
| "md.colorSwatch"
|
|
147
148
|
// Language/file type icons
|
|
148
149
|
| "lang.default"
|
|
149
150
|
| "lang.typescript"
|
|
@@ -308,6 +309,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
|
|
|
308
309
|
"md.quoteBorder": "▏",
|
|
309
310
|
"md.hrChar": "─",
|
|
310
311
|
"md.bullet": "•",
|
|
312
|
+
"md.colorSwatch": "■",
|
|
311
313
|
// Language/file icons (emoji-centric, no Nerd Font required)
|
|
312
314
|
"lang.default": "⌘",
|
|
313
315
|
"lang.typescript": "🟦",
|
|
@@ -568,6 +570,8 @@ const NERD_SYMBOLS: SymbolMap = {
|
|
|
568
570
|
"md.hrChar": "─",
|
|
569
571
|
// pick: | alt: •
|
|
570
572
|
"md.bullet": "\uf111",
|
|
573
|
+
// pick: ■ | alt: (U+F096)
|
|
574
|
+
"md.colorSwatch": "■",
|
|
571
575
|
// Language icons (nerd font devicons)
|
|
572
576
|
"lang.default": "",
|
|
573
577
|
"lang.typescript": "\u{E628}",
|
|
@@ -730,6 +734,7 @@ const ASCII_SYMBOLS: SymbolMap = {
|
|
|
730
734
|
"md.quoteBorder": "|",
|
|
731
735
|
"md.hrChar": "-",
|
|
732
736
|
"md.bullet": "*",
|
|
737
|
+
"md.colorSwatch": "[]",
|
|
733
738
|
// Language icons (ASCII uses abbreviations)
|
|
734
739
|
"lang.default": "code",
|
|
735
740
|
"lang.typescript": "ts",
|
|
@@ -1519,6 +1524,7 @@ export class Theme {
|
|
|
1519
1524
|
quoteBorder: this.#symbols["md.quoteBorder"],
|
|
1520
1525
|
hrChar: this.#symbols["md.hrChar"],
|
|
1521
1526
|
bullet: this.#symbols["md.bullet"],
|
|
1527
|
+
colorSwatch: this.#symbols["md.colorSwatch"],
|
|
1522
1528
|
};
|
|
1523
1529
|
}
|
|
1524
1530
|
|
|
@@ -2340,6 +2346,7 @@ export function getSymbolTheme(): SymbolTheme {
|
|
|
2340
2346
|
table: theme.boxSharp,
|
|
2341
2347
|
quoteBorder: theme.md.quoteBorder,
|
|
2342
2348
|
hrChar: theme.md.hrChar,
|
|
2349
|
+
colorSwatch: theme.md.colorSwatch,
|
|
2343
2350
|
spinnerFrames: theme.getSpinnerFrames("activity"),
|
|
2344
2351
|
};
|
|
2345
2352
|
}
|
|
@@ -44,6 +44,8 @@ output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | di
|
|
|
44
44
|
Read task/agent output by ID. Single id returns text/dict; multiple ids return a list.
|
|
45
45
|
tool.<name>(args) → unknown
|
|
46
46
|
Invoke any session tool by name. `args` is the tool's parameter object.
|
|
47
|
+
llm(prompt, model?="default", system?=None, schema?=None) → str | dict
|
|
48
|
+
Oneshot, stateless LLM call (no history, no tools). `model` picks a tier: "smol" (fast), "default" (this session's model), "slow" (most capable). Pass `system` for a system prompt. Pass a JSON-Schema `schema` to force structured output and get the parsed object back; otherwise returns the completion text.
|
|
47
49
|
```
|
|
48
50
|
</prelude>
|
|
49
51
|
|
|
@@ -453,6 +453,15 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
|
|
|
453
453
|
}
|
|
454
454
|
|
|
455
455
|
const IRC_REPLY_MAX_BYTES = 4096;
|
|
456
|
+
export const ANTHROPIC_TOOL_CALL_BATCH_CAP = 4;
|
|
457
|
+
const CLAUDE_OPUS_4_8_MODEL_ID = /(?:^|[./_-])claude-opus-4[.-]8\b/i;
|
|
458
|
+
|
|
459
|
+
export function resolveToolCallBatchCapForModel(model: Model | undefined): number | undefined {
|
|
460
|
+
if (!model) return undefined;
|
|
461
|
+
return model.provider === "anthropic" && CLAUDE_OPUS_4_8_MODEL_ID.test(model.id)
|
|
462
|
+
? ANTHROPIC_TOOL_CALL_BATCH_CAP
|
|
463
|
+
: undefined;
|
|
464
|
+
}
|
|
456
465
|
|
|
457
466
|
/**
|
|
458
467
|
* Collapse degenerate IRC ephemeral replies before they hit the relay.
|
|
@@ -993,6 +1002,10 @@ export class AgentSession {
|
|
|
993
1002
|
this.#flushPendingAgentEnd();
|
|
994
1003
|
}
|
|
995
1004
|
|
|
1005
|
+
#syncToolCallBatchCap(model: Model | undefined = this.model): void {
|
|
1006
|
+
this.agent.maxToolCallsPerTurn = resolveToolCallBatchCapForModel(model);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
996
1009
|
#flushPendingAgentEnd(): void {
|
|
997
1010
|
const pending = this.#pendingAgentEndEmit;
|
|
998
1011
|
if (!pending) return;
|
|
@@ -1097,6 +1110,7 @@ export class AgentSession {
|
|
|
1097
1110
|
this.#agentId = config.agentId;
|
|
1098
1111
|
this.#agentRegistry = config.agentRegistry;
|
|
1099
1112
|
this.#providerSessionId = config.providerSessionId;
|
|
1113
|
+
this.#syncToolCallBatchCap();
|
|
1100
1114
|
this.agent.setAssistantMessageEventInterceptor((message, assistantMessageEvent) => {
|
|
1101
1115
|
const event: AgentEvent = {
|
|
1102
1116
|
type: "message_update",
|
|
@@ -6162,6 +6176,7 @@ export class AgentSession {
|
|
|
6162
6176
|
this.#closeProviderSessionsForModelSwitch(currentModel, model);
|
|
6163
6177
|
}
|
|
6164
6178
|
this.agent.setModel(model);
|
|
6179
|
+
this.#syncToolCallBatchCap(model);
|
|
6165
6180
|
|
|
6166
6181
|
// Re-evaluate append-only context mode — provider or setting may have changed
|
|
6167
6182
|
this.#syncAppendOnlyContext(model);
|
|
@@ -8214,6 +8229,7 @@ export class AgentSession {
|
|
|
8214
8229
|
this.#setModelWithProviderSessionReset(match);
|
|
8215
8230
|
} else {
|
|
8216
8231
|
this.agent.setModel(match);
|
|
8232
|
+
this.#syncToolCallBatchCap(match);
|
|
8217
8233
|
}
|
|
8218
8234
|
}
|
|
8219
8235
|
}
|
|
@@ -8272,6 +8288,9 @@ export class AgentSession {
|
|
|
8272
8288
|
this.#scheduledHiddenNextTurnGeneration = previousScheduledHiddenNextTurnGeneration;
|
|
8273
8289
|
if (previousModel) {
|
|
8274
8290
|
this.agent.setModel(previousModel);
|
|
8291
|
+
this.#syncToolCallBatchCap(previousModel);
|
|
8292
|
+
} else {
|
|
8293
|
+
this.#syncToolCallBatchCap(undefined);
|
|
8275
8294
|
}
|
|
8276
8295
|
this.#thinkingLevel = previousThinkingLevel;
|
|
8277
8296
|
this.agent.setThinkingLevel(toReasoningEffort(previousThinkingLevel));
|