Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
32 changes: 21 additions & 11 deletions src/ui/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { saveTasksToActiveSession } from "./commands/workflow-commands.ts";
import { type ToolExecutionStatus } from "./parts/types.ts";
import { useMessageQueue, type QueuedMessage } from "./hooks/use-message-queue.ts";
import { useVerboseMode } from "./hooks/use-verbose-mode.ts";

import {
globalRegistry,
parseSlashCommand,
Expand Down Expand Up @@ -95,6 +96,7 @@ import {
isBackgroundTerminationKey,
} from "./utils/background-agent-termination.ts";
import { loadCommandHistory, appendCommandHistory } from "./utils/command-history.ts";
import { createClipboardAdapter, type ClipboardAdapter } from "./utils/clipboard.ts";
import type { McpServerToggleMap, McpSnapshotView } from "./utils/mcp-output.ts";
import {
normalizeHitlAnswer,
Expand Down Expand Up @@ -2250,6 +2252,15 @@ export function ChatApp({
// Renderer ref for copy-on-selection (OpenTUI Selection API)
const renderer = useRenderer();

// Platform-aware clipboard adapter (Strategy pattern).
// Tries OSC 52 first; falls back to native commands (pbcopy, xclip, etc.)
// on terminals that don't support OSC 52 (macOS Terminal.app, VS Code).
const clipboardRef = useRef<ClipboardAdapter | null>(null);
if (!clipboardRef.current) {
clipboardRef.current = createClipboardAdapter(renderer);
}
const clipboard = clipboardRef.current;

// Copy-on-selection: auto-copy selected text to clipboard on mouse release
// Keep selection visible so user can also use Ctrl+C / Ctrl+Shift+C to copy
const handleMouseUp = useCallback(() => {
Expand All @@ -2258,14 +2269,13 @@ export function ChatApp({
if (selection) {
const selectedText = selection.getSelectedText();
if (selectedText) {
// Type assertion for method that exists at runtime but not in type definitions
(renderer as unknown as { copyToClipboardOSC52: (text: string) => void }).copyToClipboardOSC52(selectedText);
clipboard.copy(selectedText);
}
}
} catch {
// Ignore errors from mouse selection — can occur when renderables are in a transitional state
}
}, [renderer]);
}, [renderer, clipboard]);

// Pending questions queue for HITL flow
const [, setPendingQuestions] = useState<UserQuestion[]>([]);
Expand Down Expand Up @@ -2296,6 +2306,7 @@ export function ChatApp({
// Verbose mode: shows timestamps, model info on messages (ctrl+e toggle)
const { toggle: toggleVerbose } = useVerboseMode();


// State for showing user question dialog
const [activeQuestion, setActiveQuestion] = useState<UserQuestion | null>(null);

Expand Down Expand Up @@ -6171,18 +6182,16 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
}, []);

// Handle clipboard copy - copies selected text to system clipboard
// Uses the platform-aware clipboard adapter (OSC 52 → native fallback)
// Checks both textarea selection and renderer (mouse-drag) selection
const handleCopy = useCallback(() => {
const textarea = textareaRef.current;
// Type assertion for method that exists at runtime but not in type definitions
const copyToClipboard = (text: string) =>
(renderer as unknown as { copyToClipboardOSC52: (text: string) => void }).copyToClipboardOSC52(text);

// First, check textarea selection (input area)
if (textarea?.hasSelection()) {
const selectedText = textarea.getSelectedText();
if (selectedText) {
copyToClipboard(selectedText);
clipboard.copy(selectedText);
return;
}
}
Expand All @@ -6192,11 +6201,11 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
if (selection) {
const selectedText = selection.getSelectedText();
if (selectedText) {
copyToClipboard(selectedText);
clipboard.copy(selectedText);
renderer.clearSelection();
}
}
}, [renderer]);
}, [renderer, clipboard]);

// Handle bracketed paste events from OpenTUI
// This is the primary paste handler for modern terminals that support bracketed paste mode
Expand Down Expand Up @@ -6228,8 +6237,8 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
event.raw,
);

