@oh-my-pi/pi-coding-agent 16.1.20 → 16.1.22

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 (38) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/cli.js +2284 -2272
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  5. package/dist/types/mcp/oauth-flow.d.ts +42 -5
  6. package/dist/types/mcp/transports/stdio.test.d.ts +1 -0
  7. package/dist/types/modes/components/custom-editor.d.ts +32 -0
  8. package/dist/types/modes/components/tool-execution.d.ts +5 -5
  9. package/dist/types/modes/controllers/event-controller.d.ts +10 -0
  10. package/dist/types/modes/controllers/input-controller.d.ts +2 -1
  11. package/dist/types/utils/clipboard.d.ts +10 -0
  12. package/package.json +12 -12
  13. package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
  14. package/src/advisor/__tests__/advisor.test.ts +44 -0
  15. package/src/advisor/advise-tool.ts +33 -0
  16. package/src/autolearn/controller.ts +17 -2
  17. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
  18. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
  19. package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
  20. package/src/mcp/manager.ts +12 -3
  21. package/src/mcp/oauth-discovery.ts +48 -1
  22. package/src/mcp/oauth-flow.ts +121 -7
  23. package/src/mcp/transports/stdio.test.ts +28 -0
  24. package/src/mcp/transports/stdio.ts +5 -2
  25. package/src/modes/components/chat-transcript-builder.ts +31 -0
  26. package/src/modes/components/custom-editor.test.ts +80 -0
  27. package/src/modes/components/custom-editor.ts +86 -6
  28. package/src/modes/components/tool-execution.ts +50 -25
  29. package/src/modes/controllers/event-controller.ts +57 -8
  30. package/src/modes/controllers/input-controller.ts +70 -27
  31. package/src/modes/controllers/mcp-command-controller.ts +18 -2
  32. package/src/modes/utils/ui-helpers.ts +40 -0
  33. package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
  34. package/src/prompts/system/autolearn-nudge.md +4 -2
  35. package/src/prompts/tools/todo.md +1 -1
  36. package/src/session/agent-session.ts +4 -0
  37. package/src/tools/todo.ts +20 -10
  38. package/src/utils/clipboard.ts +57 -0
@@ -5,6 +5,8 @@ import {
5
5
  CustomEditor,
6
6
  extractBracketedImagePastePaths,
7
7
  extractBracketedPastePaths,
8
+ extractImagePathFromText,
9
+ extractPastePathsFromText,
8
10
  SPACE_HOLD_MECHANICAL_RUN,
9
11
  SPACE_HOLD_RELEASE_MS,
10
12
  SPACE_REPEAT_MAX_GAP_MS,
@@ -88,6 +90,22 @@ describe("CustomEditor bracketed path paste", () => {
88
90
  ]);
89
91
  });
90
92
 
93
+ it("strips `file://` URLs to the local filesystem path before loading the image", () => {
94
+ // macOS / Ghostty / iTerm2 sometimes forward the pasteboard's
95
+ // `public.file-url` representation when the user does Finder→Copy
96
+ // then Cmd+V. Without decoding, `loadImageInput` would try to read a
97
+ // literal `file:///…` path and fail.
98
+ expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/Pictures/photo.png"))).toEqual([
99
+ "/Users/me/Pictures/photo.png",
100
+ ]);
101
+ });
102
+
103
+ it("percent-decodes spaces inside `file://` URLs", () => {
104
+ expect(extractBracketedImagePastePaths(bracketedPaste("file:///Users/me/My%20Pictures/photo.png"))).toEqual([
105
+ "/Users/me/My Pictures/photo.png",
106
+ ]);
107
+ });
108
+
91
109
  it("extracts explicit non-image paths without classifying them as image paths", () => {
92
110
  expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
93
111
  expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
@@ -107,6 +125,68 @@ describe("CustomEditor bracketed path paste", () => {
107
125
  });
108
126
  });
109
127
 
