@oh-my-pi/pi-coding-agent 17.1.2 → 17.1.4

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 (110) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/cli.js +3806 -3798
  3. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +1 -0
  4. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  5. package/dist/types/cli/update-cli.d.ts +41 -0
  6. package/dist/types/cli/usage-cli.d.ts +4 -2
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-registry.d.ts +19 -0
  9. package/dist/types/config/settings-schema.d.ts +30 -7
  10. package/dist/types/cursor.d.ts +47 -1
  11. package/dist/types/eval/js/process-entry.d.ts +2 -1
  12. package/dist/types/eval/js/worker-core.d.ts +4 -1
  13. package/dist/types/extensibility/extensions/runner.d.ts +1 -0
  14. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +4 -0
  15. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +4 -0
  16. package/dist/types/extensibility/legacy-pi-tui-shim.d.ts +15 -7
  17. package/dist/types/extensibility/shared-events.d.ts +2 -0
  18. package/dist/types/modes/components/assistant-message.d.ts +9 -0
  19. package/dist/types/modes/components/custom-editor.d.ts +12 -8
  20. package/dist/types/plan-mode/approved-plan.d.ts +3 -2
  21. package/dist/types/secrets/obfuscator.d.ts +7 -31
  22. package/dist/types/session/agent-session.d.ts +19 -4
  23. package/dist/types/session/prewalk.d.ts +2 -1
  24. package/dist/types/session/session-advisors.d.ts +8 -0
  25. package/dist/types/session/session-metadata.d.ts +29 -0
  26. package/dist/types/session/turn-recovery.d.ts +5 -5
  27. package/dist/types/stt/sherpa-runtime.d.ts +38 -0
  28. package/dist/types/thinking.d.ts +24 -0
  29. package/dist/types/tools/auto-generated-guard.d.ts +5 -2
  30. package/dist/types/tools/bash.d.ts +5 -4
  31. package/dist/types/tools/computer/exposure.d.ts +8 -0
  32. package/dist/types/tools/computer/supervisor.d.ts +1 -1
  33. package/dist/types/tools/computer/worker-entry.d.ts +1 -1
  34. package/dist/types/tools/computer/worker.d.ts +1 -1
  35. package/dist/types/tools/computer.d.ts +6 -0
  36. package/dist/types/tools/memory-render.d.ts +1 -4
  37. package/dist/types/tools/shell-tokenize.d.ts +14 -0
  38. package/dist/types/tools/todo.d.ts +7 -1
  39. package/package.json +12 -12
  40. package/src/advisor/__tests__/advisor.test.ts +80 -10
  41. package/src/advisor/runtime.ts +27 -1
  42. package/src/cli/__tests__/auth-gateway-catalog.test.ts +111 -0
  43. package/src/cli/auth-gateway-cli.ts +63 -16
  44. package/src/cli/config-cli.ts +25 -7
  45. package/src/cli/update-cli.ts +229 -29
  46. package/src/cli/usage-cli.ts +144 -15
  47. package/src/cli.ts +21 -15
  48. package/src/commands/update.ts +6 -0
  49. package/src/config/__tests__/model-registry.test.ts +42 -7
  50. package/src/config/model-registry.ts +67 -4
  51. package/src/config/settings-schema.ts +39 -8
  52. package/src/config/settings.ts +24 -2
  53. package/src/cursor.ts +153 -0
  54. package/src/dap/session.ts +70 -11
  55. package/src/edit/hashline/filesystem.ts +1 -1
  56. package/src/edit/modes/patch.ts +1 -1
  57. package/src/eval/__tests__/js-context-manager.test.ts +9 -1
  58. package/src/eval/__tests__/process-entry-import.test.ts +111 -1
  59. package/src/eval/js/process-entry.ts +9 -5
  60. package/src/eval/js/worker-core.ts +6 -2
  61. package/src/exec/bash-executor.ts +4 -2
  62. package/src/extensibility/extensions/runner.ts +23 -8
  63. package/src/extensibility/legacy-pi-ai-shim.ts +9 -0
  64. package/src/extensibility/legacy-pi-coding-agent-shim.ts +14 -2
  65. package/src/extensibility/legacy-pi-tui-shim.ts +33 -0
  66. package/src/extensibility/shared-events.ts +2 -0
  67. package/src/main.ts +22 -1
  68. package/src/modes/acp/acp-agent.ts +6 -0
  69. package/src/modes/components/assistant-message.ts +88 -11
  70. package/src/modes/components/chat-transcript-builder.ts +2 -0
  71. package/src/modes/components/custom-editor.test.ts +170 -0
  72. package/src/modes/components/custom-editor.ts +79 -29
  73. package/src/modes/components/settings-defs.ts +5 -1
  74. package/src/modes/controllers/event-controller.ts +96 -4
  75. package/src/modes/controllers/selector-controller.ts +14 -0
  76. package/src/modes/interactive-mode.ts +34 -8
  77. package/src/modes/utils/context-usage.ts +19 -2
  78. package/src/modes/utils/interactive-context-helpers.ts +1 -0
  79. package/src/plan-mode/approved-plan.ts +18 -10
  80. package/src/prompts/system/custom-system-prompt.md +1 -1
  81. package/src/prompts/system/system-prompt.md +10 -1
  82. package/src/prompts/tools/ast-edit.md +1 -1
  83. package/src/sdk.ts +4 -0
  84. package/src/secrets/obfuscator.ts +36 -126
  85. package/src/session/agent-session.ts +69 -60
  86. package/src/session/prewalk.ts +8 -3
  87. package/src/session/session-advisors.ts +67 -3
  88. package/src/session/session-metadata.ts +53 -0
  89. package/src/session/session-provider-boundary.ts +0 -1
  90. package/src/session/session-tools.ts +35 -1
  91. package/src/session/stream-guards.ts +1 -1
  92. package/src/session/turn-recovery.ts +36 -19
  93. package/src/slash-commands/builtin-registry.ts +49 -7
  94. package/src/stt/asr-worker.ts +2 -37
  95. package/src/stt/sherpa-runtime.ts +71 -0
  96. package/src/task/executor.ts +6 -5
  97. package/src/thinking.ts +39 -0
  98. package/src/tools/auto-generated-guard.ts +18 -5
  99. package/src/tools/bash.ts +43 -15
  100. package/src/tools/computer/exposure.ts +38 -0
  101. package/src/tools/computer/supervisor.ts +61 -13
  102. package/src/tools/computer/worker-entry.ts +28 -19
  103. package/src/tools/computer/worker.ts +3 -7
  104. package/src/tools/computer.ts +65 -10
  105. package/src/tools/gh-cache-invalidation.ts +2 -82
  106. package/src/tools/memory-render.ts +11 -2
  107. package/src/tools/render-utils.ts +8 -3
  108. package/src/tools/shell-tokenize.ts +83 -0
  109. package/src/tools/todo.ts +44 -17
  110. package/src/tools/write.ts +1 -1
