@@ -151,6 +151,9 @@ import { WORK_SURFACE_REVEALED_EVENT } from "./workSurfaceVisibility";
151151
152152function installWindowAde ( ) {
153153 ( window as any ) . ade = {
154+ app : {
155+ hasClipboardImage : vi . fn ( ) . mockResolvedValue ( false ) ,
156+ } ,
154157 pty : {
155158 resize : vi . fn ( ) . mockResolvedValue ( undefined ) ,
156159 write : vi . fn ( ) . mockResolvedValue ( undefined ) ,
@@ -185,6 +188,27 @@ async function flushAnimationFrame() {
185188 } ) ;
186189}
187190
191+ async function flushPromises ( ) {
192+ await act ( async ( ) => {
193+ await Promise . resolve ( ) ;
194+ } ) ;
195+ }
196+
197+ function createPasteEvent ( text : string ) : Event {
198+ const event = new Event ( "paste" , { bubbles : true , cancelable : true } ) ;
199+ Object . defineProperty ( event , "clipboardData" , {
200+ configurable : true ,
201+ value : {
202+ files : [ ] ,
203+ items : [ ] ,
204+ getData : vi . fn ( ( type : string ) => (
205+ type === "text/plain" || type === "text" ? text : ""
206+ ) ) ,
207+ } ,
208+ } ) ;
209+ return event ;
210+ }
211+
188212function triggerResizeObserver ( ) {
189213 const latest = resizeObservers . at ( - 1 ) ;
190214 if ( ! latest ) throw new Error ( "ResizeObserver not installed" ) ;
@@ -525,6 +549,162 @@ describe("TerminalView", () => {
525549 expect ( terminal ?. options . scrollback ) . toBe ( 20_000 ) ;
526550 } ) ;
527551
552+ it ( "writes text paste contents directly to the PTY" , async ( ) => {
553+ render ( < TerminalView ptyId = "pty-text-paste" sessionId = "session-text-paste" isActive /> ) ;
554+ await flushAllTimers ( ) ;
555+
556+ const terminal = mockState . terminalInstances . at ( - 1 ) as {
557+ element : HTMLElement | null ;
558+ } | undefined ;
559+ expect ( terminal ?. element ) . toBeTruthy ( ) ;
560+
561+ const ptyWrite = window . ade . pty . write as unknown as ReturnType < typeof vi . fn > ;
562+ const hasClipboardImage = window . ade . app . hasClipboardImage as unknown as ReturnType < typeof vi . fn > ;
563+ ptyWrite . mockClear ( ) ;
564+ hasClipboardImage . mockClear ( ) ;
565+
566+ const event = createPasteEvent ( "hello from clipboard" ) ;
567+ terminal ! . element ! . dispatchEvent ( event ) ;
568+
569+ expect ( event . defaultPrevented ) . toBe ( true ) ;
570+ expect ( ptyWrite ) . toHaveBeenCalledWith ( {
571+ ptyId : "pty-text-paste" ,
572+ data : "hello from clipboard" ,
573+ } ) ;
574+ expect ( hasClipboardImage ) . not . toHaveBeenCalled ( ) ;
575+ } ) ;
576+
577+ it ( "maps macOS Cmd+V with an image-only clipboard to Ctrl+V terminal input" , async ( ) => {
578+ const platformDescriptor = Object . getOwnPropertyDescriptor ( window . navigator , "platform" ) ;
579+ const originalPlatform = window . navigator . platform ;
580+ try {
581+ Object . defineProperty ( window . navigator , "platform" , {
582+ configurable : true ,
583+ value : "MacIntel" ,
584+ } ) ;
585+ ( window . ade . app . hasClipboardImage as unknown as ReturnType < typeof vi . fn > ) . mockResolvedValue ( true ) ;
586+
587+ render ( < TerminalView ptyId = "pty-image-paste" sessionId = "session-image-paste" isActive /> ) ;
588+ await flushAllTimers ( ) ;
589+
590+ const terminal = mockState . terminalInstances . at ( - 1 ) as {
591+ attachCustomKeyEventHandler : ReturnType < typeof vi . fn > ;
592+ element : HTMLElement | null ;
593+ } | undefined ;
594+ expect ( terminal ?. element ) . toBeTruthy ( ) ;
595+ const keyHandler = terminal ?. attachCustomKeyEventHandler . mock . calls . at ( - 1 ) ?. [ 0 ] as ( ( ev : KeyboardEvent ) => boolean ) | undefined ;
596+ expect ( keyHandler ) . toBeTruthy ( ) ;
597+
598+ const ptyWrite = window . ade . pty . write as unknown as ReturnType < typeof vi . fn > ;
599+ ptyWrite . mockClear ( ) ;
600+
601+ const handled = keyHandler ! ( {
602+ type : "keydown" ,
603+ key : "v" ,
604+ metaKey : true ,
605+ ctrlKey : false ,
606+ altKey : false ,
607+ shiftKey : false ,
608+ preventDefault : vi . fn ( ) ,
609+ } as unknown as KeyboardEvent ) ;
610+ expect ( handled ) . toBe ( false ) ;
611+
612+ const event = createPasteEvent ( "" ) ;
613+ terminal ! . element ! . dispatchEvent ( event ) ;
614+ await flushPromises ( ) ;
615+
616+ expect ( event . defaultPrevented ) . toBe ( true ) ;
617+ expect ( window . ade . app . hasClipboardImage ) . toHaveBeenCalledTimes ( 1 ) ;
618+ expect ( ptyWrite ) . toHaveBeenCalledWith ( {
619+ ptyId : "pty-image-paste" ,
620+ data : "\x16" ,
621+ } ) ;
622+
623+ await act ( async ( ) => {
624+ await vi . advanceTimersByTimeAsync ( 130 ) ;
625+ } ) ;
626+ expect ( ptyWrite ) . toHaveBeenCalledTimes ( 1 ) ;
627+ } finally {
628+ if ( platformDescriptor ) {
629+ Object . defineProperty ( window . navigator , "platform" , platformDescriptor ) ;
630+ } else {
631+ Object . defineProperty ( window . navigator , "platform" , {
632+ configurable : true ,
633+ value : originalPlatform ,
634+ } ) ;
635+ }
636+ }
637+ } ) ;
638+
639+ it ( "falls back to native image paste when macOS Cmd+V does not fire a paste event" , async ( ) => {
640+ const platformDescriptor = Object . getOwnPropertyDescriptor ( window . navigator , "platform" ) ;
641+ const clipboardDescriptor = Object . getOwnPropertyDescriptor ( window . navigator , "clipboard" ) ;
642+ const originalPlatform = window . navigator . platform ;
643+ try {
644+ Object . defineProperty ( window . navigator , "platform" , {
645+ configurable : true ,
646+ value : "MacIntel" ,
647+ } ) ;
648+ Object . defineProperty ( window . navigator , "clipboard" , {
649+ configurable : true ,
650+ value : {
651+ readText : vi . fn ( ) . mockResolvedValue ( "" ) ,
652+ writeText : vi . fn ( ) . mockResolvedValue ( undefined ) ,
653+ } ,
654+ } ) ;
655+ ( window . ade . app . hasClipboardImage as unknown as ReturnType < typeof vi . fn > ) . mockResolvedValue ( true ) ;
656+
657+ render ( < TerminalView ptyId = "pty-image-fallback" sessionId = "session-image-fallback" isActive /> ) ;
658+ await flushAllTimers ( ) ;
659+
660+ const terminal = mockState . terminalInstances . at ( - 1 ) as {
661+ attachCustomKeyEventHandler : ReturnType < typeof vi . fn > ;
662+ } | undefined ;
663+ const keyHandler = terminal ?. attachCustomKeyEventHandler . mock . calls . at ( - 1 ) ?. [ 0 ] as ( ( ev : KeyboardEvent ) => boolean ) | undefined ;
664+ expect ( keyHandler ) . toBeTruthy ( ) ;
665+
666+ const ptyWrite = window . ade . pty . write as unknown as ReturnType < typeof vi . fn > ;
667+ ptyWrite . mockClear ( ) ;
668+
669+ const handled = keyHandler ! ( {
670+ type : "keydown" ,
671+ key : "v" ,
672+ metaKey : true ,
673+ ctrlKey : false ,
674+ altKey : false ,
675+ shiftKey : false ,
676+ preventDefault : vi . fn ( ) ,
677+ } as unknown as KeyboardEvent ) ;
678+ expect ( handled ) . toBe ( false ) ;
679+
680+ await act ( async ( ) => {
681+ await vi . advanceTimersByTimeAsync ( 130 ) ;
682+ } ) ;
683+ await flushPromises ( ) ;
684+
685+ expect ( window . navigator . clipboard . readText ) . toHaveBeenCalledTimes ( 1 ) ;
686+ expect ( window . ade . app . hasClipboardImage ) . toHaveBeenCalledTimes ( 1 ) ;
687+ expect ( ptyWrite ) . toHaveBeenCalledWith ( {
688+ ptyId : "pty-image-fallback" ,
689+ data : "\x16" ,
690+ } ) ;
691+ } finally {
692+ if ( platformDescriptor ) {
693+ Object . defineProperty ( window . navigator , "platform" , platformDescriptor ) ;
694+ } else {
695+ Object . defineProperty ( window . navigator , "platform" , {
696+ configurable : true ,
697+ value : originalPlatform ,
698+ } ) ;
699+ }
700+ if ( clipboardDescriptor ) {
701+ Object . defineProperty ( window . navigator , "clipboard" , clipboardDescriptor ) ;
702+ } else {
703+ Reflect . deleteProperty ( window . navigator , "clipboard" ) ;
704+ }
705+ }
706+ } ) ;
707+
528708 it ( "keeps live parked runtimes available so switching away does not discard TUI state" , async ( ) => {
529709 const view = render ( < TerminalView ptyId = "pty-live" sessionId = "session-live" isActive /> ) ;
530710 await flushAllTimers ( ) ;
0 commit comments