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

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 (165) hide show
  1. package/CHANGELOG.md +74 -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/exa/index.d.ts +1 -19
  19. package/dist/types/exa/mcp-client.d.ts +10 -3
  20. package/dist/types/exa/types.d.ts +0 -83
  21. package/dist/types/export/ttsr.d.ts +14 -0
  22. package/dist/types/extensibility/extensions/types.d.ts +8 -1
  23. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +1 -1
  24. package/dist/types/internal-urls/local-protocol.d.ts +10 -0
  25. package/dist/types/mcp/oauth-flow.d.ts +2 -2
  26. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  27. package/dist/types/modes/components/{status-line.d.ts → status-line/component.d.ts} +2 -32
  28. package/dist/types/modes/components/status-line/index.d.ts +1 -0
  29. package/dist/types/modes/components/status-line/types.d.ts +31 -2
  30. package/dist/types/modes/controllers/mcp-command-controller.d.ts +8 -0
  31. package/dist/types/modes/image-references.d.ts +8 -3
  32. package/dist/types/modes/interactive-mode.d.ts +9 -1
  33. package/dist/types/modes/theme/theme.d.ts +2 -1
  34. package/dist/types/modes/types.d.ts +3 -1
  35. package/dist/types/modes/utils/ui-helpers.d.ts +2 -2
  36. package/dist/types/session/agent-session.d.ts +0 -2
  37. package/dist/types/task/render.d.ts +1 -0
  38. package/dist/types/tools/ask.d.ts +1 -0
  39. package/dist/types/tools/browser/tab-worker.d.ts +15 -0
  40. package/dist/types/tools/index.d.ts +17 -2
  41. package/dist/types/tools/render-utils.d.ts +1 -1
  42. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  43. package/dist/types/utils/block-context.d.ts +35 -0
  44. package/dist/types/utils/git.d.ts +6 -0
  45. package/dist/types/utils/image-loading.d.ts +12 -0
  46. package/package.json +29 -9
  47. package/src/capability/rule-buckets.ts +4 -2
  48. package/src/capability/rule.ts +10 -1
  49. package/src/cli/auth-broker-cli.ts +6 -7
  50. package/src/cli/auth-gateway-cli.ts +4 -3
  51. package/src/cli/list-models.ts +5 -0
  52. package/src/cli/update-cli.ts +138 -16
  53. package/src/commit/agentic/tools/split-commit.ts +8 -1
  54. package/src/config/model-provider-priority.ts +1 -0
  55. package/src/config/model-registry.ts +81 -2
  56. package/src/debug/index.ts +4 -8
  57. package/src/discovery/at-imports.ts +273 -0
  58. package/src/discovery/builtin-rules/index.ts +4 -0
  59. package/src/discovery/builtin-rules/ts-no-test-timers.md +55 -0
  60. package/src/discovery/builtin-rules/ts-redundant-clear-guard.md +75 -0
  61. package/src/discovery/helpers.ts +2 -1
  62. package/src/edit/diff.ts +114 -4
  63. package/src/edit/hashline/diff.ts +1 -1
  64. package/src/edit/hashline/execute.ts +1 -1
  65. package/src/edit/modes/patch.ts +6 -2
  66. package/src/edit/modes/replace.ts +1 -1
  67. package/src/edit/renderer.ts +12 -2
  68. package/src/eval/__tests__/helpers-local-roots.test.ts +58 -0
  69. package/src/eval/backend.ts +15 -0
  70. package/src/eval/js/context-manager.ts +4 -2
  71. package/src/eval/js/executor.ts +3 -0
  72. package/src/eval/js/index.ts +7 -1
  73. package/src/eval/js/shared/helpers.ts +53 -6
  74. package/src/eval/js/shared/runtime.ts +8 -0
  75. package/src/eval/js/worker-core.ts +1 -0
  76. package/src/eval/js/worker-protocol.ts +6 -0
  77. package/src/eval/py/executor.ts +12 -0
  78. package/src/eval/py/index.ts +7 -1
  79. package/src/eval/py/prelude.py +43 -4
  80. package/src/eval/py/runner.py +1 -0
  81. package/src/exa/index.ts +1 -26
  82. package/src/exa/mcp-client.ts +10 -10
  83. package/src/exa/types.ts +0 -97
  84. package/src/export/ttsr.ts +122 -1
  85. package/src/extensibility/extensions/types.ts +8 -1
  86. package/src/extensibility/legacy-pi-ai-shim.ts +1 -1
  87. package/src/extensibility/plugins/doctor.ts +1 -1
  88. package/src/extensibility/plugins/legacy-pi-compat.ts +6 -5
  89. package/src/goals/tools/goal-tool.ts +1 -1
  90. package/src/internal-urls/docs-index.generated.ts +7 -6
  91. package/src/internal-urls/local-protocol.ts +13 -0
  92. package/src/lsp/render.ts +8 -6
  93. package/src/mcp/oauth-flow.ts +3 -3
  94. package/src/mcp/render.ts +7 -1
  95. package/src/modes/components/agent-dashboard.ts +6 -4
  96. package/src/modes/components/custom-editor.ts +12 -6
  97. package/src/modes/components/login-dialog.ts +1 -1
  98. package/src/modes/components/oauth-selector.ts +4 -4
  99. package/src/modes/components/read-tool-group.ts +10 -3
  100. package/src/modes/components/{status-line.ts → status-line/component.ts} +18 -40
  101. package/src/modes/components/status-line/index.ts +1 -0
  102. package/src/modes/components/status-line/types.ts +23 -8
  103. package/src/modes/components/tool-execution.ts +1 -1
  104. package/src/modes/components/transcript-container.ts +17 -10
  105. package/src/modes/components/user-message.ts +6 -3
  106. package/src/modes/components/welcome.ts +1 -1
  107. package/src/modes/controllers/event-controller.ts +8 -0
  108. package/src/modes/controllers/extension-ui-controller.ts +143 -127
  109. package/src/modes/controllers/input-controller.ts +60 -11
  110. package/src/modes/controllers/mcp-command-controller.ts +52 -17
  111. package/src/modes/controllers/selector-controller.ts +4 -11
  112. package/src/modes/controllers/ssh-command-controller.ts +2 -2
  113. package/src/modes/image-references.ts +13 -7
  114. package/src/modes/interactive-mode.ts +35 -3
  115. package/src/modes/rpc/rpc-mode.ts +1 -1
  116. package/src/modes/setup-wizard/scenes/sign-in.ts +3 -11
  117. package/src/modes/theme/theme.ts +95 -1
  118. package/src/modes/types.ts +3 -1
  119. package/src/modes/utils/ui-helpers.ts +14 -5
  120. package/src/prompts/tools/bash.md +1 -1
  121. package/src/prompts/tools/eval.md +4 -4
  122. package/src/sdk.ts +31 -14
  123. package/src/session/agent-session.ts +290 -196
  124. package/src/session/session-manager.ts +1 -1
  125. package/src/slash-commands/builtin-registry.ts +9 -1
  126. package/src/system-prompt.ts +15 -9
  127. package/src/task/index.ts +9 -1
  128. package/src/task/render.ts +36 -14
  129. package/src/tools/ask.ts +14 -5
  130. package/src/tools/bash-interactive.ts +1 -1
  131. package/src/tools/bash.ts +14 -2
  132. package/src/tools/browser/render.ts +5 -2
  133. package/src/tools/browser/tab-worker.ts +211 -91
  134. package/src/tools/debug.ts +5 -2
  135. package/src/tools/eval-render.ts +6 -3
  136. package/src/tools/eval.ts +1 -1
  137. package/src/tools/gh-renderer.ts +29 -15
  138. package/src/tools/index.ts +32 -4
  139. package/src/tools/inspect-image-renderer.ts +12 -5
  140. package/src/tools/job.ts +9 -6
  141. package/src/tools/memory-render.ts +19 -5
  142. package/src/tools/read.ts +165 -18
  143. package/src/tools/render-utils.ts +3 -1
  144. package/src/tools/resolve.ts +1 -1
  145. package/src/tools/review.ts +1 -1
  146. package/src/tools/ssh.ts +4 -1
  147. package/src/tools/todo.ts +8 -1
  148. package/src/tools/tool-timeouts.ts +1 -1
  149. package/src/tools/write.ts +1 -1
  150. package/src/tui/code-cell.ts +1 -1
  151. package/src/utils/block-context.ts +312 -0
  152. package/src/utils/git.ts +41 -0
  153. package/src/utils/image-loading.ts +31 -1
  154. package/src/web/search/providers/codex.ts +1 -1
  155. package/src/web/search/render.ts +14 -6
  156. package/dist/types/exa/factory.d.ts +0 -13
  157. package/dist/types/exa/render.d.ts +0 -19
  158. package/dist/types/exa/researcher.d.ts +0 -9
  159. package/dist/types/exa/search.d.ts +0 -9
  160. package/dist/types/exa/websets.d.ts +0 -9
  161. package/src/exa/factory.ts +0 -60
  162. package/src/exa/render.ts +0 -244
  163. package/src/exa/researcher.ts +0 -36
  164. package/src/exa/search.ts +0 -47
  165. package/src/exa/websets.ts +0 -248
