11export const ANSI_OSC_INTRODUCER_PATTERN = "(?:\\x1b\\]|\\x9d)" ;
22export const ANSI_STRING_TERMINATOR_PATTERN = "(?:\\x1b\\\\|\\x07|\\x9c)" ;
33const ANSI_OSC_PATTERN = `${ ANSI_OSC_INTRODUCER_PATTERN } [^\\x07\\x1b\\x9c]*${ ANSI_STRING_TERMINATOR_PATTERN } ` ;
4+ export const ANSI_COMPAT_CONTROL_SEQUENCE_PATTERN =
5+ "[\\u001B\\u009B][[\\]()#;?]*(?:\\d{1,4}(?:[;:]\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]" ;
46
57const ansiOscAtIndexRegex = new RegExp ( ANSI_OSC_PATTERN , "y" ) ;
68
@@ -27,6 +29,229 @@ type AnsiCsiScan = {
2729 value : string ;
2830} ;
2931
32+ type AnsiStripState = "text" | "escape" | "osc" | "osc-escape" | "csi" | "compat" ;
33+
34+ function isCompatPrefixCode ( code : number ) : boolean {
35+ return (
36+ code === 0x5b ||
37+ code === 0x5d ||
38+ code === 0x28 ||
39+ code === 0x29 ||
40+ code === 0x23 ||
41+ code === 0x3b ||
42+ code === 0x3f
43+ ) ;
44+ }
45+
46+ function isCompatParameterCode ( code : number ) : boolean {
47+ return ( code >= 0x30 && code <= 0x39 ) || code === 0x3a || code === 0x3b ;
48+ }
49+
50+ function isDigitCode ( code : number ) : boolean {
51+ return code >= 0x30 && code <= 0x39 ;
52+ }
53+
54+ function isCompatFinalCode ( code : number ) : boolean {
55+ return (
56+ ( code >= 0x30 && code <= 0x39 ) ||
57+ ( code >= 0x40 && code <= 0x5a ) ||
58+ code === 0x63 ||
59+ ( code >= 0x66 && code <= 0x6e ) ||
60+ ( code >= 0x71 && code <= 0x75 ) ||
61+ code === 0x79 ||
62+ code === 0x3d ||
63+ code === 0x3e ||
64+ code === 0x3c ||
65+ code === 0x7e
66+ ) ;
67+ }
68+
69+ /**
70+ * Incrementally strip the ANSI grammar accepted by the agent output sanitizer.
71+ * Parser state stays constant-size so unterminated OSC payloads cannot escape
72+ * or accumulate outside the caller's output limits.
73+ */
74+ export class AnsiSequenceStripper {
75+ private state : AnsiStripState = "text" ;
76+ private csiCompatPrefixOnly = false ;
77+ private compatInParameters = false ;
78+ private compatParameterDigits = 0 ;
79+
80+ write ( input : string ) : string {
81+ if ( typeof input !== "string" ) {
82+ throw new TypeError ( `Expected a \`string\`, got \`${ typeof input } \`` ) ;
83+ }
84+ if (
85+ this . state === "text" &&
86+ ! input . includes ( "\u001B" ) &&
87+ ! input . includes ( "\u009B" ) &&
88+ ! input . includes ( "\u009D" )
89+ ) {
90+ return input ;
91+ }
92+
93+ const output : string [ ] = [ ] ;
94+ let index = 0 ;
95+ while ( index < input . length ) {
96+ const code = input . charCodeAt ( index ) ;
97+
98+ if ( this . state === "text" ) {
99+ if ( code === 0x1b ) {
100+ this . state = "escape" ;
101+ } else if ( code === 0x9b ) {
102+ this . state = "csi" ;
103+ this . csiCompatPrefixOnly = true ;
104+ } else if ( code === 0x9d ) {
105+ this . state = "osc" ;
106+ } else {
107+ output . push ( input . charAt ( index ) ) ;
108+ }
109+ index += 1 ;
110+ continue ;
111+ }
112+
113+ if ( this . state === "osc" ) {
114+ if ( code === 0x07 || code === 0x9c ) {
115+ this . state = "text" ;
116+ } else if ( code === 0x1b ) {
117+ this . state = "osc-escape" ;
118+ }
119+ index += 1 ;
120+ continue ;
121+ }
122+
123+ if ( this . state === "osc-escape" ) {
124+ if ( code === 0x5c || code === 0x07 || code === 0x9c ) {
125+ this . state = "text" ;
126+ } else if ( code !== 0x1b ) {
127+ this . state = "osc" ;
128+ }
129+ index += 1 ;
130+ continue ;
131+ }
132+
133+ if ( this . state === "csi" ) {
134+ if ( code === 0x18 || code === 0x1a ) {
135+ this . state = "text" ;
136+ index += 1 ;
137+ } else if ( code === 0x1b ) {
138+ this . state = "escape" ;
139+ index += 1 ;
140+ } else if ( code === 0x9b ) {
141+ this . csiCompatPrefixOnly = true ;
142+ index += 1 ;
143+ } else if ( code === 0x9d ) {
144+ this . state = "osc" ;
145+ index += 1 ;
146+ } else if ( code <= 0x1f || code === 0x7f ) {
147+ output . push ( input . charAt ( index ) ) ;
148+ index += 1 ;
149+ } else if ( code >= 0x20 && code <= 0x3f ) {
150+ if ( ! isCompatPrefixCode ( code ) ) {
151+ this . csiCompatPrefixOnly = false ;
152+ }
153+ index += 1 ;
154+ } else if ( ( code === 0x5b || code === 0x5d ) && this . csiCompatPrefixOnly ) {
155+ // The compatibility grammar accepts bracket runs before parameters.
156+ // Keep them pending so a chunk split cannot expose the final byte.
157+ this . state = "compat" ;
158+ this . compatInParameters = false ;
159+ this . compatParameterDigits = 0 ;
160+ index += 1 ;
161+ } else if ( code >= 0x40 && code <= 0x7e ) {
162+ this . state = "text" ;
163+ index += 1 ;
164+ } else {
165+ this . state = "text" ;
166+ }
167+ continue ;
168+ }
169+
170+ if ( this . state === "escape" ) {
171+ if ( code === 0x5d ) {
172+ this . state = "osc" ;
173+ index += 1 ;
174+ } else if ( code === 0x5b ) {
175+ this . state = "csi" ;
176+ this . csiCompatPrefixOnly = true ;
177+ index += 1 ;
178+ } else if ( code === 0x1b ) {
179+ index += 1 ;
180+ } else if ( code === 0x9b ) {
181+ this . state = "csi" ;
182+ this . csiCompatPrefixOnly = true ;
183+ index += 1 ;
184+ } else if ( code === 0x9d ) {
185+ this . state = "osc" ;
186+ index += 1 ;
187+ } else if ( isCompatPrefixCode ( code ) ) {
188+ this . state = "compat" ;
189+ this . compatInParameters = false ;
190+ this . compatParameterDigits = 0 ;
191+ index += 1 ;
192+ } else if ( isDigitCode ( code ) ) {
193+ this . state = "compat" ;
194+ this . compatInParameters = true ;
195+ this . compatParameterDigits = 1 ;
196+ index += 1 ;
197+ } else if ( isCompatFinalCode ( code ) ) {
198+ this . state = "text" ;
199+ index += 1 ;
200+ } else {
201+ this . state = "text" ;
202+ }
203+ continue ;
204+ }
205+
206+ if ( code === 0x18 || code === 0x1a ) {
207+ this . state = "text" ;
208+ index += 1 ;
209+ } else if ( code === 0x1b ) {
210+ this . state = "escape" ;
211+ index += 1 ;
212+ } else if ( code === 0x9b ) {
213+ this . state = "csi" ;
214+ this . csiCompatPrefixOnly = true ;
215+ index += 1 ;
216+ } else if ( code === 0x9d ) {
217+ this . state = "osc" ;
218+ index += 1 ;
219+ } else if ( ! this . compatInParameters && isCompatPrefixCode ( code ) ) {
220+ index += 1 ;
221+ } else if ( ! this . compatInParameters && isDigitCode ( code ) ) {
222+ this . compatInParameters = true ;
223+ this . compatParameterDigits = 1 ;
224+ index += 1 ;
225+ } else if ( this . compatInParameters && isCompatParameterCode ( code ) ) {
226+ if ( code === 0x3a || code === 0x3b ) {
227+ this . compatParameterDigits = 0 ;
228+ index += 1 ;
229+ } else if ( this . compatParameterDigits < 4 ) {
230+ this . compatParameterDigits += 1 ;
231+ index += 1 ;
232+ } else {
233+ this . state = "text" ;
234+ index += 1 ;
235+ }
236+ } else if ( isCompatFinalCode ( code ) ) {
237+ this . state = "text" ;
238+ index += 1 ;
239+ } else {
240+ this . state = "text" ;
241+ }
242+ }
243+ return output . join ( "" ) ;
244+ }
245+
246+ finish ( ) : string {
247+ this . state = "text" ;
248+ this . csiCompatPrefixOnly = false ;
249+ this . compatInParameters = false ;
250+ this . compatParameterDigits = 0 ;
251+ return "" ;
252+ }
253+ }
254+
30255/** Scan one CSI parser pass, retaining independently executed C0 controls. */
31256export function scanAnsiCsiAt ( input : string , index : number ) : AnsiCsiScan | undefined {
32257 const introducerLength = csiIntroducerLength ( input , index ) ;
0 commit comments