@oh-my-pi/pi-coding-agent 15.10.4 → 15.10.5

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 (141) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/types/capability/rule-buckets.d.ts +1 -1
  3. package/dist/types/capability/rule.d.ts +6 -1
  4. package/dist/types/cli/update-cli.d.ts +11 -1
  5. package/dist/types/config/model-registry.d.ts +18 -1
  6. package/dist/types/discovery/at-imports.d.ts +15 -0
  7. package/dist/types/edit/diff.d.ts +3 -2
  8. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +1 -0
  9. package/dist/types/eval/backend.d.ts +7 -0
  10. package/dist/types/eval/js/context-manager.d.ts +1 -0
  11. package/dist/types/eval/js/executor.d.ts +2 -0
  12. package/dist/types/eval/js/index.d.ts +1 -1
  13. package/dist/types/eval/js/shared/helpers.d.ts +6 -0
  14. package/dist/types/eval/js/shared/runtime.d.ts +5 -0
  15. package/dist/types/eval/js/worker-protocol.d.ts +6 -0
  16. package/dist/types/eval/py/executor.d.ts +7 -0
  17. package/dist/types/eval/py/index.d.ts +1 -1
  18. package/dist/types/export/ttsr.d.ts +14 -0
  19. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  20. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  21. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  22. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  25. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  27. package/dist/types/modes/image-references.d.ts +8 -3
  28. package/dist/types/modes/interactive-mode.d.ts +1 -1
  29. package/dist/types/modes/theme/theme.d.ts +2 -1
  30. package/dist/types/modes/types.d.ts +2 -1
  31. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  32. package/dist/types/session/agent-session.d.ts +0 -2
  33. package/dist/types/tools/ask.d.ts +1 -0
  34. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  35. package/dist/types/tools/index.d.ts +17 -0
  36. package/dist/types/tools/render-utils.d.ts +1 -1
  37. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  38. package/dist/types/utils/block-context.d.ts +35 -0
  39. package/dist/types/utils/image-loading.d.ts +12 -0
  40. package/package.json +29 -9
  41. package/src/capability/rule-buckets.ts +4 -2
  42. package/src/capability/rule.ts +10 -1
  43. package/src/cli/auth-broker-cli.ts +6 -7
  44. package/src/cli/auth-gateway-cli.ts +1 -1
  45. package/src/cli/list-models.ts +5 -0
  46. package/src/cli/update-cli.ts +138 -16
  47. package/src/config/model-registry.ts +81 -2
  48. package/src/debug/index.ts +4 -8
  49. package/src/discovery/at-imports.ts +273 -0
  50. package/src/discovery/builtin-rules/index.ts +4 -0
  51. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  52. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  53. package/src/discovery/helpers.ts +2 -1
  54. package/src/edit/diff.ts +114 -4
  55. package/src/edit/hashline/diff.ts +1 -1
  56. package/src/edit/hashline/execute.ts +1 -1
  57. package/src/edit/modes/patch.ts +6 -2
  58. package/src/edit/modes/replace.ts +1 -1
  59. package/src/edit/renderer.ts +12 -2
  60. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  61. package/src/eval/backend.ts +15 -0
  62. package/src/eval/js/context-manager.ts +4 -2
  63. package/src/eval/js/executor.ts +3 -0
  64. package/src/eval/js/index.ts +7 -1
  65. package/src/eval/js/shared/helpers.ts +53 -6
  66. package/src/eval/js/shared/runtime.ts +8 -0
  67. package/src/eval/js/worker-core.ts +1 -0
  68. package/src/eval/js/worker-protocol.ts +6 -0
  69. package/src/eval/py/executor.ts +12 -0
  70. package/src/eval/py/index.ts +7 -1
  71. package/src/eval/py/prelude.py +43 -4
  72. package/src/eval/py/runner.py +1 -0
  73. package/src/exa/render.ts +1 -1
  74. package/src/export/ttsr.ts +122 -1
  75. package/src/extensibility/extensions/types.ts +8 -1
  76. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  77. package/src/extensibility/plugins/doctor.ts +1 -1
  78. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  79. package/src/goals/tools/goal-tool.ts +1 -1
  80. package/src/internal-urls/docs-index.generated.ts +6 -5
  81. package/src/internal-urls/local-protocol.ts +13 -0
  82. package/src/lsp/render.ts +8 -6
  83. package/src/mcp/oauth-flow.ts +3 -3
  84. package/src/mcp/render.ts +7 -1
  85. package/src/modes/components/custom-editor.ts +12 -6
  86. package/src/modes/components/login-dialog.ts +1 -1
  87. package/src/modes/components/oauth-selector.ts +4 -4
  88. package/src/modes/components/read-tool-group.ts +10 -3
  89. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  90. package/src/modes/components/status-line/index.ts +1 -0
  91. package/src/modes/components/status-line/types.ts +23 -8
  92. package/src/modes/components/tool-execution.ts +1 -1
  93. package/src/modes/components/transcript-container.ts +17 -10
  94. package/src/modes/components/user-message.ts +6 -3
  95. package/src/modes/components/welcome.ts +1 -1
  96. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  97. package/src/modes/controllers/input-controller.ts +36 -10
  98. package/src/modes/controllers/mcp-command-controller.ts +28 -12
  99. package/src/modes/controllers/selector-controller.ts +4 -11
  100. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  101. package/src/modes/image-references.ts +13 -7
  102. package/src/modes/interactive-mode.ts +2 -2
  103. package/src/modes/rpc/rpc-mode.ts +1 -1
  104. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  105. package/src/modes/theme/theme.ts +95 -1
  106. package/src/modes/types.ts +2 -1
  107. package/src/modes/utils/ui-helpers.ts +14 -5
  108. package/src/prompts/tools/bash.md +1 -1
  109. package/src/prompts/tools/eval.md +4 -4
  110. package/src/sdk.ts +31 -14
  111. package/src/session/agent-session.ts +213 -155
  112. package/src/session/session-manager.ts +1 -1
  113. package/src/slash-commands/builtin-registry.ts +1 -1
  114. package/src/system-prompt.ts +15 -9
  115. package/src/task/render.ts +20 -8
  116. package/src/tools/ask.ts +14 -5
  117. package/src/tools/bash-interactive.ts +1 -1
  118. package/src/tools/bash.ts +14 -2
  119. package/src/tools/browser/render.ts +5 -2
  120. package/src/tools/browser/tab-worker.ts +211 -91
  121. package/src/tools/debug.ts +5 -2
  122. package/src/tools/eval-render.ts +6 -3
  123. package/src/tools/eval.ts +1 -1
  124. package/src/tools/gh-renderer.ts +29 -15
  125. package/src/tools/index.ts +32 -0
  126. package/src/tools/inspect-image-renderer.ts +12 -5
  127. package/src/tools/job.ts +9 -6
  128. package/src/tools/memory-render.ts +19 -5
  129. package/src/tools/read.ts +165 -18
  130. package/src/tools/render-utils.ts +3 -1
  131. package/src/tools/resolve.ts +1 -1
  132. package/src/tools/review.ts +1 -1
  133. package/src/tools/ssh.ts +4 -1
  134. package/src/tools/todo.ts +8 -1
  135. package/src/tools/tool-timeouts.ts +1 -1
  136. package/src/tools/write.ts +1 -1
  137. package/src/tui/code-cell.ts +1 -1
  138. package/src/utils/block-context.ts +312 -0
  139. package/src/utils/image-loading.ts +31 -1
  140. package/src/web/search/providers/codex.ts +1 -1
  141. package/src/web/search/render.ts +14 -6
