diff --git a/packages/coding-agent/src/modes/controllers/input-controller.ts b/packages/coding-agent/src/modes/controllers/input-controller.ts index b4e134d6f..348644375 100644 --- a/packages/coding-agent/src/modes/controllers/input-controller.ts +++ b/packages/coding-agent/src/modes/controllers/input-controller.ts @@ -840,7 +840,11 @@ export class InputController { this.ctx.showStatus("Clipboard is empty"); return false; } - this.ctx.editor.pasteText(text); + // Route to the focused component when it accepts pastes (modal + // Input prompts), matching the enhanced-paste text path (#2127). + const focused = this.ctx.ui.getFocused(); + const target = focused && focused !== this.ctx.editor && hasPasteText(focused) ? focused : this.ctx.editor; + target.pasteText(text); this.ctx.ui.requestRender(); return true; } diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index d882af5f3..2d07153bf 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -125,6 +125,53 @@ async function readImageViaPowerShell(): Promise { } } +// PowerShell one-liner that emits the clipboard text verbatim on stdout, or +// nothing when the clipboard holds no text. `[Console]::Out.Write` avoids the +// trailing newline Write-Output would add; output encoding is forced to UTF-8 +// so non-ASCII text survives the interop boundary regardless of console +// codepage. +const POWERSHELL_TEXT_SCRIPT = ` +$ErrorActionPreference = 'Stop' +[Console]::OutputEncoding = [Text.Encoding]::UTF8 +[Console]::Out.Write([string](Get-Clipboard -Raw)) +`; + +/** + * Read clipboard text through the Windows host's PowerShell. + * + * Same rationale as `readImageViaPowerShell`: WSLg's Wayland clipboard only + * works when `wl-clipboard` happens to be installed in the distro, while + * `powershell.exe` is always reachable over WSL interop. Spawned async so a + * cold PowerShell start cannot block the TUI event loop. + * + * Returns null when the bridge fails (caller falls through to wl-paste/xclip); + * an empty string is a successful "no text on the clipboard" read. + */ +async function readTextViaPowerShell(): Promise { + try { + const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_TEXT_SCRIPT], { + stdout: "pipe", + stderr: "ignore", + stdin: "ignore", + }); + const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS); + let stdout = ""; + try { + stdout = await new Response(proc.stdout).text(); + await proc.exited; + } catch (err) { + logger.warn("clipboard: powershell text read failed", { error: String(err) }); + return null; + } finally { + clearTimeout(timer); + } + if (proc.exitCode !== 0) return null; + return stdout.replaceAll("\r\n", "\n"); + } catch { + return null; + } +} + /** * Read an image from the system clipboard. * @@ -174,20 +221,9 @@ export async function readTextFromClipboard(): Promise { 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 text = await readTextViaPowerShell(); + if (text !== null) return text; + // Bridge failed — fall through to the wl-paste/xclip paths below. } const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY); const hasX11Display = Boolean(process.env.DISPLAY); diff --git a/packages/coding-agent/test/input-controller-smart-paste.test.ts b/packages/coding-agent/test/input-controller-smart-paste.test.ts index 0d8723a78..51abebcdf 100644 --- a/packages/coding-agent/test/input-controller-smart-paste.test.ts +++ b/packages/coding-agent/test/input-controller-smart-paste.test.ts @@ -10,14 +10,14 @@ 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() { +function createContext(options?: { focused?: { pasteText(text: string): void } }) { 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"], + ui: { requestRender, getFocused: () => options?.focused ?? null } as unknown as InteractiveModeContext["ui"], showStatus, } as unknown as InteractiveModeContext; return { ctx, spies: { pasteText, insertText, requestRender, showStatus } }; @@ -58,6 +58,21 @@ describe("InputController.handleImagePaste smart-paste fallback", () => { expect(spies.showStatus).not.toHaveBeenCalled(); }); + it("routes the text fallback to a focused paste-capable component (#2127 contract)", async () => { + const focusedPasteText = vi.fn(); + const { ctx, spies } = createContext({ focused: { pasteText: focusedPasteText } }); + const controller = new InputController(ctx, { + readImage: async () => null, + readText: async () => "api-key-123", + }); + + const result = await controller.handleImagePaste(); + + expect(result).toBe(true); + expect(focusedPasteText).toHaveBeenCalledWith("api-key-123"); + expect(spies.pasteText).not.toHaveBeenCalled(); + }); + it("reports an empty clipboard when neither image nor text is available", async () => { const { ctx, spies } = createContext(); const controller = new InputController(ctx, {