@oh-my-pi/pi-coding-agent 16.1.19 → 16.1.21

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 (59) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +3795 -3760
  3. package/dist/types/advisor/advise-tool.d.ts +3 -0
  4. package/dist/types/cli/gallery-cli.d.ts +6 -0
  5. package/dist/types/commands/gallery.d.ts +1 -1
  6. package/dist/types/config/service-tier.d.ts +34 -0
  7. package/dist/types/config/settings-schema.d.ts +36 -33
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -0
  9. package/dist/types/mcp/oauth-flow.d.ts +42 -5
  10. package/dist/types/modes/components/custom-editor.d.ts +32 -0
  11. package/dist/types/modes/components/tool-execution.d.ts +5 -5
  12. package/dist/types/modes/controllers/event-controller.d.ts +10 -0
  13. package/dist/types/modes/controllers/input-controller.d.ts +2 -1
  14. package/dist/types/session/agent-session.d.ts +2 -0
  15. package/dist/types/task/executor.d.ts +9 -1
  16. package/dist/types/task/parallel.d.ts +17 -1
  17. package/dist/types/tools/index.d.ts +3 -1
  18. package/dist/types/utils/clipboard.d.ts +10 -0
  19. package/package.json +13 -13
  20. package/scripts/generate-legacy-pi-bundled-registry.ts +10 -0
  21. package/src/advisor/__tests__/advisor.test.ts +44 -0
  22. package/src/advisor/advise-tool.ts +33 -0
  23. package/src/autolearn/controller.ts +17 -2
  24. package/src/cli/gallery-cli.ts +31 -2
  25. package/src/cli/gallery-fixtures/agentic.ts +13 -4
  26. package/src/commands/gallery.ts +11 -3
  27. package/src/config/service-tier.ts +87 -0
  28. package/src/config/settings-schema.ts +48 -23
  29. package/src/eval/agent-bridge.ts +2 -0
  30. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +1 -1
  31. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +4 -4
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +193 -5
  33. package/src/main.ts +1 -0
  34. package/src/mcp/manager.ts +12 -3
  35. package/src/mcp/oauth-flow.ts +121 -7
  36. package/src/mcp/transports/stdio.ts +5 -0
  37. package/src/modes/components/chat-transcript-builder.ts +31 -0
  38. package/src/modes/components/custom-editor.test.ts +80 -0
  39. package/src/modes/components/custom-editor.ts +86 -6
  40. package/src/modes/components/tool-execution.ts +50 -25
  41. package/src/modes/controllers/event-controller.ts +57 -8
  42. package/src/modes/controllers/input-controller.ts +213 -93
  43. package/src/modes/controllers/mcp-command-controller.ts +18 -2
  44. package/src/modes/utils/ui-helpers.ts +40 -0
  45. package/src/prompts/system/autolearn-nudge-autocontinue.md +5 -0
  46. package/src/prompts/system/autolearn-nudge.md +4 -2
  47. package/src/prompts/tools/todo.md +1 -1
  48. package/src/sdk.ts +1 -0
  49. package/src/session/agent-session.ts +76 -15
  50. package/src/session/session-history-format.ts +21 -4
  51. package/src/task/executor.ts +79 -2
  52. package/src/task/index.ts +11 -6
  53. package/src/task/parallel.ts +59 -7
  54. package/src/tools/index.ts +3 -1
  55. package/src/tools/irc.ts +16 -2
  56. package/src/tools/todo.ts +20 -10
  57. package/src/utils/clipboard.ts +57 -0
  58. package/src/utils/shell-snapshot-fn-env.sh +60 -0
  59. package/src/utils/shell-snapshot.ts +77 -20
