@oh-my-pi/pi-coding-agent 15.9.3 → 15.9.67

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 (142) hide show
  1. package/CHANGELOG.md +74 -1
  2. package/dist/types/cli/classify-install-target.d.ts +5 -1
  3. package/dist/types/config/keybindings.d.ts +4 -1
  4. package/dist/types/config/settings-schema.d.ts +24 -5
  5. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  6. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  7. package/dist/types/eval/backend.d.ts +6 -6
  8. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  9. package/dist/types/eval/idle-timeout.d.ts +16 -14
  10. package/dist/types/eval/js/executor.d.ts +3 -3
  11. package/dist/types/eval/py/executor.d.ts +2 -2
  12. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  13. package/dist/types/modes/components/assistant-message.d.ts +16 -0
  14. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  15. package/dist/types/modes/components/custom-editor.d.ts +3 -1
  16. package/dist/types/modes/components/error-banner.d.ts +11 -0
  17. package/dist/types/modes/components/model-selector.d.ts +1 -0
  18. package/dist/types/modes/components/tool-execution.d.ts +15 -0
  19. package/dist/types/modes/components/transcript-container.d.ts +1 -0
  20. package/dist/types/modes/components/user-message.d.ts +1 -1
  21. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  22. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  23. package/dist/types/modes/image-references.d.ts +17 -0
  24. package/dist/types/modes/interactive-mode.d.ts +8 -1
  25. package/dist/types/modes/types.d.ts +8 -1
  26. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  27. package/dist/types/modes/utils/ui-helpers.d.ts +1 -0
  28. package/dist/types/session/blob-store.d.ts +12 -11
  29. package/dist/types/session/session-manager.d.ts +5 -3
  30. package/dist/types/system-prompt.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/eval-render.d.ts +8 -0
  35. package/dist/types/tools/render-utils.d.ts +25 -0
  36. package/dist/types/tui/code-cell.d.ts +6 -0
  37. package/dist/types/tui/hyperlink.d.ts +12 -0
  38. package/dist/types/tui/output-block.d.ts +11 -0
  39. package/dist/types/web/search/render.d.ts +1 -2
  40. package/package.json +9 -9
  41. package/src/autoresearch/dashboard.ts +11 -21
  42. package/src/cli/classify-install-target.ts +31 -5
  43. package/src/cli/claude-trace-cli.ts +13 -1
  44. package/src/cli/plugin-cli.ts +45 -0
  45. package/src/cli/web-search-cli.ts +0 -1
  46. package/src/config/keybindings.ts +58 -1
  47. package/src/config/model-registry.ts +54 -4
  48. package/src/config/settings-schema.ts +25 -5
  49. package/src/debug/raw-sse.ts +18 -4
  50. package/src/edit/file-snapshot-store.ts +1 -1
  51. package/src/edit/index.ts +1 -1
  52. package/src/edit/renderer.ts +7 -7
  53. package/src/edit/streaming.ts +1 -1
  54. package/src/eval/__tests__/agent-bridge.test.ts +100 -27
  55. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  56. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  57. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  58. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  59. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  60. package/src/eval/agent-bridge.ts +4 -5
  61. package/src/eval/backend.ts +6 -6
  62. package/src/eval/bridge-timeout.ts +44 -0
  63. package/src/eval/idle-timeout.ts +33 -15
  64. package/src/eval/js/executor.ts +10 -10
  65. package/src/eval/llm-bridge.ts +4 -5
  66. package/src/eval/py/executor.ts +6 -6
  67. package/src/eval/py/kernel.ts +11 -1
  68. package/src/eval/py/spawn-options.ts +126 -0
  69. package/src/eval/py/tool-bridge.ts +43 -5
  70. package/src/export/ttsr.ts +9 -0
  71. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +31 -2
  72. package/src/extensibility/extensions/runner.ts +2 -0
  73. package/src/internal-urls/docs-index.generated.ts +9 -8
  74. package/src/lsp/client.ts +80 -2
  75. package/src/lsp/index.ts +38 -4
  76. package/src/lsp/render.ts +3 -3
  77. package/src/main.ts +8 -2
  78. package/src/modes/components/agent-dashboard.ts +13 -4
  79. package/src/modes/components/assistant-message.ts +44 -1
  80. package/src/modes/components/copy-selector.ts +249 -0
  81. package/src/modes/components/custom-editor.ts +14 -2
  82. package/src/modes/components/error-banner.ts +33 -0
  83. package/src/modes/components/extensions/extension-list.ts +17 -8
  84. package/src/modes/components/history-search.ts +19 -11
  85. package/src/modes/components/model-selector.ts +125 -29
  86. package/src/modes/components/oauth-selector.ts +28 -12
  87. package/src/modes/components/session-observer-overlay.ts +13 -15
  88. package/src/modes/components/session-selector.ts +24 -13
  89. package/src/modes/components/tool-execution.ts +71 -13
  90. package/src/modes/components/transcript-container.ts +93 -32
  91. package/src/modes/components/tree-selector.ts +19 -7
  92. package/src/modes/components/user-message-selector.ts +25 -14
  93. package/src/modes/components/user-message.ts +9 -2
  94. package/src/modes/controllers/command-controller.ts +0 -116
  95. package/src/modes/controllers/event-controller.ts +67 -12
  96. package/src/modes/controllers/input-controller.ts +33 -1
  97. package/src/modes/controllers/selector-controller.ts +38 -1
  98. package/src/modes/image-references.ts +111 -0
  99. package/src/modes/interactive-mode.ts +52 -17
  100. package/src/modes/theme/theme.ts +46 -10
  101. package/src/modes/types.ts +11 -2
  102. package/src/modes/utils/copy-targets.ts +254 -0
  103. package/src/modes/utils/ui-helpers.ts +23 -2
  104. package/src/prompts/ci-green-request.md +5 -3
  105. package/src/prompts/system/project-prompt.md +1 -0
  106. package/src/prompts/tools/ast-edit.md +1 -1
  107. package/src/prompts/tools/ast-grep.md +1 -1
  108. package/src/prompts/tools/read.md +1 -1
  109. package/src/prompts/tools/search.md +1 -1
  110. package/src/sdk.ts +17 -9
  111. package/src/session/agent-session.ts +43 -14
  112. package/src/session/blob-store.ts +96 -9
  113. package/src/session/session-manager.ts +19 -10
  114. package/src/slash-commands/builtin-registry.ts +3 -11
  115. package/src/system-prompt.ts +4 -0
  116. package/src/task/render.ts +38 -11
  117. package/src/tiny/title-client.ts +7 -1
  118. package/src/tool-discovery/mode.ts +24 -0
  119. package/src/tools/archive-reader.ts +339 -31
  120. package/src/tools/bash.ts +18 -8
  121. package/src/tools/browser/render.ts +5 -4
  122. package/src/tools/debug.ts +3 -3
  123. package/src/tools/eval-render.ts +24 -9
  124. package/src/tools/eval.ts +14 -19
  125. package/src/tools/fetch.ts +34 -14
  126. package/src/tools/gh.ts +65 -11
  127. package/src/tools/index.ts +6 -8
  128. package/src/tools/read.ts +65 -19
  129. package/src/tools/render-utils.ts +46 -0
  130. package/src/tools/search-tool-bm25.ts +4 -6
  131. package/src/tools/search.ts +60 -11
  132. package/src/tools/ssh.ts +21 -8
  133. package/src/tools/write.ts +17 -8
  134. package/src/tui/code-cell.ts +19 -4
  135. package/src/tui/hyperlink.ts +42 -7
  136. package/src/tui/output-block.ts +14 -0
  137. package/src/web/search/index.ts +2 -2
  138. package/src/web/search/render.ts +23 -55
  139. package/dist/types/eval/heartbeat.d.ts +0 -45
  140. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  141. package/src/eval/heartbeat.ts +0 -74
  142. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
