Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 1 addition & 15 deletions src/screens/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import { MCPManager, loadMCPConfig, type MCPServerConfig } from "../mcp/client.j
import { seedSystemMessages, MODE_PROMPTS } from "../agent/system-prompt.js";
import { checkForUpdate } from "../utils/update.js";
import { readClipboardImage, MAX_IMAGE_BYTES } from "../utils/clipboard-image.js";
import { copyToClipboard } from "../utils/clipboard.js";
import { SessionLedger } from "../agent/session-ledger.js";
import { COMPACTION_PROMPT, extractSummary, MAX_CONSECUTIVE_COMPACT_FAILURES } from "../agent/compaction-prompt.js";
import { compactMessagesForApi } from "../agent/compaction.js";
Expand Down Expand Up @@ -961,21 +962,6 @@ export async function runREPL(

// ─── Clipboard helpers ────────────────────────────────────────────────────

/** Write text to the system clipboard cross-platform. Returns true on success. */
function copyToClipboard(text: string): boolean {
try {
if (process.platform === "darwin") {
spawnSync("pbcopy", [], { input: text, encoding: "utf-8" });
} else if (process.platform === "win32") {
spawnSync("clip", [], { input: text, encoding: "utf-8", shell: true });
} else {
const r = spawnSync("xclip", ["-selection", "clipboard"], { input: text, encoding: "utf-8" });
if (r.error) spawnSync("xsel", ["--clipboard", "--input"], { input: text, encoding: "utf-8" });
}
return true;
} catch { return false; }
}

/** Convert StyledLine[] to plain text (strip all styling). */
function styledLinesToText(lines: StyledLine[]): string {
return lines.map(line => line.map(s => s.text).join("")).join("\n");
Expand Down
54 changes: 54 additions & 0 deletions src/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test";
import { copyToClipboard, type SpawnSyncFn } from "./clipboard";

function fakeSpawn(
plan: Record<string, { status?: number | null; error?: Error }>,
): SpawnSyncFn {
return (command) => {
const r = plan[command] ?? { status: 1, error: new Error("ENOENT") };
return {
status: r.status ?? null,
error: r.error,
signal: null,
output: [null, "", ""],
pid: 0,
stdout: "",
stderr: "",
};
};
}

describe("copyToClipboard", () => {
const original = process.platform;

test("darwin: succeeds only when pbcopy exits 0", () => {
Object.defineProperty(process, "platform", { value: "darwin" });
expect(copyToClipboard("hi", fakeSpawn({ pbcopy: { status: 0 } }))).toBe(true);
expect(copyToClipboard("hi", fakeSpawn({ pbcopy: { status: 1 } }))).toBe(false);
expect(copyToClipboard("hi", fakeSpawn({ pbcopy: { error: new Error("ENOENT") } }))).toBe(false);
Object.defineProperty(process, "platform", { value: original });
});

test("win32: succeeds only when clip exits 0", () => {
Object.defineProperty(process, "platform", { value: "win32" });
expect(copyToClipboard("hi", fakeSpawn({ clip: { status: 0 } }))).toBe(true);
expect(copyToClipboard("hi", fakeSpawn({ clip: { status: 1 } }))).toBe(false);
Object.defineProperty(process, "platform", { value: original });
});

test("linux: falls back from xclip to xsel; false when both fail", () => {
Object.defineProperty(process, "platform", { value: "linux" });
expect(copyToClipboard("hi", fakeSpawn({
xclip: { status: 0 },
}))).toBe(true);
expect(copyToClipboard("hi", fakeSpawn({
xclip: { error: new Error("ENOENT") },
xsel: { status: 0 },
}))).toBe(true);
expect(copyToClipboard("hi", fakeSpawn({
xclip: { error: new Error("ENOENT") },
xsel: { error: new Error("ENOENT") },
}))).toBe(false);
Object.defineProperty(process, "platform", { value: original });
});
});
43 changes: 43 additions & 0 deletions src/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
/**
* Cross-platform clipboard write.
*
* Returns true only when the platform clipboard tool exits successfully —
* missing binaries / non-zero status must not be reported as success.
*/

import { spawnSync, type SpawnSyncReturns } from "node:child_process";

export type SpawnSyncFn = (
command: string,
args: string[],
options: { input: string; encoding: "utf-8"; shell?: boolean },
) => SpawnSyncReturns<string>;

function ok(r: SpawnSyncReturns<string>): boolean {
return !r.error && (r.status ?? 1) === 0;
}

/**
* Write `text` to the system clipboard.
* @param spawn injectable for unit tests (defaults to `spawnSync`)
*/
export function copyToClipboard(
text: string,
spawn: SpawnSyncFn = spawnSync as SpawnSyncFn,
): boolean {
try {
if (process.platform === "darwin") {
return ok(spawn("pbcopy", [], { input: text, encoding: "utf-8" }));
}
if (process.platform === "win32") {
return ok(spawn("clip", [], { input: text, encoding: "utf-8", shell: true }));
}
// Linux / BSD — try xclip, then xsel
const xclip = spawn("xclip", ["-selection", "clipboard"], { input: text, encoding: "utf-8" });
if (ok(xclip)) return true;
const xsel = spawn("xsel", ["--clipboard", "--input"], { input: text, encoding: "utf-8" });
return ok(xsel);
} catch {
return false;
}
}
Loading