diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 8647033f1..ec49723ae 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -24,6 +24,7 @@ - Fixed codemode output items running together, so models could not tell where one `text()` or `console.log()` output ended and the next began. With several text items, each now starts with a `==> text N/M <==` line, and `console` calls follow the other output in one `` block with one line per call - Fixed `/mcp` waiting for all servers to connect before opening; the manager now updates live and remains usable while enabling, reconnecting, or disabling servers ([#10562](https://github.com/earendil-works/pi/issues/10562)) - Fixed images being dropped as "could not be resized" when running under `node --watch` on Node 24.19+ and 26.x, where Node posts its own messages on the image resize worker channel ([#10527](https://github.com/earendil-works/pi/issues/10527)) +- Fixed clipboard paste doing nothing in Termux, and failed copies there omitting the Termux:API install hint ([#10391](https://github.com/earendil-works/pi/issues/10391)) ## [1.0.4] - 2026-10-05 diff --git a/packages/coding-agent/src/utils/clipboard.ts b/packages/coding-agent/src/utils/clipboard.ts index 926d9f9f9..540635e64 100644 --- a/packages/coding-agent/src/utils/clipboard.ts +++ b/packages/coding-agent/src/utils/clipboard.ts @@ -52,17 +52,18 @@ async function copyViaWindowsClipboard(text: string): Promise { /** Read plain text from the system clipboard. */ export async function readClipboardText(): Promise { + const commands: [string, string[]][] = []; + // Termux reports platform "android", not "linux" (#10391). + if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]); if (platform() === "linux") { - const commands: [string, string[]][] = []; - if (process.env.TERMUX_VERSION) commands.push(["termux-clipboard-get", []]); if (process.env.WAYLAND_DISPLAY) commands.push(["wl-paste", ["--no-newline", "--type", "text"]]); if (process.env.DISPLAY) { commands.push(["xclip", ["-selection", "clipboard", "-out"]], ["xsel", ["--clipboard", "--output"]]); } - for (const [command, args] of commands) { - const bytes = await runClipboardCommand(command, args, { timeoutMs: 5000 }); - if (bytes !== undefined) return bytes.toString("utf8") || null; - } + } + for (const [command, args] of commands) { + const bytes = await runClipboardCommand(command, args, { timeoutMs: 5000 }); + if (bytes !== undefined) return bytes.toString("utf8") || null; } try { return (await getNativeClipboard()?.getText()) || null; @@ -129,10 +130,10 @@ export async function copyToClipboard(text: string): Promise { } if (copied) return; if (oversized) throw new Error("Clipboard unavailable: text exceeds the OSC 52 size limit"); + if (env.TERMUX_VERSION) { + throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package"); + } if (p === "linux") { - if (env.TERMUX_VERSION) { - throw new Error("Clipboard unavailable: install the Termux:API app and `termux-api` package"); - } if (env.WAYLAND_DISPLAY) { throw new Error("Clipboard unavailable: install `wl-clipboard` (`wl-copy`) or check Wayland access"); } diff --git a/packages/coding-agent/test/clipboard.test.ts b/packages/coding-agent/test/clipboard.test.ts index ea8f8c7b9..d428d8077 100644 --- a/packages/coding-agent/test/clipboard.test.ts +++ b/packages/coding-agent/test/clipboard.test.ts @@ -76,7 +76,6 @@ describe("readClipboardText", () => { ["WAYLAND_DISPLAY", "wl-paste", ["--no-newline", "--type", "text"], ["wl-paste"]], ["DISPLAY", "xclip", ["-selection", "clipboard", "-out"], ["xclip"]], ["DISPLAY", "xsel", ["--clipboard", "--output"], ["xclip", "xsel"]], - ["TERMUX_VERSION", "termux-clipboard-get", [], ["termux-clipboard-get"]], ] as const) { test.each(["clipboard text", ""])(`${command} result %j stops fallback`, async (text) => { // Regression test for #7248: empty Wayland content must not fall through to stale X11. @@ -100,6 +99,15 @@ describe("readClipboardText", () => { expect(mocks.getNativeClipboard).toHaveBeenCalledExactlyOnceWith(); expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wl-paste", "xclip", "xsel"]); }); + test.each(["clipboard text", ""])("Termux reads termux-clipboard-get result %j on Android", async (text) => { + // Regression test for #10391: Termux reports platform "android". + mocks.platform.mockReturnValue("android"); + vi.stubEnv("TERMUX_VERSION", "0.119"); + mocks.command.mockResolvedValue(Buffer.from(text)); + await expect(readClipboardText()).resolves.toBe(text || null); + expect(mocks.command).toHaveBeenCalledExactlyOnceWith("termux-clipboard-get", [], { timeoutMs: 5000 }); + expect(mocks.getNativeClipboard).not.toHaveBeenCalled(); + }); test("falls back to X11 tools when wl-paste is unavailable", async () => { mocks.platform.mockReturnValue("linux"); vi.stubEnv("WAYLAND_DISPLAY", "wayland-0"); @@ -257,6 +265,27 @@ describe("copyToClipboard", () => { ); expect(mocks.command.mock.calls.map(([name]) => name)).toEqual(["wl-copy", "xclip", "xsel"]); }); + test("Termux on Android writes through termux-clipboard-set", async () => { + mocks.platform.mockReturnValue("android"); + vi.stubEnv("TERMUX_VERSION", "0.119"); + mocks.getNativeClipboard.mockReturnValue(undefined); + await copyToClipboard("hello"); + expect(mocks.command).toHaveBeenCalledExactlyOnceWith("termux-clipboard-set", [], { + input: "hello", + timeoutMs: 5000, + }); + expect(osc52Writes).toHaveLength(0); + }); + test("reports the Termux:API requirement on Android", async () => { + // Regression test for #10391: Termux reports platform "android". + mocks.platform.mockReturnValue("android"); + vi.stubEnv("TERMUX_VERSION", "0.119"); + mocks.getNativeClipboard.mockReturnValue(undefined); + mocks.command.mockResolvedValue(undefined); + await expect(copyToClipboard("hello")).rejects.toThrow( + "Clipboard unavailable: install the Termux:API app and `termux-api` package", + ); + }); test("uses OSC 52 when native and command writes fail in a remote session", async () => { vi.stubEnv("SSH_CONNECTION", "client server"); mocks.clipboard.setText.mockRejectedValue(new Error("native failed"));