Skip to content
Open
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: 12 additions & 4 deletions lib/input-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ export class InputHandler {
private onKeyCallback?: (keyEvent: IKeyEvent) => void;
private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
private getModeCallback?: (mode: number) => boolean;
private onCopyCallback?: () => boolean;
private keydownListener: ((e: KeyboardEvent) => void) | null = null;
private keypressListener: ((e: KeyboardEvent) => void) | null = null;
private pasteListener: ((e: ClipboardEvent) => void) | null = null;
Expand All @@ -184,6 +185,7 @@ export class InputHandler {
* @param onKey - Optional callback for raw key events
* @param customKeyEventHandler - Optional custom key event handler
* @param getMode - Optional callback to query terminal mode state (for application cursor mode)
* @param onCopy - Optional callback to handle copy (Cmd+C/Ctrl+C with selection)
*/
constructor(
ghostty: Ghostty,
Expand All @@ -192,7 +194,8 @@ export class InputHandler {
onBell: () => void,
onKey?: (keyEvent: IKeyEvent) => void,
customKeyEventHandler?: (event: KeyboardEvent) => boolean,
getMode?: (mode: number) => boolean
getMode?: (mode: number) => boolean,
onCopy?: () => boolean
) {
this.encoder = ghostty.createKeyEncoder();
this.container = container;
Expand All @@ -201,6 +204,7 @@ export class InputHandler {
this.onKeyCallback = onKey;
this.customKeyEventHandler = customKeyEventHandler;
this.getModeCallback = getMode;
this.onCopyCallback = onCopy;

// Attach event listeners
this.attach();
Expand Down Expand Up @@ -327,11 +331,15 @@ export class InputHandler {
return;
}

// Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
// SelectionManager handles the actual copying
// Handle Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt)
// Note: Ctrl+C on all platforms sends interrupt signal (0x03)
if (event.metaKey && event.code === 'KeyC') {
// Let browser/SelectionManager handle copy
// Try to copy selection via callback
// If there's a selection and copy succeeds, prevent default
// If no selection, let it fall through (browser may have other text selected)
if (this.onCopyCallback && this.onCopyCallback()) {
event.preventDefault();
}
return;
}

Expand Down
83 changes: 72 additions & 11 deletions lib/selection-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,21 @@ export class SelectionManager {
);
}

/**
* Copy the current selection to clipboard
* @returns true if there was text to copy, false otherwise
*/
copySelection(): boolean {
if (!this.hasSelection()) return false;

const text = this.getSelection();
if (text) {
this.copyToClipboard(text);
return true;
}
return false;
}

/**
* Clear the selection
*/
Expand Down Expand Up @@ -841,26 +856,72 @@ export class SelectionManager {

/**
* Copy text to clipboard
*
* Strategy (modern APIs first):
* 1. Try ClipboardItem API (works in Safari and modern browsers)
* - Safari requires the ClipboardItem to be created synchronously within user gesture
* 2. Try navigator.clipboard.writeText (modern async API, may fail in Safari)
* 3. Fall back to execCommand (legacy, for older browsers)
*/
private async copyToClipboard(text: string): Promise<void> {
// First try: modern async clipboard API
if (navigator.clipboard && navigator.clipboard.writeText) {
private copyToClipboard(text: string): void {
// First try: ClipboardItem API (modern, Safari-compatible)
// Safari allows this because we create the ClipboardItem synchronously
// within the user gesture, even though the write is async
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
try {
await navigator.clipboard.writeText(text);
const blob = new Blob([text], { type: 'text/plain' });
const clipboardItem = new ClipboardItem({
'text/plain': blob,
});
navigator.clipboard.write([clipboardItem]).catch((err) => {
console.warn('ClipboardItem write failed, trying writeText:', err);
// Try writeText as fallback
this.copyWithWriteText(text);
});
return;
} catch (err) {
// Clipboard API failed (common in non-HTTPS or non-focused contexts)
// Fall through to legacy method
// ClipboardItem not supported or failed, fall through
}
}

// Second try: legacy execCommand method via textarea
// Second try: basic async writeText (works in Chrome, may fail in Safari)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch((err) => {
console.warn('Clipboard writeText failed, trying execCommand:', err);
// Fall back to execCommand
this.copyWithExecCommand(text);
});
return;
}

// Third try: legacy execCommand fallback
this.copyWithExecCommand(text);
}

/**
* Copy using navigator.clipboard.writeText
*/
private copyWithWriteText(text: string): void {
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).catch((err) => {
console.warn('Clipboard writeText failed, trying execCommand:', err);
this.copyWithExecCommand(text);
});
} else {
this.copyWithExecCommand(text);
}
}

/**
* Copy using legacy execCommand (fallback for older browsers)
*/
private copyWithExecCommand(text: string): void {
const previouslyFocused = document.activeElement as HTMLElement;
try {
// Position textarea offscreen but in a way that allows selection
const textarea = this.textarea;
textarea.value = text;
textarea.style.position = 'fixed'; // Avoid scrolling to bottom
textarea.style.position = 'fixed';
textarea.style.left = '-9999px';
textarea.style.top = '0';
textarea.style.width = '1px';
Expand All @@ -880,11 +941,11 @@ export class SelectionManager {
}

if (!success) {
console.error('❌ execCommand copy failed');
console.warn('execCommand copy failed');
}
} catch (err) {
console.error('❌ Fallback copy failed:', err);
// Still try to restore focus even on error
console.warn('execCommand copy threw:', err);
// Restore focus on error
if (previouslyFocused) {
previouslyFocused.focus();
}
Expand Down
12 changes: 12 additions & 0 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,10 @@ export class Terminal implements ITerminalCore {
(mode: number) => {
// Query terminal mode state (e.g., mode 1 for application cursor mode)
return this.wasmTerm?.getMode(mode, false) ?? false;
},
() => {
// Handle Cmd+C copy - returns true if there was a selection to copy
return this.copySelection();
}
);

Expand Down Expand Up @@ -755,6 +759,14 @@ export class Terminal implements ITerminalCore {
this.selectionManager?.clearSelection();
}

/**
* Copy the current selection to clipboard
* @returns true if there was text to copy, false otherwise
*/
public copySelection(): boolean {
return this.selectionManager?.copySelection() || false;
}

/**
* Select all text in the terminal
*/
Expand Down