@@ -30,6 +30,11 @@ export class ExtensionUiController {
30
30
  #extensionTerminalInputUnsubscribers = new Set<() => void>();
31
31
  #hookWidgetsAbove = new Map<string, ExtensionUiComponent>();
32
32
  #hookWidgetsBelow = new Map<string, ExtensionUiComponent>();
33
+ // Single-file dialog surface (`editorContainer` + focus) is shared by the
34
+ // selector / input / editor modals, so only one may be presented at a time;
35
+ // the rest queue. See `#presentDialog`.
36
+ #dialogActive = false;
37
+ #dialogQueue: Array<() => void> = [];
33
38
  constructor(private ctx: InteractiveModeContext) {}
34
39
 
35
40
  /**
@@ -528,58 +533,47 @@ export class ExtensionUiController {
528
533
  dialogOptions?: InteractiveSelectorDialogOptions,
529
534
  extra?: { slider?: HookSelectorSlider },
530
535
  ): Promise<string | undefined> {
531
- const { promise, finish, attachAbort } = this.#createHookDialogState(
532
- () => this.hideHookSelector(),
533
- dialogOptions?.signal,
534
- );
535
- const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
536
- this.ctx.hookSelector = new HookSelectorComponent(
537
- title,
538
- options,
539
- option => {
540
- this.hideHookSelector();
541
- finish(option);
542
- },
543
- () => {
544
- this.hideHookSelector();
545
- finish(undefined);
546
- },
547
- {
548
- onLeft: dialogOptions?.onLeft
549
- ? () => {
550
- this.hideHookSelector();
551
- dialogOptions.onLeft?.();
552
- finish(undefined);
553
- }
554
- : undefined,
555
- onRight: dialogOptions?.onRight
556
- ? () => {
557
- this.hideHookSelector();
558
- dialogOptions.onRight?.();
559
- finish(undefined);
560
- }
561
- : undefined,
562
- onExternalEditor: dialogOptions?.onExternalEditor,
563
- helpText: dialogOptions?.helpText,
564
- initialIndex: dialogOptions?.initialIndex,
565
- timeout: dialogOptions?.timeout,
566
- onTimeout: dialogOptions?.onTimeout,
567
- tui: this.ctx.ui,
568
- outline: dialogOptions?.outline,
569
- disabledIndices: dialogOptions?.disabledIndices,
570
- selectionMarker: dialogOptions?.selectionMarker,
571
- checkedIndices: dialogOptions?.checkedIndices,
572
- markableCount: dialogOptions?.markableCount,
573
- maxVisible,
574
- slider: extra?.slider,
575
- },
576
- );
577
- this.ctx.editorContainer.clear();
578
- this.ctx.editorContainer.addChild(this.ctx.hookSelector);
579
- this.ctx.ui.setFocus(this.ctx.hookSelector);
580
- this.ctx.ui.requestRender();
581
- attachAbort();
582
- return promise;
536
+ return this.#presentDialog(dialogOptions?.signal, settle => {
537
+ const maxVisible = Math.max(4, Math.min(15, this.ctx.ui.terminal.rows - 12));
538
+ this.ctx.hookSelector = new HookSelectorComponent(
539
+ title,
540
+ options,
541
+ option => settle(option),
542
+ () => settle(undefined),
543
+ {
544
+ onLeft: dialogOptions?.onLeft
545
+ ? () => {
546
+ dialogOptions.onLeft?.();
547
+ settle(undefined);
548
+ }
549
+ : undefined,
550
+ onRight: dialogOptions?.onRight
551
+ ? () => {
552
+ dialogOptions.onRight?.();
553
+ settle(undefined);
554
+ }
555
+ : undefined,
556
+ onExternalEditor: dialogOptions?.onExternalEditor,
557
+ helpText: dialogOptions?.helpText,
558
+ initialIndex: dialogOptions?.initialIndex,
559
+ timeout: dialogOptions?.timeout,
560
+ onTimeout: dialogOptions?.onTimeout,
561
+ tui: this.ctx.ui,
562
+ outline: dialogOptions?.outline,
563
+ disabledIndices: dialogOptions?.disabledIndices,
564
+ selectionMarker: dialogOptions?.selectionMarker,
565
+ checkedIndices: dialogOptions?.checkedIndices,
566
+ markableCount: dialogOptions?.markableCount,
567
+ maxVisible,
568
+ slider: extra?.slider,
569
+ },
570
+ );
571
+ this.ctx.editorContainer.clear();
572
+ this.ctx.editorContainer.addChild(this.ctx.hookSelector);
573
+ this.ctx.ui.setFocus(this.ctx.hookSelector);
574
+ this.ctx.ui.requestRender();
575
+ return () => this.hideHookSelector();
576
+ });
583
577
  }
584
578
  /**
585
579
  * Hide the hook selector.
@@ -609,33 +603,24 @@ export class ExtensionUiController {
609
603
  placeholder?: string,
610
604
  dialogOptions?: ExtensionUIDialogOptions,
611
605
  ): Promise<string | undefined> {
612
- const { promise, finish, attachAbort } = this.#createHookDialogState(
613
- () => this.hideHookInput(),
614
- dialogOptions?.signal,
615
- );
616
- this.ctx.hookInput = new HookInputComponent(
617
- title,
618
- placeholder,
619
- value => {
620
- this.hideHookInput();
621
- finish(value);
622
- },
623
- () => {
624
- this.hideHookInput();
625
- finish(undefined);
626
- },
627
- {
628
- timeout: dialogOptions?.timeout,
629
- onTimeout: dialogOptions?.onTimeout,
630
- tui: this.ctx.ui,
631
- },
632
- );
633
- this.ctx.editorContainer.clear();
634
- this.ctx.editorContainer.addChild(this.ctx.hookInput);
635
- this.ctx.ui.setFocus(this.ctx.hookInput);
636
- this.ctx.ui.requestRender();
637
- attachAbort();
638
- return promise;
606
+ return this.#presentDialog(dialogOptions?.signal, settle => {
607
+ this.ctx.hookInput = new HookInputComponent(
608
+ title,
609
+ placeholder,
610
+ value => settle(value),
611
+ () => settle(undefined),
612
+ {
613
+ timeout: dialogOptions?.timeout,
614
+ onTimeout: dialogOptions?.onTimeout,
615
+ tui: this.ctx.ui,
616
+ },
617
+ );
618
+ this.ctx.editorContainer.clear();
619
+ this.ctx.editorContainer.addChild(this.ctx.hookInput);
620
+ this.ctx.ui.setFocus(this.ctx.hookInput);
621
+ this.ctx.ui.requestRender();
622
+ return () => this.hideHookInput();
623
+ });
639
624
  }
640
625
 
641
626
  /**
@@ -659,31 +644,21 @@ export class ExtensionUiController {
659
644
  dialogOptions?: ExtensionUIDialogOptions,
660
645
  editorOptions?: { promptStyle?: boolean },
661
646
  ): Promise<string | undefined> {
662
- const { promise, finish, attachAbort } = this.#createHookDialogState(
663
- () => this.hideHookEditor(),
664
- dialogOptions?.signal,
665
- );
666
- this.ctx.hookEditor = new HookEditorComponent(
667
- this.ctx.ui,
668
- title,
669
- prefill,
670
- value => {
671
- this.hideHookEditor();
672
- finish(value);
673
- },
674
- () => {
675
- this.hideHookEditor();
676
- finish(undefined);
677
- },
678
- editorOptions,
679
- );
680
-
681
- this.ctx.editorContainer.clear();
682
- this.ctx.editorContainer.addChild(this.ctx.hookEditor);
683
- this.ctx.ui.setFocus(this.ctx.hookEditor);
684
- this.ctx.ui.requestRender();
685
- attachAbort();
686
- return promise;
647
+ return this.#presentDialog(dialogOptions?.signal, settle => {
648
+ this.ctx.hookEditor = new HookEditorComponent(
649
+ this.ctx.ui,
650
+ title,
651
+ prefill,
652
+ value => settle(value),
653
+ () => settle(undefined),
654
+ editorOptions,
655
+ );
656
+ this.ctx.editorContainer.clear();
657
+ this.ctx.editorContainer.addChild(this.ctx.hookEditor);
658
+ this.ctx.ui.setFocus(this.ctx.hookEditor);
659
+ this.ctx.ui.requestRender();
660
+ return () => this.hideHookEditor();
661
+ });
687
662
  }
688
663
 
689
664
  /**
@@ -834,37 +809,78 @@ export class ExtensionUiController {
834
809
  }
835
810
  }
836
811
 
837
- #createHookDialogState(
838
- hide: () => void,
812
+ /**
813
+ * Present a modal dialog on the shared editor surface, serializing against any
814
+ * dialog already open. `present` builds the component, swaps it into
815
+ * `editorContainer`, steals focus, and returns a `hide` closure; it is invoked
816
+ * with a single `settle` callback that the component fires on submit/cancel.
817
+ *
818
+ * Because selector / input / editor all clear `editorContainer` and re-focus,
819
+ * showing a second one while the first is open would orphan the first — its
820
+ * promise would hang until the caller's signal aborts. So at most one dialog is
821
+ * presented at a time and the rest queue (FIFO). `settle` (or an abort) hides
822
+ * the current dialog and hands the surface to the next queued request. A request
823
+ * whose signal aborts before its turn resolves `undefined` and is never shown.
824
+ */
825
+ #presentDialog(
839
826
  signal: AbortSignal | undefined,
840
- ): {
841
- promise: Promise<string | undefined>;
842
- finish: (value: string | undefined) => void;
843
- attachAbort: () => void;
844
- } {
845
- const { promise, resolve } = Promise.withResolvers<string | undefined>();
827
+ present: (settle: (value: string | undefined) => void) => () => void,
828
+ ): Promise<string | undefined> {
829
+ const { promise, resolve, reject } = Promise.withResolvers<string | undefined>();
846
830
  let settled = false;
847
- const onAbort = () => {
848
- hide();
849
- if (!settled) {
850
- settled = true;
851
- resolve(undefined);
852
- }
853
- };
854
- const finish = (value: string | undefined) => {
831
+ let started = false;
832
+ let hide: (() => void) | undefined;
833
+
834
+ function onAbort(): void {
835
+ settle(undefined);
836
+ }
837
+
838
+ const settle = (value: string | undefined): void => {
855
839
  if (settled) return;
856
840
  settled = true;
857
841
  signal?.removeEventListener("abort", onAbort);
842
+ if (started) {
843
+ hide?.();
844
+ this.#dialogActive = false;
845
+ this.#advanceDialogQueue();
846
+ }
858
847
  resolve(value);
859
848
  };
860
- const attachAbort = () => {
861
- if (!signal) return;
862
- if (signal.aborted) {
863
- onAbort();
864
- } else {
865
- signal.addEventListener("abort", onAbort, { once: true });
849
+
850
+ const startPresentation = (): void => {
851
+ if (settled) {
852
+ // Aborted before its turn arrived — never present, hand off the surface.
853
+ this.#advanceDialogQueue();
854
+ return;
855
+ }
856
+ started = true;
857
+ this.#dialogActive = true;
858
+ try {
859
+ hide = present(settle);
860
+ } catch (error) {
861
+ settled = true;
862
+ signal?.removeEventListener("abort", onAbort);
863
+ this.#dialogActive = false;
864
+ reject(error);
865
+ this.#advanceDialogQueue();
866
866
  }
867
867
  };
868
- return { promise, finish, attachAbort };
868
+
869
+ if (signal?.aborted) {
870
+ resolve(undefined);
871
+ return promise;
872
+ }
873
+ signal?.addEventListener("abort", onAbort, { once: true });
874
+
875
+ if (this.#dialogActive) {
876
+ this.#dialogQueue.push(startPresentation);
877
+ } else {
878
+ startPresentation();
879
+ }
880
+ return promise;
881
+ }
882
+
883
+ #advanceDialogQueue(): void {
884
+ this.#dialogQueue.shift()?.();
869
885
  }
870
886
  }
@@ -397,11 +397,8 @@ export class InputController {
397
397
 
398
398
  // Queue input during compaction
399
399
  if (this.ctx.session.isCompacting) {
400
- if (this.ctx.pendingImages.length > 0) {
401
- this.ctx.showStatus("Compaction in progress. Retry after it completes to send images.");
402
- return;
403
- }
404
- this.ctx.queueCompactionMessage(text, "steer");
400
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
401
+ this.ctx.queueCompactionMessage(text, "steer", images);
405
402
  return;
406
403
  }
407
404
 
@@ -636,7 +633,8 @@ export class InputController {
636
633
  // the queued entry is later re-parsed into a skill invocation is a
637
634
  // separate concern owned by the compaction-resume path.
638
635
  if (this.ctx.session.isCompacting) {
639
- this.ctx.queueCompactionMessage(text, "followUp");
636
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
637
+ this.ctx.queueCompactionMessage(text, "followUp", images);
640
638
  return;
641
639
  }
642
640
 
@@ -657,11 +655,20 @@ export class InputController {
657
655
  return;
658
656
  }
659
657
 
658
+ // Forward any pending clipboard-pasted images alongside the queued text;
659
+ // otherwise the follow-up would drop the image (mirrors the Enter/steer path).
660
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
661
+
660
662
  if (this.ctx.session.isStreaming) {
661
663
  this.ctx.editor.addToHistory(text);
662
664
  this.ctx.editor.setText("");
663
- await this.ctx.withLocalSubmission(text, () =>
664
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
665
+ this.ctx.editor.imageLinks = undefined;
666
+ this.ctx.pendingImages = [];
667
+ this.ctx.pendingImageLinks = [];
668
+ await this.ctx.withLocalSubmission(
669
+ text,
670
+ () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
671
+ { imageCount: images?.length ?? 0 },
665
672
  );
666
673
  this.ctx.updatePendingMessagesDisplay();
667
674
  this.ctx.ui.requestRender();
@@ -671,7 +678,12 @@ export class InputController {
671
678
  // Not streaming — just submit normally
672
679
  this.ctx.editor.addToHistory(text);
673
680
  this.ctx.editor.setText("");
674
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text));
681
+ this.ctx.editor.imageLinks = undefined;
682
+ this.ctx.pendingImages = [];
683
+ this.ctx.pendingImageLinks = [];
684
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
685
+ imageCount: images?.length ?? 0,
686
+ });
675
687
  }
676
688
 
677
689
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
@@ -717,10 +729,24 @@ export class InputController {
717
729
  this.ctx.pendingImageLinks.push(imageLink);
718
730
  this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
719
731
  const imageNum = this.ctx.pendingImages.length;
720
- this.ctx.editor.insertText(`[Image #${imageNum}] `);
732
+ const dims = await this.#imageDimensions(imageData);
733
+ const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
734
+ this.ctx.editor.insertText(`${label} `);
721
735
  this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
722
736
  }
723
737
 
738
+ /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the
739
+ * header can't be decoded, so the caller falls back to a bare `[Image #N]`. */
740
+ async #imageDimensions(image: ImageContent): Promise<{ width: number; height: number } | undefined> {
741
+ try {
742
+ const { width, height } = await new Bun.Image(Buffer.from(image.data, "base64")).metadata();
743
+ if (width && height) return { width, height };
744
+ } catch {
745
+ // Unknown/corrupt header — fall back to a bare label.
746
+ }
747
+ return undefined;
748
+ }
749
+
724
750
  async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise<boolean> {
725
751
  let imageData = await ensureSupportedImageInput(image);
726
752
  if (!imageData) {
@@ -802,7 +802,7 @@ export class MCPCommandController {
802
802
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
803
803
  }
804
804
  if (state === "connected") {
805
- block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
805
+ block.setStatus(theme.fg("success", `${theme.status.enabled} Connected to "${name}"`));
806
806
  } else if (state === "connecting") {
807
807
  block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
808
808
  } else {
@@ -873,10 +873,10 @@ export class MCPCommandController {
873
873
 
874
874
  // Show success message
875
875
  const scopeLabel = scope === "user" ? "user" : "project";
876
- const lines = ["", theme.fg("success", `✓ Added server "${name}" to ${scopeLabel} config`), ""];
876
+ const lines = ["", theme.fg("success", `+ Added server "${name}" to ${scopeLabel} config`), ""];
877
877
 
878
878
  if (isConnected) {
879
- lines.push(theme.fg("success", `✓ Successfully connected to server`));
879
+ lines.push(theme.fg("success", `${theme.status.enabled} Successfully connected to server`));
880
880
  lines.push("");
881
881
  } else if (isConnecting) {
882
882
  lines.push(theme.fg("muted", `◌ Server is connecting in background...`));
@@ -1096,7 +1096,7 @@ export class MCPCommandController {
1096
1096
  // Reload MCP manager
1097
1097
  await this.#reloadMCP();
1098
1098
 
1099
- this.#showMessage(["", theme.fg("success", `✓ Removed server "${name}" from ${scope} config`), ""].join("\n"));
1099
+ this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n"));
1100
1100
  } catch (error) {
1101
1101
  this.ctx.showError(`Failed to remove server: ${error instanceof Error ? error.message : String(error)}`);
1102
1102
  }
@@ -1156,7 +1156,7 @@ export class MCPCommandController {
1156
1156
 
1157
1157
  const lines = [
1158
1158
  "",
1159
- theme.fg("success", `✓ Successfully connected to "${name}"`),
1159
+ theme.fg("success", `${theme.status.enabled} Successfully connected to "${name}"`),
1160
1160
  "",
1161
1161
  ` Server: ${connection.serverInfo.name} v${connection.serverInfo.version}`,
1162
1162
  ` Tools: ${tools.length}`,
@@ -1243,12 +1243,18 @@ export class MCPCommandController {
1243
1243
  ? theme.fg("muted", "Connecting")
1244
1244
  : theme.fg("warning", "Not connected yet");
1245
1245
  this.#showMessage(
1246
- ["", theme.fg("success", `✓ Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"),
1246
+ [
1247
+ "",
1248
+ theme.fg("success", `${theme.status.enabled} Enabled "${name}"`),
1249
+ "",
1250
+ ` Status: ${status}`,
1251
+ "",
1252
+ ].join("\n"),
1247
1253
  );
1248
1254
  } else {
1249
1255
  await this.ctx.mcpManager?.disconnectServer(name);
1250
1256
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []);
1251
- this.#showMessage(["", theme.fg("success", `✓ Disabled "${name}"`), ""].join("\n"));
1257
+ this.#showMessage(["", theme.fg("muted", `${theme.status.disabled} Disabled "${name}"`), ""].join("\n"));
1252
1258
  }
1253
1259
  return;
1254
1260
  }
@@ -1279,7 +1285,9 @@ export class MCPCommandController {
1279
1285
 
1280
1286
  const lines = [
1281
1287
  "",
1282
- theme.fg("success", `✓ ${enabled ? "Enabled" : "Disabled"} "${name}" (${found.scope} config)`),
1288
+ enabled
1289
+ ? theme.fg("success", `${theme.status.enabled} Enabled "${name}" (${found.scope} config)`)
1290
+ : theme.fg("muted", `${theme.status.disabled} Disabled "${name}" (${found.scope} config)`),
1283
1291
  ];
1284
1292
  if (status) {
1285
1293
  lines.push("");
@@ -1317,7 +1325,7 @@ export class MCPCommandController {
1317
1325
  await this.#reloadMCP();
1318
1326
 
1319
1327
  this.#showMessage(
1320
- ["", theme.fg("success", `✓ Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1328
+ ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1321
1329
  );
1322
1330
  } catch (error) {
1323
1331
  this.ctx.showError(`Failed to clear auth: ${error instanceof Error ? error.message : String(error)}`);
@@ -1411,7 +1419,12 @@ export class MCPCommandController {
1411
1419
  await this.#reloadMCP();
1412
1420
  const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0;
1413
1421
  this.#showMessage(
1414
- ["", theme.fg("success", "✓ MCP reload complete"), ` Connected servers: ${connectedCount}`, ""].join("\n"),
1422
+ [
1423
+ "",
1424
+ theme.fg("success", `${theme.icon.loop} MCP reload complete`),
1425
+ ` Connected servers: ${connectedCount}`,
1426
+ "",
1427
+ ].join("\n"),
1415
1428
  );
1416
1429
  } catch (error) {
1417
1430
  this.ctx.showError(`Failed to reload MCP: ${error instanceof Error ? error.message : String(error)}`);
@@ -1442,9 +1455,12 @@ export class MCPCommandController {
1442
1455
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1443
1456
  const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name);
1444
1457
  this.#showMessage(
1445
- ["\n", theme.fg("success", `✓ Reconnected to "${name}"`), ` Tools: ${serverTools.length}`, "\n"].join(
1458
+ [
1446
1459
  "\n",
1447
- ),
1460
+ theme.fg("success", `${theme.status.enabled} Reconnected to "${name}"`),
1461
+ ` Tools: ${serverTools.length}`,
1462
+ "\n",
1463
+ ].join("\n"),
1448
1464
  );
1449
1465
  } else {
1450
1466
  this.ctx.showError(`Failed to reconnect to "${name}". Check server status and logs.`);
@@ -1,6 +1,7 @@
1
1
  import { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
- import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
3
- import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
2
+ import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
4
+ import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
4
5
  import type { Component, OverlayHandle } from "@oh-my-pi/pi-tui";
5
6
  import { Input, Loader, Spacer, Text } from "@oh-my-pi/pi-tui";
6
7
  import { getAgentDbPath, getProjectDir, normalizePathForComparison } from "@oh-my-pi/pi-utils";
@@ -57,14 +58,6 @@ import type { SessionObserverRegistry } from "../session-observer-registry";
57
58
  import { computeContextBreakdown } from "../utils/context-usage";
58
59
  import { buildCopyTargets } from "../utils/copy-targets";
59
60
 
60
- const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
61
- "anthropic",
62
- "openai-codex",
63
- "gitlab-duo",
64
- "google-gemini-cli",
65
- "google-antigravity",
66
- ]);
67
-
68
61
  const MANUAL_LOGIN_TIP = "Tip: You can complete pairing with /login <redirect URL>.";
69
62
 
70
63
  export class SelectorController {
@@ -928,7 +921,7 @@ export class SelectorController {
928
921
  async #handleOAuthLogin(providerId: string): Promise<void> {
929
922
  this.ctx.showStatus(`Logging in to ${providerId}…`);
930
923
  const manualInput = this.ctx.oauthManualInput;
931
- const useManualInput = CALLBACK_SERVER_PROVIDERS.has(providerId as OAuthProvider);
924
+ const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
932
925
  try {
933
926
  await this.ctx.session.modelRegistry.authStorage.login(providerId as OAuthProvider, {
934
927
  onAuth: (info: { url: string; instructions?: string }) => {
@@ -209,7 +209,7 @@ export class SSHCommandController {
209
209
  const scopeLabel = scope === "user" ? "user" : "project";
210
210
  const lines = [
211
211
  "",
212
- theme.fg("success", `✓ Added SSH host "${name}" to ${scopeLabel} config`),
212
+ theme.fg("success", `+ Added SSH host "${name}" to ${scopeLabel} config`),
213
213
  "",
214
214
  ` Host: ${host}`,
215
215
  ];
@@ -368,7 +368,7 @@ export class SSHCommandController {
368
368
  await this.ctx.session.refreshSshTool();
369
369
 
370
370
  this.#showMessage(
371
- ["", theme.fg("success", `✓ Removed SSH host "${name}" from ${scope} config`), ""].join("\n"),
371
+ ["", theme.fg("success", `- Removed SSH host "${name}" from ${scope} config`), ""].join("\n"),
372
372
  );
373
373
  } catch (error) {
374
374
  this.ctx.showError(`Failed to remove host: ${error instanceof Error ? error.message : String(error)}`);
@@ -3,30 +3,36 @@ import { logger } from "@oh-my-pi/pi-utils";
3
3
  import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
4
4
  import { fileHyperlink } from "../tui/hyperlink";
5
5
 
6
- const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g;
6
+ /** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens.
7
+ * Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata
8
+ * tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */
9
+ export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g;
7
10
 
8
11
  type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
9
12
  type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
10
13
 
11
- export interface ImageReferenceRenderers {
14
+ export type PlaceholderKind = "image" | "paste";
15
+
16
+ export interface PlaceholderRenderers {
12
17
  renderText: (text: string) => string;
13
- renderReference: (label: string, index: number) => string;
18
+ renderReference: (label: string, kind: PlaceholderKind, index: number) => string;
14
19
  }
15
20
 
16
- export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string {
17
- IMAGE_REFERENCE_REGEX.lastIndex = 0;
21
+ export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string {
22
+ PLACEHOLDER_REGEX.lastIndex = 0;
18
23
  let result = "";
19
24
  let last = 0;
20
25
  let matched = false;
21
26
 
22
27
  for (;;) {
23
- const match = IMAGE_REFERENCE_REGEX.exec(text);
28
+ const match = PLACEHOLDER_REGEX.exec(text);
24
29
  if (match === null) break;
25
30
  matched = true;
26
31
  if (match.index > last) {
27
32
  result += renderers.renderText(text.slice(last, match.index));
28
33
  }
29
- result += renderers.renderReference(match[0], Number(match[1]));
34
+ const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image";
35
+ result += renderers.renderReference(match[0], kind, Number(match[2]));
30
36
  last = match.index + match[0].length;
31
37
  }
32
38
 
@@ -2806,8 +2806,8 @@ export class InteractiveMode implements InteractiveModeContext {
2806
2806
  this.#uiHelpers.updatePendingMessagesDisplay();
2807
2807
  }
2808
2808
 
2809
- queueCompactionMessage(text: string, mode: "steer" | "followUp"): void {
2810
- this.#uiHelpers.queueCompactionMessage(text, mode);
2809
+ queueCompactionMessage(text: string, mode: "steer" | "followUp", images?: ImageContent[]): void {
2810
+ this.#uiHelpers.queueCompactionMessage(text, mode, images);
2811
2811
  }
2812
2812
 
2813
2813
  flushCompactionQueue(options?: { willRetry?: boolean }): Promise<void> {
@@ -10,7 +10,7 @@
10
10
  * - Events: AgentSessionEvent objects streamed as they occur
11
11
  * - Extension UI: Extension UI requests are emitted, client responds with extension_ui_response
12
12
  */
13
- import { getOAuthProviders } from "@oh-my-pi/pi-ai/utils/oauth";
13
+ import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
14
14
  import { $env, readJsonl, Snowflake } from "@oh-my-pi/pi-utils";
15
15
  import {
16
16
  type ExtensionUIContext,
@@ -1,20 +1,12 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
- import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
2
+ import { PASTE_CODE_LOGIN_PROVIDERS } from "@oh-my-pi/pi-ai";
3
+ import type { OAuthProvider } from "@oh-my-pi/pi-ai/oauth/types";
3
4
  import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
4
5
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
5
6
  import { OAuthSelectorComponent } from "../../components/oauth-selector";
6
7
  import { theme } from "../../theme/theme";
7
8
  import type { SetupSceneHost, SetupTab } from "./types";
8
9
 
9
- /** Providers whose OAuth flow needs a pasted code/redirect URL rather than a callback server. */
10
- const CALLBACK_SERVER_PROVIDERS: Partial<Record<OAuthProvider, true>> = {
11
- anthropic: true,
12
- "openai-codex": true,
13
- "gitlab-duo": true,
14
- "google-gemini-cli": true,
15
- "google-antigravity": true,
16
- };
17
-
18
10
  function loginUrlLink(url: string): string {
19
11
  return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`;
20
12
  }
@@ -119,7 +111,7 @@ export class SignInTab implements SetupTab {
119
111
 
120
112
  async #login(providerId: string): Promise<void> {
121
113
  if (this.#loggingInProvider || this.#disposed) return;
122
- const useManualInput = CALLBACK_SERVER_PROVIDERS[providerId as OAuthProvider] === true;
114
+ const useManualInput = PASTE_CODE_LOGIN_PROVIDERS.has(providerId);
123
115
  this.#selector.stopValidation();
124
116
  this.#loggingInProvider = providerId;
125
117
  this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];