fix(coding-agent/modes): fall back to clipboard image when pasted screenshot path is missing
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user