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" ;
@@ -26,6 +27,8 @@ const dlog = debug("wave:termwrap");
2627const TermFileName = "term" ;
2728const TermCacheFileName = "cache:term:full" ;
2829const 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)
2932export const SupportsImageInput = true ;
3033
3134// detect webgl support
@@ -46,8 +49,86 @@ type TermWrapOptions = {
4649 keydownHandler ?: ( e : KeyboardEvent ) => boolean ;
4750 useWebGl ?: boolean ;
4851 sendDataHandler ?: ( data : string ) => void ;
52+ nodeModel ?: BlockNodeModel ;
4953} ;
5054
55+ // for xterm OSC handlers, we return true always because we "own" the OSC number.
56+ // even if data is invalid we don't want to propagate to other handlers.
57+ function handleOsc52Command ( data : string , blockId : string , loaded : boolean , termWrap : TermWrap ) : boolean {
58+ if ( ! loaded ) {
59+ return true ;
60+ }
61+ const isBlockFocused = termWrap . nodeModel ? globalStore . get ( termWrap . nodeModel . isFocused ) : false ;
62+ if ( ! document . hasFocus ( ) || ! isBlockFocused ) {
63+ console . log ( "OSC 52: rejected, window or block not focused" ) ;
64+ return true ;
65+ }
66+ if ( ! data || data . length === 0 ) {
67+ console . log ( "OSC 52: empty data received" ) ;
68+ return true ;
69+ }
70+ if ( data . length > Osc52MaxRawLength ) {
71+ console . log ( "OSC 52: raw data too large" , data . length ) ;
72+ return true ;
73+ }
74+
75+ const semicolonIndex = data . indexOf ( ";" ) ;
76+ if ( semicolonIndex === - 1 ) {
77+ console . log ( "OSC 52: invalid format (no semicolon)" , data . substring ( 0 , 50 ) ) ;
78+ return true ;
79+ }
80+
81+ const clipboardSelection = data . substring ( 0 , semicolonIndex ) ;
82+ const base64Data = data . substring ( semicolonIndex + 1 ) ;
83+
84+ // clipboard query ("?") is not supported for security (prevents clipboard theft)
85+ if ( base64Data === "?" ) {
86+ console . log ( "OSC 52: clipboard query not supported" ) ;
87+ return true ;
88+ }
89+
90+ if ( base64Data . length === 0 ) {
91+ return true ;
92+ }
93+
94+ if ( clipboardSelection . length > 10 ) {
95+ console . log ( "OSC 52: clipboard selection too long" , clipboardSelection ) ;
96+ return true ;
97+ }
98+
99+ const estimatedDecodedSize = Math . ceil ( base64Data . length * 0.75 ) ;
100+ if ( estimatedDecodedSize > Osc52MaxDecodedSize ) {
101+ console . log ( "OSC 52: data too large" , estimatedDecodedSize , "bytes" ) ;
102+ return true ;
103+ }
104+
105+ try {
106+ // strip whitespace from base64 data (some terminals chunk with newlines per RFC 4648)
107+ const cleanBase64Data = base64Data . replace ( / \s + / g, "" ) ;
108+ const decodedText = base64ToString ( cleanBase64Data ) ;
109+
110+ // validate actual decoded size (base64 estimate can be off for multi-byte UTF-8)
111+ const actualByteSize = new TextEncoder ( ) . encode ( decodedText ) . length ;
112+ if ( actualByteSize > Osc52MaxDecodedSize ) {
113+ console . log ( "OSC 52: decoded text too large" , actualByteSize , "bytes" ) ;
114+ return true ;
115+ }
116+
117+ fireAndForget ( async ( ) => {
118+ try {
119+ await navigator . clipboard . writeText ( decodedText ) ;
120+ dlog ( "OSC 52: copied" , decodedText . length , "characters to clipboard" ) ;
121+ } catch ( err ) {
122+ console . error ( "OSC 52: clipboard write failed:" , err ) ;
123+ }
124+ } ) ;
125+ } catch ( e ) {
126+ console . error ( "OSC 52: base64 decode error:" , e ) ;
127+ }
128+
129+ return true ;
130+ }
131+
51132// for xterm handlers, we return true always because we "own" OSC 7.
52133// even if it is invalid we dont want to propagate to other handlers
53134function handleOsc7Command ( data : string , blockId : string , loaded : boolean ) : boolean {
@@ -315,6 +396,7 @@ export class TermWrap {
315396 promptMarkers : TermTypes . IMarker [ ] = [ ] ;
316397 shellIntegrationStatusAtom : jotai . PrimitiveAtom < "ready" | "running-command" | null > ;
317398 lastCommandAtom : jotai . PrimitiveAtom < string | null > ;
399+ nodeModel : BlockNodeModel ; // this can be null
318400
319401 // IME composition state tracking
320402 // Prevents duplicate input when switching input methods during composition (e.g., using Capslock)
@@ -341,6 +423,7 @@ export class TermWrap {
341423 this . tabId = tabId ;
342424 this . blockId = blockId ;
343425 this . sendDataHandler = waveOptions . sendDataHandler ;
426+ this . nodeModel = waveOptions . nodeModel ;
344427 this . ptyOffset = 0 ;
345428 this . dataBytesProcessed = 0 ;
346429 this . hasResized = false ;
@@ -386,9 +469,13 @@ export class TermWrap {
386469 loggedWebGL = true ;
387470 }
388471 }
472+ // Register OSC handlers
389473 this . terminal . parser . registerOscHandler ( 7 , ( data : string ) => {
390474 return handleOsc7Command ( data , this . blockId , this . loaded ) ;
391475 } ) ;
476+ this . terminal . parser . registerOscHandler ( 52 , ( data : string ) => {
477+ return handleOsc52Command ( data , this . blockId , this . loaded , this ) ;
478+ } ) ;
392479 this . terminal . parser . registerOscHandler ( 16162 , ( data : string ) => {
393480 return handleOsc16162Command ( data , this . blockId , this . loaded , this ) ;
394481 } ) ;
0 commit comments