@oh-my-pi/pi-coding-agent 17.2.2 → 17.2.4

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 (104) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/dist/{CHANGELOG-xfpkakrn.md → CHANGELOG-bpmhv26t.md} +55 -0
  3. package/dist/cli.js +3294 -3294
  4. package/dist/types/advisor/advise-tool.d.ts +6 -0
  5. package/dist/types/advisor/runtime.d.ts +4 -4
  6. package/dist/types/capability/mcp.d.ts +9 -0
  7. package/dist/types/cli/models-cli.d.ts +1 -1
  8. package/dist/types/config/keybindings.d.ts +1 -1
  9. package/dist/types/config/settings-schema.d.ts +1 -1
  10. package/dist/types/discovery/helpers.d.ts +6 -0
  11. package/dist/types/discovery/omp-extension-roots.d.ts +21 -2
  12. package/dist/types/extensibility/extensions/loader.d.ts +6 -2
  13. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +2 -2
  14. package/dist/types/launch/paths.d.ts +2 -1
  15. package/dist/types/mcp/request-id.d.ts +19 -0
  16. package/dist/types/mcp/types.d.ts +14 -0
  17. package/dist/types/memory-backend/index.d.ts +1 -0
  18. package/dist/types/memory-backend/messages.d.ts +15 -0
  19. package/dist/types/modes/components/ask-dialog.d.ts +0 -1
  20. package/dist/types/modes/components/model-browser.d.ts +7 -5
  21. package/dist/types/modes/components/model-picker.d.ts +9 -3
  22. package/dist/types/modes/components/read-tool-group.d.ts +9 -0
  23. package/dist/types/modes/types.d.ts +1 -1
  24. package/dist/types/registry/agent-lifecycle.d.ts +14 -5
  25. package/dist/types/registry/agent-registry.d.ts +4 -3
  26. package/dist/types/sdk.d.ts +1 -1
  27. package/dist/types/secrets/index.d.ts +6 -8
  28. package/dist/types/session/agent-session.d.ts +6 -1
  29. package/dist/types/session/model-controls.d.ts +0 -1
  30. package/dist/types/session/session-advisors.d.ts +7 -0
  31. package/dist/types/session/session-maintenance.d.ts +1 -0
  32. package/dist/types/session/session-manager.d.ts +11 -5
  33. package/dist/types/system-prompt.d.ts +2 -0
  34. package/dist/types/tools/browser/launch.d.ts +30 -0
  35. package/dist/types/tools/browser/registry.d.ts +6 -1
  36. package/dist/types/tools/browser/shared-daemon.d.ts +26 -0
  37. package/dist/types/tools/default-renderer.d.ts +3 -0
  38. package/package.json +12 -12
  39. package/scripts/legacy-pi-virtual-module.ts +12 -2
  40. package/src/advisor/advise-tool.ts +18 -0
  41. package/src/advisor/runtime.ts +6 -6
  42. package/src/capability/mcp.ts +7 -0
  43. package/src/cli/models-cli.ts +13 -16
  44. package/src/collab/host.ts +1 -1
  45. package/src/config/keybindings.ts +14 -9
  46. package/src/config/mcp-schema.json +5 -0
  47. package/src/config/model-registry.ts +7 -1
  48. package/src/config/settings-schema.ts +1 -1
  49. package/src/discovery/builtin.ts +10 -0
  50. package/src/discovery/helpers.ts +10 -0
  51. package/src/discovery/mcp-json.ts +11 -1
  52. package/src/discovery/omp-extension-roots.ts +79 -20
  53. package/src/discovery/omp-plugins.ts +10 -1
  54. package/src/extensibility/extensions/loader.ts +66 -28
  55. package/src/extensibility/plugins/marketplace/registry.ts +4 -8
  56. package/src/launch/broker.ts +59 -41
  57. package/src/launch/paths.ts +2 -5
  58. package/src/main.ts +24 -22
  59. package/src/mcp/config.ts +1 -0
  60. package/src/mcp/request-id.ts +24 -0
  61. package/src/mcp/transports/http.ts +4 -2
  62. package/src/mcp/transports/sse.ts +4 -2
  63. package/src/mcp/transports/stdio.ts +4 -2
  64. package/src/mcp/types.ts +15 -0
  65. package/src/memory-backend/index.ts +1 -0
  66. package/src/memory-backend/messages.ts +19 -0
  67. package/src/modes/components/agent-hub.ts +1 -1
  68. package/src/modes/components/ask-dialog.ts +65 -23
  69. package/src/modes/components/custom-editor.ts +1 -1
  70. package/src/modes/components/model-browser.ts +25 -17
  71. package/src/modes/components/model-picker.ts +10 -6
  72. package/src/modes/components/read-tool-group.ts +26 -0
  73. package/src/modes/components/tool-execution.ts +77 -20
  74. package/src/modes/controllers/command-controller.ts +2 -2
  75. package/src/modes/controllers/event-controller.ts +182 -10
  76. package/src/modes/controllers/selector-controller.ts +27 -6
  77. package/src/modes/types.ts +5 -1
  78. package/src/prompts/tools/bash.md +1 -1
  79. package/src/registry/agent-lifecycle.ts +36 -11
  80. package/src/registry/agent-registry.ts +12 -5
  81. package/src/sdk.ts +68 -29
  82. package/src/secrets/index.ts +15 -17
  83. package/src/session/agent-session.ts +11 -2
  84. package/src/session/agent-storage.ts +6 -4
  85. package/src/session/history-storage.ts +4 -2
  86. package/src/session/messages.ts +31 -7
  87. package/src/session/model-controls.ts +0 -1
  88. package/src/session/session-advisors.ts +19 -1
  89. package/src/session/session-maintenance.ts +5 -2
  90. package/src/session/session-manager.ts +128 -76
  91. package/src/session/session-tools.ts +25 -23
  92. package/src/slash-commands/builtin-registry.ts +2 -2
  93. package/src/system-prompt.ts +6 -1
  94. package/src/task/executor.ts +8 -1
  95. package/src/tiny/text.ts +12 -0
  96. package/src/tools/bash.ts +11 -5
  97. package/src/tools/browser/launch.ts +54 -9
  98. package/src/tools/browser/registry.ts +123 -30
  99. package/src/tools/browser/shared-daemon.ts +190 -0
  100. package/src/tools/browser.ts +1 -1
  101. package/src/tools/default-renderer.ts +19 -4
  102. package/src/tools/index.ts +10 -6
  103. package/src/web/search/providers/anthropic.ts +3 -1
  104. package/src/web/search/providers/codex.ts +31 -6
