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
64 changes: 51 additions & 13 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();
}
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 All @@ -6205,9 +6214,21 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
if (!textarea) return;

event.preventDefault();
textarea.insertText(normalizePastedText(event.text));
const normalized = normalizePastedText(event.text);
const pastedContent = normalized.trim();

if (!pastedContent) {
const clipboardText = clipboard.readText();
if (clipboardText) {
textarea.insertText(normalizePastedText(clipboardText));
handleTextareaContentChange();
}
return;
}

textarea.insertText(normalized);
handleTextareaContentChange();
}, [handleTextareaContentChange, normalizePastedText]);
}, [handleTextareaContentChange, normalizePastedText, clipboard]);

// Get current autocomplete suggestions count for navigation
const autocompleteSuggestions = workflowState.showAutocomplete
Expand All @@ -6228,8 +6249,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 @@ -6437,6 +6458,19 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
return;
}

if ((event.ctrl || event.meta) && event.name === "v") {
const textarea = textareaRef.current;
if (textarea) {
const clipboardText = clipboard.readText();
if (clipboardText) {
event.preventDefault();
textarea.insertText(normalizePastedText(clipboardText));
handleTextareaContentChange();
return;
}
}
}

// While a dialog is active, it owns keyboard input exclusively.
// Keep Ctrl+C handling above for copy/interrupt semantics.
if (activeQuestion || showModelSelector) {
Expand Down Expand Up @@ -6567,6 +6601,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 Expand Up @@ -7179,6 +7214,9 @@ Important: Do not add any text before or after the sub-agent's output. Pass thro
setIsEditingQueue,
parallelAgents,
compactionSummary,
clipboard,
normalizePastedText,
handleTextareaContentChange,
addMessage,
renderer,
emitMessageSubmitTelemetry,
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
143 changes: 143 additions & 0 deletions src/ui/utils/clipboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* Tests for ClipboardAdapter modeled after OpenCode clipboard behavior.
*/

import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test";
import { createClipboardAdapter } from "./clipboard.ts";

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

describe("ClipboardAdapter", () => {
const originalPlatform = process.platform;
const originalWayland = process.env.WAYLAND_DISPLAY;
const originalStdoutIsTTY = process.stdout.isTTY;

const setPlatform = (platform: NodeJS.Platform): void => {
Object.defineProperty(process, "platform", {
value: platform,
configurable: true,
});
};

beforeEach(() => {
process.env.WAYLAND_DISPLAY = undefined;
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
});

afterEach(() => {
process.env.WAYLAND_DISPLAY = originalWayland;
Object.defineProperty(process.stdout, "isTTY", {
value: originalStdoutIsTTY,
configurable: true,
});
setPlatform(originalPlatform);
});

describe("copy", () => {
test("writes OSC52 and native clipboard on macOS", () => {
setPlatform("darwin");

const writeSpy = spyOn(process.stdout, "write").mockReturnValue(true);
const whichSpy = spyOn(Bun, "which").mockImplementation((cmd: string) => {
if (cmd === "osascript") return "/usr/bin/osascript" 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();

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

expect(result).toBe(true);
expect(writeSpy).toHaveBeenCalled();
expect(spawnSpy).toHaveBeenCalledWith(
expect.objectContaining({
cmd: ["osascript", "-e", "set the clipboard to \"hello\""],
}),
);

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

test("uses wl-copy on Wayland Linux", () => {
setPlatform("linux");
process.env.WAYLAND_DISPLAY = "wayland-0";

const whichSpy = spyOn(Bun, "which").mockImplementation((cmd: string) => {
if (cmd === "wl-copy") return "/usr/bin/wl-copy" 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();

expect(adapter.copy("test")).toBe(true);
expect(spawnSpy).toHaveBeenCalledWith(
expect.objectContaining({
cmd: ["wl-copy"],
}),
);

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

test("falls back to OSC52-only when no native command is available", () => {
const writeSpy = spyOn(process.stdout, "write").mockReturnValue(true);
const whichSpy = spyOn(Bun, "which").mockReturnValue(null as ReturnType<typeof Bun.which>);

const adapter = createClipboardAdapter();

expect(adapter.copy("hello")).toBe(true);
expect(writeSpy).toHaveBeenCalled();

whichSpy.mockRestore();
writeSpy.mockRestore();
});
});

describe("readText", () => {
test("reads clipboard text with pbpaste on macOS", () => {
setPlatform("darwin");

const whichSpy = spyOn(Bun, "which").mockImplementation((cmd: string) => {
if (cmd === "pbpaste") return "/usr/bin/pbpaste" as ReturnType<typeof Bun.which>;
return null as ReturnType<typeof Bun.which>;
});
const spawnSpy = spyOn(Bun, "spawnSync").mockReturnValue({
success: true,
stdout: new TextEncoder().encode("from-clipboard"),
} as ReturnType<typeof Bun.spawnSync>);

const adapter = createClipboardAdapter();

expect(adapter.readText()).toBe("from-clipboard");

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

test("returns undefined when no read strategy exists", () => {
setPlatform("darwin");
const whichSpy = spyOn(Bun, "which").mockReturnValue(null as ReturnType<typeof Bun.which>);

const adapter = createClipboardAdapter();

expect(adapter.readText()).toBeUndefined();

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

});
Loading
Loading