@@ -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
  }
@@ -32,6 +32,15 @@ function isExpandable(obj: unknown): obj is Expandable {
32
32
  return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
33
33
  }
34
34
 
35
+ /** Minimal contract for any component that can receive a paste payload directly. */
36
+ interface PasteTarget {
37
+ pasteText(text: string): void;
38
+ }
39
+
40
+ function hasPasteText(value: unknown): value is PasteTarget {
41
+ return typeof value === "object" && value !== null && typeof (value as PasteTarget).pasteText === "function";
42
+ }
43
+
35
44
  const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000;
36
45
  // A cached model fires its file-load events in a short burst and then goes silent
37
46
  // while onnxruntime builds the session; a genuine download keeps streaming progress
@@ -250,10 +259,24 @@ export class InputController {
250
259
  this.#enhancedPaste = new EnhancedPasteController({
251
260
  write: data => this.ctx.ui.terminal.write(data),
252
261
  pasteText: text => {
253
- this.ctx.editor.pasteText(text);
262
+ // Route enhanced-paste text to the currently focused component when it
263
+ // exposes a `pasteText` hook (modal Input prompts: OAuth API-key entry,
264
+ // Perplexity OTP, GitHub Enterprise URL, manual redirect URL). Falling
265
+ // back to the main editor would have buried the text in the detached
266
+ // editor while the modal Input had focus (#2127).
267
+ const focused = this.ctx.ui.getFocused();
268
+ const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
269
+ target.pasteText(text);
254
270
  this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
255
271
  },
256
272
  pasteImage: async image => {
273
+ // Images can only land in the main editor — when a modal Input is
274
+ // focused, refuse rather than dump the binary blob in a hidden buffer.
275
+ const focused = this.ctx.ui.getFocused();
276
+ if (focused && focused !== this.ctx.editor && hasPasteText(focused)) {
277
+ this.ctx.showStatus("Image paste is not supported in this prompt");
278
+ return;
279
+ }
257
280
  await this.#normalizeAndInsertPastedImage(image, `Unsupported pasted image format: ${image.mimeType}`);
258
281
  },
259
282
  showStatus: message => this.ctx.showStatus(message),
@@ -397,11 +420,8 @@ export class InputController {
397
420
 
398
421
  // Queue input during compaction
399
422
  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");
423
+ const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
424
+ this.ctx.queueCompactionMessage(text, "steer", images);
405
425
  return;
406
426
  }
407
427
 
@@ -636,7 +656,8 @@ export class InputController {
636
656
  // the queued entry is later re-parsed into a skill invocation is a
637
657
  // separate concern owned by the compaction-resume path.
638
658
  if (this.ctx.session.isCompacting) {
639
- this.ctx.queueCompactionMessage(text, "followUp");
659
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
660
+ this.ctx.queueCompactionMessage(text, "followUp", images);
640
661
  return;
641
662
  }
642
663
 
@@ -657,11 +678,20 @@ export class InputController {
657
678
  return;
658
679
  }
659
680
 
681
+ // Forward any pending clipboard-pasted images alongside the queued text;
682
+ // otherwise the follow-up would drop the image (mirrors the Enter/steer path).
683
+ const images = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
684
+
660
685
  if (this.ctx.session.isStreaming) {
661
686
  this.ctx.editor.addToHistory(text);
662
687
  this.ctx.editor.setText("");
663
- await this.ctx.withLocalSubmission(text, () =>
664
- this.ctx.session.prompt(text, { streamingBehavior: "followUp" }),
688
+ this.ctx.editor.imageLinks = undefined;
689
+ this.ctx.pendingImages = [];
690
+ this.ctx.pendingImageLinks = [];
691
+ await this.ctx.withLocalSubmission(
692
+ text,
693
+ () => this.ctx.session.prompt(text, { streamingBehavior: "followUp", images }),
694
+ { imageCount: images?.length ?? 0 },
665
695
  );
666
696
  this.ctx.updatePendingMessagesDisplay();
667
697
  this.ctx.ui.requestRender();
@@ -671,7 +701,12 @@ export class InputController {
671
701
  // Not streaming — just submit normally
672
702
  this.ctx.editor.addToHistory(text);
673
703
  this.ctx.editor.setText("");
674
- await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text));
704
+ this.ctx.editor.imageLinks = undefined;
705
+ this.ctx.pendingImages = [];
706
+ this.ctx.pendingImageLinks = [];
707
+ await this.ctx.withLocalSubmission(text, () => this.ctx.session.prompt(text, { images }), {
708
+ imageCount: images?.length ?? 0,
709
+ });
675
710
  }
676
711
 
677
712
  restoreQueuedMessagesToEditor(options?: { abort?: boolean; currentText?: string }): number {
@@ -717,10 +752,24 @@ export class InputController {
717
752
  this.ctx.pendingImageLinks.push(imageLink);
718
753
  this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
719
754
  const imageNum = this.ctx.pendingImages.length;
720
- this.ctx.editor.insertText(`[Image #${imageNum}] `);
755
+ const dims = await this.#imageDimensions(imageData);
756
+ const label = dims ? `[Image #${imageNum}, ${dims.width}x${dims.height}]` : `[Image #${imageNum}]`;
757
+ this.ctx.editor.insertText(`${label} `);
721
758
  this.ctx.ui.requestRender(false, { allowUnknownViewportMutation: true });
722
759
  }
723
760
 
761
+ /** Probe pixel dimensions for the marker label (`[Image #N, WxH]`). Returns undefined when the
762
+ * header can't be decoded, so the caller falls back to a bare `[Image #N]`. */
763
+ async #imageDimensions(image: ImageContent): Promise<{ width: number; height: number } | undefined> {
764
+ try {
765
+ const { width, height } = await new Bun.Image(Buffer.from(image.data, "base64")).metadata();
766
+ if (width && height) return { width, height };
767
+ } catch {
768
+ // Unknown/corrupt header — fall back to a bare label.
769
+ }
770
+ return undefined;
771
+ }
772
+
724
773
  async #normalizeAndInsertPastedImage(image: ImageContent, unsupportedMessage: string): Promise<boolean> {
725
774
  let imageData = await ensureSupportedImageInput(image);
726
775
  if (!imageData) {
@@ -4,7 +4,7 @@
4
4
  * Handles /mcp subcommands for managing MCP servers.
5
5
  */
6
6
  import * as path from "node:path";
7
- import { Spacer, Text } from "@oh-my-pi/pi-tui";
7
+ import { type Component, replaceTabs, Spacer, Text } from "@oh-my-pi/pi-tui";
8
8
  import { getMCPConfigPath, getProjectDir } from "@oh-my-pi/pi-utils";
9
9
  import type { SourceMeta } from "../../capability/types";
10
10
  import { analyzeAuthError, discoverOAuthEndpoints, MCPManager } from "../../mcp";
@@ -36,6 +36,7 @@ import {
36
36
  import type { MCPAuthConfig, MCPServerConfig, MCPServerConnection } from "../../mcp/types";
37
37
  import type { OAuthCredential } from "../../session/auth-storage";
38
38
  import { shortenPath } from "../../tools/render-utils";
39
+ import { urlHyperlink } from "../../tui";
39
40
  import { openPath } from "../../utils/open";
40
41
  import { ChatBlock } from "../components/chat-block";
41
42
  import { MCPAddWizard } from "../components/mcp-add-wizard";
@@ -51,6 +52,26 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string)
51
52
  return Promise.race([promise, timeoutPromise]).finally(() => clearTimeout(timer));
52
53
  }
53
54
 
55
+ /** Renders the MCP OAuth fallback URL without hard-wrapping the copy target. */
56
+ export class MCPAuthorizationLinkPrompt implements Component {
57
+ readonly #url: string;
58
+
59
+ constructor(url: string) {
60
+ this.#url = url;
61
+ }
62
+
63
+ invalidate(): void {}
64
+
65
+ render(_width: number): string[] {
66
+ const link = urlHyperlink(this.#url, "Click here to authorize");
67
+ return [
68
+ ` ${theme.fg("success", "Open authorization URL:")}`,
69
+ ` ${theme.fg("accent", link)}`,
70
+ ` ${theme.fg("muted", `Copy URL: ${replaceTabs(this.#url)}`)}`,
71
+ ];
72
+ }
73
+ }
74
+
54
75
  /**
55
76
  * Animated "Connecting to …" transcript block. Owns its spinner interval: it
56
77
  * starts on mount and is cleared on {@link ChatBlock.finish}/dispose, so callers
@@ -610,15 +631,13 @@ export class MCPCommandController {
610
631
  block.addChild(new Text(theme.fg("success", "→ Opening browser automatically..."), 1, 0));
611
632
  block.addChild(new Spacer(1));
612
633
  block.addChild(new Text(theme.fg("muted", "Alternative if browser did not open:"), 1, 0));
613
- block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
614
- block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
634
+ block.addChild(new MCPAuthorizationLinkPrompt(info.url));
615
635
  this.ctx.ui.requestRender();
616
636
  } catch (_error) {
617
637
  // Show error if browser doesn't open
618
638
  block.addChild(new Spacer(1));
619
639
  block.addChild(new Text(theme.fg("warning", "→ Could not open browser automatically"), 1, 0));
620
- block.addChild(new Text(theme.fg("success", "Copy this exact URL in your browser:"), 1, 0));
621
- block.addChild(new Text(theme.fg("accent", info.url), 1, 0));
640
+ block.addChild(new MCPAuthorizationLinkPrompt(info.url));
622
641
  this.ctx.ui.requestRender();
623
642
  }
624
643
  },
@@ -802,7 +821,7 @@ export class MCPCommandController {
802
821
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
803
822
  }
804
823
  if (state === "connected") {
805
- block.setStatus(theme.fg("success", `✓ Connected to "${name}"`));
824
+ block.setStatus(theme.fg("success", `${theme.status.enabled} Connected to "${name}"`));
806
825
  } else if (state === "connecting") {
807
826
  block.setStatus(theme.fg("muted", `◌ "${name}" is still connecting...`));
808
827
  } else {
@@ -873,10 +892,10 @@ export class MCPCommandController {
873
892
 
874
893
  // Show success message
875
894
  const scopeLabel = scope === "user" ? "user" : "project";
876
- const lines = ["", theme.fg("success", `✓ Added server "${name}" to ${scopeLabel} config`), ""];
895
+ const lines = ["", theme.fg("success", `+ Added server "${name}" to ${scopeLabel} config`), ""];
877
896
 
878
897
  if (isConnected) {
879
- lines.push(theme.fg("success", `✓ Successfully connected to server`));
898
+ lines.push(theme.fg("success", `${theme.status.enabled} Successfully connected to server`));
880
899
  lines.push("");
881
900
  } else if (isConnecting) {
882
901
  lines.push(theme.fg("muted", `◌ Server is connecting in background...`));
@@ -1096,7 +1115,7 @@ export class MCPCommandController {
1096
1115
  // Reload MCP manager
1097
1116
  await this.#reloadMCP();
1098
1117
 
1099
- this.#showMessage(["", theme.fg("success", `✓ Removed server "${name}" from ${scope} config`), ""].join("\n"));
1118
+ this.#showMessage(["", theme.fg("success", `- Removed server "${name}" from ${scope} config`), ""].join("\n"));
1100
1119
  } catch (error) {
1101
1120
  this.ctx.showError(`Failed to remove server: ${error instanceof Error ? error.message : String(error)}`);
1102
1121
  }
@@ -1156,7 +1175,7 @@ export class MCPCommandController {
1156
1175
 
1157
1176
  const lines = [
1158
1177
  "",
1159
- theme.fg("success", `✓ Successfully connected to "${name}"`),
1178
+ theme.fg("success", `${theme.status.enabled} Successfully connected to "${name}"`),
1160
1179
  "",
1161
1180
  ` Server: ${connection.serverInfo.name} v${connection.serverInfo.version}`,
1162
1181
  ` Tools: ${tools.length}`,
@@ -1243,12 +1262,18 @@ export class MCPCommandController {
1243
1262
  ? theme.fg("muted", "Connecting")
1244
1263
  : theme.fg("warning", "Not connected yet");
1245
1264
  this.#showMessage(
1246
- ["", theme.fg("success", `✓ Enabled "${name}"`), "", ` Status: ${status}`, ""].join("\n"),
1265
+ [
1266
+ "",
1267
+ theme.fg("success", `${theme.status.enabled} Enabled "${name}"`),
1268
+ "",
1269
+ ` Status: ${status}`,
1270
+ "",
1271
+ ].join("\n"),
1247
1272
  );
1248
1273
  } else {
1249
1274
  await this.ctx.mcpManager?.disconnectServer(name);
1250
1275
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager?.getTools() ?? []);
1251
- this.#showMessage(["", theme.fg("success", `✓ Disabled "${name}"`), ""].join("\n"));
1276
+ this.#showMessage(["", theme.fg("muted", `${theme.status.disabled} Disabled "${name}"`), ""].join("\n"));
1252
1277
  }
1253
1278
  return;
1254
1279
  }
@@ -1279,7 +1304,9 @@ export class MCPCommandController {
1279
1304
 
1280
1305
  const lines = [
1281
1306
  "",
1282
- theme.fg("success", `✓ ${enabled ? "Enabled" : "Disabled"} "${name}" (${found.scope} config)`),
1307
+ enabled
1308
+ ? theme.fg("success", `${theme.status.enabled} Enabled "${name}" (${found.scope} config)`)
1309
+ : theme.fg("muted", `${theme.status.disabled} Disabled "${name}" (${found.scope} config)`),
1283
1310
  ];
1284
1311
  if (status) {
1285
1312
  lines.push("");
@@ -1317,7 +1344,7 @@ export class MCPCommandController {
1317
1344
  await this.#reloadMCP();
1318
1345
 
1319
1346
  this.#showMessage(
1320
- ["", theme.fg("success", `✓ Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1347
+ ["", theme.fg("success", `- Cleared auth for "${name}" (${found.scope} config)`), ""].join("\n"),
1321
1348
  );
1322
1349
  } catch (error) {
1323
1350
  this.ctx.showError(`Failed to clear auth: ${error instanceof Error ? error.message : String(error)}`);
@@ -1411,7 +1438,12 @@ export class MCPCommandController {
1411
1438
  await this.#reloadMCP();
1412
1439
  const connectedCount = this.ctx.mcpManager?.getConnectedServers().length ?? 0;
1413
1440
  this.#showMessage(
1414
- ["", theme.fg("success", "✓ MCP reload complete"), ` Connected servers: ${connectedCount}`, ""].join("\n"),
1441
+ [
1442
+ "",
1443
+ theme.fg("success", `${theme.icon.loop} MCP reload complete`),
1444
+ ` Connected servers: ${connectedCount}`,
1445
+ "",
1446
+ ].join("\n"),
1415
1447
  );
1416
1448
  } catch (error) {
1417
1449
  this.ctx.showError(`Failed to reload MCP: ${error instanceof Error ? error.message : String(error)}`);
@@ -1442,9 +1474,12 @@ export class MCPCommandController {
1442
1474
  await this.ctx.session.refreshMCPTools(this.ctx.mcpManager.getTools());
1443
1475
  const serverTools = this.ctx.mcpManager.getTools().filter(t => t.mcpServerName === name);
1444
1476
  this.#showMessage(
1445
- ["\n", theme.fg("success", `✓ Reconnected to "${name}"`), ` Tools: ${serverTools.length}`, "\n"].join(
1477
+ [
1446
1478
  "\n",
1447
- ),
1479
+ theme.fg("success", `${theme.status.enabled} Reconnected to "${name}"`),
1480
+ ` Tools: ${serverTools.length}`,
1481
+ "\n",
1482
+ ].join("\n"),
1448
1483
  );
1449
1484
  } else {
1450
1485
  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)}`);