@@ -15,6 +15,7 @@ import {
15
15
  wrapTextWithAnsi,
16
16
  } from "@oh-my-pi/pi-tui";
17
17
  import type {
18
+ ExtensionAskDialogOption,
18
19
  ExtensionAskDialogQuestion,
19
20
  ExtensionAskDialogResultItem,
20
21
  ExtensionAskDialogSubmitResult,
@@ -337,6 +338,45 @@ function renderRowLabel(
337
338
  return lines;
338
339
  }
339
340
 
341
+ /**
342
+ * Coerce untrusted dialog questions into a render-safe shape. The live ask
343
+ * dialog is reached from the public `askDialog` extension surface and from
344
+ * streamed tool args, where a question entry can arrive with a missing or
345
+ * non-string `question` field. The render helpers (`replaceTabs`,
346
+ * `renderQuestionTitle`, `questionTabLabel`) assume strings, so a malformed
347
+ * entry throws and takes down the whole TUI render loop. Mirrors
348
+ * `normalizeRenderQuestions` on the transcript path.
349
+ */
350
+ function normalizeDialogQuestions(questions: ExtensionAskDialogQuestion[]): ExtensionAskDialogQuestion[] {
351
+ if (!Array.isArray(questions)) return [];
352
+ const out: ExtensionAskDialogQuestion[] = [];
353
+ for (const entry of questions) {
354
+ if (!entry || typeof entry !== "object") continue;
355
+ const q = entry as Partial<ExtensionAskDialogQuestion>;
356
+ const options: ExtensionAskDialogOption[] = [];
357
+ if (Array.isArray(q.options)) {
358
+ for (const opt of q.options) {
359
+ if (!opt || typeof opt !== "object") continue;
360
+ const o = opt as Partial<ExtensionAskDialogOption>;
361
+ options.push({
362
+ label: typeof o.label === "string" ? o.label : "",
363
+ ...(typeof o.description === "string" ? { description: o.description } : {}),
364
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
365
+ });
366
+ }
367
+ }
368
+ out.push({
369
+ id: typeof q.id === "string" ? q.id : "?",
370
+ question: typeof q.question === "string" ? q.question : "",
371
+ ...(typeof q.header === "string" ? { header: q.header } : {}),
372
+ options,
373
+ ...(typeof q.multi === "boolean" ? { multi: q.multi } : {}),
374
+ ...(Number.isInteger(q.recommended) ? { recommended: q.recommended } : {}),
375
+ });
376
+ }
377
+ return out;
378
+ }
379
+
340
380
  export class AskDialogComponent implements Component {
341
381
  #states: QuestionState[];
342
382
  #activeTabIndex = 0;
@@ -352,13 +392,15 @@ export class AskDialogComponent implements Component {
352
392
  #stableHeight: { key: string; total: number } | undefined;
353
393
  #previewCache: PreviewRenderCache = new Map();
354
394
  #overflowLayouts = new WeakMap<ExtensionAskDialogQuestion, Set<string>>();
395
+ readonly #questions: ExtensionAskDialogQuestion[];
355
396
 
356
397
  constructor(
357
- private readonly questions: ExtensionAskDialogQuestion[],
398
+ questions: ExtensionAskDialogQuestion[],
358
399
  private readonly callbacks: AskDialogCallbacks,
359
400
  private readonly options: AskDialogOptions = {},
360
401
  ) {
361
- this.#states = questions.map(question => {
402
+ this.#questions = normalizeDialogQuestions(questions);
403
+ this.#states = this.#questions.map(question => {
362
404
  const recommended = Number.isInteger(question.recommended) ? question.recommended : 0;
363
405
  const maxIndex = Math.max(0, question.options.length - 1);
364
406
  return {
@@ -474,8 +516,8 @@ export class AskDialogComponent implements Component {
474
516
  const tabBarRows = this.#hasSubmitTab() ? 1 : 0;
475
517
  const mdTheme = getMarkdownTheme();
476
518
  let needed = MIN_DIALOG_ROWS;
477
- for (let index = 0; index < this.questions.length; index++) {
478
- const question = this.questions[index];
519
+ for (let index = 0; index < this.#questions.length; index++) {
520
+ const question = this.#questions[index];
479
521
  const state = this.#states[index];
480
522
  if (!question || !state) continue;
481
523
  const headerRows = tabBarRows + renderQuestionTitle(question, width).length;
@@ -493,7 +535,7 @@ export class AskDialogComponent implements Component {
493
535
  if (this.#hasSubmitTab()) {
494
536
  // Warning line + blank, one summary line per question, blank, and
495
537
  // the Submit row; note lines added later scroll within the body.
496
- const body = 2 + this.questions.length + 2;
538
+ const body = 2 + this.#questions.length + 2;
497
539
  needed = Math.max(needed, chrome + tabBarRows + 1 + Math.max(MIN_BODY_ROWS, body));
498
540
  }
499
541
  return Math.min(needed, maxHeight);
@@ -507,11 +549,11 @@ export class AskDialogComponent implements Component {
507
549
  // Multi questions confirm on the Submit tab (Enter toggles, never
508
550
  // submits), so any multi question forces the tab even when there is
509
551
  // only one question.
510
- return this.questions.length > 1 || this.questions.some(question => question.multi);
552
+ return this.#questions.length > 1 || this.#questions.some(question => question.multi);
511
553
  }
512
554
 
513
555
  #submitTabIndex(): number {
514
- return this.questions.length;
556
+ return this.#questions.length;
515
557
  }
516
558
 
517
559
  #isSubmitTab(): boolean {
@@ -519,7 +561,7 @@ export class AskDialogComponent implements Component {
519
561
  }
520
562
 
521
563
  #currentQuestionIndex(): number {
522
- return clamp(this.#activeTabIndex, 0, Math.max(0, this.questions.length - 1));
564
+ return clamp(this.#activeTabIndex, 0, Math.max(0, this.#questions.length - 1));
523
565
  }
524
566
 
525
567
  #requestRender(): void {
@@ -530,7 +572,7 @@ export class AskDialogComponent implements Component {
530
572
  const lines: string[] = [];
531
573
  if (this.#hasSubmitTab()) {
532
574
  const tabs: Tab[] = [
533
- ...this.questions.map((question, index) => ({
575
+ ...this.#questions.map((question, index) => ({
534
576
  id: String(index),
535
577
  label: questionTabLabel(question, index),
536
578
  })),
@@ -545,7 +587,7 @@ export class AskDialogComponent implements Component {
545
587
  return lines;
546
588
  }
547
589
  const questionIndex = this.#currentQuestionIndex();
548
- const question = this.questions[questionIndex];
590
+ const question = this.#questions[questionIndex];
549
591
  if (!question) return lines;
550
592
  lines.push(...renderQuestionTitle(question, width));
551
593
  return lines;
@@ -559,7 +601,7 @@ export class AskDialogComponent implements Component {
559
601
  const scroll = indicator ? ` ${indicator} scroll ·` : "";
560
602
  return `Enter submit · ↑/↓ scroll ·${scroll} ${cancel}`;
561
603
  }
562
- const question = this.questions[this.#currentQuestionIndex()];
604
+ const question = this.#questions[this.#currentQuestionIndex()];
563
605
  const action = question?.multi ? "Space/Enter toggle · n note" : "Enter select · n note";
564
606
  const tabs = this.#hasSubmitTab() ? " · Tab/←/→" : "";
565
607
  if (this.#questionCanPage && indicator) {
@@ -585,7 +627,7 @@ export class AskDialogComponent implements Component {
585
627
  }
586
628
 
587
629
  #activeQuestionState(): { question: ExtensionAskDialogQuestion; state: QuestionState } | undefined {
588
- const question = this.questions[this.#currentQuestionIndex()];
630
+ const question = this.#questions[this.#currentQuestionIndex()];
589
631
  const state = this.#states[this.#currentQuestionIndex()];
590
632
  if (!question || !state) return undefined;
591
633
  return { question, state };
@@ -672,18 +714,18 @@ export class AskDialogComponent implements Component {
672
714
  }
673
715
 
674
716
  #switchTab(direction: 1 | -1): void {
675
- const tabCount = this.questions.length + 1;
717
+ const tabCount = this.#questions.length + 1;
676
718
  this.#activeTabIndex = (this.#activeTabIndex + direction + tabCount) % tabCount;
677
719
  this.#submitScrollOffset = 0;
678
720
  }
679
721
 
680
722
  #advanceAfterQuestion(): void {
681
723
  const current = this.#currentQuestionIndex();
682
- if (this.questions.length === 1) {
724
+ if (this.#questions.length === 1) {
683
725
  this.#finishSubmit();
684
726
  return;
685
727
  }
686
- this.#activeTabIndex = current + 1 < this.questions.length ? current + 1 : this.#submitTabIndex();
728
+ this.#activeTabIndex = current + 1 < this.#questions.length ? current + 1 : this.#submitTabIndex();
687
729
  this.#submitScrollOffset = 0;
688
730
  this.#requestRender();
689
731
  }
@@ -829,8 +871,8 @@ export class AskDialogComponent implements Component {
829
871
  );
830
872
  allLines.push("");
831
873
  }
832
- for (let index = 0; index < this.questions.length; index++) {
833
- const question = this.questions[index];
874
+ for (let index = 0; index < this.#questions.length; index++) {
875
+ const question = this.#questions[index];
834
876
  const state = this.#states[index];
835
877
  if (!question || !state) continue;
836
878
  const label = questionTabLabel(question, index);
@@ -895,8 +937,8 @@ export class AskDialogComponent implements Component {
895
937
 
896
938
  #unansweredCount(): number {
897
939
  let count = 0;
898
- for (let index = 0; index < this.questions.length; index++) {
899
- const question = this.questions[index];
940
+ for (let index = 0; index < this.#questions.length; index++) {
941
+ const question = this.#questions[index];
900
942
  const state = this.#states[index];
901
943
  if (!question || !state) continue;
902
944
  if (state.selectedOptions.size === 0 && state.customInput === undefined) count += 1;
@@ -911,8 +953,8 @@ export class AskDialogComponent implements Component {
911
953
  return;
912
954
  }
913
955
  this.options.onTimeout?.();
914
- for (let index = 0; index < this.questions.length; index++) {
915
- const question = this.questions[index];
956
+ for (let index = 0; index < this.#questions.length; index++) {
957
+ const question = this.#questions[index];
916
958
  const state = this.#states[index];
917
959
  if (!question || !state) continue;
918
960
  if (state.selectedOptions.size === 0 && state.customInput === undefined) {
@@ -952,8 +994,8 @@ export class AskDialogComponent implements Component {
952
994
 
953
995
  #buildResults(): ExtensionAskDialogResultItem[] {
954
996
  const results: ExtensionAskDialogResultItem[] = [];
955
- for (let index = 0; index < this.questions.length; index++) {
956
- const question = this.questions[index];
997
+ for (let index = 0; index < this.#questions.length; index++) {
998
+ const question = this.#questions[index];
957
999
  const state = this.#states[index];
958
1000
  if (!question || !state) continue;
959
1001
  const selectedOptions = question.options
@@ -56,7 +56,7 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
56
56
  "app.thinking.toggle": ["ctrl+t"],
57
57
  "app.editor.external": ["ctrl+g"],
58
58
  "app.history.search": ["ctrl+r"],
59
- "app.message.dequeue": ["alt+up"],
59
+ "app.message.dequeue": ["alt+up", "shift+up"],
60
60
  "app.retry": ["alt+r"],
61
61
  "app.clipboard.pasteImage": ["ctrl+v"],
62
62
  "app.clipboard.pasteTextRaw": ["ctrl+shift+v", "alt+shift+v"],
@@ -314,10 +314,10 @@ function padLeftVisible(text: string, width: number): string {
314
314
  export interface ModelBrowserOptions {
315
315
  /** Render the dim `provider/` prefix before model ids. Default true. */
316
316
  showProvider?: boolean;
317
- /** Session token count used to disable models whose context window is exceeded. */
317
+ /** Session token count used to flag models whose context window is exceeded. */
318
318
  currentContextTokens?: number;
319
- /** When true, rows over the current context are unselectable (session-switch mode). */
320
- disableOverContext?: boolean;
319
+ /** When true, over-context rows render grayed; picking one compacts first (session-switch mode). */
320
+ markOverContext?: boolean;
321
321
  /** Host-provided empty-state text (e.g. provider discovery status). */
322
322
  emptyText?: () => string | undefined;
323
323
  }
@@ -351,7 +351,7 @@ export class ModelBrowser implements Component {
351
351
  #maxVisible = 10;
352
352
  #showProvider: boolean;
353
353
  #currentContextTokens: number;
354
- #disableOverContext: boolean;
354
+ #markOverContext: boolean;
355
355
  #emptyText?: () => string | undefined;
356
356
  /** Keep role-like virtual rows in their host-defined order during search. */
357
357
  #preserveQueryOrder = false;
@@ -375,7 +375,7 @@ export class ModelBrowser implements Component {
375
375
  this.#showProvider = options.showProvider ?? true;
376
376
  const tokens = options.currentContextTokens ?? 0;
377
377
  this.#currentContextTokens = Number.isFinite(tokens) && tokens > 0 ? Math.floor(tokens) : 0;
378
- this.#disableOverContext = options.disableOverContext ?? false;
378
+ this.#markOverContext = options.markOverContext ?? false;
379
379
  this.#emptyText = options.emptyText;
380
380
  }
381
381
 
@@ -420,9 +420,9 @@ export class ModelBrowser implements Component {
420
420
  setPreserveQueryOrder(preserve: boolean): void {
421
421
  this.#preserveQueryOrder = preserve;
422
422
  }
423
- /** Allow hosts to toggle context-window eligibility between browser modes. */
424
- setDisableOverContext(disable: boolean): void {
425
- this.#disableOverContext = disable;
423
+ /** Allow hosts to toggle context-window flagging between browser modes. */
424
+ setMarkOverContext(mark: boolean): void {
425
+ this.#markOverContext = mark;
426
426
  }
427
427
  /** Focused: accent cursor + selected-row background band. Unfocused: dim cursor, no band. */
428
428
  setFocused(focused: boolean): void {
@@ -461,8 +461,13 @@ export class ModelBrowser implements Component {
461
461
  }
462
462
 
463
463
  #isDisabled(item: ModelBrowserItem): boolean {
464
- if (item.id === "separator") return true;
465
- if (!this.#disableOverContext || this.#currentContextTokens <= 0) return false;
464
+ return item.id === "separator";
465
+ }
466
+
467
+ /** True when `item`'s context window is smaller than the live session token count (grayed row; hosts compact before switching). */
468
+ isOverContext(item: ModelBrowserItem): boolean {
469
+ if (item.id === "separator") return false;
470
+ if (!this.#markOverContext || this.#currentContextTokens <= 0) return false;
466
471
  const contextWindow = item.model.contextWindow ?? 0;
467
472
  return contextWindow > 0 && this.#currentContextTokens > contextWindow;
468
473
  }
@@ -718,7 +723,7 @@ export class ModelBrowser implements Component {
718
723
  const line = theme.fg("muted", "─".repeat(dashCount));
719
724
  return ` ${line} `;
720
725
  }
721
- const disabled = this.#isDisabled(item);
726
+ const overContext = this.isOverContext(item);
722
727
  const prefix = selected && this.#focused ? `${theme.fg("accent", theme.nav.cursor)} ` : " ";
723
728
  const providerPrefix = this.#showProvider ? theme.fg("dim", `${item.provider}/`) : "";
724
729
  const name = item.labelColor
@@ -728,7 +733,7 @@ export class ModelBrowser implements Component {
728
733
  : item.id;
729
734
  const currentMark =
730
735
  item.selector === this.#currentSelector ? ` ${theme.fg("success", theme.status.enabled)}` : "";
731
- const overLimit = disabled
736
+ const overLimit = overContext
732
737
  ? ` ${theme.status.disabled} context>${formatNumber(item.model.contextWindow ?? 0).toLowerCase()}`
733
738
  : "";
734
739
  let left = `${prefix}${providerPrefix}${name}${currentMark}${overLimit}`;
@@ -743,12 +748,15 @@ export class ModelBrowser implements Component {
743
748
  const gap = Math.max(0, available - visibleWidth(left));
744
749
 
745
750
  let line = `${left}${" ".repeat(gap)} ${meta}`;
746
- if (disabled) {
747
- line = theme.fg("dim", Bun.stripANSI(line));
751
+ if (overContext) {
752
+ // Gray the whole row but keep the selection cursor visible: over-context
753
+ // models stay selectable (the host compacts before switching).
754
+ const plainPrefix = Bun.stripANSI(prefix);
755
+ line = `${prefix}${theme.fg("dim", Bun.stripANSI(line).slice(plainPrefix.length))}`;
748
756
  }
749
757
  // The bg band is reserved for the mouse: it marks hover, nothing else.
750
758
  // Keyboard selection is the cursor glyph + accent name.
751
- if (hovered && !disabled) {
759
+ if (hovered) {
752
760
  line = theme.bg("selectedBg", line);
753
761
  }
754
762
  return line;
@@ -772,8 +780,8 @@ export class ModelBrowser implements Component {
772
780
  }
773
781
  const line1 = truncateToWidth(theme.fg("muted", ` ${facts.join(" · ")}`), width);
774
782
 
775
- if (this.#isDisabled(selected)) {
776
- const warning = ` ${theme.status.disabled} current context ${formatNumber(this.#currentContextTokens).toLowerCase()} exceeds ${formatNumber(model.contextWindow ?? 0).toLowerCase()} limit`;
783
+ if (this.isOverContext(selected)) {
784
+ const warning = ` ${theme.status.disabled} context ${formatNumber(this.#currentContextTokens).toLowerCase()} exceeds ${formatNumber(model.contextWindow ?? 0).toLowerCase()} limit · compacts with current model, then switches`;
777
785
  return [line1, truncateToWidth(theme.fg("warning", warning), width)];
778
786
  }
779
787
 
@@ -22,8 +22,12 @@ import { bottomBorder, row, topBorder } from "./overlay-box";
22
22
  import { resolveSegmentPalette } from "./segment-track";
23
23
 
24
24
  export interface ModelPickerCallbacks {
25
- /** A model was chosen for a session-only switch. `selector` is `provider/id`. */
26
- onPick: (model: Model, selector: string) => void;
25
+ /**
26
+ * A model was chosen for a session-only switch. `selector` is `provider/id`.
27
+ * `overContext` is true when the session transcript exceeds the model's
28
+ * context window — the host must compact before switching.
29
+ */
30
+ onPick: (model: Model, selector: string, meta: { overContext: boolean }) => void;
27
31
  /** A configured ctrl+p quick role was chosen. */
28
32
  onPickRole?: (entry: ResolvedRoleModel) => void;
29
33
  /** The picker was dismissed. */
@@ -31,7 +35,7 @@ export interface ModelPickerCallbacks {
31
35
  }
32
36
 
33
37
  export interface ModelPickerOptions {
34
- /** Session token count; models with smaller context windows are disabled. */
38
+ /** Session token count; models with smaller context windows are grayed and compact-first on pick. */
35
39
  currentContextTokens?: number;
36
40
  /** `provider/id` of the session's active model; highlighted and preselected. */
37
41
  currentSelector?: string;
@@ -97,7 +101,7 @@ export class ModelPickerComponent implements Component {
97
101
 
98
102
  this.#browser = new ModelBrowser(settings, {
99
103
  currentContextTokens: options.currentContextTokens,
100
- disableOverContext: true,
104
+ markOverContext: true,
101
105
  emptyText: () => (this.#roleMode ? " No quick roles in the Ctrl+P cycle" : undefined),
102
106
  });
103
107
  this.#browser.onActivate = item => {
@@ -106,7 +110,7 @@ export class ModelPickerComponent implements Component {
106
110
  callbacks.onPickRole?.(quickRole);
107
111
  return;
108
112
  }
109
- callbacks.onPick(item.model, item.selector);
113
+ callbacks.onPick(item.model, item.selector, { overContext: this.#browser.isOverContext(item) });
110
114
  };
111
115
  this.#browser.onCancel = () => callbacks.onCancel();
112
116
  this.#browser.onQueryChange = query => this.#syncItemsForQuery(query);
@@ -193,7 +197,7 @@ export class ModelPickerComponent implements Component {
193
197
 
194
198
  this.#roleMode = roleMode;
195
199
  this.#browser.setShowProvider(!roleMode);
196
- this.#browser.setDisableOverContext(!roleMode);
200
+ this.#browser.setMarkOverContext(!roleMode);
197
201
  this.#browser.setPreserveQueryOrder(roleMode);
198
202
  const currentSelector = roleMode ? this.#currentQuickRoleSelector : this.#currentSelector;
199
203
  this.#browser.setCurrentSelector(currentSelector);
@@ -397,6 +397,32 @@ export class ReadToolGroupComponent extends Container implements ToolExecutionHa
397
397
  this.#updateDisplay();
398
398
  }
399
399
 
400
+ /**
401
+ * Re-key an entry whose streamed tool-call id changed mid-stream (a provider
402
+ * rewriting the id across deltas; see EventController's
403
+ * `#streamedToolCallIdByIndex`). Preserves row order so a sibling read run is
404
+ * not visibly reshuffled, and no-ops when the rename would collide.
405
+ */
406
+ renameEntry(oldId: string, newId: string): void {
407
+ if (oldId === newId || !newId) return;
408
+ const entry = this.#entries.get(oldId);
409
+ if (!entry || this.#entries.has(newId)) return;
410
+ entry.toolCallId = newId;
411
+ const reordered = [...this.#entries].map(([key, value]): [string, ReadEntry] => [
412
+ key === oldId ? newId : key,
413
+ value,
414
+ ]);
415
+ this.#entries.clear();
416
+ for (const [key, value] of reordered) this.#entries.set(key, value);
417
+ this.#updateDisplay();
418
+ }
419
+ /** Remove one call without discarding successful siblings in the shared group. */
420
+ removeEntry(toolCallId: string): boolean {
421
+ if (!this.#entries.delete(toolCallId)) return this.#entries.size === 0;
422
+ this.#updateDisplay();
423
+ return this.#entries.size === 0;
424
+ }
425
+
400
426
  updateResult(
401
427
  result: { content: Array<{ type: string; text?: string }>; details?: unknown; isError?: boolean },
402
428
  isPartial = false,
@@ -276,6 +276,9 @@ let toolExecutionInstanceSeq = 0;
276
276
  export class ToolExecutionComponent extends Container implements NativeScrollbackLiveRegion {
277
277
  #contentBox: Box; // Used for custom tools and bash visual truncation
278
278
  #contentText: WidthAwareText; // Generic fallback (no custom/built-in renderer)
279
+ // Which container the constructor mounted: bespoke/built-in renderers use
280
+ // #contentBox, everything else the generic #contentText fallback.
281
+ #usesContentBox = false;
279
282
  #multiFileBoxes: (Box | Spacer)[] = []; // Extra boxes for multi-file edit results
280
283
  #imageComponents: Image[] = [];
281
284
  #imageSpacers: Spacer[] = [];
@@ -411,26 +414,13 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
411
414
  // strips PLAIN-blank edges, so framed/minimal blocks (no bg set) drop these
412
415
  // lines and keep their tight spacing — only tinted lines survive.
413
416
  this.#contentBox = new Box(0, 1);
414
- this.#contentText = new WidthAwareText(
415
- contentWidth =>
416
- formatDefaultToolExecution(
417
- {
418
- label: this.#toolLabel,
419
- args: this.#args,
420
- result: this.#result ? { output: this.#getTextOutput(), isError: this.#result.isError } : undefined,
421
- options: this.#renderState,
422
- },
423
- contentWidth,
424
- theme,
425
- ),
426
- 1,
427
- 1,
428
- );
417
+ this.#contentText = new WidthAwareText(contentWidth => this.#renderDefaultCard(contentWidth), 1, 1);
429
418
 
430
419
  // Use Box for custom tools or built-in tools that have renderers
431
420
  const hasRenderer = toolName in toolRenderers;
432
421
  const hasCustomRenderer = !!(tool?.renderCall || tool?.renderResult);
433
- if (hasCustomRenderer || hasRenderer) {
422
+ this.#usesContentBox = hasCustomRenderer || hasRenderer;
423
+ if (this.#usesContentBox) {
434
424
  this.addChild(this.#contentBox);
435
425
  } else {
436
426
  this.addChild(this.#contentText);
@@ -998,12 +988,21 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
998
988
 
999
989
  // Non-self-framing tools (custom/extension renderers and the generic
1000
990
  // fallback) get a padded, state-tinted block — built-ins that draw their
1001
- // own frame opt out below via the framed-component mark.
1002
- const stateBgKey = this.#isPartial ? "toolPendingBg" : this.#result?.isError ? "toolErrorBg" : "toolSuccessBg";
991
+ // own frame opt out below via the framed-component mark. A benign skip
992
+ // (steering/peer interrupt aborted a still-pending call) never ran, so it
993
+ // gets the neutral pending tint rather than the error tint (#7199).
994
+ const benignSkip = this.#isBenignSkip();
995
+ const stateBgKey =
996
+ this.#isPartial || benignSkip ? "toolPendingBg" : this.#result?.isError ? "toolErrorBg" : "toolSuccessBg";
1003
997
  const stateBgFn = (t: string) => theme.bg(stateBgKey, t);
1004
998
 
1005
- // Check for custom tool rendering
1006
- if (this.#tool && (this.#tool.renderCall || this.#tool.renderResult)) {
999
+ // A benign skip is a synthetic placeholder for a call that never executed,
1000
+ // so bypass any bespoke error frame and draw the neutral generic card —
1001
+ // the per-tool ✘/red-border would misread normal mid-turn steering as a
1002
+ // failure (#7199).
1003
+ if (benignSkip) {
1004
+ this.#renderBenignSkipCard(stateBgFn);
1005
+ } else if (this.#tool && (this.#tool.renderCall || this.#tool.renderResult)) {
1007
1006
  const tool = this.#tool;
1008
1007
  const mergeCallAndResult = Boolean((tool as { mergeCallAndResult?: boolean }).mergeCallAndResult);
1009
1008
  // Custom tools use Box for flexible component rendering
@@ -1399,4 +1398,62 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1399
1398
 
1400
1399
  return output;
1401
1400
  }
1401
+
1402
+ /**
1403
+ * Format the generic call/result card at `contentWidth`. Shared by the
1404
+ * #contentText fallback and the benign-skip path so both render identically.
1405
+ */
1406
+ #renderDefaultCard(contentWidth: number): string {
1407
+ return formatDefaultToolExecution(
1408
+ {
1409
+ label: this.#toolLabel,
1410
+ args: this.#args,
1411
+ result: this.#result
1412
+ ? { output: this.#getTextOutput(), isError: this.#result.isError, skipped: this.#isBenignSkip() }
1413
+ : undefined,
1414
+ options: this.#renderState,
1415
+ },
1416
+ contentWidth,
1417
+ theme,
1418
+ );
1419
+ }
1420
+
1421
+ /**
1422
+ * True for a steering/peer-interrupt placeholder. A synthetic placeholder
1423
+ * identifies a call that never entered `tool.execute`; an interrupted
1424
+ * placeholder identifies one that started but threw before returning usable
1425
+ * output. Both are normal steering control flow and render neutrally (#7199).
1426
+ */
1427
+ #isBenignSkip(): boolean {
1428
+ if (this.#isPartial || !this.#result) return false;
1429
+ const details = this.#result.details as
1430
+ | { __synthetic?: boolean; __interrupted?: boolean; source?: string; execution?: string }
1431
+ | undefined;
1432
+ if (details?.source !== "interrupt_skipped") return false;
1433
+ return details.__synthetic === true || (details.__interrupted === true && details.execution === "started");
1434
+ }
1435
+
1436
+ /**
1437
+ * Render a benign skip as the neutral generic card, replacing any bespoke
1438
+ * renderer's error frame. Generic-fallback tools already route through
1439
+ * {@link #renderDefaultCard} (which emits the info card for a skip); they
1440
+ * only need the neutral tint. Bespoke-renderer tools get their content box
1441
+ * swapped for the same neutral card.
1442
+ */
1443
+ #renderBenignSkipCard(stateBgFn: (text: string) => string): void {
1444
+ if (!this.#usesContentBox) {
1445
+ this.#contentText.setCustomBgFn(stateBgFn);
1446
+ this.#contentText.invalidate();
1447
+ return;
1448
+ }
1449
+ for (const box of this.#multiFileBoxes) {
1450
+ this.removeChild(box);
1451
+ }
1452
+ this.#multiFileBoxes = [];
1453
+ this.#contentBox.setBgFn(undefined);
1454
+ this.#contentBox.clear();
1455
+ this.#contentBox.setPaddingX(1);
1456
+ this.#contentBox.setBgFn(stateBgFn);
1457
+ this.#contentBox.addChild(new WidthAwareText(contentWidth => this.#renderDefaultCard(contentWidth), 0, 0));
1458
+ }
1402
1459
  }
@@ -28,7 +28,7 @@ import {
28
28
  seedAlreadyExists,
29
29
  summarizeMentalModel,
30
30
  } from "../../hindsight";
31
- import { resolveMemoryBackend } from "../../memory-backend";
31
+ import { memoryStatsUnavailableMessage, resolveMemoryBackend } from "../../memory-backend";
32
32
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
33
33
  import { BorderedLoader } from "../../modes/components/bordered-loader";
34
34
  import { DynamicBorder } from "../../modes/components/dynamic-border";
@@ -658,7 +658,7 @@ export class CommandController {
658
658
  try {
659
659
  const payload = await hook?.(agentDir, this.ctx.sessionManager.getCwd(), this.ctx.session);
660
660
  if (!payload) {
661
- this.ctx.showWarning(`Memory ${action} is not available for the ${backend.id} backend.`);
661
+ this.ctx.showWarning(memoryStatsUnavailableMessage(backend.id, action));
662
662
  return;
663
663
  }
664
664
  showMarkdownPanel(this.ctx, `Memory ${action === "stats" ? "Stats" : "Diagnostics"}`, payload);