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:
HugoAir Dev
2026-06-10 17:59:57 +09:00
parent 765b972a00
commit 5cd9c016d1
3 changed files with 72 additions and 17 deletions
@@ -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;
}
+50 -14
View File
@@ -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, {