11// Copyright 2025, Command Line Inc.
22// SPDX-License-Identifier: Apache-2.0
33
4+ import type { BlockNodeModel } from "@/app/block/blocktypes" ;
45import { getFileSubject } from "@/app/store/wps" ;
56import { sendWSCommand } from "@/app/store/ws" ;
67import { RpcApi } from "@/app/store/wshclientapi" ;
78import { TabRpcClient } from "@/app/store/wshrpcutil" ;
8- import {
9- WOS ,
10- atoms ,
11- fetchWaveFile ,
12- getApi ,
13- getSettingsKeyAtom ,
14- globalStore ,
15- openLink ,
16- recordTEvent ,
17- } from "@/store/global" ;
9+ import { WOS , fetchWaveFile , getApi , getSettingsKeyAtom , globalStore , openLink , recordTEvent } from "@/store/global" ;
1810import * as services from "@/store/services" ;
1911import { PLATFORM , PlatformMacOS } from "@/util/platformutil" ;
2012import { base64ToArray , base64ToString , fireAndForget } from "@/util/util" ;
@@ -35,6 +27,8 @@ const dlog = debug("wave:termwrap");
3527const TermFileName = "term" ;
3628const TermCacheFileName = "cache:term:full" ;
3729const MinDataProcessedForCache = 100 * 1024 ;
30+ const Osc52MaxDecodedSize = 75 * 1024 ; // max clipboard size for OSC 52 (matches common terminal implementations)
31+ const Osc52MaxRawLength = 128 * 1024 ; // includes selector + base64 + whitespace (rough check)
3832export const SupportsImageInput = true ;
3933
4034// detect webgl support
@@ -55,6 +49,7 @@ type TermWrapOptions = {
5549 keydownHandler ?: ( e : KeyboardEvent ) => boolean ;
5650 useWebGl ?: boolean ;
5751 sendDataHandler ?: ( data : string ) => void ;
52+ nodeModel ?: BlockNodeModel ;
5853} ;
5954
6055function handleOscWaveCommand ( data : string , blockId : string , loaded : boolean ) : boolean {
@@ -119,6 +114,83 @@ function handleOscWaveCommand(data: string, blockId: string, loaded: boolean): b
119114 return true ;
120115}
121116
117+ // for xterm OSC handlers, we return true always because we "own" the OSC number.
118+ // even if data is invalid we don't want to propagate to other handlers.
119+ function handleOsc52Command ( data : string , blockId : string , loaded : boolean , termWrap : TermWrap ) : boolean {
120+ if ( ! loaded ) {
121+ return true ;
122+ }
123+ const isBlockFocused = termWrap . nodeModel ? globalStore . get ( termWrap . nodeModel . isFocused ) : false ;
124+ if ( ! document . hasFocus ( ) || ! isBlockFocused ) {
125+ console . log ( "OSC 52: rejected, window or block not focused" ) ;
126+ return true ;
127+ }
128+ if ( ! data || data . length === 0 ) {
129+ console . log ( "OSC 52: empty data received" ) ;
130+ return true ;
131+ }
132+ if ( data . length > Osc52MaxRawLength ) {
133+ console . log ( "OSC 52: raw data too large" , data . length ) ;
134+ return true ;
135+ }
136+
137+ const semicolonIndex = data . indexOf ( ";" ) ;
138+ if ( semicolonIndex === - 1 ) {
139+ console . log ( "OSC 52: invalid format (no semicolon)" , data . substring ( 0 , 50 ) ) ;
140+ return true ;
141+ }
142+
143+ const clipboardSelection = data . substring ( 0 , semicolonIndex ) ;
144+ const base64Data = data . substring ( semicolonIndex + 1 ) ;
145+
146+ // clipboard query ("?") is not supported for security (prevents clipboard theft)
147+ if ( base64Data === "?" ) {
148+ console . log ( "OSC 52: clipboard query not supported" ) ;
149+ return true ;
150+ }
151+
152+ if ( base64Data . length === 0 ) {
153+ return true ;
154+ }
155+
156+ if ( clipboardSelection . length > 10 ) {
157+ console . log ( "OSC 52: clipboard selection too long" , clipboardSelection ) ;
158+ return true ;
159+ }
160+
161+ const estimatedDecodedSize = Math . ceil ( base64Data . length * 0.75 ) ;
162+ if ( estimatedDecodedSize > Osc52MaxDecodedSize ) {
163+ console . log ( "OSC 52: data too large" , estimatedDecodedSize , "bytes" ) ;
164+ return true ;
165+ }
166+
167+ try {
168+ // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648)
169+ const cleanBase64Data = base64Data . replace ( / \s + / g, "" ) ;
170+ const decodedText = base64ToString ( cleanBase64Data ) ;
171+
172+ // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8)
173+ const actualByteSize = new TextEncoder ( ) . encode ( decodedText ) . length ;
174+ if ( actualByteSize > Osc52MaxDecodedSize ) {
175+ console . log ( "OSC 52: decoded text too large" , actualByteSize , "bytes" ) ;
176+ return true ;
177+ }
178+
179+ fireAndForget ( async ( ) => {
180+ try {
181+ await navigator . clipboard . writeText ( decodedText ) ;
182+ dlog ( "OSC 52: copied" , decodedText . length , "characters to clipboard" ) ;
183+ } catch ( err ) {
184+ console . error ( "OSC 52: clipboard write failed:" , err ) ;
185+ }
186+ } ) ;
187+ } catch ( e ) {
188+ console . error ( "OSC 52: base64 decode error:" , e ) ;
189+ }
190+
191+ return true ;
192+ }
193+
122194// for xterm handlers, we return true always because we "own" OSC 7.
123195// even if it is invalid we dont want to propagate to other handlers
124196function handleOsc7Command ( data : string , blockId : string , loaded : boolean ) : boolean {
@@ -386,6 +458,7 @@ export class TermWrap {
386458 promptMarkers : TermTypes . IMarker [ ] = [ ] ;
387459 shellIntegrationStatusAtom : jotai . PrimitiveAtom < "ready" | "running-command" | null > ;
388460 lastCommandAtom : jotai . PrimitiveAtom < string | null > ;
461+ nodeModel : BlockNodeModel ; // this can be null
389462
390463 // IME composition state tracking
391464 // Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -412,6 +485,7 @@ export class TermWrap {
412485 this . tabId = tabId ;
413486 this . blockId = blockId ;
414487 this . sendDataHandler = waveOptions . sendDataHandler ;
488+ this . nodeModel = waveOptions . nodeModel ;
415489 this . ptyOffset = 0 ;
416490 this . dataBytesProcessed = 0 ;
417491 this . hasResized = false ;
@@ -457,13 +531,16 @@ export class TermWrap {
457531 loggedWebGL = true ;
458532 }
459533 }
460- // Register OSC 9283 handler
461- this . terminal . parser . registerOscHandler ( 9283 , ( data : string ) => {
462- return handleOscWaveCommand ( data , this . blockId , this . loaded ) ;
463- } ) ;
534+ // Register OSC handlers
464535 this . terminal . parser . registerOscHandler ( 7 , ( data : string ) => {
465536 return handleOsc7Command ( data , this . blockId , this . loaded ) ;
466537 } ) ;
538+ this . terminal . parser . registerOscHandler ( 52 , ( data : string ) => {
539+ return handleOsc52Command ( data , this . blockId , this . loaded , this ) ;
540+ } ) ;
541+ this . terminal . parser . registerOscHandler ( 9283 , ( data : string ) => {
542+ return handleOscWaveCommand ( data , this . blockId , this . loaded ) ;
543+ } ) ;
467544 this . terminal . parser . registerOscHandler ( 16162 , ( data : string ) => {
468545 return handleOsc16162Command ( data , this . blockId , this . loaded , this ) ;
469546 } ) ;
0 commit comments