@@ -254,6 +254,180 @@ function capHistory(history: string, maxLines: number): string {
254254 return hasTrailingNewline ? `${ capped } \n` : capped ;
255255}
256256
257+ function isCsiFinalByte ( codePoint : number ) : boolean {
258+ return codePoint >= 0x40 && codePoint <= 0x7e ;
259+ }
260+
261+ function shouldStripCsiSequence ( body : string , finalByte : string ) : boolean {
262+ if ( finalByte === "n" ) {
263+ return true ;
264+ }
265+ if ( finalByte === "R" && / ^ [ 0 - 9 ; ? ] * $ / . test ( body ) ) {
266+ return true ;
267+ }
268+ if ( finalByte === "c" && / ^ [ > 0 - 9 ; ? ] * $ / . test ( body ) ) {
269+ return true ;
270+ }
271+ return false ;
272+ }
273+
274+ function shouldStripOscSequence ( content : string ) : boolean {
275+ return / ^ ( 1 0 | 1 1 | 1 2 ) ; (?: \? | r g b : ) / . test ( content ) ;
276+ }
277+
278+ function stripStringTerminator ( value : string ) : string {
279+ if ( value . endsWith ( "\u001b\\" ) ) {
280+ return value . slice ( 0 , - 2 ) ;
281+ }
282+ const lastCharacter = value . at ( - 1 ) ;
283+ if ( lastCharacter === "\u0007" || lastCharacter === "\u009c" ) {
284+ return value . slice ( 0 , - 1 ) ;
285+ }
286+ return value ;
287+ }
288+
289+ function findStringTerminatorIndex ( input : string , start : number ) : number | null {
290+ for ( let index = start ; index < input . length ; index += 1 ) {
291+ const codePoint = input . charCodeAt ( index ) ;
292+ if ( codePoint === 0x07 || codePoint === 0x9c ) {
293+ return index + 1 ;
294+ }
295+ if ( codePoint === 0x1b && input . charCodeAt ( index + 1 ) === 0x5c ) {
296+ return index + 2 ;
297+ }
298+ }
299+ return null ;
300+ }
301+
302+ function isEscapeIntermediateByte ( codePoint : number ) : boolean {
303+ return codePoint >= 0x20 && codePoint <= 0x2f ;
304+ }
305+
306+ function isEscapeFinalByte ( codePoint : number ) : boolean {
307+ return codePoint >= 0x30 && codePoint <= 0x7e ;
308+ }
309+
310+ function findEscapeSequenceEndIndex ( input : string , start : number ) : number | null {
311+ let cursor = start ;
312+ while ( cursor < input . length && isEscapeIntermediateByte ( input . charCodeAt ( cursor ) ) ) {
313+ cursor += 1 ;
314+ }
315+ if ( cursor >= input . length ) {
316+ return null ;
317+ }
318+ return isEscapeFinalByte ( input . charCodeAt ( cursor ) ) ? cursor + 1 : start + 1 ;
319+ }
320+
321+ function sanitizeTerminalHistoryChunk (
322+ pendingControlSequence : string ,
323+ data : string ,
324+ ) : { visibleText : string ; pendingControlSequence : string } {
325+ const input = `${ pendingControlSequence } ${ data } ` ;
326+ let visibleText = "" ;
327+ let index = 0 ;
328+
329+ const append = ( value : string ) => {
330+ visibleText += value ;
331+ } ;
332+
333+ while ( index < input . length ) {
334+ const codePoint = input . charCodeAt ( index ) ;
335+
336+ if ( codePoint === 0x1b ) {
337+ const nextCodePoint = input . charCodeAt ( index + 1 ) ;
338+ if ( Number . isNaN ( nextCodePoint ) ) {
339+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
340+ }
341+
342+ if ( nextCodePoint === 0x5b ) {
343+ let cursor = index + 2 ;
344+ while ( cursor < input . length ) {
345+ if ( isCsiFinalByte ( input . charCodeAt ( cursor ) ) ) {
346+ const sequence = input . slice ( index , cursor + 1 ) ;
347+ const body = input . slice ( index + 2 , cursor ) ;
348+ if ( ! shouldStripCsiSequence ( body , input [ cursor ] ?? "" ) ) {
349+ append ( sequence ) ;
350+ }
351+ index = cursor + 1 ;
352+ break ;
353+ }
354+ cursor += 1 ;
355+ }
356+ if ( cursor >= input . length ) {
357+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
358+ }
359+ continue ;
360+ }
361+
362+ if (
363+ nextCodePoint === 0x5d ||
364+ nextCodePoint === 0x50 ||
365+ nextCodePoint === 0x5e ||
366+ nextCodePoint === 0x5f
367+ ) {
368+ const terminatorIndex = findStringTerminatorIndex ( input , index + 2 ) ;
369+ if ( terminatorIndex === null ) {
370+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
371+ }
372+ const sequence = input . slice ( index , terminatorIndex ) ;
373+ const content = stripStringTerminator ( input . slice ( index + 2 , terminatorIndex ) ) ;
374+ if ( nextCodePoint !== 0x5d || ! shouldStripOscSequence ( content ) ) {
375+ append ( sequence ) ;
376+ }
377+ index = terminatorIndex ;
378+ continue ;
379+ }
380+
381+ const escapeSequenceEndIndex = findEscapeSequenceEndIndex ( input , index + 1 ) ;
382+ if ( escapeSequenceEndIndex === null ) {
383+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
384+ }
385+ append ( input . slice ( index , escapeSequenceEndIndex ) ) ;
386+ index = escapeSequenceEndIndex ;
387+ continue ;
388+ }
389+
390+ if ( codePoint === 0x9b ) {
391+ let cursor = index + 1 ;
392+ while ( cursor < input . length ) {
393+ if ( isCsiFinalByte ( input . charCodeAt ( cursor ) ) ) {
394+ const sequence = input . slice ( index , cursor + 1 ) ;
395+ const body = input . slice ( index + 1 , cursor ) ;
396+ if ( ! shouldStripCsiSequence ( body , input [ cursor ] ?? "" ) ) {
397+ append ( sequence ) ;
398+ }
399+ index = cursor + 1 ;
400+ break ;
401+ }
402+ cursor += 1 ;
403+ }
404+ if ( cursor >= input . length ) {
405+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
406+ }
407+ continue ;
408+ }
409+
410+ if ( codePoint === 0x9d || codePoint === 0x90 || codePoint === 0x9e || codePoint === 0x9f ) {
411+ const terminatorIndex = findStringTerminatorIndex ( input , index + 1 ) ;
412+ if ( terminatorIndex === null ) {
413+ return { visibleText, pendingControlSequence : input . slice ( index ) } ;
414+ }
415+ const sequence = input . slice ( index , terminatorIndex ) ;
416+ const content = stripStringTerminator ( input . slice ( index + 1 , terminatorIndex ) ) ;
417+ if ( codePoint !== 0x9d || ! shouldStripOscSequence ( content ) ) {
418+ append ( sequence ) ;
419+ }
420+ index = terminatorIndex ;
421+ continue ;
422+ }
423+
424+ append ( input [ index ] ?? "" ) ;
425+ index += 1 ;
426+ }
427+
428+ return { visibleText, pendingControlSequence : "" } ;
429+ }
430+
257431function legacySafeThreadId ( threadId : string ) : string {
258432 return threadId . replace ( / [ ^ a - z A - Z 0 - 9 . _ - ] / g, "_" ) ;
259433}
@@ -378,6 +552,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
378552 status : "starting" ,
379553 pid : null ,
380554 history,
555+ pendingHistoryControlSequence : "" ,
381556 exitCode : null ,
382557 exitSignal : null ,
383558 updatedAt : new Date ( ) . toISOString ( ) ,
@@ -407,10 +582,12 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
407582 existing . cwd = input . cwd ;
408583 existing . runtimeEnv = nextRuntimeEnv ;
409584 existing . history = "" ;
585+ existing . pendingHistoryControlSequence = "" ;
410586 await this . persistHistory ( existing . threadId , existing . terminalId , existing . history ) ;
411587 } else if ( existing . status === "exited" || existing . status === "error" ) {
412588 existing . runtimeEnv = nextRuntimeEnv ;
413589 existing . history = "" ;
590+ existing . pendingHistoryControlSequence = "" ;
414591 await this . persistHistory ( existing . threadId , existing . terminalId , existing . history ) ;
415592 } else if ( currentRuntimeEnv !== nextRuntimeEnv ) {
416593 existing . runtimeEnv = nextRuntimeEnv ;
@@ -469,6 +646,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
469646 await this . runWithThreadLock ( input . threadId , async ( ) => {
470647 const session = this . requireSession ( input . threadId , input . terminalId ) ;
471648 session . history = "" ;
649+ session . pendingHistoryControlSequence = "" ;
472650 session . updatedAt = new Date ( ) . toISOString ( ) ;
473651 await this . persistHistory ( input . threadId , input . terminalId , session . history ) ;
474652 this . emitEvent ( {
@@ -497,6 +675,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
497675 status : "starting" ,
498676 pid : null ,
499677 history : "" ,
678+ pendingHistoryControlSequence : "" ,
500679 exitCode : null ,
501680 exitSignal : null ,
502681 updatedAt : new Date ( ) . toISOString ( ) ,
@@ -520,6 +699,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
520699 const rows = input . rows ?? session . rows ;
521700
522701 session . history = "" ;
702+ session . pendingHistoryControlSequence = "" ;
523703 await this . persistHistory ( input . threadId , input . terminalId , session . history ) ;
524704 await this . startSession ( session , { ...input , cols, rows } , "restarted" ) ;
525705 return this . snapshot ( session ) ;
@@ -694,9 +874,16 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
694874 }
695875
696876 private onProcessData ( session : TerminalSessionState , data : string ) : void {
697- session . history = capHistory ( `${ session . history } ${ data } ` , this . historyLineLimit ) ;
877+ const sanitized = sanitizeTerminalHistoryChunk ( session . pendingHistoryControlSequence , data ) ;
878+ session . pendingHistoryControlSequence = sanitized . pendingControlSequence ;
879+ if ( sanitized . visibleText . length > 0 ) {
880+ session . history = capHistory (
881+ `${ session . history } ${ sanitized . visibleText } ` ,
882+ this . historyLineLimit ,
883+ ) ;
884+ this . queuePersist ( session . threadId , session . terminalId , session . history ) ;
885+ }
698886 session . updatedAt = new Date ( ) . toISOString ( ) ;
699- this . queuePersist ( session . threadId , session . terminalId , session . history ) ;
700887 this . emitEvent ( {
701888 type : "output" ,
702889 threadId : session . threadId ,
@@ -713,6 +900,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
713900 session . pid = null ;
714901 session . hasRunningSubprocess = false ;
715902 session . status = "exited" ;
903+ session . pendingHistoryControlSequence = "" ;
716904 session . exitCode = Number . isInteger ( event . exitCode ) ? event . exitCode : null ;
717905 session . exitSignal = Number . isInteger ( event . signal ) ? event . signal : null ;
718906 session . updatedAt = new Date ( ) . toISOString ( ) ;
@@ -736,6 +924,7 @@ export class TerminalManagerRuntime extends EventEmitter<TerminalManagerEvents>
736924 session . pid = null ;
737925 session . hasRunningSubprocess = false ;
738926 session . status = "exited" ;
927+ session . pendingHistoryControlSequence = "" ;
739928 session . updatedAt = new Date ( ) . toISOString ( ) ;
740929 this . killProcessWithEscalation ( process , session . threadId , session . terminalId ) ;
741930 this . evictInactiveSessionsIfNeeded ( ) ;
0 commit comments