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
59 changes: 59 additions & 0 deletions src/tui/clipboard/clipboard-context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* React access to the clipboard writer.
*
* Shaped like `mouse-context.tsx` and for the same reason: the chat
* bubbles are presentational and prop-drilling a writer down through
* `ChatLog` → `FinalisedMessage` → every bubble would be a bigger change
* than the feature earns.
*
* Unlike the mouse context there is a **default** when no provider is
* mounted, because a copy button with no clipboard is not a degraded
* button, it is a broken one. The default is created lazily and shared,
* so the common case — the real TUI, which mounts no provider — needs no
* wiring at all. `createClipboardWriter` refuses to act on a non-TTY
* stdout, which is what keeps that default from touching a real human's
* clipboard when a component test happens to render a copy button.
*
* Tests that want to *observe* a copy mount `ClipboardProvider` with a
* fake and get an exact record of what was copied.
*/
import { createContext, useContext, type ReactElement, type ReactNode } from "react";
import {
createClipboardWriter,
type ClipboardWriter,
} from "./copy-to-clipboard.js";

const ClipboardContext = createContext<ClipboardWriter | null>(null);

let defaultWriter: ClipboardWriter | null = null;

/**
* The process-wide writer used when no provider is mounted. Lazy so that
* merely importing a chat component does not read `process.platform` or
* capture a `process.stdout` that a harness may still replace.
*/
export function getDefaultClipboardWriter(): ClipboardWriter {
defaultWriter ??= createClipboardWriter();
return defaultWriter;
}

export interface ClipboardProviderProps {
readonly writer: ClipboardWriter;
readonly children: ReactNode;
}

export function ClipboardProvider({
writer,
children,
}: ClipboardProviderProps): ReactElement {
return (
<ClipboardContext.Provider value={writer}>
{children}
</ClipboardContext.Provider>
);
}

/** The active clipboard writer — the provider's, or the shared default. */
export function useClipboard(): ClipboardWriter {
return useContext(ClipboardContext) ?? getDefaultClipboardWriter();
}
212 changes: 212 additions & 0 deletions src/tui/clipboard/copy-to-clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { describe, expect, it } from "vitest";
import {
createClipboardWriter,
createNullClipboardWriter,
fitsInOsc52,
osc52Sequence,
platformClipboardCommand,
OSC52_MAX_BASE64_CHARS,
type ClipboardCommandRunner,
} from "./copy-to-clipboard.js";

interface FakeStdout {
isTTY: boolean;
writes: string[];
write(chunk: string): boolean;
}

function makeStdout(isTty: boolean): FakeStdout {
const writes: string[] = [];
return {
isTTY: isTty,
writes,
write(chunk: string): boolean {
writes.push(chunk);
return true;
},
};
}

interface RunLog {
runner: ClipboardCommandRunner;
calls: Array<{ command: string; args: readonly string[]; text: string }>;
}

function makeRunner(result: boolean): RunLog {
const calls: RunLog["calls"] = [];
return {
calls,
runner: async (command, args, text) => {
calls.push({ command, args, text });
return result;
},
};
}

describe("osc52Sequence", () => {
it("wraps base64 in the OSC 52 clipboard sequence", () => {
expect(osc52Sequence("hi")).toBe("\u001B]52;c;aGk=\u0007");
});

it("encodes non-ASCII as UTF-8 bytes, not UTF-16 units", () => {
// A terminal decodes the payload as bytes; encoding "é" as its
// UTF-16 code unit would paste a replacement character.
expect(osc52Sequence("é")).toBe(
`\u001B]52;c;${Buffer.from("é", "utf8").toString("base64")}\u0007`,
);
});

it("carries newlines through untouched", () => {
const decoded = Buffer.from(
osc52Sequence("a\nb").slice("\u001B]52;c;".length, -1),
"base64",
).toString("utf8");
expect(decoded).toBe("a\nb");
});
});

describe("fitsInOsc52", () => {
it("accepts an ordinary chat message", () => {
expect(fitsInOsc52("a normal reply")).toBe(true);
});

it("rejects a payload past the terminal-safe ceiling", () => {
const tooBig = "x".repeat(OSC52_MAX_BASE64_CHARS);
expect(fitsInOsc52(tooBig)).toBe(false);
});
});

