fix(coding-agent/modes): fall back to clipboard image when pasted screenshot path is missing

This commit is contained in:
art-wiedzmin
2026-06-14 17:33:17 +02:00
committed by can1357
parent 1e11040c50
commit e31926e698
2 changed files with 118 additions and 14 deletions
@@ -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<boolean> {
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<void> {
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");
@@ -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");
});
});