diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 45683aa68..78c73a9a3 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -33,6 +33,7 @@ - Fixed the TUI usage display failing to resolve a used fraction for limits that only populate `remainingFraction` (no `usedFraction`, `used`/`limit`, or `percent`+`used`). The TUI's local `resolveFraction` was missing the inverted-remaining fallback that the shared `resolveUsedFraction` from `@oh-my-pi/pi-ai` already handles — replaced the local copy with the shared function so the TUI and CLI paths resolve fractions identically. - Fixed long-running SSH command boxes leaving a stale `⏳ SSH: [host]` header above the final `⇄ SSH: [host]` header in terminal scrollback. The SSH renderer now keeps its partial-result chrome on the pending icon/state and opts the block out of stream-commit while `isPartial` holds (via the new `ToolRenderer.provisionalPartialResult` flag honored by `ToolExecutionComponent.isTranscriptBlockCommitStable`), so the stable-prefix ratchet can't promote the partial header to native scrollback only to have the final render strand it above the settled frame ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)). - Fixed Gemini over-planning runs that emit long chains of thinking headers (`**Refining …**`, `## Examining …`) without ever issuing a tool call. The session now interrupts that stream, discards the partial reasoning turn, injects a hidden tool-call reminder, and continues with the corrective context instead of burning the full budget on planning. +- Fixed `Cmd+V` on macOS silently dropping image-only clipboard pastes (screenshot via `Cmd+Shift+5` "save to clipboard", Chrome image copy, …) — the user had to fall back to `Ctrl+V`. Follow-up to #3506: that fix handled clipboards exposing a file URL or path; the screenshot path leaves only raw image bytes on the pasteboard. macOS terminals (iTerm2, Terminal.app, Warp, Ghostty without OSC 5522, Windows Terminal forwarding, …) intercept `Cmd+V` and read `NSPasteboardTypeString` first; for an image-only clipboard that read returns `""`, so the terminal forwards a complete-but-empty bracketed paste (`\x1b[200~\x1b[201~`). `CustomEditor.handleInput` inserted the empty payload and the keystroke disappeared. A new `isEmptyBracketedPaste` guard now routes any complete, empty-or-whitespace-only bracketed paste through the same `onPasteImage` smart reader the `app.clipboard.pasteImage` keybind uses, so `Cmd+V` attaches the clipboard image (or falls back to the #1628 smart text paste / "clipboard is empty" diagnostic) instead of silent nothing. Bracketed pastes carrying any text (including the explicit image-file path branch from #3506) keep their existing routing. ([#3601](https://github.com/can1357/oh-my-pi/issues/3601)) ## [16.1.23] - 2026-06-26 diff --git a/packages/coding-agent/src/modes/components/custom-editor.ts b/packages/coding-agent/src/modes/components/custom-editor.ts index f50d2730c..dfa853fed 100644 --- a/packages/coding-agent/src/modes/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/components/custom-editor.ts @@ -234,6 +234,22 @@ export function extractBracketedImagePastePath(data: string): string | undefined return paths?.length === 1 ? paths[0] : undefined; } +/** + * `true` when `data` is exactly one complete bracketed paste whose payload is + * empty (or whitespace-only). macOS terminals (iTerm2, Warp, Ghostty, …) ask + * the OS pasteboard for `NSPasteboardTypeString` when the user hits `Cmd+V`; + * an image-only clipboard (e.g. `Cmd+Shift+5` screenshot saved to clipboard, + * Chrome image copy) returns `""`, so the terminal still emits the start/end + * markers around an empty payload. Without a fallback the keystroke would + * dead-end silently and force users back to `Ctrl+V`. + */ +export function isEmptyBracketedPaste(data: string): boolean { + if (!data.startsWith(BRACKETED_PASTE_START)) return false; + const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length); + if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return false; + return data.slice(BRACKETED_PASTE_START.length, endIndex).trim().length === 0; +} + /** * Return a single image file path when `text` is exactly one explicit path * pointing at a supported image extension (`.png`, `.jpg`/`.jpeg`, `.gif`, @@ -591,6 +607,18 @@ export class CustomEditor extends Editor { return; } + // #3601: a `Cmd+V`/`Ctrl+V` on an image-only clipboard makes terminals + // that strip the pasteboard to text first (iTerm2, Warp, Ghostty, + // Terminal.app, Windows Terminal, …) forward an empty bracketed paste + // — the marker pair with no payload. Route it through the same smart + // clipboard reader the `app.clipboard.pasteImage` keybind uses so the + // keystroke attaches the image (or falls back to a text paste / empty + // notice) instead of disappearing. + if (isEmptyBracketedPaste(data) && this.onPasteImage) { + void this.onPasteImage(); + return; + } + const parsedKey = parseKey(data); const canonical = parsedKey !== undefined ? canonicalKeyId(parsedKey) : undefined; diff --git a/packages/coding-agent/test/issue-3601-repro.test.ts b/packages/coding-agent/test/issue-3601-repro.test.ts new file mode 100644 index 000000000..beccf8192 --- /dev/null +++ b/packages/coding-agent/test/issue-3601-repro.test.ts @@ -0,0 +1,172 @@ +/** + * Repro for #3601: macOS `Cmd+V` is silently dropped for image-only clipboards. + * + * Follow-up to #3506 — that fix covered the case where the terminal forwards + * the clipboard's text (a file path) verbatim. The remaining symptom is the + * macOS screenshot path (Cmd+Shift+5 → "save to clipboard"): the pasteboard + * holds raw image bytes with no text representation, so a terminal that + * intercepts `Cmd+V` and reads `NSPasteboardTypeString` first (iTerm2, + * Terminal.app, Warp, Ghostty without OSC 5522, …) sends an EMPTY bracketed + * paste — `\x1b[200~\x1b[201~` — to the app. Without a fallback, the editor + * inserts the empty payload and the keystroke disappears. The user has to + * fall back to `Ctrl+V`, which is delivered as a normal keypress and routes + * through `app.clipboard.pasteImage` → `InputController.handleImagePaste` → + * `clipboard.readImage()`. + * + * Defended contract: a complete, empty bracketed paste MUST invoke the same + * `onPasteImage` smart-paste reader that the configured keybind triggers, so + * the keystroke either attaches the clipboard image or falls back to the + * text-paste / "clipboard is empty" diagnostics — never to silent nothing. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import type { ImageContent } from "@oh-my-pi/pi-ai"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { CustomEditor } from "@oh-my-pi/pi-coding-agent/modes/components/custom-editor"; +import { getEditorTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; +import { InputController } from "@oh-my-pi/pi-coding-agent/modes/controllers/input-controller"; +import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; + +const BRACKETED_PASTE_START = "\x1b[200~"; +const BRACKETED_PASTE_END = "\x1b[201~"; + +const ONE_PX_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", +); + +function createCtx() { + const editor = new CustomEditor(getEditorTheme()); + const pasteText = vi.fn(); + const requestRender = vi.fn(); + const showStatus = vi.fn(); + const ctx = { + editor, + ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"], + sessionManager: { + getCwd: () => process.cwd(), + putBlob: async () => ({ hash: "h", path: "/tmp/h.png", displayPath: "/tmp/h.png" }), + } as unknown as InteractiveModeContext["sessionManager"], + showStatus, + } as unknown as InteractiveModeContext; + // `editor.pasteText` is consulted by the smart fallback; spy after construction so the + // CustomEditor still owns its real pasteText implementation everywhere else. + editor.pasteText = pasteText; + return { ctx, editor, spies: { pasteText, requestRender, showStatus } }; +} + +describe("CustomEditor empty bracketed paste (issue #3601)", () => { + it("invokes onPasteImage for an empty bracketed paste so Cmd+V on image-only clipboards reaches the smart reader", () => { + const { editor } = createCtx(); + const onPasteImage = vi.fn(async () => true); + editor.onPasteImage = onPasteImage; + + editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`); + + expect(onPasteImage).toHaveBeenCalledTimes(1); + // And the empty payload MUST NOT also fall through to the underlying editor (would + // add a literal empty paste / undo entry). + expect(editor.getText()).toBe(""); + }); + + it("invokes onPasteImage for a whitespace-only bracketed paste (matches terminals that pad the empty pasteboard read)", () => { + const { editor } = createCtx(); + const onPasteImage = vi.fn(async () => true); + editor.onPasteImage = onPasteImage; + + editor.handleInput(`${BRACKETED_PASTE_START} \n${BRACKETED_PASTE_END}`); + + expect(onPasteImage).toHaveBeenCalledTimes(1); + expect(editor.getText()).toBe(""); + }); + + it("does not hijack a bracketed paste that carries real text (Ctrl+V text fallback)", () => { + const { editor } = createCtx(); + const onPasteImage = vi.fn(async () => true); + editor.onPasteImage = onPasteImage; + + editor.handleInput(`${BRACKETED_PASTE_START}hello world${BRACKETED_PASTE_END}`); + + expect(onPasteImage).not.toHaveBeenCalled(); + expect(editor.getText()).toBe("hello world"); + }); + + it("does not hijack a bracketed paste that resolves to an explicit image-file path (existing #3506 path)", () => { + const { editor } = createCtx(); + const onPasteImage = vi.fn(async () => true); + const onPasteImagePath = vi.fn(); + editor.onPasteImage = onPasteImage; + editor.onPasteImagePath = onPasteImagePath; + + editor.handleInput(`${BRACKETED_PASTE_START}/tmp/screenshot.png${BRACKETED_PASTE_END}`); + + // The image-path branch fires; the empty-paste branch must stay out of the way. + expect(onPasteImagePath).toHaveBeenCalledWith("/tmp/screenshot.png"); + expect(onPasteImage).not.toHaveBeenCalled(); + }); + + it("ignores the empty-paste handler when no onPasteImage is registered (no behavior change for hosts that opt out)", () => { + const { editor } = createCtx(); + // editor.onPasteImage left undefined. + + // MUST not throw, MUST not modify the buffer, MUST not change focus. + editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`); + + expect(editor.getText()).toBe(""); + }); +}); + +describe("InputController + empty bracketed paste end-to-end (issue #3601)", () => { + let tmpDir: string; + let imgPath: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "issue-3601-")); + imgPath = path.join(tmpDir, "screenshot.png"); + await fs.writeFile(imgPath, ONE_PX_PNG); + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { "images.autoResize": false } }); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + resetSettingsForTest(); + vi.restoreAllMocks(); + }); + + it("end-to-end: empty bracketed paste attaches the clipboard image bytes (image-only macOS screenshot scenario)", async () => { + const editor = new CustomEditor(getEditorTheme()); + const pendingImages: ImageContent[] = []; + editor.pendingImages = pendingImages; + const requestRender = vi.fn(); + const showStatus = vi.fn(); + const ctx = { + editor, + ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"], + sessionManager: { + getCwd: () => process.cwd(), + putBlob: async () => ({ hash: "h", path: imgPath, displayPath: imgPath }), + } as unknown as InteractiveModeContext["sessionManager"], + showStatus, + } as unknown as InteractiveModeContext; + const controller = new InputController(ctx, { + readImage: async () => ({ data: ONE_PX_PNG, mimeType: "image/png" }), + readText: async () => "", // pbpaste returns empty for image-only pasteboards + }); + // Wire the same dispatch the production setup uses. + editor.onPasteImage = () => controller.handleImagePaste(); + + editor.handleInput(`${BRACKETED_PASTE_START}${BRACKETED_PASTE_END}`); + // Drain all queued microtasks so the editor's `void onPasteImage()` and the + // async chain inside `#insertPendingImage` (materializeImageReferenceLinks, + // imageDimensions) all finish before assertions run. + for (let i = 0; i < 50; i++) await Promise.resolve(); + + expect(showStatus).not.toHaveBeenCalled(); + expect(pendingImages.length).toBe(1); + expect(pendingImages[0]?.mimeType).toBe("image/png"); + }); +});