@@ -8,6 +8,7 @@ import {
8
8
  CustomEditor,
9
9
  extractBracketedImagePastePaths,
10
10
  extractBracketedPastePaths,
11
+ extractImagePastePathsFromText,
11
12
  extractImagePathFromText,
12
13
  extractPastePathsFromText,
13
14
  SPACE_HOLD_MECHANICAL_RUN,
@@ -182,6 +183,43 @@ describe("CustomEditor bracketed path paste", () => {
182
183
  expect(editor.getText()).toBe("/tmp/report.csv");
183
184
  expect(imagePathCalls).toBe(0);
184
185
  });
186
+
187
+ it("attaches a spaced screenshot path as an image instead of inserting it as literal text", () => {
188
+ // #6578: the raw macOS "copy screenshot path" payload has unescaped
189
+ // spaces, which defeats the segment splitter. Before the whole-text
190
+ // fallback the paste degraded to literal text in the prompt.
191
+ const { editor } = makeEditor();
192
+ const screenshot = "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png";
193
+ const pasted: string[] = [];
194
+ editor.onPasteImagePath = path => {
195
+ pasted.push(path);
196
+ };
197
+
198
+ editor.handleInput(bracketedPaste(screenshot));
199
+
200
+ expect(pasted).toEqual([screenshot]);
201
+ expect(editor.getText()).toBe("");
202
+ });
203
+
204
+ it("keeps a two-file drag with unescaped spaces as text instead of attaching one fused path", () => {
205
+ // PR #6582 review: selecting two screenshots and dropping them together
206
+ // emits a single space-separated payload the splitter also refuses
207
+ // (`PM.png` carries no directory). Fusing it into one path attaches
208
+ // nothing — `handleImagePathPaste` hits ENOENT and only shows a status,
209
+ // never restoring the text — so the drop must degrade to a text paste.
210
+ const { editor } = makeEditor();
211
+ const dropped =
212
+ "/Users/me/Desktop/Screenshot 2026-07-24 at 1.55.12 PM.png /Users/me/Desktop/Screenshot 2026-07-24 at 1.56.00 PM.png";
213
+ const pasted: string[] = [];
214
+ editor.onPasteImagePath = path => {
215
+ pasted.push(path);
216
+ };
217
+
218
+ editor.handleInput(bracketedPaste(dropped));
219
+
220
+ expect(pasted).toEqual([]);
221
+ expect(editor.getText()).toBe(dropped);
222
+ });
185
223
  });
