fix(coding-agent): spawn the WSL text clipboard read async and route the text fallback by focus
Address review notes on #2248: - readTextFromClipboard's WSL branch now mirrors readImageViaPowerShell (Bun.spawn + kill timer sharing POWERSHELL_TIMEOUT_MS) instead of execSync, so a cold powershell.exe start cannot block the TUI event loop on the first smart-paste miss. - The smart-paste text fallback routes through ui.getFocused() + hasPasteText like the enhanced-paste text path, so the payload lands in focused modal Input prompts instead of the hidden main editor (#2127 contract). Covered by a new routing test.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -125,6 +125,53 @@ async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string | null> {
|
||||
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<string> {
|
||||
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);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user