@@ -333,7 +333,7 @@ export function isTerminalCopyShortcut(
333333 platform = navigator . platform ,
334334) {
335335 if ( event . key . toLowerCase ( ) !== "c" ) return false ;
336- return isMacPlatform ( platform ) ? event . metaKey : event . ctrlKey && event . shiftKey ;
336+ return isMacPlatform ( platform ) ? event . metaKey : event . ctrlKey ;
337337}
338338
339339export function isTerminalPasteShortcut (
@@ -463,7 +463,6 @@ export interface GhosttyTerminalSurfaceOptions {
463463 readonly onData : ( data : string ) => void ;
464464 readonly onResize : ( cols : number , rows : number ) => void ;
465465 readonly onSelectionChange : ( ) => void ;
466- readonly onCopy : ( text : string ) => void ;
467466 readonly beforeKey : ( event : KeyboardEvent ) => boolean ;
468467 readonly onLinkActivate : ( text : string , event : MouseEvent ) => void ;
469468}
@@ -531,6 +530,8 @@ export class GhosttyTerminalSurface {
531530 private theme : GhosttyTheme ;
532531 private readonly suppressedKeyCodes = new Set < string > ( ) ;
533532 private pasteShortcutToken = 0 ;
533+ private copyShortcutToken = 0 ;
534+ private clearSelectionAfterCopy = false ;
534535 private wheelRemainder = 0 ;
535536 private dprMedia : MediaQueryList | null = null ;
536537 // Read live on every blink decision, and watched so that dropping the
@@ -901,9 +902,58 @@ export class GhosttyTerminalSurface {
901902 return ;
902903 }
903904 if ( isTerminalCopyShortcut ( event ) && this . hasSelection ( ) ) {
904- event . preventDefault ( ) ;
905+ // A plain Ctrl+C/Cmd+C fires the browser's native copy event, caught in
906+ // onCopyEvent; not preventing the default keeps that path alive. WebKit
907+ // omits the keyboard copy event without a DOM selection, so race the
908+ // clipboard write against it the same way paste races its read. The
909+ // Shift variant has no native event (Chrome binds Ctrl+Shift+C to
910+ // inspect), so synthesize one with execCommand("copy").
911+ if ( event . shiftKey ) {
912+ event . preventDefault ( ) ;
913+ document . execCommand ( "copy" ) ;
914+ } else {
915+ // A plain Ctrl+C is also SIGINT on non-mac: clear the selection once
916+ // it copies so the next Ctrl+C reaches the shell. The Shift chord and
917+ // Cmd+C are copy-only, so they keep the selection; resetting the flag
918+ // up front also drops any clear owed by an earlier gesture that never
919+ // completed.
920+ this . clearSelectionAfterCopy = ! event . shiftKey && ! isMacPlatform ( navigator . platform ) ;
921+ const clipboard = navigator . clipboard ;
922+ if ( typeof clipboard ?. writeText === "function" ) {
923+ // Defer the write past the default action: the native copy event
924+ // (dispatched synchronously with the default action) claims the
925+ // token first when it fires, and the write covers browsers whose
926+ // shortcut produces no copy event. Skipping a write the native
927+ // event already handled stops a stale resolution from clobbering a
928+ // clipboard the user filled after this copy.
929+ const token = ++ this . copyShortcutToken ;
930+ const selection = this . getSelection ( ) ;
931+ void Promise . resolve ( ) . then ( ( ) => {
932+ if ( this . disposed || this . copyShortcutToken !== token ) return ;
933+ void clipboard . writeText ( selection ) . then (
934+ ( ) => {
935+ // The write may have been superseded while in flight; only
936+ // touch the selection if this gesture still owns the token.
937+ if ( this . disposed || this . copyShortcutToken !== token ) return ;
938+ if ( this . clearSelectionAfterCopy ) {
939+ this . clearSelectionAfterCopy = false ;
940+ this . clearSelection ( ) ;
941+ }
942+ } ,
943+ ( ) => {
944+ // The write failed and the native event has already had its
945+ // chance, so nothing copied and no clear is owed by this
946+ // gesture; a newer one may have just set the flag, so only
947+ // drop it if this gesture still owns the token.
948+ if ( this . copyShortcutToken === token ) {
949+ this . clearSelectionAfterCopy = false ;
950+ }
951+ } ,
952+ ) ;
953+ } ) ;
954+ }
955+ }
905956 this . suppressedKeyCodes . add ( event . code ) ;
906- this . options . onCopy ( this . getSelection ( ) ) ;
907957 return ;
908958 }
909959 if ( isTerminalPasteShortcut ( event ) ) {
@@ -989,6 +1039,18 @@ export class GhosttyTerminalSurface {
9891039 this . dprMedia . addEventListener ( "change" , this . onDevicePixelRatioChange ) ;
9901040 }
9911041
1042+ private readonly onCopyEvent = ( event : ClipboardEvent ) => {
1043+ if ( ! this . hasSelection ( ) ) return ;
1044+ event . preventDefault ( ) ;
1045+ event . clipboardData ?. setData ( "text/plain" , this . getSelection ( ) ) ;
1046+ // The native event beat any deferred write; drop the in-flight fallback.
1047+ this . copyShortcutToken += 1 ;
1048+ if ( this . clearSelectionAfterCopy ) {
1049+ this . clearSelectionAfterCopy = false ;
1050+ this . clearSelection ( ) ;
1051+ }
1052+ } ;
1053+
9921054 private readonly onPaste = ( event : ClipboardEvent ) => {
9931055 // Always suppress the browser's default insertion: content the textarea
9941056 // would receive (for example an html-only clipboard converted to text)
@@ -1384,6 +1446,7 @@ export class GhosttyTerminalSurface {
13841446 this . input . addEventListener ( "blur" , this . onBlur ) ;
13851447 this . input . addEventListener ( "input" , this . onInput ) ;
13861448 this . input . addEventListener ( "paste" , this . onPaste ) ;
1449+ this . input . addEventListener ( "copy" , this . onCopyEvent ) ;
13871450 this . input . addEventListener ( "compositionstart" , this . onCompositionStart ) ;
13881451 this . input . addEventListener ( "compositionend" , this . onCompositionEnd ) ;
13891452 this . canvas . addEventListener ( "pointerdown" , this . onPointerDown ) ;
@@ -1408,6 +1471,7 @@ export class GhosttyTerminalSurface {
14081471 this . input . removeEventListener ( "blur" , this . onBlur ) ;
14091472 this . input . removeEventListener ( "input" , this . onInput ) ;
14101473 this . input . removeEventListener ( "paste" , this . onPaste ) ;
1474+ this . input . removeEventListener ( "copy" , this . onCopyEvent ) ;
14111475 this . input . removeEventListener ( "compositionstart" , this . onCompositionStart ) ;
14121476 this . input . removeEventListener ( "compositionend" , this . onCompositionEnd ) ;
14131477 this . canvas . removeEventListener ( "pointerdown" , this . onPointerDown ) ;
0 commit comments