@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.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 (109) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  4. package/dist/types/commands/dry-balance.d.ts +31 -0
  5. package/dist/types/config/model-registry.d.ts +2 -0
  6. package/dist/types/config/models-config-schema.d.ts +3 -0
  7. package/dist/types/config/settings-schema.d.ts +13 -4
  8. package/dist/types/config/settings.d.ts +11 -0
  9. package/dist/types/discovery/helpers.d.ts +1 -0
  10. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  11. package/dist/types/hindsight/bank.d.ts +17 -9
  12. package/dist/types/hindsight/mental-models.d.ts +1 -1
  13. package/dist/types/hindsight/state.d.ts +9 -3
  14. package/dist/types/mcp/manager.d.ts +1 -1
  15. package/dist/types/modes/components/assistant-message.d.ts +11 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  17. package/dist/types/modes/components/error-banner.d.ts +11 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +4 -2
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/image-references.d.ts +17 -0
  22. package/dist/types/modes/interactive-mode.d.ts +7 -0
  23. package/dist/types/modes/types.d.ts +7 -0
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  25. package/dist/types/session/agent-session.d.ts +9 -0
  26. package/dist/types/session/auth-storage.d.ts +2 -2
  27. package/dist/types/session/blob-store.d.ts +12 -11
  28. package/dist/types/session/session-manager.d.ts +5 -3
  29. package/dist/types/system-prompt.d.ts +2 -0
  30. package/dist/types/task/types.d.ts +2 -0
  31. package/dist/types/tiny/title-client.d.ts +16 -1
  32. package/dist/types/tool-discovery/mode.d.ts +8 -0
  33. package/dist/types/tools/archive-reader.d.ts +5 -1
  34. package/dist/types/tools/index.d.ts +16 -0
  35. package/dist/types/tools/path-utils.d.ts +11 -0
  36. package/dist/types/tui/hyperlink.d.ts +12 -0
  37. package/dist/types/web/search/render.d.ts +1 -2
  38. package/package.json +9 -9
  39. package/src/cli/classify-install-target.ts +31 -5
  40. package/src/cli/dry-balance-cli.ts +823 -0
  41. package/src/cli/plugin-cli.ts +45 -0
  42. package/src/cli/web-search-cli.ts +0 -1
  43. package/src/cli-commands.ts +1 -0
  44. package/src/commands/dry-balance.ts +43 -0
  45. package/src/config/model-registry.ts +60 -4
  46. package/src/config/models-config-schema.ts +2 -0
  47. package/src/config/settings-schema.ts +14 -4
  48. package/src/config/settings.ts +38 -0
  49. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  50. package/src/discovery/github.ts +37 -1
  51. package/src/discovery/helpers.ts +3 -1
  52. package/src/eval/__tests__/agent-bridge.test.ts +72 -0
  53. package/src/eval/py/tool-bridge.ts +43 -5
  54. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  55. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  56. package/src/hindsight/backend.ts +184 -35
  57. package/src/hindsight/bank.ts +32 -22
  58. package/src/hindsight/mental-models.ts +1 -1
  59. package/src/hindsight/state.ts +21 -7
  60. package/src/internal-urls/docs-index.generated.ts +6 -6
  61. package/src/internal-urls/omp-protocol.ts +8 -2
  62. package/src/main.ts +7 -1
  63. package/src/mcp/manager.ts +40 -21
  64. package/src/modes/components/assistant-message.ts +22 -0
  65. package/src/modes/components/custom-editor.ts +14 -2
  66. package/src/modes/components/error-banner.ts +33 -0
  67. package/src/modes/components/tool-execution.ts +44 -0
  68. package/src/modes/components/transcript-container.ts +102 -30
  69. package/src/modes/components/tree-selector.ts +29 -2
  70. package/src/modes/components/user-message.ts +9 -2
  71. package/src/modes/controllers/event-controller.ts +42 -3
  72. package/src/modes/controllers/input-controller.ts +41 -3
  73. package/src/modes/image-references.ts +111 -0
  74. package/src/modes/interactive-mode.ts +48 -13
  75. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  76. package/src/modes/types.ts +10 -1
  77. package/src/modes/utils/ui-helpers.ts +23 -2
  78. package/src/prompts/agents/explore.md +1 -0
  79. package/src/prompts/agents/librarian.md +1 -0
  80. package/src/prompts/ci-green-request.md +5 -3
  81. package/src/prompts/dry-balance-bench.md +8 -0
  82. package/src/prompts/system/project-prompt.md +1 -0
  83. package/src/sdk.ts +99 -18
  84. package/src/session/agent-session.ts +103 -19
  85. package/src/session/auth-storage.ts +4 -0
  86. package/src/session/blob-store.ts +96 -9
  87. package/src/session/session-manager.ts +19 -10
  88. package/src/system-prompt.ts +4 -0
  89. package/src/task/executor.ts +6 -2
  90. package/src/task/index.ts +8 -7
  91. package/src/task/types.ts +2 -0
  92. package/src/tiny/title-client.ts +7 -1
  93. package/src/tool-discovery/mode.ts +24 -0
  94. package/src/tools/archive-reader.ts +339 -31
  95. package/src/tools/bash.ts +3 -4
  96. package/src/tools/fetch.ts +29 -9
  97. package/src/tools/gh.ts +65 -11
  98. package/src/tools/index.ts +22 -8
  99. package/src/tools/job.ts +3 -3
  100. package/src/tools/memory-reflect.ts +2 -2
  101. package/src/tools/path-utils.ts +21 -0
  102. package/src/tools/read.ts +58 -12
  103. package/src/tools/search-tool-bm25.ts +4 -6
  104. package/src/tools/search.ts +78 -12
  105. package/src/tui/hyperlink.ts +42 -7
  106. package/src/utils/file-mentions.ts +7 -107
  107. package/src/utils/title-generator.ts +58 -37
  108. package/src/web/search/index.ts +2 -2
  109. package/src/web/search/render.ts +20 -52
