@oh-my-pi/pi-coding-agent 16.2.9 → 16.2.12

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 (110) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/cli.js +3464 -3306
  3. package/dist/types/cli/bench-cli.d.ts +0 -5
  4. package/dist/types/cli/dry-balance-cli.d.ts +0 -5
  5. package/dist/types/cli/models-cli.d.ts +1 -1
  6. package/dist/types/config/inline-tool-descriptors-mode.d.ts +1 -2
  7. package/dist/types/config/model-registry.d.ts +1 -23
  8. package/dist/types/config/model-resolver.d.ts +10 -15
  9. package/dist/types/config/models-config-schema.d.ts +0 -6
  10. package/dist/types/config/models-config.d.ts +0 -6
  11. package/dist/types/config/settings-schema.d.ts +31 -1
  12. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/runner.d.ts +15 -0
  14. package/dist/types/extensibility/skills.d.ts +29 -0
  15. package/dist/types/lsp/client.d.ts +9 -3
  16. package/dist/types/modes/components/todo-reminder.d.ts +3 -1
  17. package/dist/types/modes/interactive-mode.d.ts +0 -1
  18. package/dist/types/modes/skill-command.d.ts +1 -1
  19. package/dist/types/modes/types.d.ts +0 -1
  20. package/dist/types/stt/asr-client.d.ts +7 -3
  21. package/dist/types/task/spawn-policy.d.ts +17 -0
  22. package/dist/types/task/spawn-policy.test.d.ts +1 -0
  23. package/dist/types/task/types.d.ts +8 -2
  24. package/dist/types/tools/__tests__/eval-description.test.d.ts +1 -0
  25. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +2 -2
  26. package/dist/types/tools/browser/registry.d.ts +2 -0
  27. package/dist/types/tools/browser/run-cancellation.d.ts +4 -0
  28. package/dist/types/tools/browser/tab-supervisor.d.ts +30 -1
  29. package/dist/types/tools/eval.d.ts +3 -6
  30. package/dist/types/tools/write.d.ts +1 -1
  31. package/dist/types/utils/image-vision-fallback.d.ts +1 -1
  32. package/package.json +14 -13
  33. package/scripts/bundle-dist.ts +23 -4
  34. package/scripts/generate-docs-index.ts +116 -24
  35. package/src/advisor/__tests__/advisor.test.ts +1 -1
  36. package/src/async/job-manager.ts +27 -3
  37. package/src/auto-thinking/classifier.ts +1 -1
  38. package/src/cli/bench-cli.ts +3 -12
  39. package/src/cli/dry-balance-cli.ts +1 -6
  40. package/src/cli/grep-cli.ts +1 -1
  41. package/src/cli/models-cli.ts +3 -81
  42. package/src/commands/models.ts +1 -2
  43. package/src/commit/model-selection.ts +4 -4
  44. package/src/config/inline-tool-descriptors-mode.ts +1 -2
  45. package/src/config/model-discovery.ts +150 -82
  46. package/src/config/model-registry.ts +11 -225
  47. package/src/config/model-resolver.ts +23 -135
  48. package/src/config/models-config-schema.ts +0 -22
  49. package/src/config/prompt-templates.ts +20 -0
  50. package/src/config/settings-schema.ts +35 -1
  51. package/src/config/settings.ts +60 -24
  52. package/src/eval/__tests__/agent-bridge.test.ts +17 -3
  53. package/src/eval/agent-bridge.ts +7 -9
  54. package/src/eval/completion-bridge.ts +1 -1
  55. package/src/eval/py/__tests__/runner-shell-output.test.ts +157 -0
  56. package/src/eval/py/runner.py +169 -18
  57. package/src/extensibility/extensions/model-api.ts +1 -3
  58. package/src/extensibility/extensions/runner.ts +62 -5
  59. package/src/extensibility/skills.ts +77 -0
  60. package/src/internal-urls/docs-index.generated.txt +2 -2
  61. package/src/lsp/client.ts +162 -45
  62. package/src/lsp/config.ts +17 -3
  63. package/src/lsp/index.ts +31 -21
  64. package/src/main.ts +0 -1
  65. package/src/mcp/transports/http.ts +19 -17
  66. package/src/mcp/transports/stdio.test.ts +51 -1
  67. package/src/mcp/transports/stdio.ts +19 -9
  68. package/src/memories/index.ts +0 -1
  69. package/src/mnemopi/backend.ts +1 -1
  70. package/src/modes/acp/acp-agent.ts +6 -9
  71. package/src/modes/components/model-selector.ts +32 -186
  72. package/src/modes/components/todo-reminder.ts +5 -1
  73. package/src/modes/controllers/event-controller.ts +1 -16
  74. package/src/modes/controllers/selector-controller.ts +57 -36
  75. package/src/modes/interactive-mode.ts +0 -6
  76. package/src/modes/rpc/rpc-mode.ts +5 -8
  77. package/src/modes/skill-command.ts +8 -20
  78. package/src/modes/types.ts +0 -1
  79. package/src/prompts/system/subagent-system-prompt.md +2 -2
  80. package/src/prompts/system/tool-call-loop-redirect.md +8 -0
  81. package/src/prompts/tools/eval.md +2 -2
  82. package/src/prompts/tools/task.md +1 -1
  83. package/src/sdk.ts +2 -9
  84. package/src/session/agent-session.ts +89 -8
  85. package/src/session/session-context.ts +2 -1
  86. package/src/session/session-manager.ts +2 -1
  87. package/src/session/unexpected-stop-classifier.ts +1 -1
  88. package/src/stt/asr-client.ts +87 -27
  89. package/src/stt/downloader.ts +8 -2
  90. package/src/task/executor.ts +1 -3
  91. package/src/task/index.ts +37 -32
  92. package/src/task/spawn-policy.test.ts +63 -0
  93. package/src/task/spawn-policy.ts +58 -0
  94. package/src/task/types.ts +77 -6
  95. package/src/tools/__tests__/eval-description.test.ts +19 -0
  96. package/src/tools/ast-grep.ts +34 -12
  97. package/src/tools/browser/cmux/cmux-tab.ts +66 -24
  98. package/src/tools/browser/registry.ts +25 -0
  99. package/src/tools/browser/run-cancellation.ts +47 -0
  100. package/src/tools/browser/tab-supervisor.ts +120 -7
  101. package/src/tools/browser/tab-worker.ts +22 -10
  102. package/src/tools/browser.ts +1 -0
  103. package/src/tools/eval.ts +15 -10
  104. package/src/tools/grep.ts +11 -8
  105. package/src/tools/inspect-image.ts +1 -1
  106. package/src/tools/write.ts +49 -9
  107. package/src/utils/commit-message-generator.ts +0 -1
  108. package/src/utils/git.ts +22 -1
  109. package/src/utils/image-vision-fallback.ts +2 -3
  110. package/src/utils/title-generator.ts +1 -1
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
 
