@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/config/model-registry.d.ts +1 -1
  3. package/dist/types/config/models-config-schema.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +1 -10
  5. package/dist/types/edit/file-snapshot-store.d.ts +19 -0
  6. package/dist/types/eval/__tests__/llm-bridge.test.d.ts +1 -0
  7. package/dist/types/eval/llm-bridge.d.ts +25 -0
  8. package/dist/types/export/html/template.generated.d.ts +1 -1
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +15 -0
  10. package/dist/types/modes/theme/theme.d.ts +2 -1
  11. package/dist/types/session/agent-session.d.ts +2 -0
  12. package/dist/types/tools/index.d.ts +0 -1
  13. package/package.json +8 -8
  14. package/src/config/model-registry.ts +89 -5
  15. package/src/config/models-config-schema.ts +1 -1
  16. package/src/config/settings-schema.ts +1 -10
  17. package/src/edit/file-snapshot-store.ts +34 -0
  18. package/src/edit/hashline/diff.ts +3 -8
  19. package/src/edit/renderer.ts +1 -1
  20. package/src/eval/__tests__/llm-bridge.test.ts +297 -0
  21. package/src/eval/js/shared/prelude.txt +8 -0
  22. package/src/eval/js/tool-bridge.ts +4 -0
  23. package/src/eval/llm-bridge.ts +181 -0
  24. package/src/eval/py/prelude.py +52 -31
  25. package/src/export/html/template.generated.ts +1 -1
  26. package/src/export/html/template.js +0 -13
  27. package/src/extensibility/plugins/legacy-pi-compat.ts +60 -23
  28. package/src/internal-urls/docs-index.generated.ts +4 -5
  29. package/src/main.ts +4 -0
  30. package/src/modes/components/model-selector.ts +119 -22
  31. package/src/modes/components/status-line/presets.ts +1 -0
  32. package/src/modes/components/status-line/segments.ts +23 -0
  33. package/src/modes/interactive-mode.ts +22 -87
  34. package/src/modes/theme/theme.ts +7 -0
  35. package/src/prompts/tools/eval.md +2 -0
  36. package/src/session/agent-session.ts +19 -0
  37. package/src/session/session-manager.ts +47 -0
  38. package/src/tools/ast-edit.ts +1 -1
  39. package/src/tools/ast-grep.ts +6 -17
  40. package/src/tools/eval.ts +24 -48
  41. package/src/tools/index.ts +0 -4
  42. package/src/tools/read.ts +23 -33
  43. package/src/tools/renderers.ts +0 -2
  44. package/src/tools/search.ts +12 -21
  45. package/src/tools/write.ts +1 -3
  46. package/src/utils/file-mentions.ts +1 -3
  47. package/dist/types/tools/calculator.d.ts +0 -77
  48. package/src/prompts/tools/calculator.md +0 -10
  49. 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
- async #loadModels(): Promise<void> {
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: models.map(item => item.model),
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: models.map(item => item.model),
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
- async #refreshSelectedProvider(): Promise<void> {
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
- await this.#modelRegistry.refreshProvider(providerId);
484
- await this.#loadModels();
485
- this.#buildProviderTabs();
486
- this.#updateTabBar();
487
- this.#applyTabFilter();
488
- this.#tui.requestRender();
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
- void this.#refreshSelectedProvider().catch(error => {
501
- this.#errorMessage = error instanceof Error ? error.message : String(error);
502
- this.#updateList();
503
- this.#tui.requestRender();
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 (animated row when a subagent
538
- // is doing the work for a still-pending todo) updates as subagents
539
- // start, finish, or fail. Also handles spinner start/stop.
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, spinnerOn: boolean): string {
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}${runningGlyph} ${todo.content}`) + marker;
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}${runningGlyph} ${todo.content}`) + marker;
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
- // Cache matcher results so we don't re-scan the description list per row
1099
- // twice (once for the spinner decision, once for the render).
1100
- const matchedSet = new Set<TodoItem>();
1101
- const isMatched = (todo: TodoItem): boolean => {
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, matchedSet.has(todo), needsSpinner));
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, matchedSet.has(todo), needsSpinner));
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", "Approve and keep context", "Refine plan"],
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();
@@ -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));