@@ -1,11 +1,12 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import type { AutocompleteProvider, SlashCommand } from "@oh-my-pi/pi-tui";
3
- import { $env, sanitizeText } from "@oh-my-pi/pi-utils";
3
+ import { $env, logger, sanitizeText } from "@oh-my-pi/pi-utils";
4
4
  import { getRoleInfo } from "../../config/model-registry";
5
5
  import { isSettingsInitialized, settings } from "../../config/settings";
6
6
  import { renderSegmentTrack } from "../../modes/components/segment-track";
7
7
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
8
8
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
9
+ import { materializeImageReferenceLinks } from "../../modes/image-references";
9
10
  import { createPromptActionAutocompleteProvider } from "../../modes/prompt-action-autocomplete";
10
11
  import type { InteractiveModeContext } from "../../modes/types";
11
12
  import type { AgentSessionEvent } from "../../session/agent-session";
@@ -253,6 +254,8 @@ export class InputController {
253
254
  if (this.ctx.onInputCallback) {
254
255
  this.ctx.editor.setText("");
255
256
  this.ctx.pendingImages = [];
257
+ this.ctx.pendingImageLinks = [];
258
+ this.ctx.editor.imageLinks = undefined;
256
259
  this.ctx.onInputCallback({ text: "", cancelled: false, started: true });
257
260
  }
258
261
  return;
@@ -260,12 +263,15 @@ export class InputController {
260
263
 
261
264
  const runner = this.ctx.session.extensionRunner;
262
265
  let inputImages = this.ctx.pendingImages.length > 0 ? [...this.ctx.pendingImages] : undefined;
266
+ let inputImageLinks = this.ctx.pendingImageLinks.length > 0 ? [...this.ctx.pendingImageLinks] : undefined;
263
267
 
264
268
  if (runner?.hasHandlers("input")) {
265
269
  const result = await runner.emitInput(text, inputImages, "interactive");
266
270
  if (result?.handled) {
267
271
  this.ctx.editor.setText("");
268
272
  this.ctx.pendingImages = [];
273
+ this.ctx.pendingImageLinks = [];
274
+ this.ctx.editor.imageLinks = undefined;
269
275
  return;
270
276
  }
271
277
  if (result?.text !== undefined) {
@@ -273,6 +279,10 @@ export class InputController {
273
279
  }
274
280
  if (result?.images !== undefined) {
275
281
  inputImages = result.images;
282
+ inputImageLinks = await materializeImageReferenceLinks(
283
+ inputImages,
284
+ this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
285
+ );
276
286
  }
277
287
  }
278
288
 
@@ -356,8 +366,10 @@ export class InputController {
356
366
  if (this.ctx.session.isStreaming) {
357
367
  this.ctx.editor.addToHistory(text);
358
368
  this.ctx.editor.setText("");
369
+ this.ctx.editor.imageLinks = undefined;
359
370
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
360
371
  this.ctx.pendingImages = [];
372
+ this.ctx.pendingImageLinks = [];
361
373
  // Record the signature so the queued message's eventual delivery
362
374
  // (a user-role `message_start` event) leaves any draft the user has
363
375
  // typed since queuing intact. Same protection as #783, applied to
@@ -406,16 +418,28 @@ export class InputController {
406
418
  }
407
419
  }
408
420
  })
409
- .catch(() => {});
421
+ .catch(err => {
422
+ logger.warn("title-generator: uncaught auto-title error", {
423
+ sessionId: this.ctx.session.sessionId,
424
+ reason: "uncaught-auto-title-error",
425
+ error: err instanceof Error ? err.message : String(err),
426
+ });
427
+ });
410
428
  }
411
429
 
412
430
  if (this.ctx.onInputCallback) {
413
431
  // Include any pending images from clipboard paste
432
+ this.ctx.editor.imageLinks = undefined;
414
433
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
415
434
  this.ctx.pendingImages = [];
435
+ this.ctx.pendingImageLinks = [];
416
436
 
417
437
  // Render user message immediately, then let session events catch up
418
- const submission = this.ctx.startPendingSubmission({ text, images });
438
+ const submission = this.ctx.startPendingSubmission({
439
+ text,
440
+ images,
441
+ imageLinks: inputImageLinks,
442
+ });
419
443
 
420
444
  this.ctx.onInputCallback(submission);
421
445
  }
@@ -679,11 +703,25 @@ export class InputController {
679
703
  }
680
704
  }