186
224
  describe("CustomEditor configured paste image keys", () => {
187
225
  it("routes Ghostty Cmd+V kitty key events through the macOS image-paste default", () => {
@@ -250,6 +288,12 @@ describe("extractImagePathFromText (issue #3506)", () => {
250
288
  );
251
289
  });
252
290
 
291
+ it("returns undefined for two spaced paths the splitter could not separate", () => {
292
+ // Only the whole-text pass survives the splitter here, and it must not
293
+ // fuse the pair into one path the loader can never resolve.
294
+ expect(extractImagePathFromText("/tmp/a.png /tmp/b shot.png")).toBeUndefined();
295
+ });
296
+
253
297
  it("does not hijack prose that happens to contain a path-shaped fragment", () => {
254
298
  // The whole-text branch is gated on ABSOLUTE_PATH_PREFIX_REGEX, so a
255
299
  // non-anchored prefix ("see ...") never triggers it.
@@ -264,6 +308,132 @@ describe("extractPastePathsFromText", () => {
264
308
  });
265
309
  });
266
310
 
311
+ describe("extractImagePastePathsFromText (issue #6578)", () => {
312
+ const MAC_SCREENSHOT =
313
+ "/var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.55.12 PM.png";
314
+ const WINDOWS_SPACED = "C:\\Users\\me\\My Pictures\\shot 1.png";
315
+
316
+ // Every case must resolve identically on the stripped-marker route
317
+ // (assembled pastes) and the bracketed route, since the latter now
318
+ // delegates to the former.
319
+ const cases: { name: string; text: string; expected: string[] | undefined }[] = [
320
+ { name: "a macOS screenshot path with unescaped spaces", text: MAC_SCREENSHOT, expected: [MAC_SCREENSHOT] },
321
+ { name: "a Windows drive path with unescaped spaces", text: WINDOWS_SPACED, expected: [WINDOWS_SPACED] },
322
+ {
323
+ name: "a home-anchored path with unescaped spaces",
324
+ text: "~/Pictures/Cleanshot 2026-07-24 at 12.00.png",
325
+ expected: ["~/Pictures/Cleanshot 2026-07-24 at 12.00.png"],
326
+ },
327
+ {
328
+ name: "a shell-escaped spaced path",
329
+ text: "/tmp/My\\ Photos/shot\\ 1.png",
330
+ expected: ["/tmp/My Photos/shot 1.png"],
331
+ },
332
+ {
333
+ name: "a double-quoted spaced path",
334
+ text: '"/tmp/My Photos/shot 1.png"',
335
+ expected: ["/tmp/My Photos/shot 1.png"],
336
+ },
337
+ { name: "a spaced path with a non-image extension", text: "/tmp/my report 2026.csv", expected: undefined },
338
+ {
339
+ // Ends in a real image extension, so only the absolute-prefix
340
+ // anchor keeps the whole-text fallback from swallowing the prose.
341
+ name: "prose ending in a path-shaped fragment",
342
+ text: "see /Users/me/Desktop/Screen Shot 1.png",
343
+ expected: undefined,
344
+ },
345
+ {
346
+ name: "two spaced image paths on separate lines",
347
+ text: "/tmp/a shot.png\n/tmp/b shot.png",
348
+ expected: undefined,
349
+ },
350
+ {
351
+ name: "a bare spaced filename with no leading separator",
352
+ text: "Screenshot 2026-07-24 at 1.55.12 PM.png",
353
+ expected: undefined,
354
+ },
355
+ {
356
+ name: "two POSIX paths dragged together when one has unescaped spaces",
357
+ text: "/tmp/a.png /tmp/b shot.png",
358
+ expected: undefined,
359
+ },
360
+ {
361
+ name: "two macOS screenshots dragged together",
362
+ text: `${MAC_SCREENSHOT} /var/folders/xx/T/TemporaryItems/NSIRD_screencaptureui_ab/Screenshot 2026-07-24 at 1.56.00 PM.png`,
363
+ expected: undefined,
364
+ },
365
+ {
366
+ name: "two home-anchored paths with unescaped spaces",
367
+ text: "~/a.png ~/Pictures/b shot.png",
368
+ expected: undefined,
369
+ },
370
+ {
371
+ name: "two Windows drive paths with unescaped spaces",
372
+ text: `C:\\Users\\me\\a.png ${WINDOWS_SPACED}`,
373
+ expected: undefined,
374
+ },
375
+ {
376
+ name: "two `file://` URLs with unescaped spaces",
377
+ text: "file:///tmp/a.png file:///tmp/b shot.png",
378
+ expected: undefined,
379
+ },
380
+ {
381
+ name: "two UNC paths with unescaped spaces",
382
+ text: "\\\\srv\\share\\a.png \\\\srv\\share\\b shot.png",
383
+ expected: undefined,
384
+ },
385
+ {
386
+ name: "a tab-separated pair of dragged paths",
387
+ text: "/tmp/a.png\t/tmp/b shot.png",
388
+ expected: undefined,
389
+ },
390
+ {
391
+ name: "an absolute path followed by a dot-relative path with unescaped spaces",
392
+ text: "/tmp/a.png ./b shot.png",
393
+ expected: undefined,
394
+ },
395
+ {
396
+ name: "an absolute path followed by a parent-relative path with unescaped spaces",
397
+ text: "/tmp/a.png ../pics/b shot.png",
398
+ expected: undefined,
399
+ },
400
+ {
401
+ name: "a Windows drive path followed by a dot-relative path with unescaped spaces",
402
+ text: "C:\\Users\\me\\a.png .\\b shot.png",
403
+ expected: undefined,
404
+ },
405
+ {
406
+ // The interior `Photos/shot` token after an unescaped space is the
407
+ // shape of a spaced directory name, not of a second dragged path —
408
+ // this is why bare relatives are not multi-path anchors.
409
+ name: "a single path with an unescaped spaced directory name",
410
+ text: "/Users/me/My Photos/shot 1.png",
411
+ expected: ["/Users/me/My Photos/shot 1.png"],
412
+ },
413
+ {
414
+ // The escape asserts the space belongs to the path, so the `/sub`
415
+ // that follows is a component rather than a second drag payload.
416
+ name: "a path whose escaped space precedes a slash-led component",
417
+ text: "/tmp/odd dir\\ /sub/a b.png",
418
+ expected: ["/tmp/odd dir /sub/a b.png"],
419
+ },
420
+ {
421
+ // Splitter-success path: both segments are explicit, so the
422
+ // whole-text pass never runs and the pair still attaches as two.
423
+ name: "two explicit image paths the splitter can separate",
424
+ text: "/tmp/a.png /tmp/b.png",
425
+ expected: ["/tmp/a.png", "/tmp/b.png"],
426
+ },
427
+ ];
428
+
429
+ for (const { name, text, expected } of cases) {
430
+ it(`${expected ? "recovers" : "rejects"} ${name} on both paste routes`, () => {
431
+ expect(extractImagePastePathsFromText(text)).toEqual(expected);
432
+ expect(extractBracketedImagePastePaths(bracketedPaste(text))).toEqual(expected);
433
+ });
434
+ }
435
+ });
436
+
267
437
  describe("CustomEditor space-hold push-to-talk", () => {
268
438
  beforeAll(async () => {
269
439
  await initTheme();
@@ -78,14 +78,35 @@ const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
78
78
  const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
79
79
  const FILE_URI_REGEX = /^file:\/\//i;
80
80
  const WINDOWS_DRIVE_PATH_REGEX = /^[a-z]:[\\/]/i;
81
+ /**
82
+ * Alternation of the filesystem prefixes that make a path unambiguously
83
+ * absolute (POSIX root, home, `file://`, UNC, Windows drive). Shared by
84
+ * {@link ABSOLUTE_PATH_PREFIX_REGEX} and {@link INTERIOR_PATH_ANCHOR_REGEX} so
85
+ * the leading-anchor test and the second-anchor test can never disagree about
86
+ * what counts as the start of a path.
87
+ */
88
+ const ABSOLUTE_PATH_PREFIX_SOURCE = String.raw`(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])`;
81
89
  /**
82
90
  * Whole-string anchor for paths that are unambiguously absolute. Restricts the
83
- * "treat the entire clipboard text as one path" branch of
84
- * {@link extractImagePathFromText} to inputs that start with a clearly-anchored
85
- * filesystem prefix, so prose containing a path-shaped fragment (e.g.
86
- * "see /tmp/x.png") never hijacks the smart fallback.
91
+ * "treat the entire text as one path" pass of {@link extractWholeTextImagePath}
92
+ * to inputs that start with a clearly-anchored filesystem prefix, so prose
93
+ * containing a path-shaped fragment (e.g. "see /tmp/x.png") never hijacks the
94
+ * smart fallback.
87
95
  */
88
- const ABSOLUTE_PATH_PREFIX_REGEX = /^(?:\/|~\/|file:\/\/|\\\\|[A-Za-z]:[\\/])/;
96
+ const ABSOLUTE_PATH_PREFIX_REGEX = new RegExp(`^${ABSOLUTE_PATH_PREFIX_SOURCE}`);
97
+ /**
98
+ * A second path anchor after *unescaped* whitespace — the signature of a
99
+ * multi-path payload (`/tmp/a.png /tmp/b shot.png`, `/tmp/a.png ./b shot.png`)
100
+ * rather than of one path whose name merely contains spaces. Anchors are the
101
+ * absolute prefixes plus dot-relative starts (`./`, `../`, `.\`), which never
102
+ * begin a component of a single sane path. Bare relatives (`dir/b shot.png`)
103
+ * are deliberately NOT anchors: an interior `token/` after a space is exactly
104
+ * the shape of a single path with a spaced directory name
105
+ * (`/Users/me/My Photos/shot 1.png`), which this fallback exists to recover.
106
+ * Escaped whitespace (`/tmp/My\ Photos/x.png`) is exempt: the escape is the
107
+ * terminal asserting the space belongs to the path.
108
+ */
109
+ const INTERIOR_PATH_ANCHOR_REGEX = new RegExp(String.raw`(?<!\\)\s(?:${ABSOLUTE_PATH_PREFIX_SOURCE}|\.\.?[\\/])`);
89
110
 
90
111
  /** Max gap (ms) between two spaces for the later one to count as OS key auto-repeat rather than a
91
112
  * deliberate press. OS auto-repeat is fast; a deliberate tap (even a fast one) is slower. */
@@ -228,16 +249,28 @@ export function extractPastePathsFromText(text: string): string[] | undefined {
228
249
  return extractExplicitPathSegments(text);
229
250
  }
230
251
 
231
- export function extractBracketedPastePaths(data: string): string[] | undefined {
232
- if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
233
- const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
234
- if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
235
- return extractExplicitPathSegments(data.slice(BRACKETED_PASTE_START.length, endIndex));
236
- }
237
-
238
- export function extractBracketedImagePastePaths(data: string): string[] | undefined {
239
- const paths = extractBracketedPastePaths(data);
240
- return paths?.every(isImagePath) ? paths : undefined;
252
+ /**
253
+ * Whole-text-as-path pass shared by {@link extractImagePastePathsFromText}
254
+ * and {@link extractImagePathFromText}: treat the entire text as one path
255
+ * when it is anchored by {@link ABSOLUTE_PATH_PREFIX_REGEX}, contains no
256
+ * newlines, and points at a supported image extension. Recovers single paths
257
+ * whose unescaped spaces defeat the segment splitter (macOS screenshot names).
258
+ *
259
+ * Refuses payloads carrying a second {@link INTERIOR_PATH_ANCHOR_REGEX} anchor.
260
+ * Dragging two files at once emits `/tmp/a.png /tmp/b shot.png`, which the
261
+ * splitter also refuses (`shot.png` is not explicit); swallowing it as one path
262
+ * attaches nothing, and `handleImagePathPaste`'s ENOENT branch only surfaces a
263
+ * status — unlike its other failure branches it never re-pastes the text — so
264
+ * both paths would vanish. Genuinely ambiguous input lands here too (a
265
+ * directory whose name ends in a space, as in `/tmp/odd dir /sub/x.png`); a
266
+ * plain text paste is the losing-nothing outcome, so ambiguity resolves that way.
267
+ */
268
+ function extractWholeTextImagePath(text: string): string | undefined {
269
+ const trimmed = text.trim();
270
+ if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
271
+ if (INTERIOR_PATH_ANCHOR_REGEX.test(trimmed)) return undefined;
272
+ const wholePath = normalizePastedPath(trimmed);
273
+ return wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath) ? wholePath : undefined;
241
274
  }
242
275
 
243
276
  /**
@@ -245,10 +278,35 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
245
278
  * payload that has already been stripped of the `\x1b[200~` / `\x1b[201~`
246
279
  * markers — used by the assembled-paste router in {@link CustomEditor.handleInput}
247
280
  * so split bracketed pastes get the same image-path detection as single-chunk ones.
281
+ *
282
+ * When the segment splitter fails (an unescaped space in a real path breaks
283
+ * its every-segment-is-a-path invariant), falls back to
284
+ * {@link extractWholeTextImagePath}, so a dropped macOS screenshot
285
+ * (`Screenshot 2026-06-25 at 1.23.45 PM.png`) attaches as an image instead of
286
+ * degrading to literal text (#6578).
248
287
  */
249
288
  export function extractImagePastePathsFromText(text: string): string[] | undefined {
250
289
  const paths = extractPastePathsFromText(text);
251
- return paths?.every(isImagePath) ? paths : undefined;
290
+ if (paths !== undefined) return paths.every(isImagePath) ? paths : undefined;
291
+ const wholePath = extractWholeTextImagePath(text);
292
+ return wholePath ? [wholePath] : undefined;
293
+ }
294
+
295
+ function bracketedPastePayload(data: string): string | undefined {
296
+ if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
297
+ const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
298
+ if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
299
+ return data.slice(BRACKETED_PASTE_START.length, endIndex);
300
+ }
301
+
302
+ export function extractBracketedPastePaths(data: string): string[] | undefined {
303
+ const payload = bracketedPastePayload(data);
304
+ return payload === undefined ? undefined : extractExplicitPathSegments(payload);
305
+ }
306
+
307
+ export function extractBracketedImagePastePaths(data: string): string[] | undefined {
308
+ const payload = bracketedPastePayload(data);
309
+ return payload === undefined ? undefined : extractImagePastePathsFromText(payload);
252
310
  }
253
311
 
254
312
  export function extractBracketedImagePastePath(data: string): string | undefined {
@@ -272,25 +330,17 @@ export function extractBracketedImagePastePath(data: string): string | undefined
272
330
  * ambiguous multi-path clipboard text like `/tmp/a.png /tmp/b.png`
273
331
  * still falls through to the text fallback instead of being mis-loaded
274
332
  * as one giant path).
275
- * 2. Whole-text-as-path pass — only reached when the splitter failed
276
- * (every segment must look like an explicit path; an unescaped space in
277
- * a real path breaks that). Restricted to inputs anchored by
278
- * {@link ABSOLUTE_PATH_PREFIX_REGEX} so prose containing a path-shaped
279
- * fragment ("see /tmp/x.png") never hijacks the smart fallback. This
280
- * is what recovers macOS screenshot filenames like
333
+ * 2. {@link extractWholeTextImagePath} — only reached when the splitter
334
+ * failed (every segment must look like an explicit path; an unescaped
335
+ * space in a real path breaks that). This is what recovers macOS
336
+ * screenshot filenames like
281
337
  * `/Users/me/Desktop/Screenshot 2026-06-25 at 1.23.45 PM.png`.
282
338
  */
283
339
  export function extractImagePathFromText(text: string): string | undefined {
284
340
  const paths = extractPastePathsFromText(text);
285
341
  if (paths?.length === 1 && isImagePath(paths[0])) return paths[0];
286
342
  if (paths !== undefined) return undefined;
287
- const trimmed = text.trim();
288
- if (!trimmed || /[\r\n]/.test(trimmed) || !ABSOLUTE_PATH_PREFIX_REGEX.test(trimmed)) return undefined;
289
- const wholePath = normalizePastedPath(trimmed);
290
- if (wholePath && isExplicitPastedPath(wholePath) && isImagePath(wholePath)) {
291
- return wholePath;
292
- }
293
- return undefined;
343
+ return extractWholeTextImagePath(text);
294
344
  }
295
345
 
296
346
  /**
@@ -18,6 +18,7 @@ import {
18
18
  getPathsForTab,
19
19
  getType,
20
20
  getUi,
21
+ isCredential,
21
22
  SETTING_TABS,
22
23
  type SettingPath,
23
24
  type SettingTab,
@@ -192,7 +193,10 @@ function pathToSettingDef(path: SettingPath): SettingDef | null {
192
193
  if (options) {
193
194
  return { ...base, type: "submenu", options };
194
195
  }
195
- return { ...base, type: "text", secret: ui.secret === true };
196
+ // One classification drives both surfaces: a setting marked `credential`
197
+ // masks here too, so the panel cannot display one that only the CLI knows
198
+ // to redact.
199
+ return { ...base, type: "text", secret: isCredential(path) };
196
200
  }
197
201
 
198
202
  if (schemaType === "array") {
@@ -2,7 +2,7 @@ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
2
2
  import * as AIError from "@oh-my-pi/pi-ai/error";
3
3
  import { getStreamingPartialJson } from "@oh-my-pi/pi-ai/utils/block-symbols";
4
4
  import { type Component, Loader, TERMINAL } from "@oh-my-pi/pi-tui";
5
- import { logger, prompt } from "@oh-my-pi/pi-utils";
5
+ import { logger, prompt, sanitizeText } from "@oh-my-pi/pi-utils";
6
6
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
7
7
  import { extractTextContent } from "../../commit/utils";
8
8
  import { settings } from "../../config/settings";
@@ -15,7 +15,7 @@ import {
15
15
  readArgsHaveTarget,
16
16
  } from "../../modes/components/read-tool-group";
17
17
  import { TodoReminderComponent } from "../../modes/components/todo-reminder";
18
- import { ToolExecutionComponent } from "../../modes/components/tool-execution";
18
+ import { ToolExecutionComponent, type ToolExecutionHandle } from "../../modes/components/tool-execution";
19
19
  import { TtsrNotificationComponent } from "../../modes/components/ttsr-notification";
20
20
  import { createUsageRowBlock } from "../../modes/components/usage-row";
21
21
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
@@ -89,6 +89,17 @@ export class EventController {
89
89
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
90
90
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
91
91
  #toolTimelineComponents = new Map<string, Component>();
92
+ // Completions that arrived before any component existed for their call id.
93
+ // Cursor's server-resolved tools (todo) emit `tool_execution_end` through a
94
+ // synchronous callback fired mid-parse, while the `toolcall_start` for the
95
+ // same call rides `AssistantMessageEventStream` and is delivered a microtask
96
+ // later. When the server packs start and completion into one HTTP/2 chunk
97
+ // the completion is handled FIRST — with no `pendingTools` entry to settle.
98
+ // Dropping it would strand the card the streamed block creates moments
99
+ // later, so the event is held here and replayed the moment that component
100
+ // materializes (`#handleMessageUpdate`). Keyed by call id; ids are unique
101
+ // per turn, and the map is cleared with the other transcript anchors.
102
+ #orphanedToolCompletions = new Map<string, Extract<AgentSessionEvent, { type: "tool_execution_end" }>>();
92
103
  #postToolAssistantComponents = new Map<string, AssistantMessageComponent>();
93
104
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
94
105
  // Assistant component whose turn-ending error is currently mirrored in the
@@ -334,6 +345,7 @@ export class EventController {
334
345
  this.#renderedCustomMessages.clear();
335
346
  this.#lastIntent = undefined;
336
347
  this.#toolTimelineComponents.clear();
348
+ this.#orphanedToolCompletions.clear();
337
349
  this.#postToolAssistantComponents.clear();
338
350
  this.#backgroundTaskCallIds.clear();
339
351
  this.#approvalAttentionToolCallIds.clear();
@@ -422,6 +434,7 @@ export class EventController {
422
434
 
423
435
  async #handleAgentStart(_event: Extract<AgentSessionEvent, { type: "agent_start" }>): Promise<void> {
424
436
  this.#toolTimelineComponents.clear();
437
+ this.#orphanedToolCompletions.clear();
425
438
  this.#postToolAssistantComponents.clear();
426
439
  this.#lastIntent = undefined;
427
440
  this.#readToolCallArgs.clear();
@@ -777,7 +790,14 @@ export class EventController {
777
790
  this.#toolArgsReveal.finish(content.id);
778
791
  renderArgs = content.arguments;
779
792
  }
780
- if (!this.ctx.pendingTools.has(content.id)) {
793
+ // `message_update` is cumulative — every update re-lists all blocks
794
+ // of the streaming message — so creation must also be guarded by the
795
+ // timeline map: `pendingTools` loses the id the moment a completion
796
+ // settles the card, and for server-resolved (Cursor) tools that can
797
+ // happen while the message is still streaming. Without the second
798
+ // check the next cumulative update would recreate a card for a call
799
+ // that already finished, permanently pending.
800
+ if (!this.ctx.pendingTools.has(content.id) && !this.#toolTimelineComponents.has(content.id)) {
781
801
  this.#resolveDisplaceablePoll(content.name);
782
802
  this.#resetReadGroup();
783
803
  const component = new ToolExecutionComponent(
@@ -799,6 +819,16 @@ export class EventController {
799
819
  this.ctx.pendingTools.set(content.id, component);
800
820
  this.#toolTimelineComponents.set(content.id, component);
801
821
  this.#toolArgsReveal.bind(content.id, component);
822
+ // A held completion for this call means its `tool_execution_end`
823
+ // outran this streamed block (see #orphanedToolCompletions).
824
+ // Attach it now that the card exists so it settles immediately
825
+ // instead of animating forever. Only the component is settled —
826
+ // the handler's other side effects already ran on first arrival.
827
+ const orphan = this.#orphanedToolCompletions.get(content.id);
828
+ if (orphan) {
829
+ this.#orphanedToolCompletions.delete(content.id);
830
+ this.#settleHeldCompletion(component, orphan);
831
+ }
802
832
  } else {
803
833
  const component = this.ctx.pendingTools.get(content.id);
804
834
  if (component) {
@@ -1066,6 +1096,44 @@ export class EventController {
1066
1096
  }
1067
1097
  }
1068
1098
 
1099
+ /**
1100
+ * Attach a held completion to the component that was created for it after
1101
+ * the fact (see {@link #orphanedToolCompletions}) and settle the card.
1102
+ *
1103
+ * Deliberately NOT a re-entry into `#handleToolExecutionEnd`: every
1104
+ * user-facing side effect of that handler — the todo panel refresh, the
1105
+ * failure warning, plan approval, the terminal-title transition — already
1106
+ * ran when the completion first arrived. Replaying the whole handler would
1107
+ * show the same warning twice and re-push an identical `setTodos`. Only the
1108
+ * component half was missed, because the component did not exist yet.
1109
+ */
1110
+ #settleHeldCompletion(
1111
+ component: ToolExecutionHandle,
1112
+ event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>,
1113
+ ): void {
1114
+ component.updateResult({ ...event.result, isError: event.isError }, false, event.toolCallId);
1115
+ this.ctx.pendingTools.delete(event.toolCallId);
1116
+ if (
1117
+ component instanceof ToolExecutionComponent &&
1118
+ component.isDisplaceableBlock() &&
1119
+ event.toolName === "todo" &&
1120
+ component.canBeDisplacedBy("todo")
1121
+ ) {
1122
+ // Mirrors the displacement bookkeeping in `#handleToolExecutionEnd`:
1123
+ // a successful snapshot supersedes the previous live panel.
1124
+ const previous = this.#displaceableTodoComponent;
1125
+ if (previous && previous !== component && previous.isDisplaceableBlock()) {
1126
+ this.#displaceableTodoComponent = undefined;
1127
+ if (this.ctx.chatContainer.isBlockUncommitted(previous)) {
1128
+ this.ctx.chatContainer.removeChild(previous);
1129
+ }
1130
+ previous.seal();
1131
+ }
1132
+ this.#displaceableTodoComponent = component;
1133
+ }
1134
+ this.ctx.ui.requestRender();
1135
+ }
1136
+
1069
1137
  async #handleToolExecutionEnd(event: Extract<AgentSessionEvent, { type: "tool_execution_end" }>): Promise<void> {
1070
1138
  // A transient overlay (auto-compaction / auto-retry / handoff) that ran
1071
1139
  // between this tool's start and end could have detached the working
@@ -1142,6 +1210,15 @@ export class EventController {
1142
1210
  }
1143
1211
  }
1144
1212
  this.ctx.ui.requestRender();
1213
+ } else if (event.toolName === "todo") {
1214
+ // No component yet: the streamed block that creates the card has not
1215
+ // been delivered (see #orphanedToolCompletions). Hold the completion
1216
+ // for replay instead of dropping it — scoped to `todo`, the only
1217
+ // tool whose completion is emitted synchronously mid-parse. The
1218
+ // panel/warning side effects below still run NOW, on first arrival;
1219
+ // the replay settles only the component
1220
+ // (`#settleHeldCompletion`), so neither is repeated.
1221
+ this.#orphanedToolCompletions.set(event.toolCallId, event);
1145
1222
  }
1146
1223
  }
1147
1224
  // Update todo display when todo tool completes
@@ -1154,8 +1231,22 @@ export class EventController {
1154
1231
  const textContent = event.result.content.find(
1155
1232
  (content: { type: string; text?: string }) => content.type === "text",
1156
1233
  )?.text;
1234
+ // This text can be a provider error copied verbatim off the wire (the
1235
+ // Cursor todo bridge forwards the server's string), so it may carry
1236
+ // ANSI escapes, other C0/C1 controls, tabs, newlines, or a line far
1237
+ // wider than the terminal. `showWarning` renders through a plain
1238
+ // `Text`, which strips none of that — an escape reaches the terminal
1239
+ // and can repaint outside the row. `sanitizeText` drops the control
1240
+ // sequences (and returns the same reference when there are none),
1241
+ // then `previewLine` collapses the remaining whitespace and bounds
1242
+ // the width. Sanitizing first matters: truncating before stripping
1243
+ // can cut an escape mid-sequence and leave a dangling introducer.
1244
+ //
1245
+ // This is the render boundary, not the persisted result: the stored
1246
+ // error stays full-fidelity for the transcript and for replays.
1247
+ const detail = textContent ? previewLine(sanitizeText(textContent), TRUNCATE_LENGTHS.LINE) : "";
1157
1248
  this.ctx.showWarning(
1158
- `Todo update failed${textContent ? `: ${textContent}` : ". Progress may be stale until todo succeeds."}`,
1249
+ `Todo update failed${detail ? `: ${detail}` : ". Progress may be stale until todo succeeds."}`,
1159
1250
  );
1160
1251
  }
1161
1252
  // Plan approval rides a `write` to xd://propose: the dispatch metadata on
@@ -1236,6 +1327,7 @@ export class EventController {
1236
1327
  this.#readToolCallArgs.clear();
1237
1328
  this.#readToolCallAssistantComponents.clear();
1238
1329
  this.#toolTimelineComponents.clear();
1330
+ this.#orphanedToolCompletions.clear();
1239
1331
  this.#postToolAssistantComponents.clear();
1240
1332
  this.#resetReadGroup();
1241
1333
  // The turn is over: nothing else lands this turn, so the waiting poll is
@@ -420,6 +420,11 @@ export class SelectorController {
420
420
  this.ctx.session.setAutoCompactionEnabled(value as boolean);
421
421
  this.ctx.statusLine.setAutoCompactEnabled(value as boolean);
422
422
  break;
423
+ case "advisor.enabled":
424
+ this.ctx.session.setAdvisorEnabled(value as boolean);
425
+ this.ctx.statusLine.invalidate();
426
+ this.ctx.ui.requestRender();
427
+ break;
423
428
  case "steeringMode":
424
429
  this.ctx.session.setSteeringMode(value as "all" | "one-at-a-time");
425
430
  break;
@@ -1270,6 +1275,15 @@ export class SelectorController {
1270
1275
  this.ctx.editor.setText(result.editorText);
1271
1276
  }
1272
1277
  this.ctx.showStatus("Navigated to selected point");
1278
+
1279
+ // Re-answering a past `ask` commits a new sibling answer but,
1280
+ // unlike a live `ask`, leaves the agent idle. Resume it now —
1281
+ // after the transcript rebuild above — so the model consumes the
1282
+ // new answer without the resumed turn rendering against the stale
1283
+ // pre-rebuild UI (issue #6483).
1284
+ if (result.askReanswerCommitted) {
1285
+ this.ctx.session.resumeAfterAskReanswer();
1286
+ }
1273
1287
  } catch (error) {
1274
1288
  this.ctx.showError(error instanceof Error ? error.message : String(error));
1275
1289
  } finally {
@@ -1395,6 +1395,11 @@ export class InteractiveMode implements InteractiveModeContext {
1395
1395
  return;
1396
1396
  }
1397
1397
 
1398
+ if (action === "reset" && this.vibeModeEnabled) {
1399
+ this.disableLoopMode("Exit vibe mode before using reset loops. Loop mode disabled.");
1400
+ return;
1401
+ }
1402
+
1398
1403
  if (!consumeLoopLimitIteration(this.loopLimit)) {
1399
1404
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
1400
1405
  return;
@@ -3708,6 +3713,17 @@ export class InteractiveMode implements InteractiveModeContext {
3708
3713
  return;
3709
3714
  }
3710
3715
 
3716
+ // resolveApprovedPlan may return a newer draft than the path recorded in
3717
+ // plan-mode state. `AgentSession.#buildPlanModeMessage()` reads that state,
3718
+ // so if the operator refines (or dismisses and keeps planning) the next
3719
+ // planning turn must target the plan just reviewed — promote the reviewed
3720
+ // path into plan-mode state now, mirroring the print-mode approval handler.
3721
+ const planState = this.session.getPlanModeState();
3722
+ if (planState?.enabled && planState.planFilePath !== planFilePath) {
3723
+ this.session.setPlanModeState({ ...planState, planFilePath });
3724
+ this.sessionManager.appendModeChange("plan", { planFilePath });
3725
+ }
3726
+
3711
3727
  const contextUsage = this.#getPlanApprovalContextUsage();
3712
3728
  const keepContextLabel = this.#formatKeepContextLabel(contextUsage);
3713
3729
  const keepContextDisabled = this.#isKeepContextDisabled(contextUsage);
@@ -4432,6 +4448,12 @@ export class InteractiveMode implements InteractiveModeContext {
4432
4448
  this.#commandController.handleContextCommand();
4433
4449
  }
4434
4450
 
4451
+ #vibeSessionTransitionBlocked(): boolean {
4452
+ if (!this.vibeModeEnabled) return false;
4453
+ this.showWarning("Exit vibe mode first.");
4454
+ return true;
4455
+ }
4456
+
4435
4457
  #prepareSessionSwitch(): void {
4436
4458
  this.#btwController.dispose();
4437
4459
  this.#omfgController.dispose();
@@ -4440,28 +4462,32 @@ export class InteractiveMode implements InteractiveModeContext {
4440
4462
  this.#hidePlanReview();
4441
4463
  }
4442
4464
 
4443
- handleClearCommand(): Promise<void> {
4465
+ async handleClearCommand(): Promise<void> {
4466
+ if (this.#vibeSessionTransitionBlocked()) return;
4444
4467
  this.#prepareSessionSwitch();
4445
- return this.#commandController.handleClearCommand();
4468
+ await this.#commandController.handleClearCommand();
4446
4469
  }
4447
4470
 
4448
4471
  handleFreshCommand(): Promise<void> {
4449
4472
  return this.#commandController.handleFreshCommand();
4450
4473
  }
4451
4474
 
4452
- handleDropCommand(): Promise<void> {
4475
+ async handleDropCommand(): Promise<void> {
4476
+ if (this.#vibeSessionTransitionBlocked()) return;
4453
4477
  this.#prepareSessionSwitch();
4454
- return this.#commandController.handleDropCommand();
4478
+ await this.#commandController.handleDropCommand();
4455
4479
  }
4456
4480
 
4457
- handleForkCommand(): Promise<void> {
4481
+ async handleForkCommand(): Promise<void> {
4482
+ if (this.#vibeSessionTransitionBlocked()) return;
4458
4483
  this.#btwController.dispose();
4459
4484
  this.#omfgController.dispose();
4460
- return this.#commandController.handleForkCommand();
4485
+ await this.#commandController.handleForkCommand();
4461
4486
  }
4462
4487
 
4463
- handleMoveCommand(targetPath?: string): Promise<void> {
4464
- return this.#commandController.handleMoveCommand(targetPath);
4488
+ async handleMoveCommand(targetPath?: string): Promise<void> {
4489
+ if (this.#vibeSessionTransitionBlocked()) return;
4490
+ await this.#commandController.handleMoveCommand(targetPath);
4465
4491
  }
4466
4492
 
4467
4493
  handleRenameCommand(title: string): Promise<void> {