describe("platformClipboardCommand", () => {
it("uses pbcopy on macOS", () => {
expect(platformClipboardCommand("darwin", {})).toEqual({
command: "pbcopy",
args: [],
});
});

it("uses clip on Windows", () => {
expect(platformClipboardCommand("win32", {})?.command).toBe("clip");
});

it("prefers wl-copy over xclip when both sessions advertise themselves", () => {
const command = platformClipboardCommand("linux", {
WAYLAND_DISPLAY: "wayland-0",
DISPLAY: ":0",
});
expect(command?.command).toBe("wl-copy");
});

it("falls back to xclip under X11", () => {
expect(platformClipboardCommand("linux", { DISPLAY: ":0" })).toEqual({
command: "xclip",
args: ["-selection", "clipboard"],
});
});

it("has nothing to offer on a headless box", () => {
// Not a failure: OSC 52 is the correct — and only — route back to
// the clipboard of whoever is on the other end of the ssh pipe.
expect(platformClipboardCommand("linux", {})).toBeNull();
});
});

describe("createClipboardWriter", () => {
it("emits OSC 52 and runs the platform command for one copy", () => {
const stdout = makeStdout(true);
const run = makeRunner(true);
const writer = createClipboardWriter({
stdout,
runCommand: run.runner,
platform: "darwin",
env: {},
});
return writer.copy("hello").then((ok) => {
expect(ok).toBe(true);
expect(stdout.writes).toEqual([osc52Sequence("hello")]);
expect(run.calls).toEqual([
{ command: "pbcopy", args: [], text: "hello" },
]);
});
});

it("still reports success when the platform command fails but OSC 52 went out", () => {
// The SSH case: pbcopy would target the wrong machine anyway, and a
// terminal that honoured OSC 52 has the text.
const stdout = makeStdout(true);
const run = makeRunner(false);
const writer = createClipboardWriter({
stdout,
runCommand: run.runner,
platform: "darwin",
env: {},
});
return writer.copy("hello").then((ok) => expect(ok).toBe(true));
});

it("reports success from the platform command alone when the payload is too big for OSC 52", async () => {
const stdout = makeStdout(true);
const run = makeRunner(true);
const writer = createClipboardWriter({
stdout,
runCommand: run.runner,
platform: "darwin",
env: {},
});
const huge = "x".repeat(OSC52_MAX_BASE64_CHARS);
expect(await writer.copy(huge)).toBe(true);
expect(stdout.writes).toEqual([]);
expect(run.calls[0]?.text).toBe(huge);
});

it("reports failure when there is no platform command and the payload is too big", async () => {
const stdout = makeStdout(true);
const run = makeRunner(true);
const writer = createClipboardWriter({
stdout,
runCommand: run.runner,
platform: "linux",
env: {},
});
expect(await writer.copy("x".repeat(OSC52_MAX_BASE64_CHARS))).toBe(false);
expect(run.calls).toEqual([]);
});

it("does nothing at all when stdout is not a TTY", async () => {
// This guard is what keeps `npx vitest` from overwriting the
// clipboard of whoever is running the suite.
const stdout = makeStdout(false);
const run = makeRunner(true);
const writer = createClipboardWriter({
stdout,
runCommand: run.runner,
platform: "darwin",
env: {},
});
expect(await writer.copy("hello")).toBe(false);
expect(stdout.writes).toEqual([]);
expect(run.calls).toEqual([]);
});

it("falls back to the platform command when the stdout write throws", async () => {
const run = makeRunner(true);
const writer = createClipboardWriter({
stdout: {
isTTY: true,
write: () => {
throw new Error("EIO");
},
},
runCommand: run.runner,
platform: "darwin",
env: {},
});
expect(await writer.copy("hello")).toBe(true);
expect(run.calls).toHaveLength(1);
});
});

describe("createNullClipboardWriter", () => {
it("always reports failure", async () => {
expect(await createNullClipboardWriter().copy("hello")).toBe(false);
});
});
Loading