Revert "Merge pull request #2076: fix(coding-agent): prefer daemonizing CLI clipboards over arboard on Linux"
This reverts commitb3ed88d398, reversing changes made to26c6326f52.
This commit is contained in:
@@ -361,7 +361,6 @@
|
||||
- Fixed the bash result renderer recomputing styled output (`split` / `replaceTabs` / `truncateToVisualLines`) on every TUI repaint, which scaled with both transcript length and per-row output size. With a long captured session every keystroke walked hundreds of bash rows; the reporter on issue #2081 observed Ctrl+X/Ctrl+C feeling unresponsive because the main thread was pinned re-styling scrollback. The result renderer now caches its produced lines keyed by `(width, previewLines, expanded, rawOutput, isPartial)`, mirroring the existing eval-renderer cache; `invalidate()` clears the cache as before. Hot-path repaints with unchanged inputs are now O(1) ([#2081](https://github.com/can1357/oh-my-pi/issues/2081)).
|
||||
- Fixed startup model fallback selection so sessions now prefer each provider’s configured default model before choosing the first available authenticated model
|
||||
- Fixed implicit model selection path for tools and sessions by honoring persisted model-provider order when no explicit pattern is provided
|
||||
- Fixed `/copy` (and every other UI clipboard action) leaving the X11 / Wayland clipboard empty on Linux when running through tmux + QTerminal or any other stack that drops OSC 52. The native `arboard` backend cannot retain X11 / Wayland selection ownership across the short-lived napi call, so `copyToClipboard` was returning success while the desktop clipboard stayed unchanged. `utils/clipboard.ts` now reorders the backend chain on Linux to try `wl-copy` / `xclip` / `xsel` (which fork after reading stdin and keep serving the selection) before the native backend, honors an `OMP_CLIPBOARD_COMMAND` shell-command escape hatch for unusual setups, and logs a single warning when every backend fails so the silent-success regression cannot recur ([#2075](https://github.com/can1357/oh-my-pi/issues/2075))
|
||||
- Fixed the working spinner appearing to ignore Esc for 2-3 seconds when an interrupt lands mid-tool. Esc fires the abort synchronously, but the agent loop only stops the loader at `agent_end`, which it cannot reach until every in-flight tool settles in `executeToolCalls`' `await Promise.allSettled(...)` — and process/subagent/kernel-owning tools tear down gracefully (SIGTERM, 2-3s grace, SIGKILL), so the loader kept showing the unchanged "Working…/<intent>" line and read as a dropped keypress. The loader now switches to "Interrupting…" the instant Esc requests the abort and freezes intent-driven label updates until the turn unwinds (`EventController.notifyInterrupting`), so the interrupt is acknowledged immediately even while teardown completes.
|
||||
- Fixed a flaky JS eval worker startup that intermittently failed unrelated CI runs. The worker-ready wait reused Bun's 5s default per-test timeout as its floor, so a slow cold-start under `--isolate` + high concurrency was aborted mid-init; terminating a still-initializing Bun worker is the documented SIGILL/SIGTRAP crash trigger, which took down the whole test file. Worker init now floors at a fixed 15s infrastructure budget (independent of, and still dominated by, a larger per-cell `timeout`), and the JS eval test suites set a 20s file-local timeout so cold starts complete instead of being torn down.
|
||||
- Fixed reviewer-style subagent yields crashing the calling eval cell when a caller-supplied output schema declares `additionalProperties: false` without a `findings` property. `normalizeCompleteData` now consults the active validator before splicing collected `report_finding` entries onto the yielded payload, so injection is suppressed when the schema would reject it — keeping the executor's post-mortem validation in lockstep with the in-tool `yield` validation that already accepted the same raw payload ([#2070](https://github.com/can1357/oh-my-pi/issues/2070))
|
||||
|
||||
@@ -3,12 +3,6 @@ import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
||||
import * as native from "@oh-my-pi/pi-natives";
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/** Env var users can set to override clipboard copy (e.g. `xclip -selection clipboard -in -silent`). */
|
||||
const CUSTOM_COPY_COMMAND_ENV = "OMP_CLIPBOARD_COMMAND";
|
||||
|
||||
/** Timeout for any external clipboard helper. xclip / wl-copy fork after reading stdin in well under this budget. */
|
||||
const COPY_TIMEOUT_MS = 5_000;
|
||||
|
||||
function hasDisplay(): boolean {
|
||||
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
||||
}
|
||||
@@ -17,154 +11,58 @@ function isWsl(): boolean {
|
||||
return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Linux clipboard CLI fallbacks, listed in attempt order.
|
||||
*
|
||||
* The native `arboard` backend cannot retain X11 / Wayland selection ownership after the calling
|
||||
* process exits, and for short-lived napi calls it can drop ownership before any consumer sees the
|
||||
* selection — leaving the clipboard empty even though `set_text` returned success (see #2075 on
|
||||
* QTerminal + tmux). `wl-copy` / `xclip` / `xsel` all fork after reading stdin and serve the
|
||||
* selection until another app claims it, so the payload survives.
|
||||
*
|
||||
* `requiresEnv` skips backends whose display socket is absent.
|
||||
*/
|
||||
interface LinuxCliBackend {
|
||||
readonly cmd: readonly string[];
|
||||
readonly requiresEnv: "WAYLAND_DISPLAY" | "DISPLAY";
|
||||
}
|
||||
|
||||
const LINUX_CLI_BACKENDS: readonly LinuxCliBackend[] = [
|
||||
{ cmd: ["wl-copy"], requiresEnv: "WAYLAND_DISPLAY" },
|
||||
{ cmd: ["xclip", "-selection", "clipboard", "-in"], requiresEnv: "DISPLAY" },
|
||||
{ cmd: ["xsel", "--clipboard", "--input"], requiresEnv: "DISPLAY" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Spawn a clipboard CLI with `text` on stdin. Returns `true` only on a clean exit. A missing binary,
|
||||
* non-zero exit, or any spawn error is treated as a fall-through signal so the caller can try the
|
||||
* next backend.
|
||||
*/
|
||||
async function spawnClipboardCli(cmd: readonly string[], text: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: cmd as string[],
|
||||
stdin: new TextEncoder().encode(text),
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
const timer = setTimeout(() => proc.kill(), COPY_TIMEOUT_MS);
|
||||
try {
|
||||
const exitCode = await proc.exited;
|
||||
return exitCode === 0;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryCustomCommand(text: string): Promise<boolean> {
|
||||
const command = process.env[CUSTOM_COPY_COMMAND_ENV];
|
||||
if (!command) return false;
|
||||
const shell = process.platform === "win32" ? ["cmd.exe", "/c", command] : ["/bin/sh", "-c", command];
|
||||
try {
|
||||
const proc = Bun.spawn({
|
||||
cmd: shell,
|
||||
stdin: new TextEncoder().encode(text),
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
});
|
||||
const timer = setTimeout(() => proc.kill(), COPY_TIMEOUT_MS);
|
||||
try {
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode === 0) return true;
|
||||
logger.warn(`clipboard: ${CUSTOM_COPY_COMMAND_ENV} exited ${exitCode}`, { command });
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`clipboard: ${CUSTOM_COPY_COMMAND_ENV} failed`, { command, error: String(err) });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function tryLinuxCliCopy(text: string): Promise<boolean> {
|
||||
for (const backend of LINUX_CLI_BACKENDS) {
|
||||
if (!process.env[backend.requiresEnv]) continue;
|
||||
if (await spawnClipboardCli(backend.cmd, text)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function emitOsc52(text: string): void {
|
||||
if (!process.stdout.isTTY) return;
|
||||
const onError = (err: unknown) => {
|
||||
process.stdout.off("error", onError);
|
||||
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") return;
|
||||
};
|
||||
try {
|
||||
const encoded = Buffer.from(text).toString("base64");
|
||||
const osc52 = `\x1b]52;c;${encoded}\x07`;
|
||||
process.stdout.on("error", onError);
|
||||
process.stdout.write(osc52, err => {
|
||||
process.stdout.off("error", onError);
|
||||
// OSC 52 is best-effort; swallow EPIPE on broken pipes.
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") return;
|
||||
});
|
||||
} catch (err) {
|
||||
process.stdout.off("error", onError);
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
|
||||
// All write failures are ignored — OSC 52 is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy text to the system clipboard.
|
||||
*
|
||||
* Order of attempts:
|
||||
*
|
||||
* 1. **OSC 52** — emitted on a real TTY so remote terminals (SSH/mosh) that support the sequence
|
||||
* can capture the clipboard. Harmless on terminals that don't.
|
||||
* 2. **`OMP_CLIPBOARD_COMMAND`** — user-supplied shell command receiving the text on stdin.
|
||||
* Escape hatch for unusual setups (e.g. `xclip -selection clipboard -in -silent`).
|
||||
* 3. **Termux**: `termux-clipboard-set`.
|
||||
* 4. **Linux**: `wl-copy` / `xclip` / `xsel`. These commands daemonize so the clipboard payload
|
||||
* survives our process exit; the native `arboard` backend cannot retain X11/Wayland selection
|
||||
* ownership across exit and leaves the clipboard empty in QTerminal + tmux and similar
|
||||
* short-lived CLI scenarios (#2075).
|
||||
* 5. **Native `arboard`** — required on macOS/Windows and the final fallback when no Linux CLI tool
|
||||
* is installed. When this last step fails too, a single warning is logged so the silent-success
|
||||
* UX from #2075 cannot recur unnoticed.
|
||||
* Emits OSC 52 first when running in a real terminal (works over SSH/mosh),
|
||||
* then attempts native clipboard copy as best-effort for local sessions.
|
||||
* On Termux, tries `termux-clipboard-set` before native.
|
||||
*
|
||||
* @param text - UTF-8 text to place on the clipboard.
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<void> {
|
||||
emitOsc52(text);
|
||||
|
||||
if (await tryCustomCommand(text)) return;
|
||||
|
||||
if (process.env.TERMUX_VERSION) {
|
||||
if (process.stdout.isTTY) {
|
||||
const onError = (err: unknown) => {
|
||||
process.stdout.off("error", onError);
|
||||
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
|
||||
return;
|
||||
}
|
||||
};
|
||||
try {
|
||||
execSync("termux-clipboard-set", { input: text, timeout: COPY_TIMEOUT_MS });
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to native.
|
||||
const encoded = Buffer.from(text).toString("base64");
|
||||
const osc52 = `\x1b]52;c;${encoded}\x07`;
|
||||
process.stdout.on("error", onError);
|
||||
process.stdout.write(osc52, err => {
|
||||
process.stdout.off("error", onError);
|
||||
// If stdout is closed (e.g. piped to a process that exits early),
|
||||
// ignore EPIPE and proceed with native clipboard best-effort.
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
|
||||
return;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
process.stdout.off("error", onError);
|
||||
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
|
||||
// Ignore all write failures (OSC 52 is best-effort).
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === "linux" && (await tryLinuxCliCopy(text))) return;
|
||||
|
||||
// Also try native tools (best effort for local sessions)
|
||||
try {
|
||||
if (process.env.TERMUX_VERSION) {
|
||||
try {
|
||||
execSync("termux-clipboard-set", { input: text, timeout: 5000 });
|
||||
return;
|
||||
} catch {
|
||||
// Fall through to native
|
||||
}
|
||||
}
|
||||
|
||||
await native.copyToClipboard(text);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"clipboard: native copy failed and no CLI fallback succeeded. On Linux install xclip or wl-clipboard, or set OMP_CLIPBOARD_COMMAND.",
|
||||
{ error: String(err) },
|
||||
);
|
||||
} catch {
|
||||
// Ignore — clipboard copy is best-effort
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "bun:test";
|
||||
import { copyToClipboard, readImageFromClipboard } from "@oh-my-pi/pi-coding-agent/utils/clipboard";
|
||||
import { readImageFromClipboard } from "@oh-my-pi/pi-coding-agent/utils/clipboard";
|
||||
import * as native from "@oh-my-pi/pi-natives";
|
||||
import type { Subprocess } from "bun";
|
||||
|
||||
@@ -50,14 +50,7 @@ function restorePlatform(): void {
|
||||
if (platformDescriptor) Object.defineProperty(process, "platform", platformDescriptor);
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WSL_DISTRO_NAME",
|
||||
"WSL_INTEROP",
|
||||
"DISPLAY",
|
||||
"WAYLAND_DISPLAY",
|
||||
"TERMUX_VERSION",
|
||||
"OMP_CLIPBOARD_COMMAND",
|
||||
] as const;
|
||||
const ENV_KEYS = ["WSL_DISTRO_NAME", "WSL_INTEROP", "DISPLAY", "WAYLAND_DISPLAY", "TERMUX_VERSION"] as const;
|
||||
let savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -177,128 +170,3 @@ describe("readImageFromClipboard dispatch", () => {
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
type ExitMap = Record<string, number>;
|
||||
|
||||
/**
|
||||
* Mock `Bun.spawn` to record every invocation and resolve each child with the exit code mapped
|
||||
* from the first argv element. Unmapped commands default to `1` (failure) so a test that forgets
|
||||
* to whitelist a backend fails loudly instead of silently passing through.
|
||||
*/
|
||||
function spyCopySpawns(calls: SpawnCall[], exits: ExitMap) {
|
||||
function mockSpawn(opts: SpawnOptions & { cmd: string[] }): Subprocess;
|
||||
function mockSpawn(cmd: string[], opts?: SpawnOptions): Subprocess;
|
||||
function mockSpawn(first: string[] | (SpawnOptions & { cmd: string[] }), second?: SpawnOptions): Subprocess {
|
||||
const cmd = Array.isArray(first) ? first : first.cmd;
|
||||
const options = Array.isArray(first) ? (second ?? ({} as SpawnOptions)) : (first as SpawnOptions);
|
||||
calls.push({ cmd, options });
|
||||
const exit = exits[cmd[0] ?? ""] ?? 1;
|
||||
return fakeProcess("", exit);
|
||||
}
|
||||
return vi.spyOn(Bun, "spawn").mockImplementation(mockSpawn);
|
||||
}
|
||||
|
||||
describe("copyToClipboard dispatch", () => {
|
||||
it("uses xclip before the native backend on Linux+X11", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.DISPLAY = ":0";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, { xclip: 0 });
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard");
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.cmd).toEqual(["xclip", "-selection", "clipboard", "-in"]);
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prefers wl-copy when a Wayland display is present", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.WAYLAND_DISPLAY = "wayland-0";
|
||||
process.env.DISPLAY = ":0";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, { "wl-copy": 0, xclip: 0 });
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard");
|
||||
|
||||
await copyToClipboard("hi");
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.cmd).toEqual(["wl-copy"]);
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through to xsel when xclip exits non-zero", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.DISPLAY = ":0";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, { xclip: 1, xsel: 0 });
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard");
|
||||
|
||||
await copyToClipboard("hi");
|
||||
|
||||
expect(calls.map(c => c.cmd[0])).toEqual(["xclip", "xsel"]);
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the native backend when every Linux CLI fails", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.DISPLAY = ":0";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, {});
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard").mockReturnValue();
|
||||
|
||||
await copyToClipboard("hi");
|
||||
|
||||
expect(calls.map(c => c.cmd[0])).toEqual(["xclip", "xsel"]);
|
||||
expect(nativeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(nativeSpy).toHaveBeenCalledWith("hi");
|
||||
});
|
||||
|
||||
it("delegates straight to the native backend on macOS without spawning CLI helpers", async () => {
|
||||
setPlatform("darwin");
|
||||
|
||||
const spawnSpy = vi.spyOn(Bun, "spawn");
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard").mockReturnValue();
|
||||
|
||||
await copyToClipboard("hello");
|
||||
|
||||
expect(spawnSpy).not.toHaveBeenCalled();
|
||||
expect(nativeSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors OMP_CLIPBOARD_COMMAND before any other backend", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.DISPLAY = ":0";
|
||||
process.env.OMP_CLIPBOARD_COMMAND = "xclip -selection clipboard -in -silent";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, { "/bin/sh": 0, xclip: 0 });
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard");
|
||||
|
||||
await copyToClipboard("hi");
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.cmd).toEqual(["/bin/sh", "-c", "xclip -selection clipboard -in -silent"]);
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls through past a failing OMP_CLIPBOARD_COMMAND so the request still reaches a backend", async () => {
|
||||
setPlatform("linux");
|
||||
process.env.DISPLAY = ":0";
|
||||
process.env.OMP_CLIPBOARD_COMMAND = "false";
|
||||
|
||||
const calls: SpawnCall[] = [];
|
||||
spyCopySpawns(calls, { "/bin/sh": 2, xclip: 0 });
|
||||
const nativeSpy = vi.spyOn(native, "copyToClipboard");
|
||||
|
||||
await copyToClipboard("hi");
|
||||
|
||||
expect(calls.map(c => c.cmd[0])).toEqual(["/bin/sh", "xclip"]);
|
||||
expect(nativeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user