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:
HugoAir Dev
2026-06-10 17:38:28 +09:00
parent bbe85b6661
commit 765b972a00
7 changed files with 166 additions and 9 deletions
@@ -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");
});
});