@@ -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
 
@@ -2666,10 +2700,6 @@ export class InteractiveMode implements InteractiveModeContext {
2666
2700
  return this.#commandController.handleShareCommand();
2667
2701
  }
2668
2702
 
2669
- handleCopyCommand(sub?: string) {
2670
- return this.#commandController.handleCopyCommand(sub);
2671
- }
2672
-
2673
2703
  handleTodoCommand(args: string): Promise<void> {
2674
2704
  return this.#todoCommandController.handleTodoCommand(args);
2675
2705
  }
@@ -2706,6 +2736,7 @@ export class InteractiveMode implements InteractiveModeContext {
2706
2736
  this.#btwController.dispose();
2707
2737
  this.#omfgController.dispose();
2708
2738
  this.#extensionUiController.clearExtensionTerminalInputListeners();
2739
+ this.clearPinnedError();
2709
2740
  this.#planReviewContainer = undefined;
2710
2741
  }
2711
2742
 
@@ -2901,6 +2932,10 @@ export class InteractiveMode implements InteractiveModeContext {
2901
2932
  this.#selectorController.showUserMessageSelector();
2902
2933
  }
2903
2934
 
2935
+ showCopySelector(): void {
2936
+ this.#selectorController.showCopySelector();
2937
+ }
2938
+
2904
2939
  showTreeSelector(): void {
2905
2940
  this.#selectorController.showTreeSelector();
2906
2941
  }
