feat(coding-agent): fall back to text paste when clipboard holds no image
app.clipboard.pasteImage dead-ended with a status message when the clipboard held text, which made the chord useless on hosts that only deliver that one keypress (VS Code's integrated terminal forwarding Ctrl+V, Windows clipboard history via Win+V simulating Ctrl+V). - handleImagePaste now pastes clipboard text through the editor's paste semantics when no image is available; an empty clipboard reports 'Clipboard is empty'. - readTextFromClipboard reaches the Windows clipboard through host PowerShell under WSL (mirroring readImageFromClipboard), with CRLF normalization and UTF-8 output encoding. - handleClipboardTextRawPaste's empty-clipboard status message moved to the actual empty branch (was shown after successful pastes). - Clipboard reads are constructor-injectable for tests. Implements proposal 1 of #1628.
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Smart paste (#1628): `app.clipboard.pasteImage` must fall back to pasting
|
||||
* clipboard text when no image is available, instead of dead-ending with
|
||||
* "No image in clipboard". Hosts that deliver only this one chord (VS Code's
|
||||
* integrated terminal forwarding Ctrl+V, Windows clipboard history via Win+V)
|
||||
* rely on the fallback to cover both payload kinds.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "bun:test";
|
||||
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";
|
||||
|
||||
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"],
|
||||
ui: { requestRender } as unknown as InteractiveModeContext["ui"],
|
||||
showStatus,
|
||||
} as unknown as InteractiveModeContext;
|
||||
return { ctx, spies: { pasteText, insertText, requestRender, showStatus } };
|
||||
}
|
||||
|
||||
describe("InputController.handleImagePaste smart-paste fallback", () => {
|
||||
it("prefers the clipboard image and never consults text when an image is present", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const readText = vi.fn(async () => "text that must not be pasted");
|
||||
const controller = new InputController(ctx, {
|
||||
// Unsupported/undecodable payload keeps the test off the full image
|
||||
// pipeline; the contract under test is the read order, and that an
|
||||
// image failure must NOT silently degrade into a text paste.
|
||||
readImage: async () => ({ data: Buffer.from("not an image"), mimeType: "image/tiff" }),
|
||||
readText,
|
||||
});
|
||||
|
||||
const result = await controller.handleImagePaste();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(readText).not.toHaveBeenCalled();
|
||||
expect(spies.pasteText).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("Unsupported clipboard image format: image/tiff");
|
||||
});
|
||||
|
||||
it("attaches nothing and pastes clipboard text when no image is present", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => null,
|
||||
readText: async () => "copied text\nsecond line",
|
||||
});
|
||||
|
||||
const result = await controller.handleImagePaste();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(spies.pasteText).toHaveBeenCalledWith("copied text\nsecond line");
|
||||
expect(spies.requestRender).toHaveBeenCalled();
|
||||
expect(spies.showStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports an empty clipboard when neither image nor text is available", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => null,
|
||||
readText: async () => "",
|
||||
});
|
||||
|
||||
const result = await controller.handleImagePaste();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(spies.pasteText).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("Clipboard is empty");
|
||||
});
|
||||
|
||||
it("surfaces a read failure without pasting", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => {
|
||||
throw new Error("clipboard unavailable");
|
||||
},
|
||||
readText: async () => "should never be used",
|
||||
});
|
||||
|
||||
const result = await controller.handleImagePaste();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(spies.pasteText).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("Failed to read clipboard");
|
||||
});
|
||||
});
|
||||
|
||||
describe("InputController.handleClipboardTextRawPaste", () => {
|
||||
it("inserts clipboard text verbatim", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => null,
|
||||
readText: async () => "raw $TEXT",
|
||||
});
|
||||
|
||||
await controller.handleClipboardTextRawPaste();
|
||||
|
||||
expect(spies.insertText).toHaveBeenCalledWith("raw $TEXT");
|
||||
expect(spies.showStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the empty-clipboard status only when there is no text", async () => {
|
||||
const { ctx, spies } = createContext();
|
||||
const controller = new InputController(ctx, {
|
||||
readImage: async () => null,
|
||||
readText: async () => "",
|
||||
});
|
||||
|
||||
await controller.handleClipboardTextRawPaste();
|
||||
|
||||
expect(spies.insertText).not.toHaveBeenCalled();
|
||||
expect(spies.showStatus).toHaveBeenCalledWith("No text in clipboard to paste raw");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user