3
- import { resolveStdioSpawnCommand } from "./stdio";
3
+ import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
4
4
 
5
5
  describe("resolveStdioSpawnCommand", () => {
6
6
  it("hides Windows executable MCP servers when the host has no console", async () => {
@@ -43,3 +43,53 @@ describe("resolveStdioSpawnCommand", () => {
43
43
  });
44
44
  });
45
45
  });
46
+
47
+ // Regression for #3945: request() awaited stdin.write/flush, so a child that
48
+ // stops draining stdin would park the async fn past the timeout timer and past
49
+ // `return promise`, orphaning the deferred rejection and hanging the caller
50
+ // forever. `sleep` is POSIX-only, so the check is scoped to non-Windows hosts.
51
+ describe.skipIf(process.platform === "win32")("StdioTransport request write stall", () => {
52
+ it("rejects with the timeout error when the child never drains stdin", async () => {
53
+ const timeoutMs = 400;
54
+ const orphaned: Error[] = [];
55
+ const captureOrphan = (reason: unknown) => {
56
+ if (reason instanceof Error) orphaned.push(reason);
57
+ };
58
+ process.on("unhandledRejection", captureOrphan);
59
+
60
+ // `sleep 60` accepts a stdin pipe but never reads it; a 1 MB payload
61
+ // overruns the OS pipe buffer plus any FileSink JS-side buffering, so
62
+ // Bun's write() returns a Promise that only settles if the child reads.
63
+ const transport = new StdioTransport({
64
+ command: "sleep",
65
+ args: ["60"],
66
+ timeout: timeoutMs,
67
+ });
68
+
69
+ try {
70
+ await transport.connect();
71
+
72
+ const bigParam = "x".repeat(1024 * 1024);
73
+ const started = performance.now();
74
+ const outcome = await transport.request("tools/call", { name: "noop", arguments: { blob: bigParam } }).then(
75
+ () => ({ kind: "resolved" as const }),
76
+ (error: unknown) => ({ kind: "rejected" as const, error }),
77
+ );
78
+ const elapsedMs = performance.now() - started;
79
+
80
+ expect(outcome.kind).toBe("rejected");
81
+ if (outcome.kind !== "rejected") return;
82
+ if (!(outcome.error instanceof Error)) {
83
+ throw new Error(`expected Error rejection, got ${String(outcome.error)}`);
84
+ }
85
+ expect(outcome.error.message).toContain(`Request timeout after ${timeoutMs}ms`);
86
+ // Generous ceiling: bare rejection latency plus room for slow CI. The
87
+ // pre-fix behavior was an unbounded hang, not a slightly-late reject.
88
+ expect(elapsedMs).toBeLessThan(timeoutMs + 1500);
89
+ expect(orphaned).toEqual([]);
90
+ } finally {
91
+ process.off("unhandledRejection", captureOrphan);
92
+ await transport.close();
93
+ }
94
+ }, 8000);
95
+ });
@@ -575,17 +575,27 @@ export class StdioTransport implements MCPTransport {
575
575
 
576
576
  const stdin = this.#process.stdin;
577
577
  const message = `${JSON.stringify(request)}\n`;
578
- try {
579
- // Await both: Bun's FileSink can surface a broken pipe either as a
580
- // synchronous throw or as a rejected Promise (the EPIPE arrives on a
581
- // processTicksAndRejections tick). Awaiting funnels both into this catch
582
- // so the request rejects cleanly instead of leaving a floating rejected
583
- // promise that crashes the process via the unhandledRejection handler.
584
- await stdin.write(message);
585
- await stdin.flush();
586
- } catch (error: unknown) {
578
+ const failFromSend = (error: unknown) => {
579
+ if (settled) return;
587
580
  cleanup();
588
581
  reject(error instanceof Error ? error : new Error(String(error)));
582
+ };
583
+ try {
584
+ // Never `await` write/flush. Bun's FileSink returns a pending Promise
585
+ // once the OS pipe buffer fills (default ~64 KB on POSIX), and a
586
+ // subprocess that stops draining stdin will park those awaits forever.
587
+ // Awaiting here would keep the async fn stuck above `return promise`,
588
+ // past the timeout timer and the abort handler, orphaning the deferred
589
+ // rejection and hanging the caller (#3945). Route sync throws (Windows
590
+ // EPIPE) and async rejections (POSIX EPIPE on processTicksAndRejections)
591
+ // into `reject()` while leaving the returned promise free to settle
592
+ // from the response, timer, abort signal, or read-loop transport-close.
593
+ const wrote = stdin.write(message);
594
+ if (isThenable(wrote)) wrote.then(undefined, failFromSend);
595
+ const flushed = stdin.flush();
596
+ if (isThenable(flushed)) flushed.then(undefined, failFromSend);
597
+ } catch (error) {
598
+ failFromSend(error);
589
599
  }
590
600
 
591
601
  return promise;
@@ -1219,7 +1219,6 @@ async function resolveMemoryModel(options: {
1219
1219
  const resolved = resolveModelRoleValue(requestedModel, modelRegistry.getAll(), {
1220
1220
  settings: session.settings,
1221
1221
  matchPreferences: getModelMatchPreferences(session.settings),
1222
- modelRegistry,
1223
1222
  });
1224
1223
  if (resolved.model) return resolved.model;
1225
1224
  }
@@ -511,7 +511,7 @@ async function resolveMnemopiProviderOptions(
511
511
  }
512
512
 
513
513
  try {
514
- const resolved = resolveRoleSelection(["tiny", "smol"], settings, modelRegistry.getAvailable(), modelRegistry);
514
+ const resolved = resolveRoleSelection(["tiny", "smol"], settings, modelRegistry.getAvailable());
515
515
  const model = resolved?.model;
516
516
  if (!model) {
517
517
  logger.warn("Mnemopi: llmMode=smol but no tiny/smol model resolved; continuing without LLM.");
@@ -53,7 +53,7 @@ import {
53
53
  } from "../../extensibility/extensions";
54
54
  import { runExtensionCompact } from "../../extensibility/extensions/compact-handler";
55
55
  import { getSessionSlashCommands } from "../../extensibility/extensions/get-commands-handler";
56
- import { buildSkillPromptMessage } from "../../extensibility/skills";
56
+ import { buildSkillPromptMessage, parseSkillInvocation } from "../../extensibility/skills";
57
57
  import { loadSlashCommands } from "../../extensibility/slash-commands";
58
58
  import { resolveLocalUrlToPath } from "../../internal-urls";
59
59
  import { MCPManager } from "../../mcp/manager";
@@ -831,21 +831,18 @@ export class AcpAgent implements Agent {
831
831
  }
832
832
 
833
833
  async #tryRunSkillCommand(record: ManagedSessionRecord, text: string): Promise<boolean> {
834
- if (!text.startsWith("/skill:")) {
834
+ if (!record.session.skillsSettings?.enableSkillCommands) {
835
835
  return false;
836
836
  }
837
- if (!record.session.skillsSettings?.enableSkillCommands) {
837
+ const parsed = parseSkillInvocation(text);
838
+ if (!parsed) {
838
839
  return false;
839
840
  }
840
- const spaceIndex = text.indexOf(" ");
841
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
842
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
843
- const skillName = commandName.slice("skill:".length);
844
- const skill = record.session.skills.find(candidate => candidate.name === skillName);
841
+ const skill = record.session.skills.find(candidate => candidate.name === parsed.name);
845
842
  if (!skill) {
846
843
  return false;
847
844
  }
848
- const built = await buildSkillPromptMessage(skill, args);
845
+ const built = await buildSkillPromptMessage(skill, parsed.args);
849
846
  await record.session.promptCustomMessage({
850
847
  customType: SKILL_PROMPT_MESSAGE_TYPE,
851
848
  content: built.message,
@@ -58,21 +58,6 @@ function makeRoleBadgeToken(label: string, color: ThemeColor, assigned: RoleAssi
58
58
  return `${badge} ${theme.fg("dim", `(${thinkingLabel})`)}`;
59
59
  }
60
60
 
61
- function normalizeSearchText(value: string): string {
62
- return value
63
- .toLowerCase()
64
- .replace(/[^a-z0-9]+/g, " ")
65
- .trim();
66
- }
67
-
68
- function compactSearchText(value: string): string {
69
- return value.toLowerCase().replace(/[^a-z0-9]+/g, "");
70
- }
71
-
72
- function getAlphaSearchTokens(query: string): string[] {
73
- return [...normalizeSearchText(query).matchAll(/[a-z]+/g)].map(match => match[0]).filter(token => token.length > 0);
74
- }
75
-
76
61
  function computeModelRank(model: Model, roles: Record<string, RoleAssignment | undefined>): number {
77
62
  let i = 0;
78
63
  while (i < MODEL_ROLE_IDS.length) {
@@ -94,17 +79,6 @@ interface ModelItem {
94
79
  selector: string;
95
80
  }
96
81
 
97
- interface CanonicalModelItem {
98
- kind: "canonical";
99
- id: string;
100
- model: Model;
101
- selector: string;
102
- variantCount: number;
103
- searchText: string;
104
- normalizedSearchText: string;
105
- compactSearchText: string;
106
- }
107
-
108
82
  interface ScopedModelItem {
109
83
  model: Model;
110
84
  thinkingLevel?: string;
@@ -134,12 +108,8 @@ interface ProviderTabState {
134
108
  providerId?: string;
135
109
  }
136
110
  const ALL_TAB = "ALL";
137
- const CANONICAL_TAB = "CANONICAL";
138
111
 
139
- const STATIC_PROVIDER_TABS: ProviderTabState[] = [
140
- { id: ALL_TAB, label: ALL_TAB },
141
- { id: CANONICAL_TAB, label: CANONICAL_TAB },
142
- ];
112
+ const STATIC_PROVIDER_TABS: ProviderTabState[] = [{ id: ALL_TAB, label: ALL_TAB }];
143
113
 
144
114
  const MODEL_TAB_REFRESH_DEBOUNCE_MS = 120;
145
115
 
@@ -168,8 +138,6 @@ export class ModelSelectorComponent extends Container {
168
138
  #menuContainer: Container;
169
139
  #allModels: ModelItem[] = [];
170
140
  #filteredModels: ModelItem[] = [];
171
- #canonicalModels: CanonicalModelItem[] = [];
172
- #filteredCanonicalModels: CanonicalModelItem[] = [];
173
141
  #selectedIndex: number = 0;
174
142
  #roles = {} as Record<string, RoleAssignment | undefined>;
175
143
  #settings = null as unknown as Settings;
@@ -297,8 +265,7 @@ export class ModelSelectorComponent extends Container {
297
265
  // Hydrate synchronously from the current registry snapshot so the first
298
266
  // Enter after opening the selector acts on cached models instead of being
299
267
  // dropped while the offline refresh promise is still pending. This stays
300
- // on the open path, so it must remain cheap — heavy lifting lives in the
301
- // registry's one-pass getCanonicalModelSelections.
268
+ // on the open path, so it must remain cheap.
302
269
  this.#syncFromRegistryState();
303
270
 
304
271
  // Reconcile with cached discovery state in the background. A --models
@@ -342,7 +309,6 @@ export class ModelSelectorComponent extends Container {
342
309
  const resolved = resolveModelRoleValue(roleValue, allModels, {
343
310
  settings: this.#settings,
344
311
  matchPreferences,
345
- modelRegistry: this.#modelRegistry,
346
312
  });
347
313
  if (resolved.model) {
348
314
  nextRoles[role] = {
@@ -360,7 +326,6 @@ export class ModelSelectorComponent extends Container {
360
326
  const resolved = resolveModelRoleValue(`pi/${role}`, candidates, {
361
327
  settings: this.#settings,
362
328
  matchPreferences,
363
- modelRegistry: this.#modelRegistry,
364
329
  });
365
330
  if (!resolved.model) continue;
366
331
  nextRoles[role] = {
@@ -444,30 +409,6 @@ export class ModelSelectorComponent extends Container {
444
409
  });
445
410
  }
446
411
 
447
- #sortCanonicalModels(models: CanonicalModelItem[], { skipRoleRank = false }: { skipRoleRank?: boolean } = {}): void {
448
- const mruOrder = this.#settings.getStorage()?.getModelUsageOrder() ?? [];
449
- const mruIndex = new Map(mruOrder.map((key, i) => [key, i]));
450
-
451
- const modelRank = (item: CanonicalModelItem) => computeModelRank(item.model, this.#roles);
452
-
453
- models.sort((a, b) => {
454
- if (!skipRoleRank) {
455
- const aRank = modelRank(a);
456
- const bRank = modelRank(b);
457
- if (aRank !== bRank) return aRank - bRank;
458
- }
459
-
460
- const aMru = mruIndex.get(`${a.model.provider}/${a.model.id}`) ?? Number.MAX_SAFE_INTEGER;
461
- const bMru = mruIndex.get(`${b.model.provider}/${b.model.id}`) ?? Number.MAX_SAFE_INTEGER;
462
- if (aMru !== bMru) return aMru - bMru;
463
-
464
- const providerCmp = a.model.provider.localeCompare(b.model.provider);
465
- if (providerCmp !== 0) return providerCmp;
466
-
467
- return a.id.localeCompare(b.id);
468
- });
469
- }
470
-
471
412
  #loadModelsFromCurrentRegistryState(): void {
472
413
  let models: ModelItem[];
473
414
  if (this.#scopedModels.length > 0) {
@@ -498,8 +439,6 @@ export class ModelSelectorComponent extends Container {
498
439
  } catch (error) {
499
440
  this.#allModels = [];
500
441
  this.#filteredModels = [];
501
- this.#canonicalModels = [];
502
- this.#filteredCanonicalModels = [];
503
442
  this.#errorMessage = error instanceof Error ? error.message : String(error);
504
443
  return;
505
444
  }
@@ -507,42 +446,14 @@ export class ModelSelectorComponent extends Container {
507
446
 
508
447
  const candidates = models.map(item => item.model);
509
448
  this.#loadRoleModels(candidates);
510
- const canonicalSelections = this.#modelRegistry.getCanonicalModelSelections({
511
- availableOnly: this.#scopedModels.length === 0,
512
- candidates,
513
- });
514
- const canonicalModels = canonicalSelections.map(({ record, model: selectedModel }): CanonicalModelItem => {
515
- const searchText = [
516
- record.id,
517
- record.name,
518
- selectedModel.provider,
519
- selectedModel.id,
520
- selectedModel.name,
521
- ...record.variants.flatMap(variant => [variant.selector, variant.model.name]),
522
- ].join(" ");
523
- return {
524
- kind: "canonical",
525
- id: record.id,
526
- model: selectedModel,
527
- selector: record.id,
528
- variantCount: record.variants.length,
529
- searchText,
530
- normalizedSearchText: normalizeSearchText(searchText),
531
- compactSearchText: compactSearchText(searchText),
532
- };
533
- });
534
449
 
535
450
  this.#sortModels(models);
536
- this.#sortCanonicalModels(canonicalModels);
537
451
 
538
452
  this.#allModels = models;
539
453
  this.#filteredModels = models;
540
- this.#canonicalModels = canonicalModels;
541
- this.#filteredCanonicalModels = canonicalModels;
542
- const visibleModels = this.#isCanonicalTab() ? canonicalModels : models;
543
454
  this.#selectedIndex = this.#coerceSelectedIndex(
544
- Math.min(this.#selectedIndex, Math.max(0, visibleModels.length - 1)),
545
- visibleModels,
455
+ Math.min(this.#selectedIndex, Math.max(0, models.length - 1)),
456
+ models,
546
457
  );
547
458
  }
548
459
 
@@ -717,10 +628,6 @@ export class ModelSelectorComponent extends Container {
717
628
  return this.#getActiveTab().providerId;
718
629
  }
719
630
 
720
- #isCanonicalTab(): boolean {
721
- return this.#getActiveTabId() === CANONICAL_TAB;
722
- }
723
-
724
631
  #isModelOverCurrentContext(model: Model): boolean {
725
632
  const contextWindow = model.contextWindow ?? 0;
726
633
  return this.#currentContextTokens > 0 && contextWindow > 0 && this.#currentContextTokens > contextWindow;
@@ -734,7 +641,7 @@ export class ModelSelectorComponent extends Container {
734
641
  return ` ${theme.status.disabled} context>${formatNumber(model.contextWindow ?? 0).toLowerCase()}`;
735
642
  }
736
643
 
737
- #isItemDisabled(item: ModelItem | CanonicalModelItem): boolean {
644
+ #isItemDisabled(item: ModelItem): boolean {
738
645
  return this.#isModelOverContextLimit(item.model);
739
646
  }
740
647
 
@@ -745,14 +652,11 @@ export class ModelSelectorComponent extends Container {
745
652
  return this.#formatCurrentContextLimitSuffix(model);
746
653
  }
747
654
 
748
- #getVisibleItems(): ReadonlyArray<ModelItem | CanonicalModelItem> {
749
- return this.#isCanonicalTab() ? this.#filteredCanonicalModels : this.#filteredModels;
655
+ #getVisibleItems(): ReadonlyArray<ModelItem> {
656
+ return this.#filteredModels;
750
657
  }
751
658
 
752
- #coerceSelectedIndex(
753
- index: number,
754
- visibleItems: ReadonlyArray<ModelItem | CanonicalModelItem> = this.#getVisibleItems(),
755
- ): number {
659
+ #coerceSelectedIndex(index: number, visibleItems: ReadonlyArray<ModelItem> = this.#getVisibleItems()): number {
756
660
  const maxIndex = visibleItems.length - 1;
757
661
  if (maxIndex < 0) {
758
662
  return 0;
@@ -798,21 +702,16 @@ export class ModelSelectorComponent extends Container {
798
702
  }
799
703
 
800
704
  #filterModels(query: string): void {
801
- const activeTabId = this.#getActiveTabId();
802
705
  const activeProviderId = this.#getActiveProviderId();
803
- const isCanonicalTab = activeTabId === CANONICAL_TAB;
804
706
 
805
- // Start with all models or filter by provider/canonical view
806
707
  let baseModels = this.#allModels;
807
- const baseCanonicalModels = this.#canonicalModels;
808
708
  if (activeProviderId) {
809
709
  baseModels = this.#allModels.filter(m => m.provider === activeProviderId);
810
710
  }
811
711
 
812
- // Apply fuzzy filter if query is present
813
712
  if (query.trim()) {
814
713
  // If user is searching from a provider tab, auto-switch to ALL to show global provider results.
815
- if (activeProviderId && !isCanonicalTab) {
714
+ if (activeProviderId) {
816
715
  this.#activeTabIndex = 0;
817
716
  if (this.#tabBar && this.#tabBar.getActiveIndex() !== 0) {
818
717
  this.#tabBar.setActiveIndex(0);
@@ -822,55 +721,22 @@ export class ModelSelectorComponent extends Container {
822
721
  baseModels = this.#allModels;
823
722
  }
824
723
 
825
- if (isCanonicalTab) {
826
- const alphaTokens = getAlphaSearchTokens(query);
827
- const alphaFiltered =
828
- alphaTokens.length === 0
829
- ? baseCanonicalModels
830
- : baseCanonicalModels.filter(item =>
831
- alphaTokens.every(token => item.normalizedSearchText.includes(token)),
832
- );
833
- const compactQuery = compactSearchText(query);
834
- const substringFiltered =
835
- compactQuery.length === 0
836
- ? alphaFiltered
837
- : alphaFiltered.filter(item => item.compactSearchText.includes(compactQuery));
838
- const fuzzySource =
839
- substringFiltered.length > 0
840
- ? substringFiltered
841
- : alphaFiltered.length > 0
842
- ? alphaFiltered
843
- : baseCanonicalModels;
844
- // Fuzzy provides the candidate set, but `${provider}/${id}` scoring
845
- // is biased by provider-prefix length (e.g. `openai/X` beats
846
- // `openai-codex/X` purely because the prefix is shorter). Re-sort by
847
- // affinity — MRU then version — so the user's actually-used model
848
- // wins. Role rank is skipped: when narrowing by query, a weakly
849
- // matching default model should not be promoted above a stronger
850
- // non-default match.
851
- const fuzzyMatches = fuzzyFilter(fuzzySource, query, ({ searchText }) => searchText);
852
- this.#sortCanonicalModels(fuzzyMatches, { skipRoleRank: true });
853
- this.#filteredCanonicalModels = fuzzyMatches;
854
- } else {
855
- // Match against the displayed "provider/id" string so the user can
856
- // type what they see: bare names (`mimo`, `kimi`), provider prefixes
857
- // (`openrouter`), or scoped queries (`openrouter/mimo`) all flow
858
- // through the same fuzzy matcher. The score is biased by provider-
859
- // prefix length, so re-sort by MRU/version afterwards; skip role
860
- // rank so a weakly matching default doesn't trump a stronger match.
861
- const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${provider}/${id}`);
862
- this.#sortModels(fuzzyMatches, { skipRoleRank: true });
863
- this.#filteredModels = fuzzyMatches;
864
- }
724
+ // Match against the displayed "provider/id" string so the user can
725
+ // type what they see: bare names (`mimo`, `kimi`), provider prefixes
726
+ // (`openrouter`), or scoped queries (`openrouter/mimo`) all flow
727
+ // through the same fuzzy matcher. The score is biased by provider-
728
+ // prefix length, so re-sort by MRU/version afterwards; skip role
729
+ // rank so a weakly matching default doesn't trump a stronger match.
730
+ const fuzzyMatches = fuzzyFilter(baseModels, query, ({ id, provider }) => `${provider}/${id}`);
731
+ this.#sortModels(fuzzyMatches, { skipRoleRank: true });
732
+ this.#filteredModels = fuzzyMatches;
865
733
  } else {
866
734
  this.#filteredModels = baseModels;
867
- this.#filteredCanonicalModels = baseCanonicalModels;
868
735
  }
869
736
 
870
- const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
871
737
  this.#selectedIndex = this.#coerceSelectedIndex(
872
- Math.min(this.#selectedIndex, Math.max(0, visibleItems.length - 1)),
873
- visibleItems,
738
+ Math.min(this.#selectedIndex, Math.max(0, this.#filteredModels.length - 1)),
739
+ this.#filteredModels,
874
740
  );
875
741
  this.#updateList();
876
742
  }
@@ -940,8 +806,7 @@ export class ModelSelectorComponent extends Container {
940
806
 
941
807
  #updateList(): void {
942
808
  this.#listContainer.clear();
943
- const isCanonicalTab = this.#isCanonicalTab();
944
- const visibleItems = isCanonicalTab ? this.#filteredCanonicalModels : this.#filteredModels;
809
+ const visibleItems = this.#filteredModels;
945
810
 
946
811
  const maxVisible = 10;
947
812
  const startIndex = Math.max(
@@ -959,8 +824,6 @@ export class ModelSelectorComponent extends Container {
959
824
  for (let i = startIndex; i < endIndex; i++) {
960
825
  const item = visibleItems[i];
961
826
  if (!item) continue;
962
- const canonicalItem = isCanonicalTab ? (item as CanonicalModelItem) : undefined;
963
- const providerItem = isCanonicalTab ? undefined : (item as ModelItem);
964
827
 
965
828
  const isSelected = i === this.#selectedIndex;
966
829
  const isDisabled = this.#isItemDisabled(item);
@@ -988,25 +851,17 @@ export class ModelSelectorComponent extends Container {
988
851
  let line = "";
989
852
  if (isSelected) {
990
853
  const prefix = theme.fg("accent", `${theme.nav.cursor} `);
991
- if (isCanonicalTab) {
992
- const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
993
- const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
994
- line = `${prefix}${theme.fg("accent", item.id)}${variants}${backing}${badgeText}${disabledSuffix}`;
995
- } else if (showProvider) {
996
- const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
997
- line = `${prefix}${providerPrefix}${theme.fg("accent", providerItem?.id ?? item.id)}${badgeText}${disabledSuffix}`;
854
+ if (showProvider) {
855
+ const providerPrefix = theme.fg("dim", `${item.provider}/`);
856
+ line = `${prefix}${providerPrefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
998
857
  } else {
999
858
  line = `${prefix}${theme.fg("accent", item.id)}${badgeText}${disabledSuffix}`;
1000
859
  }
1001
860
  } else {
1002
861
  const prefix = " ";
1003
- if (isCanonicalTab) {
1004
- const variants = theme.fg("dim", ` [${canonicalItem?.variantCount ?? 0}]`);
1005
- const backing = theme.fg("dim", ` -> ${item.model.provider}/${item.model.id}`);
1006
- line = `${prefix}${item.id}${variants}${backing}${badgeText}${disabledSuffix}`;
1007
- } else if (showProvider) {
1008
- const providerPrefix = theme.fg("dim", `${providerItem?.provider ?? ""}/`);
1009
- line = `${prefix}${providerPrefix}${providerItem?.id ?? item.id}${badgeText}${disabledSuffix}`;
862
+ if (showProvider) {
863
+ const providerPrefix = theme.fg("dim", `${item.provider}/`);
864
+ line = `${prefix}${providerPrefix}${item.id}${badgeText}${disabledSuffix}`;
1010
865
  } else {
1011
866
  line = `${prefix}${item.id}${badgeText}${disabledSuffix}`;
1012
867
  }
@@ -1044,9 +899,6 @@ export class ModelSelectorComponent extends Container {
1044
899
  return;
1045
900
  }
1046
901
  this.#listContainer.addChild(new Spacer(1));
1047
- const suffix = isCanonicalTab
1048
- ? ` (${selected.model.provider}/${selected.model.id}, ${(selected as CanonicalModelItem).variantCount} variants)`
1049
- : "";
1050
902
  const limitWarning = this.#isItemDisabled(selected)
1051
903
  ? theme.fg(
1052
904
  "dim",
@@ -1054,7 +906,7 @@ export class ModelSelectorComponent extends Container {
1054
906
  )
1055
907
  : "";
1056
908
  this.#listContainer.addChild(
1057
- new Text(theme.fg("muted", ` Model Name: ${selected.model.name}${suffix}`) + limitWarning, 0, 0),
909
+ new Text(theme.fg("muted", ` Model Name: ${selected.model.name}`) + limitWarning, 0, 0),
1058
910
  );
1059
911
  }
1060
912
  }
@@ -1086,10 +938,8 @@ export class ModelSelectorComponent extends Container {
1086
938
  return foundIndex >= 0 ? foundIndex : 0;
1087
939
  }
1088
940
 
1089
- #getSelectedItem(): ModelItem | CanonicalModelItem | undefined {
1090
- return this.#isCanonicalTab()
1091
- ? this.#filteredCanonicalModels[this.#selectedIndex]
1092
- : this.#filteredModels[this.#selectedIndex];
941
+ #getSelectedItem(): ModelItem | undefined {
942
+ return this.#filteredModels[this.#selectedIndex];
1093
943
  }
1094
944
 
1095
945
  #coerceMenuSelectedIndex(index: number): number {
@@ -1100,7 +950,7 @@ export class ModelSelectorComponent extends Container {
1100
950
  return Math.max(0, Math.min(index, maxIndex));
1101
951
  }
1102
952
 
1103
- #moveMenuSelection(delta: number, _selectedItem: ModelItem | CanonicalModelItem, optionCount: number): void {
953
+ #moveMenuSelection(delta: number, _selectedItem: ModelItem, optionCount: number): void {
1104
954
  this.#menuSelectedIndex = (this.#menuSelectedIndex + delta + optionCount) % optionCount;
1105
955
  this.#updateMenu();
1106
956
  }
@@ -1375,11 +1225,7 @@ export class ModelSelectorComponent extends Container {
1375
1225
  }
1376
1226
  }
1377
1227
 
1378
- #handleSelect(
1379
- item: ModelItem | CanonicalModelItem,
1380
- role: string | null,
1381
- thinkingLevel?: ConfiguredThinkingLevel,
1382
- ): void {
1228
+ #handleSelect(item: ModelItem, role: string | null, thinkingLevel?: ConfiguredThinkingLevel): void {
1383
1229
  if (this.#isItemDisabled(item)) {
1384
1230
  return;
1385
1231
  }
@@ -3,7 +3,9 @@ import { theme } from "../../modes/theme/theme";
3
3
  import type { TodoItem } from "../../tools/todo";
4
4
 
5
5
  /**
6
- * Component that renders a todo completion reminder notification.
6
+ * Component that renders a todo completion reminder notification, committed into
7
+ * the transcript like a TTSR notification so it stays anchored in history rather
8
+ * than floating above the editor.
7
9
  * Shows when the agent stops with incomplete todos.
8
10
  */
9
11
  export class TodoReminderComponent extends Container {
@@ -16,6 +18,8 @@ export class TodoReminderComponent extends Container {
16
18
  ) {
17
19
  super();
18
20
 
21
+ this.addChild(new Spacer(1));
22
+
19
23
  this.#box = new Box(1, 1, t => theme.inverse(theme.fg("warning", t)));
20
24
  this.#box.setIgnoreTight(true);
21
25
  this.addChild(this.#box);
@@ -609,15 +609,6 @@ export class EventController {
609
609
  }
610
610
  for (const content of this.ctx.streamingMessage.content) {
611
611
  if (content.type !== "toolCall") continue;
612
- // Anthropic/OpenAI open a streamed tool block with an empty id (and
613
- // `{}` args) before the id/arguments arrive; Gemini assembles the
614
- // whole call first, so it never hits this. Keying `pendingTools` by
615
- // "" would create a placeholder card, and the later real-id frame —
616
- // `pendingTools.has(realId)` false — would create a SECOND card,
617
- // orphaning the blank one (no `tool_execution_*` event ever carries
618
- // "", so it is never matched, updated, or removed). Defer until the
619
- // provider assigns the real id.
620
- if (!content.id) continue;
621
612
  if (content.name === "read") {
622
613
  if (!readArgsHaveTarget(content.arguments)) {
623
614
  // Args still streaming — defer until path is parseable so we can route to the
@@ -978,13 +969,9 @@ export class EventController {
978
969
  }
979
970
  // Update todo display when todo tool completes
980
971
  if (event.toolName === "todo" && !event.isError) {
981
- const hadTodoReminder = (this.ctx.todoReminderContainer?.children.length ?? 0) > 0;
982
- this.ctx.todoReminderContainer?.clear();
983
972
  const details = event.result.details as { phases?: TodoPhase[] } | undefined;
984
973
  if (details?.phases) {
985
974
  this.ctx.setTodos(details.phases);
986
- } else if (hadTodoReminder) {
987
- this.ctx.ui.requestRender();
988
975
  }
989
976
  } else if (event.toolName === "todo" && event.isError) {
990
977
  const textContent = event.result.content.find(
@@ -1281,9 +1268,7 @@ export class EventController {
1281
1268
 
1282
1269
  async #handleTodoReminder(event: Extract<AgentSessionEvent, { type: "todo_reminder" }>): Promise<void> {
1283
1270
  const component = new TodoReminderComponent(event.todos, event.attempt, event.maxAttempts);
1284
- this.ctx.todoReminderContainer.clear();
1285
- this.ctx.todoReminderContainer.addChild(component);
1286
- this.ctx.ui.requestRender();
1271
+ this.ctx.present(component);
1287
1272
  }
1288
1273
 
1289
1274
  async #handleTodoAutoClear(_event: Extract<AgentSessionEvent, { type: "todo_auto_clear" }>): Promise<void> {