128
+ describe("extractImagePathFromText (issue #3506)", () => {
129
+ it("returns the path when the text is a single image file path", () => {
130
+ expect(extractImagePathFromText("/tmp/screenshot.png")).toBe("/tmp/screenshot.png");
131
+ expect(extractImagePathFromText("/Users/me/Pictures/photo.jpeg")).toBe("/Users/me/Pictures/photo.jpeg");
132
+ expect(extractImagePathFromText("C:\\Users\\me\\img.gif")).toBe("C:\\Users\\me\\img.gif");
133
+ });
134
+
135
+ it("ignores surrounding whitespace from a clipboard read", () => {
136
+ expect(extractImagePathFromText(" /tmp/photo.webp\n")).toBe("/tmp/photo.webp");
137
+ });
138
+
139
+ it("returns undefined for a bare filename (no explicit directory)", () => {
140
+ // Mirrors the bracketed-paste contract: a bare `.png` filename is
141
+ // almost always a project-relative reference the user wants as text,
142
+ // not a clipboard-anchored attachment.
143
+ expect(extractImagePathFromText("icon.png")).toBeUndefined();
144
+ });
145
+
146
+ it("returns undefined for non-image extensions", () => {
147
+ expect(extractImagePathFromText("/tmp/report.csv")).toBeUndefined();
148
+ expect(extractImagePathFromText("/tmp/notes.txt")).toBeUndefined();
149
+ });
150
+
151
+ it("returns undefined when the text contains anything beyond a single path", () => {
152
+ expect(extractImagePathFromText("see /tmp/screenshot.png")).toBeUndefined();
153
+ expect(extractImagePathFromText("/tmp/a.png /tmp/b.png")).toBeUndefined();
154
+ });
155
+
156
+ it("returns undefined for empty/whitespace-only input", () => {
157
+ expect(extractImagePathFromText("")).toBeUndefined();
158
+ expect(extractImagePathFromText(" ")).toBeUndefined();
159
+ });
160
+
161
+ it("decodes a `file://` URL to its filesystem path", () => {
162
+ expect(extractImagePathFromText("file:///Users/me/Pictures/photo.png")).toBe("/Users/me/Pictures/photo.png");
163
+ });
164
+
165
+ it("recovers a single anchored image path containing unescaped spaces (macOS screenshot name)", () => {
166
+ const macScreenshot = "/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png";
167
+ expect(extractImagePathFromText(macScreenshot)).toBe(macScreenshot);
168
+ expect(extractImagePathFromText("~/Pictures/Cleanshot 2026-06-25 at 12.00.png")).toBe(
169
+ "~/Pictures/Cleanshot 2026-06-25 at 12.00.png",
170
+ );
171
+ expect(extractImagePathFromText("C:\\Users\\me\\My Pictures\\img with space.jpg")).toBe(
172
+ "C:\\Users\\me\\My Pictures\\img with space.jpg",
173
+ );
174
+ });
175
+
176
+ it("does not hijack prose that happens to contain a path-shaped fragment", () => {
177
+ // The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
178
+ // non-anchored prefix ("see ...") never triggers it.
179
+ expect(extractImagePathFromText("see /Users/me/Desktop/Screenshot 1.png")).toBeUndefined();
180
+ });
181
+ });
182
+
183
+ describe("extractPastePathsFromText", () => {
184
+ it("delegates to the same logic the bracketed variant uses for path detection", () => {
185
+ expect(extractPastePathsFromText("/tmp/a.png /tmp/b.png")).toEqual(["/tmp/a.png", "/tmp/b.png"]);
186
+ expect(extractPastePathsFromText("just text")).toBeUndefined();
187
+ });
188
+ });
189
+
110
190
  describe("CustomEditor space-hold push-to-talk", () => {
111
191
  beforeAll(async () => {
112
192
  await initTheme();
@@ -1,3 +1,4 @@
1
+ import { fileURLToPath } from "node:url";
1
2
  import type { ImageContent } from "@oh-my-pi/pi-ai";
2
3
  import { addKeyAliases, canonicalKeyId, Editor, type KeyId, parseKey, parseKittySequence } from "@oh-my-pi/pi-tui";
3
4
  import type { AppKeybinding } from "../../config/keybindings";
@@ -66,6 +67,14 @@ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
66
67
  const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
67
68
  const FILE_URI_REGEX = /^file:\/\//i;
68
69
  const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
70
+ /**
71
+ * Whole-string anchor for paths that are unambiguously absolute. Restricts the
72
+ * "treat the entire clipboard text as one path" branch of
73
+ * {@link extractImagePathFromText} to inputs that start with a clearly-anchored
74
+ * filesystem prefix, so prose containing a path-shaped fragment (e.g.
75
+ * "see /tmp/x.png") never hijacks the smart fallback.
76
+ */
77
+ const ABSOLUTE_PATH_PREFIX_REGEX = /^(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])/;
69
78
 
70
79
  /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
71
80
  * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
@@ -105,6 +114,20 @@ function normalizePastedPath(path: string): string {
105
114
  const last = trimmed[trimmed.length - 1];
106
115
  const unquoted =
107
116
  trimmed.length > 1 && (first === '"' || first === "'") && last === first ? trimmed.slice(1, -1) : trimmed;
117
+ // `file://` URL → local filesystem path. Mirrors Codex's
118
+ // `normalize_pasted_path` (codex-rs/tui/src/clipboard_paste.rs) so a
119
+ // pasteboard whose text representation is a `file:///Users/…/img.png`
120
+ // URL — common when terminals forward the macOS pasteboard's
121
+ // `public.file-url` representation — loads as the file itself rather
122
+ // than failing in `loadImageInput` with a literal-`file://` path.
123
+ if (FILE_URI_REGEX.test(unquoted)) {
124
+ try {
125
+ return fileURLToPath(unquoted);
126
+ } catch {
127
+ // Malformed file URL: drop through to the shell-unescape branch
128
+ // so the caller can still reject it as a non-explicit path.
129
+ }
130
+ }
108
131
  return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
109
132
  }
110
133
 
@@ -161,12 +184,15 @@ function splitPastedPathSegments(payload: string): string[] | undefined {
161
184
  return segments.length > 0 ? segments : undefined;
162
185
  }
163
186
 
164
- export function extractBracketedPastePaths(data: string): string[] | undefined {
165
- if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
166
- const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
167
- if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
168
-
169
- const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
187
+ /**
188
+ * Extract whitespace/quoted-separated path-like segments from `payload`.
189
+ * Shared backend of {@link extractBracketedPastePaths} and {@link extractPastePathsFromText}.
190
+ * Returns the segments only when EVERY segment looks like an explicit path
191
+ * (`/`, `\`, drive letter, or `file://`); otherwise undefined so the caller
192
+ * falls back to a plain text paste.
193
+ */
194
+ function extractExplicitPathSegments(payload: string): string[] | undefined {
195
+ const pasted = payload.trim();
170
196
  if (!pasted) return undefined;
171
197
 
172
198
  const segments = splitPastedPathSegments(pasted);
@@ -181,6 +207,23 @@ export function extractBracketedPastePaths(data: string): string[] | undefined {
181
207
  return paths;
182
208
  }
183
209
 
210
+ /**
211
+ * Extract image-or-other file paths from plain (un-bracketed) clipboard text.
212
+ * Mirrors {@link extractBracketedPastePaths} for terminals/handlers that
213
+ * already stripped the `\x1b[200~`…`\x1b[201~` markers (e.g. clipboard text
214
+ * read directly via `pbpaste`/PowerShell).
215
+ */
216
+ export function extractPastePathsFromText(text: string): string[] | undefined {
217
+ return extractExplicitPathSegments(text);
218
+ }
219
+
220
+ export function extractBracketedPastePaths(data: string): string[] | undefined {
221
+ if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
222
+ const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
223
+ if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
224
+ return extractExplicitPathSegments(data.slice(BRACKETED_PASTE_START.length, endIndex));
225
+ }
226
+
184
227
  export function extractBracketedImagePastePaths(data: string): string[] | undefined {
185
228
  const paths = extractBracketedPastePaths(data);
186
229
  return paths?.every(isImagePath) ? paths : undefined;
@@ -191,6 +234,43 @@ export function extractBracketedImagePastePath(data: string): string | undefined
191
234
  return paths?.length === 1 ? paths[0] : undefined;
192
235
  }
193
236
 
237
+ /**
238
+ * Return a single image file path when `text` is exactly one explicit path
239
+ * pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`,
240
+ * `.webp`). Used by the keybind-driven clipboard image paste path so a
241
+ * clipboard whose only payload is an image file (e.g. Finder `Cmd+C` on
242
+ * macOS) attaches the image instead of pasting the path as literal text.
243
+ *
244
+ * Two-stage detection:
245
+ *
246
+ * 1. Splitter pass (shared with the bracketed-paste handler) — handles
247
+ * quoted paths, shell-escaped spaces, and unambiguous single tokens.
248
+ * Returns the single image path when it parses cleanly; explicitly
249
+ * returns `undefined` when the splitter found multiple segments (so
250
+ * ambiguous multi-path clipboard text like `/tmp/a.png /tmp/b.png`
251
+ * still falls through to the text fallback instead of being mis-loaded
252
+ * as one giant path).
253
+ * 2. Whole-text-as-path pass — only reached when the splitter failed
254
+ * (every segment must look like an explicit path; an unescaped space in
255
+ * a real path breaks that). Restricted to inputs anchored by
256
+ * {@link ABSOLUTE_PATH_PREFIX_REGEX} so prose containing a path-shaped
257
+ * fragment ("see /tmp/x.png") never hijacks the smart fallback. This
258
+ * is what recovers macOS screenshot filenames like
259
+ * `/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png`.
260
+ */
261
+ export function extractImagePathFromText(text: string): string | undefined {
262
+ const paths = extractPastePathsFromText(text);
263
+ if (paths?.length === 1 && isImagePath(paths[0])) return paths[0];
264
+ if (paths !== undefined) return undefined;
265
+ const trimmed = text.trim();
266
+ if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
267
+ const wholePath = normalizePastedPath(trimmed);
268
+ if (wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath)) {
269
+ return wholePath;
270
+ }
271
+ return undefined;
272
+ }
273
+
194
274
  /**
195
275
  * Custom editor that handles configurable app-level shortcuts for coding-agent.
196
276
  */
@@ -39,7 +39,7 @@ import {
39
39
  truncateToWidth,
40
40
  } from "../../tools/render-utils";
41
41
  import { toolRenderers } from "../../tools/renderers";
42
- import { TODO_STRIKE_TOTAL_FRAMES } from "../../tools/todo";
42
+ import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
43
43
  import { isFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
44
44
  import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
45
45
  import { renderDiff } from "./diff";
@@ -75,6 +75,28 @@ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | unde
75
75
  return lines.slice(0, lastAddIdx + 1).join("\n");
76
76
  }
77
77
 
78
+ type DisplaceableToolName = "job" | "todo";
79
+
80
+ function isTodoToolDetails(details: unknown): details is TodoToolDetails {
81
+ return (
82
+ typeof details === "object" &&
83
+ details !== null &&
84
+ "phases" in details &&
85
+ Array.isArray((details as { phases?: unknown }).phases)
86
+ );
87
+ }
88
+
89
+ function displaceableToolName(
90
+ toolName: string,
91
+ result: { details?: unknown; isError?: boolean },
92
+ isPartial: boolean,
93
+ ): DisplaceableToolName | undefined {
94
+ if (result.isError === true) return undefined;
95
+ if (toolName === "job" && isWaitingPollDetails(result.details)) return "job";
96
+ if (toolName === "todo" && !isPartial && isTodoToolDetails(result.details)) return "todo";
97
+ return undefined;
98
+ }
99
+
78
100
  function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
79
101
  let changed = false;
80
102
  const next = previews.map(preview => {
@@ -234,11 +256,11 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
234
256
  // sealed the block stays in the transcript's repaintable live region so a
235
257
  // late result still repaints instead of stranding the streaming preview.
236
258
  #sealed = false;
237
- // A `job` poll result whose watched jobs are all still running. Such a
238
- // block never finalizes (stays in the transcript live region) so a
239
- // follow-up `job` call can displace it instead of stacking another
240
- // "waiting on N jobs" frame. Cleared by `seal()`.
241
- #displaceable = false;
259
+ // Tool result snapshots that may be superseded by a later same-tool call
260
+ // while still in the transcript live region. `job` uses this for repeated
261
+ // all-running polls; `todo` uses it for per-turn state snapshots so only the
262
+ // latest list remains visible.
263
+ #displaceableByToolName: DisplaceableToolName | undefined;
242
264
  // Probe into the owning transcript (absent outside the interactive
243
265
  // transcript, e.g. in tests): whether this block is still repaintable.
244
266
  #liveRegion?: TranscriptLiveRegionProbe;
@@ -427,11 +449,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
427
449
  this.#result = result;
428
450
  this.#resultVersion++;
429
451
  this.#isPartial = isPartial;
430
- // A `job` poll that found every watched job still running is transient
431
- // "still waiting" chrome; keep the block displaceable so the next `job`
432
- // call replaces it instead of stacking another waiting frame (see the
433
- // event controller's displaceable-poll bookkeeping).
434
- this.#displaceable = this.#toolName === "job" && result.isError !== true && isWaitingPollDetails(result.details);
452
+ this.#displaceableByToolName = displaceableToolName(this.#toolName, result, isPartial);
435
453
  // When tool is complete, ensure args are marked complete so spinner stops
436
454
  if (!isPartial) {
437
455
  this.#argsComplete = true;
@@ -490,10 +508,12 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
490
508
  }
491
509
 
492
510
  /**
493
- * Start or stop spinner animation based on whether this is a partial task result.
511
+ * Start or stop spinner animation for result states that visibly tick.
494
512
  */
495
513
  #updateSpinnerAnimation(): void {
496
- // Spinner for: task tool with partial result, or edit/write while args streaming
514
+ // Spinner for: task tool with partial result, edit/write while args
515
+ // streaming, or a still-running job poll. Todo snapshots stay live for
516
+ // displacement but should remain visually static.
497
517
  const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
498
518
  const isBackgroundAsyncRunning =
499
519
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
@@ -502,7 +522,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
502
522
  // Detached async task progress rows are static now; progress snapshots
503
523
  // still call #maybeFreezeBackgroundTask before applying so rows settle
504
524
  // once the block leaves the live region.
505
- const needsSpinner = isStreamingArgs || isPartialTask || this.isDisplaceableBlock();
525
+ const needsSpinner = isStreamingArgs || isPartialTask || this.#displaceableByToolName === "job";
506
526
  if (needsSpinner && !this.#spinnerInterval) {
507
527
  const frameCount = theme.spinnerFrames.length;
508
528
  const frame = sharedSpinnerFrame(frameCount);
@@ -610,9 +630,9 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
610
630
  isTranscriptBlockFinalized(): boolean {
611
631
  if (this.#sealed) return true;
612
632
  if (this.#result === undefined) return false;
613
- // A displaceable waiting poll stays live: its rows are kept out of
614
- // native scrollback so a follow-up `job` call can remove the block.
615
- if (this.#displaceable) return false;
633
+ // A displaceable snapshot stays live: its rows are kept out of native
634
+ // scrollback so a follow-up tool call can remove the block.
635
+ if (this.#displaceableByToolName) return false;
616
636
  if (!this.#isPartial) return true;
617
637
  // Partial result: a background async tool is accepted to freeze (the agent
618
638
  // continues while it runs and would otherwise pin an unbounded live region);
@@ -630,7 +650,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
630
650
  * committed prefix survives the remaining transitions.
631
651
  */
632
652
  isTranscriptBlockCommitStable(): boolean {
633
- if (this.#displaceable) return false;
653
+ if (this.#displaceableByToolName) return false;
634
654
  if (this.isTranscriptBlockFinalized()) return true;
635
655
  // `provisionalPendingPreview` describes only the PENDING call preview
636
656
  // (`renderCall`, before any result): the result render may re-anchor it
@@ -658,7 +678,7 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
658
678
  seal(): void {
659
679
  if (this.#sealed) return;
660
680
  this.#sealed = true;
661
- this.#displaceable = false;
681
+ this.#displaceableByToolName = undefined;
662
682
  // A sealed detached task is abandoned history: settle its progress rows
663
683
  // on static gray.
664
684
  this.#backgroundTaskFrozen = true;
@@ -668,14 +688,19 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
668
688
  }
669
689
 
670
690
  /**
671
- * Whether this block is a waiting `job` poll (every watched job still
672
- * running) that has not been sealed. Such a block never finalized, so none
673
- * of its rows entered native scrollback (the ticking spinner keeps the
674
- * stable-prefix ratchet at zero) and the whole block can be removed when a
675
- * follow-up `job` call supersedes it.
691
+ * Whether this block is a supersedable result snapshot that has not been
692
+ * sealed. Such a block never finalized, so none of its rows entered native
693
+ * scrollback and the whole block can be removed when a follow-up matching
694
+ * tool call supersedes it.
676
695
  */
677
696
  isDisplaceableBlock(): boolean {
678
- return this.#displaceable && !this.#sealed;
697
+ return this.#displaceableByToolName !== undefined && !this.#sealed;
698
+ }
699
+
700
+ canBeDisplacedBy(nextToolName: string | undefined): boolean {
701
+ return (
702
+ this.#displaceableByToolName !== undefined && this.#displaceableByToolName === nextToolName && !this.#sealed
703
+ );
679
704
  }
680
705
 
681
706
  /**
@@ -77,6 +77,10 @@ export class EventController {
77
77
  // one persistent poll instead of a stack of "waiting on N jobs" frames —
78
78
  // and sealed in place the moment anything else lands below it.
79
79
  #displaceablePollComponent: ToolExecutionComponent | undefined = undefined;
80
+ // Most recent successful `todo` snapshot in the active turn. It stays live
81
+ // across intervening tool output so a later `todo` update can replace the
82
+ // old full list; the turn boundary seals the final snapshot as history.
83
+ #displaceableTodoComponent: ToolExecutionComponent | undefined = undefined;
80
84
  // Most recent TTSR notification block. A new ttsr_triggered event merges its
81
85
  // rules into this block while it is still the (live-region) transcript tail.
82
86
  #lastTtsrNotification: TtsrNotificationComponent | undefined = undefined;
@@ -226,6 +230,7 @@ export class EventController {
226
230
  this.#ircExpiryTimers.clear();
227
231
  this.#liveIrcCards.clear();
228
232
  this.#displaceablePollComponent = undefined;
233
+ this.#displaceableTodoComponent = undefined;
229
234
  this.#lastTtsrNotification = undefined;
230
235
  this.#streamingReveal.stop();
231
236
  this.#toolArgsReveal.stop();
@@ -248,6 +253,7 @@ export class EventController {
248
253
  this.#readToolCallArgs.clear();
249
254
  this.#readToolCallAssistantComponents.clear();
250
255
  this.#resetReadGroup();
256
+ this.#resolveDisplaceableTodo();
251
257
  this.#lastAssistantComponent = undefined;
252
258
  // Restore the previous turn's inline error in the transcript before dropping
253
259
  // the banner, so the error stays in history once the banner is gone.
@@ -296,6 +302,7 @@ export class EventController {
296
302
 
297
303
  this.#resetReadGroup();
298
304
  this.#resolveDisplaceablePoll();
305
+ this.#resolveDisplaceableTodo();
299
306
  const wasOptimistic = this.ctx.optimisticUserMessageSignature === signature;
300
307
  const matchedLocalSubmission = this.ctx.locallySubmittedUserSignatures.delete(signature);
301
308
  const replacesOptimistic =
@@ -423,6 +430,38 @@ export class EventController {
423
430
  this.ctx.ui.requestRender();
424
431
  }
425
432
 
433
+ #resolveDisplaceableTodo(nextToolName?: string): void {
434
+ const previous = this.#displaceableTodoComponent;
435
+ if (!previous) return;
436
+ if (!previous.isDisplaceableBlock()) {
437
+ this.#displaceableTodoComponent = undefined;
438
+ return;
439
+ }
440
+ if (previous.canBeDisplacedBy(nextToolName)) {
441
+ this.#displaceableTodoComponent = undefined;
442
+ this.ctx.chatContainer.removeChild(previous);
443
+ previous.seal();
444
+ this.ctx.ui.requestRender();
445
+ return;
446
+ }
447
+ if (nextToolName !== undefined) return;
448
+ this.#displaceableTodoComponent = undefined;
449
+ previous.seal();
450
+ this.ctx.ui.requestRender();
451
+ }
452
+
453
+ /**
454
+ * Adopt a rebuilt-tail todo snapshot as the controller's tracked live
455
+ * snapshot. Used by rebuild paths (settings/extensions overlay close, focus
456
+ * attach, /resume) to preserve displacement continuity when a turn is still
457
+ * active — without this, the next same-turn `todo` update would stack
458
+ * another panel because the controller's tracker was reset before rebuild.
459
+ * Drops the candidate when it is no longer a displaceable todo.
460
+ */
461
+ inheritDisplaceableTodo(component: ToolExecutionComponent | null | undefined): void {
462
+ this.#displaceableTodoComponent = component?.canBeDisplacedBy("todo") ? component : undefined;
463
+ }
464
+
426
465
  async #handleNotice(event: Extract<AgentSessionEvent, { type: "notice" }>): Promise<void> {
427
466
  const message = event.source ? `${event.source}: ${event.message}` : event.message;
428
467
  if (event.level === "error") {
@@ -699,8 +738,8 @@ export class EventController {
699
738
 
700
739
  async #handleToolExecutionStart(event: Extract<AgentSessionEvent, { type: "tool_execution_start" }>): Promise<void> {
701
740
  this.#updateWorkingMessageFromIntent(event.intent);
741
+ this.#resolveDisplaceablePoll(event.toolName);
702
742
  if (!this.ctx.pendingTools.has(event.toolCallId)) {
703
- this.#resolveDisplaceablePoll(event.toolName);
704
743
  if (event.toolName === "read" && readArgsHaveTarget(event.args) && !readArgsTargetInternalUrl(event.args)) {
705
744
  this.#trackReadToolCall(event.toolCallId, event.args);
706
745
  const component = this.ctx.pendingTools.get(event.toolCallId);
@@ -830,13 +869,22 @@ export class EventController {
830
869
  this.ctx.pendingTools.delete(event.toolCallId);
831
870
  this.#backgroundToolCallIds.delete(event.toolCallId);
832
871
  }
833
- if (
834
- event.toolName === "job" &&
835
- component instanceof ToolExecutionComponent &&
836
- component.isDisplaceableBlock()
837
- ) {
838
- // Remember the waiting poll so the next `job` call can displace it.
839
- this.#displaceablePollComponent = component;
872
+ if (component instanceof ToolExecutionComponent && component.isDisplaceableBlock()) {
873
+ if (event.toolName === "job" && component.canBeDisplacedBy("job")) {
874
+ // Remember the waiting poll so the next `job` call can displace it.
875
+ this.#displaceablePollComponent = component;
876
+ } else if (event.toolName === "todo" && component.canBeDisplacedBy("todo")) {
877
+ // Successful todo update supersedes the prior live snapshot. A failed
878
+ // follow-up never reaches this branch (canBeDisplacedBy("todo") returns
879
+ // false for errored results), so the last-good panel stays on screen.
880
+ const previous = this.#displaceableTodoComponent;
881
+ if (previous && previous !== component && previous.isDisplaceableBlock()) {
882
+ this.#displaceableTodoComponent = undefined;
883
+ this.ctx.chatContainer.removeChild(previous);
884
+ previous.seal();
885
+ }
886
+ this.#displaceableTodoComponent = component;
887
+ }
840
888
  }
841
889
  this.ctx.ui.requestRender();
842
890
  }
@@ -918,6 +966,7 @@ export class EventController {
918
966
  // The turn is over: nothing else lands this turn, so the waiting poll is
919
967
  // final history — seal it instead of letting its spinner tick while idle.
920
968
  this.#resolveDisplaceablePoll();
969
+ this.#resolveDisplaceableTodo();
921
970
  this.#lastAssistantComponent = undefined;
922
971
  this.ctx.ui.requestRender();
923
972
  this.#scheduleIdleCompaction();
@@ -6,6 +6,7 @@ import { $env, isEnoent, logger, sanitizeText } from "@oh-my-pi/pi-utils";
6
6
  import { isSettingsInitialized, settings } from "../../config/settings";
7
7
  import { resolveLocalRoot } from "../../internal-urls";
8
8
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
9
+ import { extractImagePathFromText } from "../../modes/components/custom-editor";
9
10
  import { renderSegmentTrack } from "../../modes/components/segment-track";
10
11
  import { TinyTitleDownloadProgressComponent } from "../../modes/components/tiny-title-download-progress";
11
12
  import { expandEmoticons } from "../../modes/emoji-autocomplete";
@@ -20,7 +21,12 @@ import { isLowSignalTitleInput } from "../../tiny/text";
20
21
  import { tinyTitleClient } from "../../tiny/title-client";
21
22
  import type { TinyTitleProgressEvent } from "../../tiny/title-protocol";
22
23
  import { shortenPath, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
23
- import { copyToClipboard, readImageFromClipboard, readTextFromClipboard } from "../../utils/clipboard";
24
+ import {
25
+ copyToClipboard,
26
+ readImageFromClipboard,
27
+ readMacFileUrlsFromClipboard,
28
+ readTextFromClipboard,
29
+ } from "../../utils/clipboard";
24
30
  import { EnhancedPasteController } from "../../utils/enhanced-paste";
25
31
  import { getEditorCommand, openInEditor } from "../../utils/external-editor";
26
32
  import { ensureSupportedImageInput, ImageInputTooLargeError, loadImageInput } from "../../utils/image-loading";
@@ -126,7 +132,12 @@ export class InputController {
126
132
  private clipboard: {
127
133
  readImage: typeof readImageFromClipboard;
128
134
  readText: typeof readTextFromClipboard;
129
- } = { readImage: readImageFromClipboard, readText: readTextFromClipboard },
135
+ readMacFileUrls?: typeof readMacFileUrlsFromClipboard;
136
+ } = {
137
+ readImage: readImageFromClipboard,
138
+ readText: readTextFromClipboard,
139
+ readMacFileUrls: readMacFileUrlsFromClipboard,
140
+ },
130
141
  ) {}
131
142
 
132
143
  #enhancedPaste?: EnhancedPasteController;
@@ -1386,33 +1397,65 @@ export class InputController {
1386
1397
  async handleImagePaste(): Promise<boolean> {
1387
1398
  try {
1388
1399
  const image = await this.clipboard.readImage();
1389
- if (!image) {
1390
- // Smart paste (#1628): no image on the clipboard — fall back to
1391
- // pasting its text so the same chord covers both payload kinds.
1392
- // Hosts that pre-empt the terminal's own paste (VS Code's
1393
- // integrated terminal, Win+V clipboard history) deliver only
1394
- // this keypress, so a miss here must not dead-end.
1395
- const text = await this.clipboard.readText();
1396
- if (!text) {
1397
- this.ctx.showStatus("Clipboard is empty");
1398
- return false;
1399
- }
1400
- // Route to the focused component when it accepts pastes (modal
1401
- // Input prompts), matching the enhanced-paste text path (#2127).
1402
- const focused = this.ctx.ui.getFocused();
1403
- const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1404
- target.pasteText(text);
1405
- this.ctx.ui.requestRender();
1400
+ if (image) {
1401
+ return await this.#normalizeAndInsertPastedImage(
1402
+ {
1403
+ type: "image",
1404
+ data: image.data.toBase64(),
1405
+ mimeType: image.mimeType,
1406
+ },
1407
+ `Unsupported clipboard image format: ${image.mimeType}`,
1408
+ );
1409
+ }
1410
+ // #3506: macOS Finder `Cmd+C` puts only a `public.file-url`
1411
+ // representation on the pasteboard. `pbpaste` (the backing call
1412
+ // for `readText` on Darwin) only surfaces plain text / RTF / EPS,
1413
+ // so it returns empty for file-url-only pasteboards — the smart
1414
+ // text fallback below would dead-end with "Clipboard is empty".
1415
+ // Reach the file URL directly via AppleScript and route every
1416
+ // image-shaped path through {@link handleImagePathPaste}, matching
1417
+ // the bracketed-paste handler in `CustomEditor.handleInput` which
1418
+ // iterates every extracted image path. Multi-image Finder
1419
+ // selections must not silently drop after the first attach.
1420
+ // `readMacFileUrls` returns an empty list off Darwin, so the
1421
+ // check is free on every other platform.
1422
+ const fileUrls = (await this.clipboard.readMacFileUrls?.()) ?? [];
1423
+ let attachedFromFileUrls = false;
1424
+ for (const url of fileUrls) {
1425
+ const candidate = extractImagePathFromText(url);
1426
+ if (!candidate) continue;
1427
+ await this.handleImagePathPaste(candidate);
1428
+ attachedFromFileUrls = true;
1429
+ }
1430
+ if (attachedFromFileUrls) return true;
1431
+ // Smart paste (#1628): no image on the clipboard — fall back to
1432
+ // pasting its text so the same chord covers both payload kinds.
1433
+ // Hosts that pre-empt the terminal's own paste (VS Code's
1434
+ // integrated terminal, Win+V clipboard history) deliver only
1435
+ // this keypress, so a miss here must not dead-end.
1436
+ const text = await this.clipboard.readText();
1437
+ if (!text) {
1438
+ this.ctx.showStatus("Clipboard is empty");
1439
+ return false;
1440
+ }
1441
+ // #3506: when the clipboard text is an explicit image file path,
1442
+ // route through {@link handleImagePathPaste} so the image is
1443
+ // loaded and attached instead of pasting the path as literal
1444
+ // text. Covers terminals that paste the Finder file path as
1445
+ // plain text rather than as a `public.file-url` (most macOS
1446
+ // terminals do this for image clipboards).
1447
+ const imagePath = extractImagePathFromText(text);
1448
+ if (imagePath) {
1449
+ await this.handleImagePathPaste(imagePath);
1406
1450
  return true;
1407
1451
  }
1408
- return await this.#normalizeAndInsertPastedImage(
1409
- {
1410
- type: "image",
1411
- data: image.data.toBase64(),
1412
- mimeType: image.mimeType,
1413
- },
1414
- `Unsupported clipboard image format: ${image.mimeType}`,
1415
- );
1452
+ // Route to the focused component when it accepts pastes (modal
1453
+ // Input prompts), matching the enhanced-paste text path (#2127).
1454
+ const focused = this.ctx.ui.getFocused();
1455
+ const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor;
1456
+ target.pasteText(text);
1457
+ this.ctx.ui.requestRender();
1458
+ return true;
1416
1459
  } catch {
1417
1460
  this.ctx.showStatus("Failed to read clipboard");
1418
1461
  return false;