@@ -1229,8 +1229,15 @@ export class MCPManager {
1229
1229
  const tokenUrl = material?.tokenUrl;
1230
1230
  const clientId = material?.clientId;
1231
1231
  const clientSecret = material?.clientSecret;
1232
- const resource =
1233
- material?.resource ?? (config.type === "http" || config.type === "sse" ? config.url : undefined);
1232
+ // `authorizationUrl` only lives on the embedded credential form;
1233
+ // legacy `MCPAuthConfig` rows never carried it. Required to filter
1234
+ // same-origin resource indicators on refresh when the authorize and
1235
+ // token endpoints sit on different origins (issue #3502 review
1236
+ // follow-up).
1237
+ const authorizationUrl = material && "authorizationUrl" in material ? material.authorizationUrl : undefined;
1238
+ const resourceIsFallback =
1239
+ !material?.resource && (config.type === "http" || config.type === "sse") && Boolean(config.url);
1240
+ const resource = material?.resource ?? (resourceIsFallback ? config.url : undefined);
1234
1241
  // Proactive refresh: 5-minute buffer before expiry
1235
1242
  // Force refresh: on 401/403 auth errors (revoked tokens, clock skew, missing expires)
1236
1243
  const REFRESH_BUFFER_MS = 5 * 60_000;
@@ -1244,6 +1251,7 @@ export class MCPManager {
1244
1251
  clientId,
1245
1252
  clientSecret,
1246
1253
  resource,
1254
+ { authorizationUrl, stripSameOriginResource: resourceIsFallback },
1247
1255
  );
1248
1256
  // Spread the old credential first so embedded refresh material survives rotation.
1249
1257
  const refreshedCredential: MCPStoredOAuthCredential = {
@@ -1252,7 +1260,8 @@ export class MCPManager {
1252
1260
  tokenUrl,
1253
1261
  clientId,
1254
1262
  clientSecret,
1255
- resource,
1263
+ resource: resourceIsFallback ? undefined : resource,
1264
+ authorizationUrl,
1256
1265
  };
1257
1266
  await this.#authStorage.set(credentialId, refreshedCredential);
1258
1267
  credential = refreshedCredential;
@@ -62,6 +62,14 @@ export interface MCPStoredOAuthCredential extends OAuthCredential {
62
62
  clientId?: string;
63
63
  clientSecret?: string;
64
64
  resource?: string;
65
+ /**
66
+ * Authorization-server URL (the issuer the grant was minted against). Used
67
+ * to filter same-origin resource indicators on refresh: RFC 8414 lets the
68
+ * authorize and token endpoints sit on different origins, so refresh
69
+ * cannot infer the original auth-server origin from `tokenUrl` alone.
70
+ * Unset on legacy credentials minted before issue #3502's fix.
71
+ */
72
+ authorizationUrl?: string;
65
73
  }
66
74
 
67
75
  const DEFAULT_PORT = 3000;
@@ -169,6 +177,42 @@ function resolveResourceUri(resource: string | undefined): string | undefined {
169
177
  return trimmed;
170
178
  }
171
179
 
180
+ interface ResourceIndicatorFilterOptions {
181
+ /** Strip any resource URL on the same origin as the authorization server. */
182
+ stripSameOriginResource?: boolean;
183
+ }
184
+
185
+ /**
186
+ * Drop a redundant fallback resource indicator relative to {@link serverUrl}.
187
+ *
188
+ * Provider-advertised resource indicators are authoritative even when they are
189
+ * origin-only (`https://gateway.example.com`) or path-scoped same-origin
190
+ * (`https://gateway.example.com/my-service/mcp`): servers can use either form
191
+ * as the audience they require for the grant.
192
+ *
193
+ * Plane is stricter for OMP-synthesized fallback resources (e.g. using the
194
+ * configured server URL `https://mcp.plane.so/http/mcp` as `resource`), so
195
+ * fallback callers opt into `stripSameOriginResource`. Provider-advertised
196
+ * `oauth.resource` values and authorization-URL `?resource=` values keep the
197
+ * preserving default.
198
+ */
199
+ function filterResourceIndicator(
200
+ resource: string | undefined,
201
+ serverUrl: string,
202
+ options: ResourceIndicatorFilterOptions = {},
203
+ ): string | undefined {
204
+ if (!resource) return undefined;
205
+ try {
206
+ const origin = new URL(serverUrl).origin;
207
+ const parsedResource = new URL(resource);
208
+ if (parsedResource.origin !== origin) return resource;
209
+ if (options.stripSameOriginResource) return undefined;
210
+ } catch {
211
+ // Malformed serverUrl will fail elsewhere; fall through.
212
+ }
213
+ return resource;
214
+ }
215
+
172
216
  export interface MCPOAuthConfig {
173
217
  /** Authorization endpoint URL */
174
218
  authorizationUrl: string;
@@ -197,6 +241,13 @@ export interface MCPOAuthConfig {
197
241
  callbackPath?: string;
198
242
  /** MCP resource URI for RFC 8707 resource indicators */
199
243
  resource?: string;
244
+ /**
245
+ * True when `resource` was synthesized from the server URL fallback rather
246
+ * than advertised by OAuth/protected-resource metadata. Fallback resources
247
+ * are stripped when same-origin with the authorization server; advertised
248
+ * path-scoped resources are preserved.
249
+ */
250
+ stripSameOriginResource?: boolean;
200
251
  /** Fetch implementation for token exchange and discovery requests. */
201
252
  fetch?: FetchImpl;
202
253
  }
@@ -219,8 +270,8 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
219
270
  super(ctrl, resolveCallbackOptions(config));
220
271
  this.#resolvedClientId = this.#resolveClientId(config);
221
272
  this.#fetch = config.fetch ?? ctrl.fetch ?? fetch;
222
- this.#resource = resolveResourceUri(
223
- config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl),
273
+ this.#resource = this.#filterResourceIndicator(
274
+ resolveResourceUri(config.resource ?? this.#resourceFromAuthorizationUrl(config.authorizationUrl)),
224
275
  );
225
276
  }
226
277
 
@@ -246,6 +297,15 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
246
297
  get resource(): string | undefined {
247
298
  return this.#resource;
248
299
  }
300
+ /**
301
+ * Authorization-server URL the flow used. Persist alongside the credential
302
+ * so refresh can filter same-origin resource indicators against the issuer's
303
+ * origin even when `tokenUrl` lives on a different origin (RFC 8414 permits
304
+ * the split).
305
+ */
306
+ get authorizationUrl(): string {
307
+ return this.config.authorizationUrl;
308
+ }
249
309
 
250
310
  async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
251
311
  if (!this.#resolvedClientId) {
@@ -271,7 +331,20 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
271
331
  }
272
332
  const existingResource = params.get("resource")?.trim();
273
333
  if (existingResource) {
274
- this.#resource = resolveResourceUri(existingResource);
334
+ // A resource already embedded in the provider's authorization URL is
335
+ // provider-authored, not OMP's server-URL fallback. Preserve same-host
336
+ // values here even when the caller marked its separate
337
+ // `config.resource` as fallback; gateway-hosted MCP servers can use
338
+ // origin-only or path-scoped values as the token audience.
339
+ const filtered = filterResourceIndicator(resolveResourceUri(existingResource), this.config.authorizationUrl);
340
+ if (filtered) {
341
+ this.#resource = filtered;
342
+ } else {
343
+ // Defensive path for future policy additions: when filtering says
344
+ // "omit", drop it from both authorize and token requests.
345
+ params.delete("resource");
346
+ this.#resource = undefined;
347
+ }
275
348
  } else if (this.#resource) {
276
349
  params.set("resource", this.#resource);
277
350
  }
@@ -395,6 +468,17 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
395
468
  }
396
469
  }