681
705
 
706
+ const imageLink = (
707
+ await materializeImageReferenceLinks(
708
+ [
709
+ {
710
+ type: "image",
711
+ data: imageData.data,
712
+ mimeType: imageData.mimeType,
713
+ },
714
+ ],
715
+ this.ctx.sessionManager.putBlob.bind(this.ctx.sessionManager),
716
+ )
717
+ )?.[0];
682
718
  this.ctx.pendingImages.push({
683
719
  type: "image",
684
720
  data: imageData.data,
685
721
  mimeType: imageData.mimeType,
686
722
  });
723
+ this.ctx.pendingImageLinks.push(imageLink);
724
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
687
725
  // Insert placeholder at cursor like Claude does
688
726
  const imageNum = this.ctx.pendingImages.length;
689
727
  const placeholder = `[Image #${imageNum}]`;
@@ -0,0 +1,111 @@
1
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import { logger } from "@oh-my-pi/pi-utils";
3
+ import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store";
4
+ import { fileHyperlink } from "../tui/hyperlink";
5
+
6
+ const IMAGE_REFERENCE_REGEX = /\[Image #([1-9]\d*)\]/g;
7
+
8
+ type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise<BlobPutResult>;
9
+ type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult;
10
+
11
+ export interface ImageReferenceRenderers {
12
+ renderText: (text: string) => string;
13
+ renderReference: (label: string, index: number) => string;
14
+ }
15
+
16
+ export function renderImageReferences(text: string, renderers: ImageReferenceRenderers): string {
17
+ IMAGE_REFERENCE_REGEX.lastIndex = 0;
18
+ let result = "";
19
+ let last = 0;
20
+ let matched = false;
21
+
22
+ for (;;) {
23
+ const match = IMAGE_REFERENCE_REGEX.exec(text);
24
+ if (match === null) break;
25
+ matched = true;
26
+ if (match.index > last) {
27
+ result += renderers.renderText(text.slice(last, match.index));
28
+ }
29
+ result += renderers.renderReference(match[0], Number(match[1]));
30
+ last = match.index + match[0].length;
31
+ }
32
+
33
+ if (!matched) {
34
+ return renderers.renderText(text);
35
+ }
36
+ if (last < text.length) {
37
+ result += renderers.renderText(text.slice(last));
38
+ }
39
+ return result;
40
+ }
41
+
42
+ export function imageReferenceHyperlink(
43
+ label: string,
44
+ index: number,
45
+ imageLinks: readonly (string | undefined)[] | undefined,
46
+ renderLabel: (text: string) => string,
47
+ ): string {
48
+ const rendered = renderLabel(label);
49
+ const target = imageLinks?.[index - 1];
50
+ return target ? fileHyperlink(target, rendered) : rendered;
51
+ }
52
+
53
+ async function materializeImageReferenceLinkAsync(
54
+ image: ImageContent,
55
+ index: number,
56
+ putBlob: ImageBlobWriter,
57
+ ): Promise<string | undefined> {
58
+ try {
59
+ const result = await putBlob(Buffer.from(image.data, "base64"), {
60
+ extension: blobExtensionForImageMimeType(image.mimeType),
61
+ });
62
+ return result.displayPath;
63
+ } catch (error) {
64
+ logger.warn("Failed to write image reference blob", {
65
+ index,
66
+ mimeType: image.mimeType,
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ return undefined;
70
+ }
71
+ }
72
+
73
+ function materializeImageReferenceLink(
74
+ image: ImageContent,
75
+ index: number,
76
+ putBlob: ImageBlobWriterSync,
77
+ ): string | undefined {
78
+ try {
79
+ const result = putBlob(Buffer.from(image.data, "base64"), {
80
+ extension: blobExtensionForImageMimeType(image.mimeType),
81
+ });
82
+ return result.displayPath;
83
+ } catch (error) {
84
+ logger.warn("Failed to write image reference blob", {
85
+ index,
86
+ mimeType: image.mimeType,
87
+ error: error instanceof Error ? error.message : String(error),
88
+ });
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ export async function materializeImageReferenceLinks(
94
+ images: readonly ImageContent[] | undefined,
95
+ putBlob: ImageBlobWriter,
96
+ ): Promise<(string | undefined)[] | undefined> {
97
+ if (!images || images.length === 0) return undefined;
98
+ const links = await Promise.all(
99
+ images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)),
100
+ );
101
+ return links.some(link => link !== undefined) ? links : undefined;
102
+ }
103
+
104
+ export function materializeImageReferenceLinksSync(
105
+ images: readonly ImageContent[] | undefined,
106
+ putBlob: ImageBlobWriterSync,
107
+ ): (string | undefined)[] | undefined {
108
+ if (!images || images.length === 0) return undefined;
109
+ const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob));
110
+ return links.some(link => link !== undefined) ? links : undefined;
111
+ }
@@ -96,6 +96,7 @@ import type { AssistantMessageComponent } from "./components/assistant-message";
96
96
  import type { BashExecutionComponent } from "./components/bash-execution";
97
97
  import { CustomEditor } from "./components/custom-editor";
98
98
  import { DynamicBorder } from "./components/dynamic-border";
99
+ import { ErrorBannerComponent } from "./components/error-banner";
99
100
  import type { EvalExecutionComponent } from "./components/eval-execution";
100
101
  import type { HookEditorComponent } from "./components/hook-editor";
101
102
  import type { HookInputComponent } from "./components/hook-input";
@@ -264,6 +265,7 @@ export class InteractiveMode implements InteractiveModeContext {
264
265
  todoContainer: Container;
265
266
  btwContainer: Container;
266
267
  omfgContainer: Container;
268
+ errorBannerContainer: Container;
267
269
  editor: CustomEditor;
268
270
  editorContainer: Container;
269
271
  hookWidgetContainerAbove: Container;
@@ -288,6 +290,7 @@ export class InteractiveMode implements InteractiveModeContext {
288
290
  todoPhases: TodoPhase[] = [];
289
291
  hideThinkingBlock = false;
290
292
  pendingImages: ImageContent[] = [];
293
+ pendingImageLinks: (string | undefined)[] = [];
291
294
  compactionQueuedMessages: CompactionQueuedMessage[] = [];
292
295
  pendingTools = new Map<string, ToolExecutionHandle>();
293
296
  pendingBashComponents: BashExecutionComponent[] = [];
@@ -404,6 +407,7 @@ export class InteractiveMode implements InteractiveModeContext {
404
407
  this.todoContainer = new Container();
405
408
  this.btwContainer = new Container();
406
409
  this.omfgContainer = new Container();
410
+ this.errorBannerContainer = new Container();
407
411
  this.editor = new CustomEditor(getEditorTheme());
408
412
  this.editor.setUseTerminalCursor(this.ui.getShowHardwareCursor());
409
413
  this.editor.setAutocompleteMaxVisible(settings.get("autocompleteMaxVisible"));
@@ -565,6 +569,7 @@ export class InteractiveMode implements InteractiveModeContext {
565
569
  this.ui.addChild(this.todoContainer);
566
570
  this.ui.addChild(this.btwContainer);
567
571
  this.ui.addChild(this.omfgContainer);
572
+ this.ui.addChild(this.errorBannerContainer);
568
573
  this.ui.addChild(this.statusLine); // Only renders hook statuses (main status in editor border)
569
574
  this.ui.addChild(this.hookWidgetContainerAbove);
570
575
  this.ui.addChild(this.editorContainer);
@@ -896,12 +901,14 @@ export class InteractiveMode implements InteractiveModeContext {
896
901
  startPendingSubmission(input: {
897
902
  text: string;
898
903
  images?: ImageContent[];
904
+ imageLinks?: (string | undefined)[];
899
905
  customType?: string;
900
906
  display?: boolean;
901
907
  }): SubmittedUserInput {
902
908
  const submission: SubmittedUserInput = {
903
909
  text: input.text,
904
910
  images: input.images,
911
+ imageLinks: input.imageLinks,
905
912
  customType: input.customType,
906
913
  display: input.display,
907
914
  cancelled: false,
@@ -913,22 +920,28 @@ export class InteractiveMode implements InteractiveModeContext {
913
920
  const imageCount = submission.images?.length ?? 0;
914
921
  this.optimisticUserMessageSignature = `${submission.text}\u0000${imageCount}`;
915
922
  this.#pendingSubmissionDispose = this.recordLocalSubmission(submission.text, imageCount);
916
- this.addMessageToChat({
917
- role: "user",
918
- content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
919
- attribution: "user",
920
- timestamp: Date.now(),
921
- });
923
+ this.addMessageToChat(
924
+ {
925
+ role: "user",
926
+ content: [{ type: "text", text: submission.text }, ...(submission.images ?? [])],
927
+ attribution: "user",
928
+ timestamp: Date.now(),
929
+ },
930
+ { imageLinks: input.imageLinks },
931
+ );
922
932
  } else {
923
933
  this.optimisticUserMessageSignature = undefined;
924
934
  this.#pendingSubmissionDispose = undefined;
925
935
  }
926
936
  this.editor.setText("");
927
- // Reconciliation checkpoint: the rebuild below replays the whole transcript
928
- // into native scrollback, so retire frozen block snapshots and let every
929
- // block render its current state.
930
- this.chatContainer.thaw();
931
- this.ui.refreshNativeScrollbackIfDirty({ allowUnknownViewport: true });
937
+ this.editor.imageLinks = undefined;
938
+ // Reconciliation checkpoint: only retire frozen block snapshots after TUI
939
+ // proves the native viewport is at the tail and replays scrollback safely.
940
+ // Unknown host viewports stay frozen; thawing them would expose live rows
941
+ // over stale native history and can yank or duplicate when ED3 is unsafe.
942
+ if (this.ui.refreshNativeScrollbackIfDirty()) {
943
+ this.chatContainer.thaw();
944
+ }
932
945
  this.ensureLoadingAnimation();
933
946
  this.ui.requestRender();
934
947
  return submission;
@@ -956,6 +969,8 @@ export class InteractiveMode implements InteractiveModeContext {
956
969
  }
957
970
  if (!submission.customType) {
958
971
  this.pendingImages = submission.images ? [...submission.images] : [];
972
+ this.pendingImageLinks = submission.imageLinks ? [...submission.imageLinks] : [];
973
+ this.editor.imageLinks = this.pendingImageLinks;
959
974
  this.rebuildChatFromMessages();
960
975
  this.editor.setText(submission.text);
961
976
  }
@@ -2492,6 +2507,19 @@ export class InteractiveMode implements InteractiveModeContext {
2492
2507
  this.#uiHelpers.showError(message);
2493
2508
  }
2494
2509
 
2510
+ showPinnedError(message: string): void {
2511
+ if (this.isBackgrounded) return;
2512
+ this.errorBannerContainer.clear();
2513
+ this.errorBannerContainer.addChild(new ErrorBannerComponent(message));
2514
+ this.ui.requestRender();
2515
+ }
2516
+
2517
+ clearPinnedError(): void {
2518
+ if (this.errorBannerContainer.children.length === 0) return;
2519
+ this.errorBannerContainer.clear();
2520
+ this.ui.requestRender();
2521
+ }
2522
+
2495
2523
  showWarning(message: string): void {
2496
2524
  this.#uiHelpers.showWarning(message);
2497
2525
  }
@@ -2622,7 +2650,10 @@ export class InteractiveMode implements InteractiveModeContext {
2622
2650
  return this.#uiHelpers.isKnownSlashCommand(text);
2623
2651
  }
2624
2652
 
2625
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
2653
+ addMessageToChat(
2654
+ message: AgentMessage,
2655
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
2656
+ ): Component[] {
2626
2657
  return this.#uiHelpers.addMessageToChat(message, options);
2627
2658
  }
2628
2659
 
@@ -2633,7 +2664,10 @@ export class InteractiveMode implements InteractiveModeContext {
2633
2664
  this.#uiHelpers.renderSessionContext(sessionContext, options);
2634
2665
  }
2635
2666
 
2636
- renderInitialMessages(prebuiltContext?: SessionContext, options?: { preserveExistingChat?: boolean }): void {
2667
+ renderInitialMessages(
2668
+ prebuiltContext?: SessionContext,
2669
+ options?: { preserveExistingChat?: boolean; clearTerminalHistory?: boolean },
2670
+ ): void {
2637
2671
  this.#uiHelpers.renderInitialMessages(prebuiltContext, options);
2638
2672
  }
2639
2673
 
@@ -2706,6 +2740,7 @@ export class InteractiveMode implements InteractiveModeContext {
2706
2740
  this.#btwController.dispose();
2707
2741
  this.#omfgController.dispose();
2708
2742
  this.#extensionUiController.clearExtensionTerminalInputListeners();
2743
+ this.clearPinnedError();
2709
2744
  this.#planReviewContainer = undefined;
2710
2745
  }
2711
2746
 
@@ -1,6 +1,6 @@
1
1
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
2
2
  import type { OAuthProvider } from "@oh-my-pi/pi-ai/utils/oauth/types";
3
- import { Input, matchesKey, truncateToWidth } from "@oh-my-pi/pi-tui";
3
+ import { Input, matchesKey, wrapTextWithAnsi } from "@oh-my-pi/pi-tui";
4
4
  import { getAgentDbPath } from "@oh-my-pi/pi-utils";
5
5
  import { OAuthSelectorComponent } from "../../components/oauth-selector";
6
6
  import { theme } from "../../theme/theme";
@@ -15,6 +15,10 @@ const CALLBACK_SERVER_PROVIDERS: Partial<Record<OAuthProvider, true>> = {
15
15
  "google-antigravity": true,
16
16
  };
17
17
 
18
+ function loginUrlLink(url: string): string {
19
+ return `\x1b]8;;${url}\x07Open login URL\x1b]8;;\x07`;
20
+ }
21
+
18
22
  interface PromptState {
19
23
  message: string;
20
24
  placeholder?: string;
@@ -33,6 +37,7 @@ export class SignInTab implements SetupTab {
33
37
  #authStorage: AuthStorage;
34
38
  #selector: OAuthSelectorComponent;
35
39
  #statusLines: string[] = [];
40
+ #authUrl: string | undefined;
36
41
  #prompt: PromptState | undefined;
37
42
  #promptResolve: ((value: string) => void) | undefined;
38
43
  #loginAbort: AbortController | undefined;
@@ -72,22 +77,31 @@ export class SignInTab implements SetupTab {
72
77
  }
73
78
 
74
79
  render(width: number): string[] {
75
- const lines = [theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), ""];
80
+ const lines: string[] = [];
76
81
  if (this.#loggingInProvider) {
77
- lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`), "");
82
+ lines.push(theme.bold(`Signing in to ${this.#loggingInProvider}`));
78
83
  } else {
84
+ lines.push(theme.fg("muted", "Pick a provider to sign in — you can connect more than one."), "");
79
85
  lines.push(...this.#selector.render(width));
80
86
  }
81
- if (this.#statusLines.length > 0) {
82
- lines.push("", ...this.#statusLines.map(line => truncateToWidth(line, width)));
87
+
88
+ const urlLines = this.#authUrl ? wrapTextWithAnsi(theme.fg("dim", this.#authUrl), width) : [];
89
+ if (this.#authUrl) {
90
+ lines.push(theme.fg("accent", `Browser login: ${loginUrlLink(this.#authUrl)}`), ...urlLines.slice(0, 2));
83
91
  }
84
92
  if (this.#prompt) {
85
- lines.push("", theme.fg("warning", this.#prompt.message));
93
+ lines.push(theme.fg("warning", this.#prompt.message));
86
94
  if (this.#prompt.placeholder) {
87
95
  lines.push(theme.fg("dim", this.#prompt.placeholder));
88
96
  }
89
97
  lines.push(this.#prompt.input.render(width)[0] ?? "");
90
98
  }
99
+ if (urlLines.length > 2) {
100
+ lines.push(...urlLines);
101
+ }
102
+ if (this.#statusLines.length > 0) {
103
+ lines.push(...this.#statusLines.flatMap(line => wrapTextWithAnsi(line, width)));
104
+ }
91
105
  return lines;
92
106
  }
93
107
 
@@ -109,6 +123,7 @@ export class SignInTab implements SetupTab {
109
123
  this.#selector.stopValidation();
110
124
  this.#loggingInProvider = providerId;
111
125
  this.#statusLines = [theme.fg("dim", "Starting OAuth flow…")];
126
+ this.#authUrl = undefined;
112
127
  this.#loginAbort = new AbortController();
113
128
  this.host.restoreFocus();
114
129
  this.host.requestRender();
@@ -116,7 +131,8 @@ export class SignInTab implements SetupTab {
116
131
  await this.#authStorage.login(providerId as OAuthProvider, {
117
132
  signal: this.#loginAbort.signal,
118
133
  onAuth: info => {
119
- this.#statusLines.push(theme.fg("accent", `Open this URL: ${info.url}`));
134
+ this.#authUrl = info.url;
135
+ this.#statusLines = [];
120
136
  if (info.instructions) {
121
137
  this.#statusLines.push(theme.fg("warning", info.instructions));
122
138
  }
@@ -140,6 +156,7 @@ export class SignInTab implements SetupTab {
140
156
  theme.fg("success", `${theme.status.success} Signed in to ${providerId}`),
141
157
  theme.fg("dim", `Credentials saved to ${getAgentDbPath()}`),
142
158
  ];
159
+ this.#authUrl = undefined;
143
160
  this.#loggingInProvider = undefined;
144
161
  this.#loginAbort = undefined;
145
162
  this.#selector.stopValidation();
@@ -150,12 +167,14 @@ export class SignInTab implements SetupTab {
150
167
  if (this.#disposed) return;
151
168
  if (this.#loginAbort?.signal.aborted) {
152
169
  this.#statusLines = [theme.fg("dim", "Login cancelled.")];
170
+ this.#authUrl = undefined;
153
171
  } else {
154
172
  const message = error instanceof Error ? error.message : String(error);
155
173
  this.#statusLines = [
156
174
  theme.fg("error", `Login failed: ${message}`),
157
175
  theme.fg("dim", "Choose another provider or press Esc to continue."),
158
176
  ];
177
+ this.#authUrl = undefined;
159
178
  }
160
179
  this.#loggingInProvider = undefined;
161
180
  this.#loginAbort = undefined;
@@ -174,6 +193,7 @@ export class SignInTab implements SetupTab {
174
193
  this.#resolvePrompt(value);
175
194
  };
176
195
  input.onEscape = () => {
196
+ this.#loginAbort?.abort();
177
197
  this.#resolvePrompt("");
178
198
  };
179
199
  this.host.setFocus(input);
@@ -40,6 +40,7 @@ export type CompactionQueuedMessage = {
40
40
  export type SubmittedUserInput = {
41
41
  text: string;
42
42
  images?: ImageContent[];
43
+ imageLinks?: (string | undefined)[];
43
44
  customType?: string;
44
45
  display?: boolean;
45
46
  cancelled: boolean;
@@ -75,6 +76,7 @@ export interface InteractiveModeContext {
75
76
  todoContainer: Container;
76
77
  btwContainer: Container;
77
78
  omfgContainer: Container;
79
+ errorBannerContainer: Container;
78
80
  editor: CustomEditor;
79
81
  editorContainer: Container;
80
82
  hookWidgetContainerAbove: Container;
@@ -106,6 +108,7 @@ export interface InteractiveModeContext {
106
108
  planModePlanFilePath?: string;
107
109
  hideThinkingBlock: boolean;
108
110
  pendingImages: ImageContent[];
111
+ pendingImageLinks: (string | undefined)[];
109
112
  compactionQueuedMessages: CompactionQueuedMessage[];
110
113
  pendingTools: Map<string, ToolExecutionHandle>;
111
114
  pendingBashComponents: BashExecutionComponent[];
@@ -157,6 +160,8 @@ export interface InteractiveModeContext {
157
160
  // UI helpers
158
161
  showStatus(message: string, options?: { dim?: boolean }): void;
159
162
  showError(message: string): void;
163
+ showPinnedError(message: string): void;
164
+ clearPinnedError(): void;
160
165
  showWarning(message: string): void;
161
166
  showNewVersionNotification(newVersion: string): void;
162
167
  clearEditor(): void;
@@ -171,6 +176,7 @@ export interface InteractiveModeContext {
171
176
  startPendingSubmission(input: {
172
177
  text: string;
173
178
  images?: ImageContent[];
179
+ imageLinks?: (string | undefined)[];
174
180
  customType?: string;
175
181
  display?: boolean;
176
182
  }): SubmittedUserInput;
@@ -191,7 +197,10 @@ export interface InteractiveModeContext {
191
197
  */
192
198
  withLocalSubmission<T>(text: string, fn: () => Promise<T>, options?: { imageCount?: number }): Promise<T>;
193
199
  isKnownSlashCommand(text: string): boolean;
194
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[];
200
+ addMessageToChat(
201
+ message: AgentMessage,
202
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
203
+ ): Component[];
195
204
  renderSessionContext(
196
205
  sessionContext: SessionContext,
197
206
  options?: { updateFooter?: boolean; populateHistory?: boolean },
@@ -18,6 +18,7 @@ import {
18
18
  import { SkillMessageComponent } from "../../modes/components/skill-message";
19
19
  import { ToolExecutionComponent } from "../../modes/components/tool-execution";
20
20
  import { UserMessageComponent } from "../../modes/components/user-message";
21
+ import { materializeImageReferenceLinksSync } from "../../modes/image-references";
21
22
  import { theme } from "../../modes/theme/theme";
22
23
  import type { CompactionQueuedMessage, InteractiveModeContext } from "../../modes/types";
23
24
  import {
@@ -40,6 +41,18 @@ type QueuedMessages = {
40
41
  followUp: string[];
41
42
  };
42
43
 
44
+ function imageLinksForMessage(
45
+ message: Extract<AgentMessage, { role: "developer" | "user" }>,
46
+ putBlobSync: InteractiveModeContext["sessionManager"]["putBlobSync"],
47
+ ): (string | undefined)[] | undefined {
48
+ if (typeof message.content === "string") return undefined;
49
+ const images = message.content.filter(
50
+ (content): content is ImageContent =>
51
+ content.type === "image" && typeof content.data === "string" && typeof content.mimeType === "string",
52
+ );
53
+ return materializeImageReferenceLinksSync(images, putBlobSync);
54
+ }
55
+
43
56
  export class UiHelpers {
44
57
  constructor(private ctx: InteractiveModeContext) {}
45
58
 
@@ -84,7 +97,10 @@ export class UiHelpers {
84
97
  this.ctx.ui.requestRender();
85
98
  }
86
99
 
87
- addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): Component[] {
100
+ addMessageToChat(
101
+ message: AgentMessage,
102
+ options?: { populateHistory?: boolean; imageLinks?: readonly (string | undefined)[] },
103
+ ): Component[] {
88
104
  switch (message.role) {
89
105
  case "bashExecution": {
90
106
  const component = new BashExecutionComponent(message.command, this.ctx.ui, message.excludeFromContext);
@@ -253,7 +269,10 @@ export class UiHelpers {
253
269
  const textContent = this.ctx.getUserMessageText(message);
254
270
  if (textContent) {
255
271
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
256
- const userComponent = new UserMessageComponent(textContent, isSynthetic);
272
+ const imageLinks =
273
+ options?.imageLinks ??
274
+ imageLinksForMessage(message, this.ctx.sessionManager.putBlobSync.bind(this.ctx.sessionManager));
275
+ const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
257
276
  this.ctx.chatContainer.addChild(userComponent);
258
277
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
259
278
  this.ctx.editor.addToHistory(textContent);
@@ -513,6 +532,8 @@ export class UiHelpers {
513
532
  }
514
533
  this.ctx.editor.setText("");
515
534
  this.ctx.pendingImages = [];
535
+ this.ctx.pendingImageLinks = [];
536
+ this.ctx.editor.imageLinks = undefined;
516
537
  this.ctx.ui.requestRender();
517
538
  }
518
539
 
@@ -4,6 +4,7 @@ description: Fast read-only codebase scout returning compressed context for hand
4
4
  tools: read, search, find, web_search
5
5
  model: pi/smol
6
6
  thinking-level: med
7
+ read-summarize: false
7
8
  output:
8
9
  properties:
9
10
  summary:
@@ -4,6 +4,7 @@ description: Researches external libraries and APIs by reading source code. Retu
4
4
  tools: read, search, find, bash, lsp, web_search, ast_grep
5
5
  model: pi/smol
6
6
  thinking-level: minimal
7
+ read-summarize: false
7
8
  output:
8
9
  properties:
9
10
  answer:
@@ -14,7 +14,7 @@ Do not stop after a single fix attempt.
14
14
  2. If any run fails, inspect failing job output and logs.
15
15
  3. Identify root cause and make minimal correct fix.
16
16
  4. Run local verification if it reduces chance of another failing push.
17
- 5. Push the branch.
17
+ {{#if headTag}}5. Push the branch and tag `{{headTag}}` atomically: `git push --atomic "{{remote}}" "{{branch}}" "+refs/tags/{{headTag}}"`.{{else}}5. Push the branch.{{/if}}
18
18
  6. Watch workflow runs for new HEAD commit again.
19
19
  7. Repeat until workflow runs for latest HEAD commit succeed.
20
20
  </procedure>
@@ -26,11 +26,13 @@ Do not stop after a single fix attempt.
26
26
 
27
27
  {{#if headTag}}
28
28
  <instruction>
29
- Once CI is green, ensure the final commit is tagged `{{headTag}}` and push that tag.
29
+ Always push the branch and tag together atomically so the tag never points at an un-pushed or non-green commit:
30
+ `git push --atomic "{{remote}}" "{{branch}}" "+refs/tags/{{headTag}}"`.
31
+ The `--atomic` flag makes the branch and tag update succeed or fail as one ref transaction; `+refs/tags/{{headTag}}` force-moves the tag to the new HEAD. Do not push the branch first and retag later.
30
32
  </instruction>
31
33
  {{/if}}
32
34
 
33
35
  <critical>
34
36
  The task is complete only when the workflow runs for the latest HEAD commit succeed.
35
- {{#if headTag}}The final green commit must be tagged `{{headTag}}` and that tag must be pushed.{{/if}}
37
+ {{#if headTag}}The latest HEAD commit must carry tag `{{headTag}}`, pushed atomically with the branch via `git push --atomic`.{{/if}}
36
38
  </critical>
@@ -0,0 +1,8 @@
1
+ Write a 20-line poem about balancing OAuth accounts across many providers.
2
+
3
+ Form:
4
+ - Exactly 20 lines, no title, no stanza breaks.
5
+ - Each line is terse and image-driven, in the spirit of haiku: 7 words or fewer, no end punctuation.
6
+ - Let the imagery carry the theme — tokens, scopes, refresh cycles, expiry, consent, revocation — rather than naming them literally.
7
+
8
+ Output only the 20 lines. No preamble, no commentary, no code fences.
@@ -3,6 +3,7 @@ PROJECT
3
3
 
4
4
  <workstation>
5
5
  {{#list environment prefix="- " join="\n"}}{{label}}: {{value}}{{/list}}
6
+ {{#if model}}- Model: {{model}}{{/if}}
6
7
  </workstation>
7
8
 
8
9
  {{#if contextFiles.length}}