diff --git a/docs/keybindings.md b/docs/keybindings.md index dfc881bbe..dfe79cfd5 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -39,10 +39,10 @@ app.stt.toggle: [] | `app.display.reset` | `Ctrl+L` | Reset terminal display | | `app.clipboard.copyLine` | `Alt+Shift+L` | Copy the current line | | `app.clipboard.copyPrompt` | `Alt+Shift+C` | Copy the whole prompt | -| `app.clipboard.pasteImage` | `Ctrl+V` (`Alt+V` fallback on Windows) | Paste an image from the clipboard | +| `app.clipboard.pasteImage` | `Ctrl+V` (`Alt+V` fallback on Windows) | Paste from the clipboard (image preferred, text fallback) | | `app.stt.toggle` | `Alt+H` | Toggle speech-to-text recording | -On Windows Terminal, `Ctrl+V` may be handled by the terminal paste command before `omp` sees it; use the `Alt+V` fallback when clipboard image paste appears to do nothing. Windows Terminal also swallows `Ctrl+Enter`, so the follow-up shortcut also binds `Ctrl+Q` — the same chord GitHub Copilot CLI uses. If your existing `keybindings.yml` already assigns `Ctrl+Q` to another action, that user remap wins and follow-up keeps `Ctrl+Enter` unless you explicitly bind `app.message.followUp`. +On Windows Terminal, `Ctrl+V` may be handled by the terminal paste command before `omp` sees it; use the `Alt+V` fallback when clipboard image paste appears to do nothing. When the clipboard holds no image, `app.clipboard.pasteImage` pastes the clipboard text instead, so hosts that deliver only this chord (VS Code's integrated terminal when configured to forward `Ctrl+V`, Windows clipboard history via `Win+V`) work for both payload kinds. Windows Terminal also swallows `Ctrl+Enter`, so the follow-up shortcut also binds `Ctrl+Q` — the same chord GitHub Copilot CLI uses. If your existing `keybindings.yml` already assigns `Ctrl+Q` to another action, that user remap wins and follow-up keeps `Ctrl+Enter` unless you explicitly bind `app.message.followUp`. Terminals that implement OSC 5522 enhanced paste can send clipboard MIME data directly to `omp`; image pastes are attached as `[Image #N]`, while text/plain paste events keep normal paste behavior. When OSC 5522 is unavailable, bracketed paste still handles text, and a pasted single image-file path is loaded as an image when the file is readable from the `omp` host. diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f488065de..13b2fbc4d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Changed + +- `app.clipboard.pasteImage` (`Ctrl+V`) now falls back to pasting clipboard text when no image is present, so hosts that deliver only that chord (VS Code's integrated terminal forwarding `Ctrl+V`, Windows clipboard history via `Win+V`) cover both payload kinds; WSL text reads now reach the Windows clipboard through host PowerShell like image reads already did ([#1628](https://github.com/can1357/oh-my-pi/issues/1628)). + ## [15.10.12] - 2026-06-10 ### Added diff --git a/packages/coding-agent/src/config/keybindings.ts b/packages/coding-agent/src/config/keybindings.ts index b652586b3..61d7c2cd9 100644 --- a/packages/coding-agent/src/config/keybindings.ts +++ b/packages/coding-agent/src/config/keybindings.ts @@ -136,7 +136,7 @@ export const KEYBINDINGS = { }, "app.clipboard.pasteImage": { defaultKeys: getDefaultPasteImageKeys(), - description: "Paste image from clipboard", + description: "Paste image or text from clipboard", }, "app.clipboard.pasteTextRaw": { defaultKeys: ["ctrl+shift+v", "alt+shift+v"], diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index de55b97ad..b4e134d6f 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -49,7 +49,14 @@ const TINY_TITLE_PROGRESS_DONE_TTL_MS = 3_000; const TINY_TITLE_PROGRESS_REVEAL_DELAY_MS = 1_000; export class InputController { - constructor(private ctx: InteractiveModeContext) {} + constructor( + private ctx: InteractiveModeContext, + /** Injectable clipboard reads so tests can drive paste flows without a real clipboard. */ + private clipboard: { + readImage: typeof readImageFromClipboard; + readText: typeof readTextFromClipboard; + } = { readImage: readImageFromClipboard, readText: readTextFromClipboard }, + ) {} #enhancedPaste?: EnhancedPasteController; @@ -821,10 +828,21 @@ export class InputController { async handleImagePaste(): Promise { try { - const image = await readImageFromClipboard(); + const image = await this.clipboard.readImage(); if (!image) { - this.ctx.showStatus("No image in clipboard (use terminal paste for text)"); - return false; + // Smart paste (#1628): no image on the clipboard — fall back to + // pasting its text so the same chord covers both payload kinds. + // Hosts that pre-empt the terminal's own paste (VS Code's + // integrated terminal, Win+V clipboard history) deliver only + // this keypress, so a miss here must not dead-end. + const text = await this.clipboard.readText(); + if (!text) { + this.ctx.showStatus("Clipboard is empty"); + return false; + } + this.ctx.editor.pasteText(text); + this.ctx.ui.requestRender(); + return true; } return await this.#normalizeAndInsertPastedImage( { @@ -842,10 +860,11 @@ export class InputController { async handleClipboardTextRawPaste(): Promise { try { - const text = await readTextFromClipboard(); + const text = await this.clipboard.readText(); if (text) { this.ctx.editor.insertText(text); this.ctx.ui.requestRender(); + } else { this.ctx.showStatus("No text in clipboard to paste raw"); } } catch { diff --git a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts index 97a179e66..cd09a6b37 100644 --- a/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts +++ b/packages/coding-agent/src/modes/utils/hotkeys-markdown.ts @@ -48,7 +48,7 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string `| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`, `| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`, `| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`, - `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`, + `| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image or text from clipboard |`, `| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`, "| `#` | Open prompt actions |", "| `/` | Slash commands |", diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 5ea555ad1..d882af5f3 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -173,6 +173,22 @@ export async function readTextFromClipboard(): Promise { if (process.env.TERMUX_VERSION) { return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString(); } + if (isWsl()) { + try { + // Reach the Windows clipboard through host PowerShell, mirroring + // readImageFromClipboard: WSLg's wl-paste only works when + // wl-clipboard happens to be installed in the distro, while + // powershell.exe is always reachable over WSL interop. + return execSync( + 'powershell.exe -NoProfile -NonInteractive -Command "[Console]::OutputEncoding=[Text.Encoding]::UTF8; [Console]::Out.Write([string](Get-Clipboard -Raw))"', + { encoding: "utf8", timeout: 5000 }, + ) + .toString() + .replaceAll("\r\n", "\n"); + } catch { + // Fall through to the wl-paste/xclip paths below. + } + } const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); const hasX11Display = Boolean(process.env.DISPLAY); if (hasWaylandDisplay) { diff --git a/packages/coding-agent/test/input-controller-smart-paste.test.ts b/packages/coding-agent/test/input-controller-smart-paste.test.ts new file mode 100644 index 000000000..0d8723a78 --- /dev/null +++ b/packages/coding-agent/test/input-controller-smart-paste.test.ts @@ -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"); + }); +});