@@ -58,6 +58,20 @@ export function Prompt(props: PromptProps) {
5858 let anchor : BoxRenderable
5959 let autocomplete : AutocompleteRef
6060
61+ // Paste coalescing: buffer rapid consecutive paste events (e.g., from MobaXterm
62+ // which fragments large pastes into multiple bracketed paste sequences)
63+ const pasteBuffer : { chunks : string [ ] ; timer : Timer | null } = {
64+ chunks : [ ] ,
65+ timer : null ,
66+ }
67+ const [ isPasting , setIsPasting ] = createSignal ( false )
68+ const PASTE_DEBOUNCE_MS = 100
69+
70+ // Cleanup paste timer on unmount
71+ onCleanup ( ( ) => {
72+ if ( pasteBuffer . timer ) clearTimeout ( pasteBuffer . timer )
73+ } )
74+
6175 const keybind = useKeybind ( )
6276 const local = useLocal ( )
6377 const sdk = useSDK ( )
@@ -488,6 +502,7 @@ export function Prompt(props: PromptProps) {
488502 async function submit ( ) {
489503 if ( props . disabled ) return
490504 if ( autocomplete ?. visible ) return
505+ if ( isPasting ( ) ) return // Block submit during paste coalescing
491506 if ( ! store . prompt . input ) return
492507 const trimmed = store . prompt . input . trim ( )
493508 if ( trimmed === "exit" || trimmed === "quit" || trimmed === ":q" ) {
@@ -688,6 +703,59 @@ export function Prompt(props: PromptProps) {
688703 return
689704 }
690705
706+ // Process a coalesced paste (called after debounce timer expires)
707+ async function processCoalescedPaste ( pastedContent : string ) {
708+ if ( ! pastedContent ) {
709+ command . trigger ( "prompt.paste" )
710+ return
711+ }
712+
713+ // Check if pasted content is a file path
714+ const filepath = pastedContent . replace ( / ^ ' + | ' + $ / g, "" ) . replace ( / \\ / g, " " )
715+ const isUrl = / ^ ( h t t p s ? ) : \/ \/ / . test ( filepath )
716+ if ( ! isUrl ) {
717+ try {
718+ const file = Bun . file ( filepath )
719+ // Handle SVG as raw text content, not as base64 image
720+ if ( file . type === "image/svg+xml" ) {
721+ const content = await file . text ( ) . catch ( ( ) => { } )
722+ if ( content ) {
723+ pasteText ( content , `[SVG: ${ file . name ?? "image" } ]` )
724+ return
725+ }
726+ }
727+ if ( file . type . startsWith ( "image/" ) ) {
728+ const content = await file
729+ . arrayBuffer ( )
730+ . then ( ( buffer ) => Buffer . from ( buffer ) . toString ( "base64" ) )
731+ . catch ( ( ) => { } )
732+ if ( content ) {
733+ await pasteImage ( {
734+ filename : file . name ,
735+ mime : file . type ,
736+ content,
737+ } )
738+ return
739+ }
740+ }
741+ } catch { }
742+ }
743+
744+ const lineCount = ( pastedContent . match ( / \n / g) ?. length ?? 0 ) + 1
745+ if ( ( lineCount >= 3 || pastedContent . length > 150 ) && ! sync . data . config . experimental ?. disable_paste_summary ) {
746+ pasteText ( pastedContent , `[Pasted ~${ lineCount } lines]` )
747+ return
748+ }
749+
750+ // Insert the text directly for small pastes
751+ input . insertText ( pastedContent )
752+ setTimeout ( ( ) => {
753+ input . getLayoutNode ( ) . markDirty ( )
754+ input . gotoBufferEnd ( )
755+ renderer . requestRender ( )
756+ } , 0 )
757+ }
758+
691759 const highlight = createMemo ( ( ) => {
692760 if ( keybind . leader ) return theme . border
693761 if ( store . mode === "shell" ) return theme . primary
@@ -853,72 +921,36 @@ export function Prompt(props: PromptProps) {
853921 }
854922 } }
855923 onSubmit = { submit }
856- onPaste = { async ( event : PasteEvent ) => {
857- if ( props . disabled ) {
858- event . preventDefault ( )
859- return
860- }
924+ onPaste = { ( event : PasteEvent ) => {
925+ event . preventDefault ( )
926+ if ( props . disabled ) return
861927
862928 // Normalize line endings at the boundary
863929 // Windows ConPTY/Terminal often sends CR-only newlines in bracketed paste
864930 // Replace CRLF first, then any remaining CR
865931 const normalizedText = event . text . replace ( / \r \n / g, "\n" ) . replace ( / \r / g, "\n" )
866- const pastedContent = normalizedText . trim ( )
867- if ( ! pastedContent ) {
868- command . trigger ( "prompt.paste" )
869- return
870- }
871932
872- // trim ' from the beginning and end of the pasted content. just
873- // ' and nothing else
874- const filepath = pastedContent . replace ( / ^ ' + | ' + $ / g, "" ) . replace ( / \\ / g, " " )
875- const isUrl = / ^ ( h t t p s ? ) : \/ \/ / . test ( filepath )
876- if ( ! isUrl ) {
933+ // Buffer the paste content for coalescing
934+ // Some terminals (e.g., MobaXterm) fragment large pastes into multiple
935+ // bracketed paste sequences, which would otherwise trigger premature submit
936+ // Don't trim individual chunks - preserve inter-fragment whitespace
937+ pasteBuffer . chunks . push ( normalizedText )
938+ setIsPasting ( true )
939+
940+ // Reset the debounce timer
941+ if ( pasteBuffer . timer ) clearTimeout ( pasteBuffer . timer )
942+ pasteBuffer . timer = setTimeout ( async ( ) => {
943+ // Coalesce all chunks and process
944+ const coalesced = pasteBuffer . chunks . join ( "" ) . trim ( )
945+ pasteBuffer . chunks = [ ]
946+ pasteBuffer . timer = null
877947 try {
878- const file = Bun . file ( filepath )
879- // Handle SVG as raw text content, not as base64 image
880- if ( file . type === "image/svg+xml" ) {
881- event . preventDefault ( )
882- const content = await file . text ( ) . catch ( ( ) => { } )
883- if ( content ) {
884- pasteText ( content , `[SVG: ${ file . name ?? "image" } ]` )
885- return
886- }
887- }
888- if ( file . type . startsWith ( "image/" ) ) {
889- event . preventDefault ( )
890- const content = await file
891- . arrayBuffer ( )
892- . then ( ( buffer ) => Buffer . from ( buffer ) . toString ( "base64" ) )
893- . catch ( ( ) => { } )
894- if ( content ) {
895- await pasteImage ( {
896- filename : file . name ,
897- mime : file . type ,
898- content,
899- } )
900- return
901- }
902- }
903- } catch { }
904- }
905-
906- const lineCount = ( pastedContent . match ( / \n / g) ?. length ?? 0 ) + 1
907- if (
908- ( lineCount >= 3 || pastedContent . length > 150 ) &&
909- ! sync . data . config . experimental ?. disable_paste_summary
910- ) {
911- event . preventDefault ( )
912- pasteText ( pastedContent , `[Pasted ~${ lineCount } lines]` )
913- return
914- }
915-
916- // Force layout update and render for the pasted content
917- setTimeout ( ( ) => {
918- input . getLayoutNode ( ) . markDirty ( )
919- input . gotoBufferEnd ( )
920- renderer . requestRender ( )
921- } , 0 )
948+ await processCoalescedPaste ( coalesced )
949+ } finally {
950+ // Only clear isPasting if no new paste arrived during processing
951+ if ( ! pasteBuffer . timer ) setIsPasting ( false )
952+ }
953+ } , PASTE_DEBOUNCE_MS )
922954 } }
923955 ref = { ( r : TextareaRenderable ) => {
924956 input = r
0 commit comments