// Ctrl+C handling must work everywhere (even in dialogs) for double-press exit
if (event.ctrl && event.name === "c") {
// Ctrl+C / Cmd+C handling must work everywhere (even in dialogs) for double-press exit
if ((event.ctrl || event.meta) && event.name === "c") {
const textarea = textareaRef.current;
// If textarea or renderer has selection and no dialog is active, copy instead of interrupt/exit
const hasRendererSelection = !!renderer.getSelection()?.getSelectedText();
Expand Down Expand Up @@ -6567,6 +6576,7 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
return;
}


// Skip other keyboard handling when a dialog is active
// The dialog components handle their own keyboard events via their own useKeyboard hooks
if (activeQuestion || showModelSelector) {
Expand Down
6 changes: 5 additions & 1 deletion src/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,11 @@ export async function startChatUI(
}

// Create the CLI renderer with:
// - mouse mode enabled for scroll wheel support (text selection via OpenTUI Selection API + Ctrl+Shift+C)
// - mouse tracking ENABLED for scroll-wheel support in scrollboxes and
// OpenTUI Selection API (mouse-drag to select, auto-copy on release).
// For native terminal text selection, hold Shift (Linux/Windows) or
// Option (macOS/iTerm2) while clicking — this is a built-in terminal
// emulator bypass that works with virtually all modern terminals.
// - useAlternateScreen: true to prevent scrollbox from corrupting terminal output
// - exitOnCtrlC: false to allow double-press Ctrl+C behavior
// - useKittyKeyboard: with disambiguate so Ctrl+C is received as keyboard event
Expand Down
182 changes: 182 additions & 0 deletions src/ui/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* Tests for ClipboardAdapter — platform-aware clipboard write strategy.
*
* Validates:
* 1. OSC 52 path is preferred when terminal reports support
* 2. Native command fallback fires when OSC 52 is not available
* 3. Chained fallback works (primary fails → secondary attempted)
* 4. `detectNativeClipboardCommand` resolution per platform
*/

import { describe, test, expect, mock, beforeEach, afterEach, spyOn } from "bun:test";
import { createClipboardAdapter } from "./clipboard.ts";
import type { CliRenderer } from "@opentui/core";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Minimal mock that satisfies the renderer surface used by ClipboardAdapter */
function makeMockRenderer(overrides: {
osc52Supported?: boolean;
copyResult?: boolean;
} = {}): CliRenderer {
const { osc52Supported = false, copyResult = true } = overrides;
return {
isOsc52Supported: mock(() => osc52Supported),
copyToClipboardOSC52: mock(() => copyResult),
} as unknown as CliRenderer;
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

describe("ClipboardAdapter", () => {
const originalTermProgram = process.env.TERM_PROGRAM;

beforeEach(() => {
// Ensure default path tests don't accidentally inherit Apple Terminal,
// which intentionally prefers native clipboard over OSC 52.
process.env.TERM_PROGRAM = "iTerm.app";
});

afterEach(() => {
process.env.TERM_PROGRAM = originalTermProgram;

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In afterEach, assigning process.env.TERM_PROGRAM = originalTermProgram will set the env var to the string "undefined" when it was originally unset (common in Node/Bun). This can leak state into later tests. Restore by delete process.env.TERM_PROGRAM when originalTermProgram === undefined, otherwise set it back to the original value (same pattern used in src/utils/detect.test.ts).

Suggested change
process.env.TERM_PROGRAM = originalTermProgram;
if (originalTermProgram === undefined) {
delete process.env.TERM_PROGRAM;
} else {
process.env.TERM_PROGRAM = originalTermProgram;
}

Copilot uses AI. Check for mistakes.
});

describe("when OSC 52 is supported", () => {
test("uses OSC 52 for copy", () => {
const renderer = makeMockRenderer({ osc52Supported: true, copyResult: true });
const adapter = createClipboardAdapter(renderer);

const result = adapter.copy("hello");

expect(result).toBe(true);
expect(renderer.copyToClipboardOSC52).toHaveBeenCalledWith("hello");
});

test("returns true when OSC 52 succeeds", () => {
const renderer = makeMockRenderer({ osc52Supported: true, copyResult: true });
const adapter = createClipboardAdapter(renderer);

expect(adapter.copy("test")).toBe(true);
});

test("falls back to native clipboard when OSC 52 write fails", () => {
const renderer = makeMockRenderer({ osc52Supported: true, copyResult: false });
const whichSpy = spyOn(Bun, "which").mockImplementation((cmd: string) => {
if (cmd === "pbcopy") return "/usr/bin/pbcopy" as ReturnType<typeof Bun.which>;
return null as ReturnType<typeof Bun.which>;
});
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue({
success: true,
} as ReturnType<typeof Bun.spawnSync>);

const adapter = createClipboardAdapter(renderer);
const result = adapter.copy("hello");

expect(result).toBe(true);

Check failure on line 79 in src/ui/utils/clipboard.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript Tests

error: expect(received).toBe(expected)

Expected: true Received: false at <anonymous> (/home/runner/work/atomic/atomic/src/ui/utils/clipboard.test.ts:79:22)
expect(renderer.copyToClipboardOSC52).toHaveBeenCalledWith("hello");
expect(spawnSpy).toHaveBeenCalled();

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test hard-codes the macOS-only pbcopy native command. CI runs on ubuntu-latest, where detectNativeClipboardCommand() never checks pbcopy, so the native strategy will be null and spawnSync won't be called—making this test fail/flaky across platforms. Adjust the mock to return a native command that matches process.platform (e.g. wl-copy/xclip on linux), or refactor detectNativeClipboardCommand/platform detection to be injectable so the test can force a deterministic command.

Copilot uses AI. Check for mistakes.
spawnSpy.mockRestore();
whichSpy.mockRestore();
});

test("prefers native clipboard on Apple Terminal even if OSC 52 is reported", () => {
process.env.TERM_PROGRAM = "Apple_Terminal";

const renderer = makeMockRenderer({ osc52Supported: true, copyResult: true });
const whichSpy = spyOn(Bun, "which").mockImplementation((cmd: string) => {
if (cmd === "pbcopy") return "/usr/bin/pbcopy" as ReturnType<typeof Bun.which>;
return null as ReturnType<typeof Bun.which>;
});
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue({
success: true,
} as ReturnType<typeof Bun.spawnSync>);

const adapter = createClipboardAdapter(renderer);
const result = adapter.copy("hello");

expect(result).toBe(true);
expect(spawnSpy).toHaveBeenCalled();

Check failure on line 103 in src/ui/utils/clipboard.test.ts

View workflow job for this annotation

GitHub Actions / TypeScript Tests

error: expect(received).toHaveBeenCalled()

Expected number of calls: >= 1 Received number of calls: 0 at <anonymous> (/home/runner/work/atomic/atomic/src/ui/utils/clipboard.test.ts:103:24)
expect((renderer.copyToClipboardOSC52 as ReturnType<typeof mock>).mock.calls.length).toBe(0);

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: this test assumes pbcopy is the native clipboard command even though the suite runs on Linux in CI. On non-darwin platforms detectNativeClipboardCommand() won’t select pbcopy, so spawnSync will not be invoked and the expectations will fail. Make the command mocked by Bun.which conditional on process.platform (linux: wl-copy/xclip/xsel, darwin: pbcopy) or inject platform/command resolution so the test can force the native strategy.

Copilot uses AI. Check for mistakes.

spawnSpy.mockRestore();
whichSpy.mockRestore();
});
});

describe("when OSC 52 is NOT supported", () => {
test("does not call OSC 52 as primary", () => {
const renderer = makeMockRenderer({ osc52Supported: false });
const adapter = createClipboardAdapter(renderer);

// On macOS (our test platform), this will attempt pbcopy as the native fallback.
// The copy result depends on platform availability, but OSC 52 should NOT be the
// primary path. We verify by checking that the adapter was created successfully
// and doesn't throw.
const result = adapter.copy("hello");
// On macOS with pbcopy available, this should succeed via native fallback
if (process.platform === "darwin") {
expect(result).toBe(true);
}
// Regardless of platform, the adapter should not throw
expect(typeof result).toBe("boolean");

Copilot AI Mar 3, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test name claims "does not call OSC 52 as primary", but it never asserts whether copyToClipboardOSC52 was called and it relies on whatever native tools happen to exist on the host. To actually validate the resolution logic deterministically, mock Bun.which/Bun.spawnSync to force a native strategy on the current platform, then assert that renderer.copyToClipboardOSC52 is not called (or only called as fallback) when osc52Supported is false.

Suggested change
const adapter = createClipboardAdapter(renderer);
// On macOS (our test platform), this will attempt pbcopy as the native fallback.
// The copy result depends on platform availability, but OSC 52 should NOT be the
// primary path. We verify by checking that the adapter was created successfully
// and doesn't throw.
const result = adapter.copy("hello");
// On macOS with pbcopy available, this should succeed via native fallback
if (process.platform === "darwin") {
expect(result).toBe(true);
}
// Regardless of platform, the adapter should not throw
expect(typeof result).toBe("boolean");
// Force native clipboard strategy resolution deterministically by mocking
// Bun.which / Bun.spawnSync, so the test does not depend on host tools.
const whichSpy = spyOn(Bun, "which").mockReturnValue("/usr/bin/fake-clipboard" as any);
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue({
success: true,
exitCode: 0,
stdout: new TextEncoder().encode(""),
stderr: new TextEncoder().encode(""),
} as any);
const adapter = createClipboardAdapter(renderer);
const result = adapter.copy("hello");
// Native path should be used as the primary strategy when OSC 52 is not supported.
expect(result).toBe(true);
expect(whichSpy.mock.calls.length).toBeGreaterThan(0);
expect(spawnSpy.mock.calls.length).toBeGreaterThan(0);
// OSC 52 must not be used as the primary copy mechanism in this scenario.
expect(
(renderer.copyToClipboardOSC52 as ReturnType<typeof mock>).mock.calls.length
).toBe(0);
whichSpy.mockRestore();
spawnSpy.mockRestore();

Copilot uses AI. Check for mistakes.
});
});

describe("strategy resolution", () => {
test("adapter is reusable across multiple copy calls", () => {
const renderer = makeMockRenderer({ osc52Supported: true, copyResult: true });
const adapter = createClipboardAdapter(renderer);

adapter.copy("first");
adapter.copy("second");
adapter.copy("third");

// isOsc52Supported is called once for lazy strategy resolution
expect((renderer.isOsc52Supported as ReturnType<typeof mock>).mock.calls.length).toBe(1);
// copyToClipboardOSC52 is called for each copy
expect((renderer.copyToClipboardOSC52 as ReturnType<typeof mock>).mock.calls.length).toBe(3);
});

test("strategy resolution is lazy (deferred until first copy)", () => {
const renderer = makeMockRenderer({ osc52Supported: true });
createClipboardAdapter(renderer);

// No calls until copy() is invoked
expect((renderer.isOsc52Supported as ReturnType<typeof mock>).mock.calls.length).toBe(0);
});

test("empty string is handled without error", () => {
const renderer = makeMockRenderer({ osc52Supported: true, copyResult: true });
const adapter = createClipboardAdapter(renderer);

expect(() => adapter.copy("")).not.toThrow();
});
});

describe("native fallback on macOS", () => {
test.skipIf(process.platform !== "darwin")(
"pbcopy fallback writes to system clipboard",
() => {
// OSC 52 not supported → should fall back to pbcopy on macOS
const renderer = makeMockRenderer({ osc52Supported: false });
const adapter = createClipboardAdapter(renderer);

const testText = `clipboard-test-${Date.now()}`;
const result = adapter.copy(testText);
expect(result).toBe(true);

// Verify by reading back with pbpaste
const readBack = Bun.spawnSync({
cmd: ["pbpaste"],
stdout: "pipe",
});
expect(readBack.stdout.toString()).toBe(testText);
}
);
});
});
Loading
Loading