@@ -12,6 +12,7 @@ import {
12
12
  import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@oh-my-pi/pi-tui";
13
13
  import { adjustHsv, colorLuma, getCustomThemesDir, isEnoent, logger, relativeLuminance } from "@oh-my-pi/pi-utils";
14
14
  import chalk from "chalk";
15
+ import { LRUCache } from "lru-cache/raw";
15
16
  import * as z from "zod/v4";
16
17
  // Embed theme JSON files at build time
17
18
  import darkThemeJson from "./dark.json" with { type: "json" };
@@ -2429,17 +2430,54 @@ function getHighlightColors(t: Theme): NativeHighlightColors {
2429
2430
  return cachedHighlightColors;
2430
2431
  }
2431
2432
 
2433
+ /**
2434
+ * Memoized native syntax highlight. Returns the joined ANSI string, or `null`
2435
+ * when the native tokenizer throws so callers can apply their own fallback.
2436
+ *
2437
+ * Keyed on `(lang, code)` and reset whenever the active `theme` instance
2438
+ * changes — the ANSI colors are baked into the highlighted output, so a theme
2439
+ * switch (which always reassigns `theme`) must invalidate every entry.
2440
+ *
2441
+ * Why this exists: animated tool blocks (eval/bash) repaint their box on every
2442
+ * ~16ms border-shimmer frame, and markdown re-lexes on every streamed delta.
2443
+ * Without memoization each frame re-tokenizes an unchanged code body through the
2444
+ * Rust FFI — ~26ms for 100 lines, ~40ms for 150 — overrunning the 16ms frame
2445
+ * budget and starving the spinner/render timers (the "TUI freeze").
2446
+ */
2447
+ const HIGHLIGHT_CACHE_MAX = 256;
2448
+ const highlightCache = new LRUCache<string, string>({ max: HIGHLIGHT_CACHE_MAX });
2449
+ let highlightCacheTheme: Theme | undefined;
2450
+
2451
+ function highlightCached(code: string, validLang: string | undefined): string | null {
2452
+ if (highlightCacheTheme !== theme) {
2453
+ highlightCache.clear();
2454
+ highlightCacheTheme = theme;
2455
+ }
2456
+ const key = `${validLang ?? ""}\x00${code}`;
2457
+ const hit = highlightCache.get(key);
2458
+ if (hit !== undefined) {
2459
+ return hit;
2460
+ }
2461
+ let highlighted: string;
2462
+ try {
2463
+ highlighted = nativeHighlightCode(code, validLang, getHighlightColors(theme));
2464
+ } catch {
2465
+ return null;
2466
+ }
2467
+ highlightCache.set(key, highlighted);
2468
+ return highlighted;
2469
+ }
2470
+
2432
2471
  /**
2433
2472
  * Highlight code with syntax coloring based on file extension or language.
2434
2473
  * Returns array of highlighted lines.
2435
2474
  */
2436
2475
  export function highlightCode(code: string, lang?: string): string[] {
2437
2476
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2438
- try {
2439
- return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2440
- } catch {
2441
- return code.split("\n");
2442
- }
2477
+ const highlighted = highlightCached(code, validLang);
2478
+ // Always return a fresh array: callers (e.g. renderCodeCell) push extra lines
2479
+ // onto the result, which would corrupt the cached string otherwise.
2480
+ return (highlighted ?? code).split("\n");
2443
2481
  }
2444
2482
 
2445
2483
  export function getSymbolTheme(): SymbolTheme {
@@ -2484,11 +2522,9 @@ export function getMarkdownTheme(): MarkdownTheme {
2484
2522
  resolveMermaidAscii,
2485
2523
  highlightCode: (code: string, lang?: string): string[] => {
2486
2524
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2487
- try {
2488
- return nativeHighlightCode(code, validLang, getHighlightColors(theme)).split("\n");
2489
- } catch {
2490
- return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2491
- }
2525
+ const highlighted = highlightCached(code, validLang);
2526
+ if (highlighted !== null) return highlighted.split("\n");
2527
+ return code.split("\n").map(line => theme.fg("mdCodeBlock", line));
2492
2528
  },
2493
2529
  };
2494
2530
  cachedMarkdownTheme = markdownTheme;
@@ -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 },
@@ -213,7 +222,6 @@ export interface InteractiveModeContext {
213
222
  // Command handling
214
223
  handleExportCommand(text: string): Promise<void>;
215
224
  handleShareCommand(): Promise<void>;
216
- handleCopyCommand(sub?: string): void;
217
225
  handleTodoCommand(args: string): Promise<void>;
218
226
  handleSessionCommand(): Promise<void>;
219
227
  handleJobsCommand(): Promise<void>;
@@ -254,6 +262,7 @@ export interface InteractiveModeContext {
254
262
  showModelSelector(options?: { temporaryOnly?: boolean }): void;
255
263
  showPluginSelector(mode?: "install" | "uninstall"): void;
256
264
  showUserMessageSelector(): void;
265
+ showCopySelector(): void;
257
266
  showTreeSelector(): void;
258
267
  showSessionSelector(): void;
259
268
  handleResumeSession(sessionPath: string): Promise<void>;
@@ -0,0 +1,254 @@
1
+ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
+ import type { ToolCall } from "@oh-my-pi/pi-ai";
3
+
4
+ /** A fenced code block extracted from assistant markdown. */
5
+ export interface CodeBlock {
6
+ /** Info string after the opening fence (language id), trimmed. */
7
+ lang: string;
8
+ /** Block body with the trailing newline stripped. */
9
+ code: string;
10
+ }
11
+
12
+ /** A runnable command found in the transcript. */
13
+ export interface LastCommand {
14
+ kind: "bash" | "eval";
15
+ code: string;
16
+ /** Highlight language: "bash" for bash, "python"/"javascript" for eval. */
17
+ language: string;
18
+ }
19
+
20
+ /**
21
+ * A node in the `/copy` picker tree. Leaves carry `content` (placed on the
22
+ * clipboard) plus `copyMessage` (the status shown afterwards); groups carry
23
+ * `children` to drill into.
24
+ */
25
+ export interface CopyTarget {
26
+ /** Stable identifier (e.g. "msg:1", "msg:1:code:0", "msg:1:all", "cmd:1"). */
27
+ id: string;
28
+ label: string;
29
+ /** Dim annotation: line/block counts, language, or tool name. */
30
+ hint?: string;
31
+ /** Full text rendered in the preview pane. */
32
+ preview: string;
33
+ /** Highlight language for code/command previews (undefined = plain/markdown). */
34
+ language?: string;
35
+ /** Leaf: text copied to the clipboard. */
36
+ content?: string;
37
+ /** Leaf: status message shown after copying. */
38
+ copyMessage?: string;
39
+ /** Group: nested targets to drill into. */
40
+ children?: CopyTarget[];
41
+ }
42
+
43
+ /** Minimal session surface needed to assemble copy targets (eases testing). */
44
+ export interface CopySource {
45
+ readonly messages: readonly AgentMessage[];
46
+ getLastVisibleHandoffText(): string | undefined;
47
+ }
48
+
49
+ /** Cap on how many recent assistant messages the picker lists. */
50
+ const MAX_MESSAGES = 50;
51
+
52
+ const CODE_BLOCK_RE = /^```([^\n]*)\n([\s\S]*?)^```/gm;
53
+
54
+ /** Extract fenced code blocks from assistant markdown, in document order. */
55
+ export function extractCodeBlocks(text: string): CodeBlock[] {
56
+ const blocks: CodeBlock[] = [];
57
+ for (const match of text.matchAll(CODE_BLOCK_RE)) {
58
+ blocks.push({ lang: match[1].trim(), code: match[2].replace(/\n$/, "") });
59
+ }
60
+ return blocks;
61
+ }
62
+
63
+ function extractEvalCode(args: unknown): { code: string; language: string } | undefined {
64
+ if (!args || typeof args !== "object") return undefined;
65
+ const cells = (args as { cells?: unknown }).cells;
66
+ if (!Array.isArray(cells)) return undefined;
67
+
68
+ const codeBlocks: string[] = [];
69
+ let language = "python";
70
+ let languageResolved = false;
71
+ for (const cell of cells) {
72
+ if (!cell || typeof cell !== "object") continue;
73
+ const code = (cell as { code?: unknown }).code;
74
+ if (typeof code !== "string" || code.length === 0) continue;
75
+ codeBlocks.push(code);
76
+ if (!languageResolved) {
77
+ language = (cell as { language?: unknown }).language === "js" ? "javascript" : "python";
78
+ languageResolved = true;
79
+ }
80
+ }
81
+
82
+ return codeBlocks.length > 0 ? { code: codeBlocks.join("\n\n"), language } : undefined;
83
+ }
84
+
85
+ function commandFromToolCall(tc: ToolCall): LastCommand | undefined {
86
+ if (tc.name === "bash" && typeof tc.arguments.command === "string") {
87
+ return { kind: "bash", code: tc.arguments.command, language: "bash" };
88
+ }
89
+ if (tc.name === "eval") {
90
+ const evalResult = extractEvalCode(tc.arguments);
91
+ if (evalResult) return { kind: "eval", code: evalResult.code, language: evalResult.language };
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ /** Walk the transcript backwards for the most recent bash command or eval code. */
97
+ export function extractLastCommand(messages: readonly AgentMessage[]): LastCommand | undefined {
98
+ for (let i = messages.length - 1; i >= 0; i--) {
99
+ const msg = messages[i];
100
+ if (msg.role !== "assistant") continue;
101
+ const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
102
+ for (let j = toolCalls.length - 1; j >= 0; j--) {
103
+ const command = commandFromToolCall(toolCalls[j]!);
104
+ if (command) return command;
105
+ }
106
+ }
107
+ return undefined;
108
+ }
109
+
110
+ /** Concatenated visible text of an assistant message, or undefined when empty. */
111
+ function assistantText(msg: AgentMessage): string | undefined {
112
+ if (msg.role !== "assistant") return undefined;
113
+ let text = "";
114
+ for (const content of msg.content) {
115
+ if (content.type === "text") text += content.text;
116
+ }
117
+ return text.trim() || undefined;
118
+ }
119
+
120
+ function pluralLines(text: string): string {
121
+ const count = text.length === 0 ? 0 : text.split("\n").length;
122
+ return `${count} line${count === 1 ? "" : "s"}`;
123
+ }
124
+
125
+ function blockHint(block: CodeBlock): string {
126
+ const lines = pluralLines(block.code);
127
+ return block.lang ? `${block.lang} · ${lines}` : lines;
128
+ }
129
+
130
+ /** First non-empty line, whitespace-collapsed, used as a message label. */
131
+ function firstLine(text: string): string {
132
+ for (const line of text.split("\n")) {
133
+ const trimmed = line.trim();
134
+ if (trimmed) return trimmed.replace(/\s+/g, " ");
135
+ }
136
+ return text.trim().replace(/\s+/g, " ");
137
+ }
138
+
139
+ /** Build the target node for one assistant message: a leaf when it has no code
140
+ * blocks, otherwise a group exposing the full message, each block, and "all". */
141
+ function messageTarget(text: string, rank: number): CopyTarget {
142
+ const id = `msg:${rank}`;
143
+ const label = firstLine(text);
144
+ const blocks = extractCodeBlocks(text);
145
+ const hint = blocks.length > 0 ? `${pluralLines(text)} · ${blocks.length} code` : pluralLines(text);
146
+ const messageCopy = rank === 1 ? "Copied last message to clipboard" : "Copied message to clipboard";
147
+
148
+ if (blocks.length === 0) {
149
+ return { id, label, hint, preview: text, content: text, copyMessage: messageCopy };
150
+ }
151
+
152
+ // The message node itself copies the full message; its code blocks are
153
+ // child copy targets you can expand into.
154
+ const children: CopyTarget[] = blocks.map((block, j) => ({
155
+ id: `${id}:code:${j}`,
156
+ label: `Block ${j + 1}`,
157
+ hint: blockHint(block),
158
+ preview: block.code,
159
+ language: block.lang || undefined,
160
+ content: block.code,
161
+ copyMessage: `Copied code block ${j + 1} to clipboard`,
162
+ }));
163
+ if (blocks.length > 1) {
164
+ const combined = blocks.map(b => b.code).join("\n\n");
165
+ children.push({
166
+ id: `${id}:all`,
167
+ label: `All ${blocks.length} blocks`,
168
+ hint: pluralLines(combined),
169
+ preview: combined,
170
+ content: combined,
171
+ copyMessage: `Copied ${blocks.length} code blocks to clipboard`,
172
+ });
173
+ }
174
+
175
+ return { id, label, hint, preview: text, content: text, copyMessage: messageCopy, children };
176
+ }
177
+
178
+ function commandTitle(command: LastCommand): string {
179
+ return command.kind === "bash" ? "Bash command" : "Eval code";
180
+ }
181
+
182
+ function commandTarget(command: LastCommand, rank: number): CopyTarget {
183
+ const title = commandTitle(command);
184
+ return {
185
+ id: `cmd:${rank}`,
186
+ label: firstLine(command.code) || title,
187
+ hint: `${command.kind} · ${pluralLines(command.code)}`,
188
+ preview: command.code,
189
+ language: command.language,
190
+ content: command.code,
191
+ copyMessage: `Copied ${command.kind === "bash" ? "bash command" : "eval code"} to clipboard`,
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Assemble the unified `/copy` target tree: recent assistant messages
197
+ * (most recent first, each drillable into its code blocks), runnable command
198
+ * targets interleaved after the assistant message that issued them, and a
199
+ * fresh-handoff fallback when no assistant message exists yet.
200
+ */
201
+ export function buildCopyTargets(source: CopySource): CopyTarget[] {
202
+ const targets: CopyTarget[] = [];
203
+ const pendingCommands: LastCommand[] = [];
204
+ let messageRank = 0;
205
+ let commandRank = 0;
206
+
207
+ const appendCommands = (commands: readonly LastCommand[]) => {
208
+ for (const command of commands) {
209
+ commandRank += 1;
210
+ targets.push(commandTarget(command, commandRank));
211
+ }
212
+ };
213
+
214
+ for (let i = source.messages.length - 1; i >= 0 && messageRank < MAX_MESSAGES; i--) {
215
+ const msg = source.messages[i];
216
+ if (msg.role !== "assistant") continue;
217
+
218
+ const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
219
+ const commands: LastCommand[] = [];
220
+ for (let j = toolCalls.length - 1; j >= 0; j--) {
221
+ const command = commandFromToolCall(toolCalls[j]!);
222
+ if (command) commands.push(command);
223
+ }
224
+
225
+ const text = assistantText(msg);
226
+ if (!text) {
227
+ pendingCommands.push(...commands);
228
+ continue;
229
+ }
230
+
231
+ messageRank += 1;
232
+ targets.push(messageTarget(text, messageRank));
233
+ appendCommands(pendingCommands);
234
+ appendCommands(commands);
235
+ pendingCommands.length = 0;
236
+ }
237
+
238
+ if (messageRank === 0) {
239
+ const handoff = source.getLastVisibleHandoffText();
240
+ if (handoff) {
241
+ targets.unshift({
242
+ id: "handoff",
243
+ label: "Handoff context",
244
+ hint: pluralLines(handoff),
245
+ preview: handoff,
246
+ content: handoff,
247
+ copyMessage: "Copied handoff context to clipboard",
248
+ });
249
+ }
250
+ appendCommands(pendingCommands);
251
+ }
252
+
253
+ return targets;
254
+ }
@@ -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
 
@@ -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>
@@ -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}}
@@ -14,7 +14,7 @@ Performs structural AST-aware rewrites via native ast-grep.
14
14
  </instruction>
15
15
 
16
16
  <output>
17
- - Replacement summary, per-file replacement counts, and change diffs as src/foo.ts#0a`, `-12:before`, `+12:after` lines in hashline mode
17
+ - Replacement summary, per-file replacement counts, and change diffs as `[src/foo.ts#1A2B]`, `-12:before`, `+12:after` lines in hashline mode
18
18
  - Parse issues when files cannot be processed
19
19
  </output>
20
20