diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index 35e73c23d..dacdb9173 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -1116,6 +1116,35 @@ export class InputController { return true; } + /** + * Win+Shift+S on Windows 11 leaves the screenshot bitmap on the clipboard + * while the terminal pastes a transient packaged-app TempState path + * (…\MicrosoftWindows.Client.Core_*\TempState\…) that is already gone — or + * never materialized — by the time we read it. Whenever a pasted image path + * can't be turned into an image locally, those clipboard bytes are the real + * payload, so prefer them before degrading to a text paste. + * + * Skipped over SSH: the clipboard read would hit the remote host, not the + * terminal that holds the screenshot. Returns true when the clipboard owned + * the outcome (image attached, or an unsupported-format status surfaced), so + * the caller stops without emitting its own degraded diagnostic. + */ + async #tryPasteClipboardImage(): Promise { + const env = process.env; + if (env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT) return false; + try { + const image = await this.clipboard.readImage(); + if (!image) return false; + await this.#normalizeAndInsertPastedImage( + { type: "image", data: image.data.toBase64(), mimeType: image.mimeType }, + `Unsupported clipboard image format: ${image.mimeType}`, + ); + return true; + } catch { + return false; + } + } + async handleImagePathPaste(path: string): Promise { try { const image = await loadImageInput({ @@ -1124,6 +1153,9 @@ export class InputController { autoResize: false, }); if (!image) { + // Path resolved but is not a readable image (e.g. a zero-byte or + // locked transient screenshot file). Prefer the clipboard bytes. + if (await this.#tryPasteClipboardImage()) return; this.ctx.editor.pasteText(path); this.ctx.ui.requestRender(); this.ctx.showStatus("Pasted path is not a supported image"); @@ -1142,13 +1174,17 @@ export class InputController { } if (isEnoent(error)) { // #2375: the bracketed paste forwarded by a local terminal carries a - // path on the *local* filesystem. When omp itself runs over SSH, that - // path is unreachable here; pasting it as text would look like the - // image was attached when in fact nothing was sent. Refuse the silent - // degrade and tell the user how to send the bytes for real. The - // pasted path is untrusted terminal input — strip control/ANSI/ - // newlines, collapse home to `~`, and bound the displayed length - // before splicing it into the status string. + // path on the *local* filesystem. The bytes may still be on the + // clipboard (Win+Shift+S), so try those before giving up. + if (await this.#tryPasteClipboardImage()) return; + // Over SSH the clipboard lives on the remote host, so the path is + // genuinely unreachable; pasting it as text would look like the + // image was attached when nothing was sent. Surface an SSH-aware + // diagnostic instead. The pasted path is untrusted terminal input — + // strip control/ANSI/newlines, collapse home to `~`, and bound the + // displayed length before splicing it into the status string. + const env = process.env; + const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT); const displayPath = truncateToWidth( shortenPath( sanitizeText(path) @@ -1157,8 +1193,6 @@ export class InputController { ), TRUNCATE_LENGTHS.CONTENT, ); - const env = process.env; - const overSsh = Boolean(env.SSH_CONNECTION || env.SSH_TTY || env.SSH_CLIENT); this.ctx.showStatus( overSsh ? `Image not found at ${displayPath}. Over SSH this path is local to your terminal — paste the image directly (clipboard image-paste shortcut) to send its bytes.` @@ -1166,6 +1200,7 @@ export class InputController { ); return; } + if (await this.#tryPasteClipboardImage()) return; this.ctx.editor.pasteText(path); this.ctx.ui.requestRender(); this.ctx.showStatus("Failed to read pasted image path"); diff --git a/packages/coding-agent/test/issue-2375-repro.test.ts b/packages/coding-agent/test/issue-2375-repro.test.ts index cc89afe20..3200f0279 100644 --- a/packages/coding-agent/test/issue-2375-repro.test.ts +++ b/packages/coding-agent/test/issue-2375-repro.test.ts @@ -14,18 +14,38 @@ * paste image bytes directly instead. */ import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test"; +import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; 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"; +// A clipboard with no image on it — the deterministic default for the +// not-found assertions so a real screenshot on the dev's clipboard cannot +// flip the new fallback path and break them. +const EMPTY_CLIPBOARD = { + readImage: async () => null, + readText: async () => "", +}; + +// Minimal 1x1 PNG used to stand in for a Win+Shift+S bitmap on the clipboard. +const ONE_PX_PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC", + "base64", +); + function createContext() { const pasteText = vi.fn(); const insertText = vi.fn(); const requestRender = vi.fn(); const showStatus = vi.fn(); const ctx = { - editor: { pasteText, insertText } as unknown as InteractiveModeContext["editor"], + editor: { pasteText, insertText, imageLinks: undefined } as unknown as InteractiveModeContext["editor"], ui: { requestRender, getFocused: () => null } as unknown as InteractiveModeContext["ui"], - sessionManager: { getCwd: () => process.cwd() } as unknown as InteractiveModeContext["sessionManager"], + sessionManager: { + getCwd: () => process.cwd(), + putBlob: async () => ({ hash: "h", path: "/tmp/h.png", displayPath: "/tmp/h.png" }), + } as unknown as InteractiveModeContext["sessionManager"], + pendingImages: [] as InteractiveModeContext["pendingImages"], + pendingImageLinks: [] as InteractiveModeContext["pendingImageLinks"], showStatus, } as unknown as InteractiveModeContext; return { ctx, spies: { pasteText, insertText, requestRender, showStatus } }; @@ -36,10 +56,12 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { const originalSshTty = process.env.SSH_TTY; const originalSshClient = process.env.SSH_CLIENT; - beforeEach(() => { + beforeEach(async () => { delete process.env.SSH_CONNECTION; delete process.env.SSH_TTY; delete process.env.SSH_CLIENT; + resetSettingsForTest(); + await Settings.init({ inMemory: true, overrides: { "images.autoResize": false } }); }); afterEach(() => { @@ -49,6 +71,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { else process.env.SSH_TTY = originalSshTty; if (originalSshClient === undefined) delete process.env.SSH_CLIENT; else process.env.SSH_CLIENT = originalSshClient; + resetSettingsForTest(); vi.restoreAllMocks(); }); @@ -70,7 +93,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { it("locally: still avoids the misleading path-as-text fallback when the file is unreachable", async () => { const { ctx, spies } = createContext(); - const controller = new InputController(ctx); + const controller = new InputController(ctx, EMPTY_CLIPBOARD); const missing = "/tmp/definitely-does-not-exist-omp-2375.png"; await controller.handleImagePathPaste(missing); @@ -83,7 +106,7 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { it("sanitizes untrusted pasted-path characters and bounds length before splicing into status", async () => { const { ctx, spies } = createContext(); - const controller = new InputController(ctx); + const controller = new InputController(ctx, EMPTY_CLIPBOARD); // Path carrying ANSI, control chars, a CR/LF, and a tab — all of which // would corrupt the TUI status line if interpolated verbatim. Long // enough to exceed the status-line truncation budget (TRUNCATE_LENGTHS @@ -105,4 +128,50 @@ describe("InputController.handleImagePathPaste (issue #2375)", () => { // displayed path must be clamped strictly inside that budget. expect(status.length).toBeLessThan(hostile.length); }); + + it("locally: attaches the clipboard image when the pasted path is a stale transient file (Win+Shift+S)", async () => { + // Windows 11 Win+Shift+S leaves the bitmap on the clipboard, but the + // terminal pastes the snip's packaged-app TempState path, which is + // already gone by the time omp reads it. The bytes are still on the + // clipboard, so the paste must succeed from there instead of dead-ending + // on "Image not found". + const { ctx, spies } = createContext(); + const controller = new InputController(ctx, { + readImage: async () => ({ data: ONE_PX_PNG, mimeType: "image/png" }), + readText: async () => "", + }); + const stale = + "C:\\Users\\u\\AppData\\Local\\Packages\\MicrosoftWindows.Client.Core_cw5n1h2txyewy\\TempState\\gone.png"; + + await controller.handleImagePathPaste(stale); + + expect(spies.pasteText).not.toHaveBeenCalled(); + expect(spies.showStatus).not.toHaveBeenCalled(); + expect(ctx.pendingImages.length).toBe(1); + expect(ctx.pendingImages[0]?.mimeType).toBe("image/png"); + }); + + it("locally: attaches the clipboard image when the pasted path resolves to a non-image file", async () => { + // The bracketed paste can resolve to an existing file that is not a + // decodable image (zero-byte/locked transient snip), which surfaces as a + // null load result rather than ENOENT. The clipboard bytes must still win + // over a degraded text paste. + const { ctx, spies } = createContext(); + const controller = new InputController(ctx, { + readImage: async () => ({ data: ONE_PX_PNG, mimeType: "image/png" }), + readText: async () => "", + }); + // This test file itself: resolvable, readable, but not an image. + const nonImage = import.meta.path.replace(/\.ts$/, ".png"); + await Bun.write(nonImage, "not really a png"); + try { + await controller.handleImagePathPaste(nonImage); + } finally { + await Bun.file(nonImage).delete(); + } + + expect(spies.pasteText).not.toHaveBeenCalled(); + expect(ctx.pendingImages.length).toBe(1); + expect(ctx.pendingImages[0]?.mimeType).toBe("image/png"); + }); });