@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
@@ -6,7 +6,6 @@ import {
6
6
  getEnvApiKey,
7
7
  getProviderDetails,
8
8
  type ProviderDetails,
9
- type ToolCall,
10
9
  type UsageLimit,
11
10
  type UsageReport,
12
11
  } from "@oh-my-pi/pi-ai";
@@ -239,121 +238,6 @@ export class CommandController {
239
238
  }
240
239
  }
241
240
 
242
- handleCopyCommand(sub?: string) {
243
- switch (sub) {
244
- case "code":
245
- return this.#copyCode();
246
- case "all":
247
- return this.#copyAllCode();
248
- case "cmd":
249
- return this.#copyLastCommand();
250
- case "last":
251
- case undefined:
252
- return this.#copyLastMessage();
253
- default:
254
- this.ctx.showError(`Unknown subcommand: ${sub}. Use code, all, cmd, or last.`);
255
- }
256
- }
257
-
258
- #copyLastMessage() {
259
- const assistantText = this.ctx.session.getLastAssistantText();
260
- if (assistantText) {
261
- this.#doCopy(assistantText, "Copied last agent message to clipboard");
262
- return;
263
- }
264
-
265
- if (!this.ctx.session.hasCopyCandidateAssistantMessage()) {
266
- const handoffText = this.ctx.session.getLastVisibleHandoffText();
267
- if (handoffText) {
268
- this.#doCopy(handoffText, "Copied handoff context to clipboard");
269
- return;
270
- }
271
- }
272
-
273
- this.ctx.showError("No agent messages to copy yet.");
274
- }
275
-
276
- #copyCode() {
277
- const text = this.ctx.session.getLastAssistantText();
278
- if (!text) {
279
- this.ctx.showError("No agent messages to copy yet.");
280
- return;
281
- }
282
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
283
- const lastMatch = matches.at(-1);
284
- if (!lastMatch) {
285
- this.ctx.showWarning("No code block found in the last agent message.");
286
- return;
287
- }
288
- this.#doCopy(lastMatch[1].replace(/\n$/, ""), "Copied last code block to clipboard");
289
- }
290
-
291
- #copyAllCode() {
292
- const text = this.ctx.session.getLastAssistantText();
293
- if (!text) {
294
- this.ctx.showError("No agent messages to copy yet.");
295
- return;
296
- }
297
- const matches = [...text.matchAll(/^```[^\n]*\n([\s\S]*?)^```/gm)];
298
- if (matches.length === 0) {
299
- this.ctx.showWarning("No code blocks found in the last agent message.");
300
- return;
301
- }
302
- const combined = matches.map(m => m[1].replace(/\n$/, "")).join("\n\n");
303
- this.#doCopy(combined, `Copied ${matches.length} code block${matches.length > 1 ? "s" : ""} to clipboard`);
304
- }
305
-
306
- #extractEvalCode(args: unknown): string | undefined {
307
- if (!args || typeof args !== "object") return undefined;
308
- const cells = (args as { cells?: unknown }).cells;
309
- if (!Array.isArray(cells)) return undefined;
310
-
311
- const codeBlocks: string[] = [];
312
- for (const cell of cells) {
313
- if (!cell || typeof cell !== "object") continue;
314
- const code = (cell as { code?: unknown }).code;
315
- if (typeof code === "string" && code.length > 0) {
316
- codeBlocks.push(code);
317
- }
318
- }
319
-
320
- return codeBlocks.length > 0 ? codeBlocks.join("\n\n") : undefined;
321
- }
322
-
323
- #copyLastCommand() {
324
- const messages = this.ctx.session.messages;
325
- // Walk backwards to find the last bash/eval tool call
326
- for (let i = messages.length - 1; i >= 0; i--) {
327
- const msg = messages[i];
328
- if (msg.role !== "assistant") continue;
329
- const toolCalls = msg.content.filter((c): c is ToolCall => c.type === "toolCall");
330
- for (let j = toolCalls.length - 1; j >= 0; j--) {
331
- const tc = toolCalls[j];
332
- if (tc.name === "bash" && typeof tc.arguments.command === "string") {
333
- this.#doCopy(tc.arguments.command, "Copied last bash command to clipboard");
334
- return;
335
- }
336
- if (tc.name === "eval") {
337
- const code = this.#extractEvalCode(tc.arguments);
338
- if (code) {
339
- this.#doCopy(code, "Copied last eval code to clipboard");
340
- return;
341
- }
342
- }
343
- }
344
- }
345
- this.ctx.showWarning("No bash or eval command found in the conversation.");
346
- }
347
-
348
- #doCopy(content: string, label: string) {
349
- try {
350
- copyToClipboard(content);
351
- this.ctx.showStatus(label);
352
- } catch (error) {
353
- this.ctx.showError(error instanceof Error ? error.message : String(error));
354
- }
355
- }
356
-
357
241
  async handleSessionCommand(): Promise<void> {
358
242
  const stats = this.ctx.session.getSessionStats();
359
243
  const premiumRequests =
@@ -49,9 +49,15 @@ export class EventController {
49
49
  #lastIntent: string | undefined = undefined;
50
50
  #backgroundToolCallIds = new Set<string>();
51
51
  #assistantMessageStreaming = false;
52
+ #agentTurnActive = false;
52
53
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
53
54
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
54
55
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
56
+ // Assistant component whose turn-ending error is currently mirrored in the
57
+ // pinned banner. Its inline `Error: …` line is suppressed while pinned and
58
+ // restored when the banner clears at the next `agent_start` (see
59
+ // #handleMessageEnd / #handleAgentStart).
60
+ #pinnedErrorComponent: AssistantMessageComponent | undefined = undefined;
55
61
  #idleCompactionTimer?: NodeJS.Timeout;
56
62
  #ircExpiryTimers = new Map<string, NodeJS.Timeout>();
57
63
  #handlers: AgentSessionEventHandlers;
@@ -172,21 +178,21 @@ export class EventController {
172
178
 
173
179
  const run = this.#handlers[event.type] as (e: AgentSessionEvent) => Promise<void>;
174
180
  await run(event);
175
- // While assistant text or a foreground tool is streaming, rows above the
176
- // viewport can re-layout after they have already entered native scrollback
177
- // (Markdown fences, wrapping, previews). Let the TUI rebuild history on
178
- // those offscreen edits instead of deferring, which otherwise leaves stale
179
- // tail rows duplicated above the live viewport.
180
- // Background-running tools are excluded so late async updates outside the
181
- // active foreground stream keep the no-yank deferral; agent_start resets
182
- // the mode at every turn boundary.
181
+ // While an assistant turn is active, visible status chrome and foreground
182
+ // transcript blocks can re-render after rows have entered native scrollback
183
+ // (idle Working loader, Markdown fences, wrapping, tool previews). Let the
184
+ // TUI use its foreground live-region path instead of idle deferral, which
185
+ // can otherwise leave the loader/status frame frozen until the next input.
186
+ // Background-running tools after the turn ends are excluded so late async
187
+ // updates keep the no-yank deferral; agent_start/agent_end bracket the
188
+ // foreground turn.
183
189
  if (STREAM_RENDER_MODE_EVENTS[event.type]) {
184
190
  this.#refreshToolRenderMode();
185
191
  }
186
192
  }
187
193
 
188
194
  #refreshToolRenderMode(): void {
189
- let foregroundToolActive = this.#assistantMessageStreaming;
195
+ let foregroundToolActive = this.#agentTurnActive || this.#assistantMessageStreaming;
190
196
  if (!foregroundToolActive) {
191
197
  for (const toolCallId of this.ctx.pendingTools.keys()) {
192
198
  if (!this.#backgroundToolCallIds.has(toolCallId)) {
@@ -199,11 +205,17 @@ export class EventController {
199
205
  }
200
206
 
201
207
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
208
+ this.#agentTurnActive = true;
202
209
  this.#lastIntent = undefined;
203
210
  this.#readToolCallArgs.clear();
204
211
  this.#readToolCallAssistantComponents.clear();
205
212
  this.#assistantMessageStreaming = false;
206
213
  this.#lastAssistantComponent = undefined;
214
+ // Restore the previous turn's inline error in the transcript before dropping
215
+ // the banner, so the error stays in history once the banner is gone.
216
+ this.#pinnedErrorComponent?.setErrorPinned(false);
217
+ this.#pinnedErrorComponent = undefined;
218
+ this.ctx.clearPinnedError();
207
219
  if (this.ctx.retryEscapeHandler) {
208
220
  this.ctx.editor.onEscape = this.ctx.retryEscapeHandler;
209
221
  this.ctx.retryEscapeHandler = undefined;
@@ -214,6 +226,7 @@ export class EventController {
214
226
  this.ctx.statusContainer.clear();
215
227
  }
216
228
  this.#cancelIdleCompaction();
229
+ this.#refreshToolRenderMode();
217
230
  this.ctx.ensureLoadingAnimation();
218
231
  this.ctx.ui.requestRender();
219
232
  }
@@ -241,16 +254,28 @@ export class EventController {
241
254
  this.ctx.ui.requestRender();
242
255
  } else if (event.message.role === "user") {
243
256
  const textContent = this.ctx.getUserMessageText(event.message);
244
- const imageCount =
257
+ const imageBlocks =
245
258
  typeof event.message.content === "string"
246
- ? 0
247
- : event.message.content.filter(content => content.type === "image").length;
259
+ ? []
260
+ : event.message.content.filter(
261
+ (content): content is ImageContent =>
262
+ content.type === "image" &&
263
+ typeof content.data === "string" &&
264
+ typeof content.mimeType === "string",
265
+ );
266
+ const imageCount = imageBlocks.length;
248
267
  const signature = `${textContent}\u0000${imageCount}`;
249
268
 
250
269
  this.#resetReadGroup();
251
270
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
252
271
  const wasLocallySubmitted = this.ctx.locallySubmittedUserSignatures.delete(signature) || wasOptimistic;
253
272
  if (!wasOptimistic) {
273
+ // Append synchronously: #emit dispatches to this listener fire-and-forget
274
+ // (see AgentSession.#emit), so any await between the user message_start and
275
+ // addMessageToChat lets later events (assistant message_start, tool execution
276
+ // start/end) append their components first and scramble transcript order /
277
+ // live-region block boundaries. addMessageToChat materializes clickable image
278
+ // links via the synchronous putBlobSync fallback, so no await is needed here.
254
279
  this.ctx.addMessageToChat(event.message);
255
280
  }
256
281
  if (wasOptimistic) {
@@ -462,11 +487,35 @@ export class EventController {
462
487
  for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
463
488
  component.setArgsComplete(toolCallId);
464
489
  }
490
+ } else {
491
+ // The turn ended without running these calls (abort/error/TTSR rewind),
492
+ // so they will never produce a result. Seal them so they stop animating
493
+ // and freeze instead of pinning the transcript live region while a retry
494
+ // streams fresh blocks below them. Background tools keep updating.
495
+ for (const [toolCallId, component] of this.ctx.pendingTools.entries()) {
496
+ if (!this.#backgroundToolCallIds.has(toolCallId) && component instanceof ToolExecutionComponent) {
497
+ component.seal();
498
+ }
499
+ }
465
500
  }
466
501
  this.#lastAssistantComponent = this.ctx.streamingComponent;
467
502
  this.#lastAssistantComponent.setUsageInfo(event.message.usage);
503
+ this.#lastAssistantComponent.markTranscriptBlockFinalized();
468
504
  this.ctx.streamingComponent = undefined;
469
505
  this.ctx.streamingMessage = undefined;
506
+ // Pin a turn-ending provider error (e.g. Anthropic content-filter block)
507
+ // above the editor so it survives transcript scroll. Cleared at the next
508
+ // turn's agent_start. Suppress the transcript's inline `Error: …` line for
509
+ // the same message while pinned so the error isn't rendered twice.
510
+ if (
511
+ event.message.stopReason === "error" &&
512
+ event.message.errorMessage &&
513
+ !isSilentAbort(event.message.errorMessage)
514
+ ) {
515
+ this.#lastAssistantComponent?.setErrorPinned(true);
516
+ this.#pinnedErrorComponent = this.#lastAssistantComponent;
517
+ this.ctx.showPinnedError(event.message.errorMessage);
518
+ }
470
519
  this.ctx.statusLine.invalidate();
471
520
  this.ctx.updateEditorTopBorder();
472
521
  }
@@ -612,6 +661,7 @@ export class EventController {
612
661
  }
613
662
  }
614
663
  async #handleAgentEnd(_event: Extract<AgentSessionEvent, { type: "agent_end" }>): Promise<void> {
664
+ this.#agentTurnActive = false;
615
665
  this.#assistantMessageStreaming = false;
616
666
  if (this.ctx.loadingAnimation) {
617
667
  this.ctx.loadingAnimation.stop();
@@ -626,6 +676,11 @@ export class EventController {
626
676
  await this.ctx.flushPendingModelSwitch();
627
677
  for (const toolCallId of Array.from(this.ctx.pendingTools.keys())) {
628
678
  if (!this.#backgroundToolCallIds.has(toolCallId)) {
679
+ // A foreground tool still pending at turn end never delivered a result;
680
+ // seal it so it freezes (and stops animating) rather than lingering in
681
+ // the transcript live region as a streaming preview until the next thaw.
682
+ const component = this.ctx.pendingTools.get(toolCallId);
683
+ if (component instanceof ToolExecutionComponent) component.seal();
629
684
  this.ctx.pendingTools.delete(toolCallId);
630
685
  }
631
686
  }
@@ -6,6 +6,7 @@ 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
@@ -417,11 +429,17 @@ export class InputController {
417
429
 
418
430
  if (this.ctx.onInputCallback) {
419
431
  // Include any pending images from clipboard paste
432
+ this.ctx.editor.imageLinks = undefined;
420
433
  const images = inputImages && inputImages.length > 0 ? [...inputImages] : undefined;
421
434
  this.ctx.pendingImages = [];
435
+ this.ctx.pendingImageLinks = [];
422
436
 
423
437
  // Render user message immediately, then let session events catch up
424
- const submission = this.ctx.startPendingSubmission({ text, images });
438
+ const submission = this.ctx.startPendingSubmission({
439
+ text,
440
+ images,
441
+ imageLinks: inputImageLinks,
442
+ });
425
443
 
426
444
  this.ctx.onInputCallback(submission);
427
445
  }
@@ -685,11 +703,25 @@ export class InputController {
685
703
  }
686
704
  }
687
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];
688
718
  this.ctx.pendingImages.push({
689
719
  type: "image",
690
720
  data: imageData.data,
691
721
  mimeType: imageData.mimeType,
692
722
  });
723
+ this.ctx.pendingImageLinks.push(imageLink);
724
+ this.ctx.editor.imageLinks = this.ctx.pendingImageLinks;
693
725
  // Insert placeholder at cursor like Claude does
694
726
  const imageNum = this.ctx.pendingImages.length;
695
727
  const placeholder = `[Image #${imageNum}]`;
@@ -37,9 +37,11 @@ import {
37
37
  setPreferredSearchProvider,
38
38
  } from "../../tools";
39
39
  import { shortenPath } from "../../tools/render-utils";
40
+ import { copyToClipboard } from "../../utils/clipboard";
40
41
  import { setSessionTerminalTitle } from "../../utils/title-generator";
41
42
  import { AgentDashboard } from "../components/agent-dashboard";
42
43
  import { AssistantMessageComponent } from "../components/assistant-message";
44
+ import { CopySelectorComponent } from "../components/copy-selector";
43
45
  import { ExtensionDashboard } from "../components/extensions";
44
46
  import { HistorySearchComponent } from "../components/history-search";
45
47
  import { ModelSelectorComponent } from "../components/model-selector";
@@ -52,6 +54,8 @@ import { ToolExecutionComponent } from "../components/tool-execution";
52
54
  import { TreeSelectorComponent } from "../components/tree-selector";
53
55
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
54
56
  import type { SessionObserverRegistry } from "../session-observer-registry";
57
+ import { computeContextBreakdown } from "../utils/context-usage";
58
+ import { buildCopyTargets } from "../utils/copy-targets";
55
59
 
56
60
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
57
61
  "anthropic",
@@ -407,6 +411,7 @@ export class SelectorController {
407
411
  }
408
412
 
409
413
  showModelSelector(options?: { temporaryOnly?: boolean }): void {
414
+ const currentContextTokens = computeContextBreakdown(this.ctx.session).usedTokens;
410
415
  this.showSelector(done => {
411
416
  const selector = new ModelSelectorComponent(
412
417
  this.ctx.ui,
@@ -470,7 +475,7 @@ export class SelectorController {
470
475
  done();
471
476
  this.ctx.ui.requestRender();
472
477
  },
473
- options,
478
+ { ...options, currentContextTokens },
474
479
  );
475
480
  return { component: selector, focus: selector };
476
481
  });
@@ -598,6 +603,38 @@ export class SelectorController {
598
603
  });
599
604
  }
600
605
 
606
+ showCopySelector(): void {
607
+ const targets = buildCopyTargets(this.ctx.session);
608
+ if (targets.length === 0) {
609
+ this.ctx.showStatus("Nothing to copy yet.");
610
+ return;
611
+ }
612
+
613
+ let overlayHandle: OverlayHandle | undefined;
614
+ const done = () => {
615
+ overlayHandle?.hide();
616
+ this.ctx.ui.requestRender();
617
+ };
618
+ const selector = new CopySelectorComponent(targets, {
619
+ onPick: target => {
620
+ done();
621
+ if (target.content === undefined) return;
622
+ void copyToClipboard(target.content);
623
+ this.ctx.showStatus(target.copyMessage ?? "Copied to clipboard");
624
+ },
625
+ onCancel: done,
626
+ });
627
+
628
+ overlayHandle = this.ctx.ui.showOverlay(selector, {
629
+ anchor: "bottom-center",
630
+ width: "100%",
631
+ maxHeight: "100%",
632
+ margin: 0,
633
+ });
634
+ this.ctx.ui.setFocus(selector);
635
+ this.ctx.ui.requestRender();
636
+ }
637
+
601
638
  showTreeSelector(): void {
602
639
  const tree = this.ctx.sessionManager.getTree();
603
640
  const realLeafId = this.ctx.sessionManager.getLeafId();
@@ -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
+ }