397
470
 
471
+ /**
472
+ * Drop redundant resource indicators for this authorization server.
473
+ * Provider-advertised path-scoped values are preserved; fallback server-URL
474
+ * values opt into same-origin stripping via `stripSameOriginResource`.
475
+ */
476
+ #filterResourceIndicator(resource: string | undefined): string | undefined {
477
+ return filterResourceIndicator(resource, this.config.authorizationUrl, {
478
+ stripSameOriginResource: this.config.stripSameOriginResource,
479
+ });
480
+ }
481
+
398
482
  /**
399
483
  * Try OAuth dynamic client registration when provider requires a client_id.
400
484
  */
@@ -507,6 +591,26 @@ export class MCPOAuthFlow extends OAuthCallbackFlow {
507
591
  }
508
592
  }
509
593
 
594
+ /**
595
+ * Options for {@link refreshMCPOAuthToken}. Carried via the trailing object
596
+ * so positional callers keep working.
597
+ */
598
+ export interface RefreshMCPOAuthTokenOptions {
599
+ fetch?: FetchImpl;
600
+ /**
601
+ * Authorization-server URL the original grant was minted against. Used to
602
+ * filter same-origin resource indicators on refresh. Defaults to `tokenUrl`'s
603
+ * origin when omitted for legacy credentials.
604
+ */
605
+ authorizationUrl?: string;
606
+ /**
607
+ * True when the refresh `resource` was synthesized from the server URL
608
+ * fallback because the credential/auth material carried no resource.
609
+ * Preserved advertised resources leave this false/undefined.
610
+ */
611
+ stripSameOriginResource?: boolean;
612
+ }
613
+
510
614
  /**
511
615
  * Refresh an MCP OAuth token using the standard refresh_token grant.
512
616
  * Returns updated credentials; preserves the old refresh token if the server doesn't rotate it.
@@ -516,17 +620,27 @@ export async function refreshMCPOAuthToken(
516
620
  refreshToken: string,
517
621
  clientId?: string,
518
622
  clientSecret?: string,
519
- resourceOrOpts?: string | { fetch?: FetchImpl },
520
- opts?: { fetch?: FetchImpl },
623
+ resourceOrOpts?: string | RefreshMCPOAuthTokenOptions,
624
+ opts?: RefreshMCPOAuthTokenOptions,
521
625
  ): Promise<OAuthCredentials> {
522
- const fetchImpl: FetchImpl = (typeof resourceOrOpts === "string" ? opts?.fetch : resourceOrOpts?.fetch) ?? fetch;
626
+ const optsFromTrailing = typeof resourceOrOpts === "string" ? opts : resourceOrOpts;
627
+ const fetchImpl: FetchImpl = optsFromTrailing?.fetch ?? fetch;
523
628
  const resource = typeof resourceOrOpts === "string" ? resourceOrOpts : undefined;
629
+ // Filter against the authorization-server origin when known (RFC 8414
630
+ // permits authorize/token endpoints on separate origins). Fall back to
631
+ // `tokenUrl` for legacy credentials minted before the issuer was persisted
632
+ // — same-origin servers (the common case) still match correctly.
633
+ const filterAnchor = optsFromTrailing?.authorizationUrl ?? tokenUrl;
524
634
  const params = new URLSearchParams({
525
635
  grant_type: "refresh_token",
526
636
  refresh_token: refreshToken,
527
637
  });
528
638
  if (clientId) params.set("client_id", clientId);
529
- const resolvedResource = resolveResourceUri(resource);
639
+ // Drop redundant indicators so refresh stays consistent with the initial
640
+ // grant; see {@link filterResourceIndicator} for context.
641
+ const resolvedResource = filterResourceIndicator(resolveResourceUri(resource), filterAnchor, {
642
+ stripSameOriginResource: optsFromTrailing?.stripSameOriginResource,
643
+ });
530
644
  if (resolvedResource) params.set("resource", resolvedResource);
531
645
  if (clientSecret) params.set("client_secret", clientSecret);
532
646
 
@@ -340,6 +340,10 @@ export class StdioTransport implements MCPTransport {
340
340
  platform: process.platform,
341
341
  });
342
342
 
343
+ // Spawn in a new session (detached → setsid) so the MCP process tree has
344
+ // no controlling terminal. Otherwise terminal job-control signals (Ctrl+Z
345
+ // SIGTSTP, background-read SIGTTIN) can stop stdio servers such as
346
+ // chrome-devtools-mcp and leave our read loop blocked on silent pipes.
343
347
  this.#process = spawn({
344
348
  cmd: spawnCommand.cmd,
345
349
  cwd,
@@ -348,6 +352,7 @@ export class StdioTransport implements MCPTransport {
348
352
  stdout: "pipe",
349
353
  stderr: "pipe",
350
354
  windowsHide: spawnCommand.windowsHide,
355
+ detached: true,
351
356
  });
352
357
 
353
358
  this.#connected = true;
@@ -83,6 +83,7 @@ export class ChatTranscriptBuilder {
83
83
  #pendingUsage: Usage | undefined;
84
84
  #lastAssistantUsage: Usage | undefined;
85
85
  #waitingPoll: ToolExecutionComponent | null = null;
86
+ #todoSnapshot: ToolExecutionComponent | null = null;
86
87
  #expandables: Array<{ setExpanded(expanded: boolean): void }> = [];
87
88
  #expanded = false;
88
89
 
@@ -128,6 +129,7 @@ export class ChatTranscriptBuilder {
128
129
  this.#pendingUsage = undefined;
129
130
  this.#lastAssistantUsage = undefined;
130
131
  this.#waitingPoll = null;
132
+ this.#todoSnapshot = null;
131
133
  this.#expandables = [];
132
134
  this.container.dispose();
133
135
  this.container.clear();
@@ -153,6 +155,24 @@ export class ChatTranscriptBuilder {
153
155
  previous.seal();
154
156
  }
155
157
 
158
+ #resolveTodoSnapshot(nextToolName?: string): void {
159
+ const previous = this.#todoSnapshot;
160
+ if (!previous) return;
161
+ if (!previous.isDisplaceableBlock()) {
162
+ this.#todoSnapshot = null;
163
+ return;
164
+ }
165
+ if (previous.canBeDisplacedBy(nextToolName)) {
166
+ this.#todoSnapshot = null;
167
+ this.container.removeChild(previous);
168
+ previous.seal();
169
+ return;
170
+ }
171
+ if (nextToolName !== undefined) return;
172
+ this.#todoSnapshot = null;
173
+ previous.seal();
174
+ }
175
+
156
176
  #ensureReadGroup(): ReadToolGroupComponent {
157
177
  if (!this.#readGroup) {
158
178
  this.#readGroup = new ReadToolGroupComponent({
@@ -189,6 +209,7 @@ export class ChatTranscriptBuilder {
189
209
  case "developer": {
190
210
  // A user prompt closes the poll-displacement window, same as the live path.
191
211
  if (message.role === "user") this.#resolveWaitingPoll();
212
+ if (message.role === "user") this.#resolveTodoSnapshot();
192
213
  const textContent = message.role === "user" ? userMessageText(message) : "";
193
214
  if (textContent) {
194
215
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
@@ -345,6 +366,16 @@ export class ChatTranscriptBuilder {
345
366
  this.#pendingTools.delete(message.toolCallId);
346
367
  if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
347
368
  this.#waitingPoll = pending;
369
+ } else if (
370
+ message.toolName === "todo" &&
371
+ pending instanceof ToolExecutionComponent &&
372
+ pending.canBeDisplacedBy("todo")
373
+ ) {
374
+ // A successful todo result supersedes the prior live snapshot. Failed
375
+ // follow-ups return false from canBeDisplacedBy("todo"), so the
376
+ // last-good panel stays on screen.
377
+ this.#resolveTodoSnapshot("todo");
378
+ this.#todoSnapshot = pending;
348
379
  }
349
380
  }
350
381
 
@@ -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
  */