@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANSI escape sequence sanitizer.
|
|
3
|
+
*
|
|
4
|
+
* Strips dangerous escape sequences from text while preserving safe SGR
|
|
5
|
+
* styling and OSC sequences (hyperlinks, etc.). Used for rendering untrusted
|
|
6
|
+
* text safely in the terminal.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// Types
|
|
13
|
+
// =============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A parsed token from an ANSI-containing string.
|
|
17
|
+
*
|
|
18
|
+
* Token types:
|
|
19
|
+
* - `text` — Plain text content
|
|
20
|
+
* - `csi` — CSI (Control Sequence Introducer): ESC + '[' + params + final byte
|
|
21
|
+
* - `osc` — OSC (Operating System Command): ESC + ']' + payload + ST/BEL
|
|
22
|
+
* - `esc` — Simple two-byte escape: ESC + final byte
|
|
23
|
+
* - `dcs` — DCS (Device Control String): ESC + 'P' + payload + ST
|
|
24
|
+
* - `pm` — PM (Privacy Message): ESC + '^' + payload + ST
|
|
25
|
+
* - `apc` — APC (Application Program Command): ESC + '_' + payload + ST
|
|
26
|
+
* - `sos` — SOS (Start of String): ESC + 'X' + payload + ST
|
|
27
|
+
* - `c1` — C1 control character (0x80–0x9F)
|
|
28
|
+
*/
|
|
29
|
+
export interface AnsiToken {
|
|
30
|
+
type: "text" | "csi" | "osc" | "esc" | "dcs" | "pm" | "apc" | "sos" | "c1"
|
|
31
|
+
value: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// =============================================================================
|
|
35
|
+
// Constants
|
|
36
|
+
// =============================================================================
|
|
37
|
+
|
|
38
|
+
const ESC = 0x1b
|
|
39
|
+
|
|
40
|
+
/** Characters that introduce ST-terminated string sequences after ESC. */
|
|
41
|
+
const STRING_SEQUENCE_INTROS: Record<number, AnsiToken["type"]> = {
|
|
42
|
+
0x50: "dcs", // 'P' — Device Control String
|
|
43
|
+
0x5e: "pm", // '^' — Privacy Message
|
|
44
|
+
0x5f: "apc", // '_' — Application Program Command
|
|
45
|
+
0x58: "sos", // 'X' — Start of String
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** C1 control codes (8-bit mode) that correspond to string sequence introducers. */
|
|
49
|
+
const C1_STRING_SEQUENCE_MAP: Record<number, AnsiToken["type"]> = {
|
|
50
|
+
0x90: "dcs", // DCS
|
|
51
|
+
0x9e: "pm", // PM
|
|
52
|
+
0x9f: "apc", // APC
|
|
53
|
+
0x98: "sos", // SOS
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// =============================================================================
|
|
57
|
+
// Tokenizer
|
|
58
|
+
// =============================================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Tokenize a string into ANSI escape sequence tokens.
|
|
62
|
+
*
|
|
63
|
+
* Parses the string character by character, identifying escape sequences
|
|
64
|
+
* and plain text segments. Each token includes its type and the raw string
|
|
65
|
+
* value (including escape characters).
|
|
66
|
+
*
|
|
67
|
+
* @param text - Input string that may contain ANSI escape sequences
|
|
68
|
+
* @returns Array of tokens
|
|
69
|
+
*/
|
|
70
|
+
export function tokenizeAnsi(text: string): AnsiToken[] {
|
|
71
|
+
const tokens: AnsiToken[] = []
|
|
72
|
+
const len = text.length
|
|
73
|
+
let i = 0
|
|
74
|
+
let textStart = i
|
|
75
|
+
|
|
76
|
+
function flushText(): void {
|
|
77
|
+
if (i > textStart) {
|
|
78
|
+
tokens.push({ type: "text", value: text.slice(textStart, i) })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
while (i < len) {
|
|
83
|
+
const code = text.charCodeAt(i)
|
|
84
|
+
|
|
85
|
+
// Check for C1 control characters (0x80–0x9F) in 8-bit mode
|
|
86
|
+
if (code >= 0x80 && code <= 0x9f) {
|
|
87
|
+
flushText()
|
|
88
|
+
|
|
89
|
+
const c1Type = C1_STRING_SEQUENCE_MAP[code]
|
|
90
|
+
if (c1Type) {
|
|
91
|
+
// C1 string sequence introducer — consume until ST
|
|
92
|
+
const start = i
|
|
93
|
+
i++
|
|
94
|
+
i = findST(text, i, len)
|
|
95
|
+
tokens.push({ type: c1Type, value: text.slice(start, i) })
|
|
96
|
+
} else if (code === 0x9b) {
|
|
97
|
+
// CSI in 8-bit mode
|
|
98
|
+
const start = i
|
|
99
|
+
i++
|
|
100
|
+
i = consumeCSI(text, i, len)
|
|
101
|
+
tokens.push({ type: "csi", value: text.slice(start, i) })
|
|
102
|
+
} else if (code === 0x9d) {
|
|
103
|
+
// OSC in 8-bit mode
|
|
104
|
+
const start = i
|
|
105
|
+
i++
|
|
106
|
+
i = findOSCEnd(text, i, len)
|
|
107
|
+
tokens.push({ type: "osc", value: text.slice(start, i) })
|
|
108
|
+
} else {
|
|
109
|
+
// Other C1 control character
|
|
110
|
+
tokens.push({ type: "c1", value: text[i] })
|
|
111
|
+
i++
|
|
112
|
+
}
|
|
113
|
+
textStart = i
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Check for ESC (0x1B)
|
|
118
|
+
if (code === ESC) {
|
|
119
|
+
flushText()
|
|
120
|
+
|
|
121
|
+
if (i + 1 >= len) {
|
|
122
|
+
// Incomplete escape at end of string — treat as malformed
|
|
123
|
+
tokens.push({ type: "esc", value: text[i] })
|
|
124
|
+
i++
|
|
125
|
+
textStart = i
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const next = text.charCodeAt(i + 1)
|
|
130
|
+
|
|
131
|
+
// CSI: ESC + '['
|
|
132
|
+
if (next === 0x5b) {
|
|
133
|
+
const start = i
|
|
134
|
+
i += 2
|
|
135
|
+
i = consumeCSI(text, i, len)
|
|
136
|
+
tokens.push({ type: "csi", value: text.slice(start, i) })
|
|
137
|
+
textStart = i
|
|
138
|
+
continue
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// OSC: ESC + ']'
|
|
142
|
+
if (next === 0x5d) {
|
|
143
|
+
const start = i
|
|
144
|
+
i += 2
|
|
145
|
+
i = findOSCEnd(text, i, len)
|
|
146
|
+
tokens.push({ type: "osc", value: text.slice(start, i) })
|
|
147
|
+
textStart = i
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// String sequences: DCS (P), PM (^), APC (_), SOS (X)
|
|
152
|
+
const stringType = STRING_SEQUENCE_INTROS[next]
|
|
153
|
+
if (stringType) {
|
|
154
|
+
const start = i
|
|
155
|
+
i += 2
|
|
156
|
+
i = findST(text, i, len)
|
|
157
|
+
tokens.push({ type: stringType, value: text.slice(start, i) })
|
|
158
|
+
textStart = i
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ESC sequences with intermediate bytes:
|
|
163
|
+
// ESC I... F where I is 0x20–0x2F (intermediate), F is 0x30–0x7E (final)
|
|
164
|
+
// Examples: ESC # 8 (DECALN), ESC ( B (G0 charset)
|
|
165
|
+
// If no valid final byte follows, consume to end of string (fail-safe
|
|
166
|
+
// to prevent payload leaks from malformed sequences).
|
|
167
|
+
if (next >= 0x20 && next <= 0x2f) {
|
|
168
|
+
const start = i
|
|
169
|
+
i += 2 // skip ESC + first intermediate
|
|
170
|
+
// Consume additional intermediate bytes
|
|
171
|
+
while (i < len) {
|
|
172
|
+
const c = text.charCodeAt(i)
|
|
173
|
+
if (c < 0x20 || c > 0x2f) break
|
|
174
|
+
i++
|
|
175
|
+
}
|
|
176
|
+
// Consume final byte (0x30–0x7E) if present
|
|
177
|
+
if (i < len) {
|
|
178
|
+
const c = text.charCodeAt(i)
|
|
179
|
+
if (c >= 0x30 && c <= 0x7e) {
|
|
180
|
+
i++
|
|
181
|
+
tokens.push({ type: "esc", value: text.slice(start, i) })
|
|
182
|
+
} else {
|
|
183
|
+
// No valid final byte — malformed sequence, consume to end of string
|
|
184
|
+
i = len
|
|
185
|
+
tokens.push({ type: "esc", value: text.slice(start, i) })
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
// Incomplete (at end of string) — consume what we have
|
|
189
|
+
tokens.push({ type: "esc", value: text.slice(start, i) })
|
|
190
|
+
}
|
|
191
|
+
textStart = i
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Simple two-byte escape sequence: ESC + byte (0x30–0x7E)
|
|
196
|
+
// 0x30–0x3F: Fp (private use, e.g. ESC 7 = DECSC, ESC 8 = DECRC)
|
|
197
|
+
// 0x40–0x5F: Fe (C1 equivalents, e.g. ESC D = IND, ESC M = RI)
|
|
198
|
+
// 0x60–0x7E: Fs (independent functions)
|
|
199
|
+
if (next >= 0x30 && next <= 0x7e) {
|
|
200
|
+
tokens.push({ type: "esc", value: text.slice(i, i + 2) })
|
|
201
|
+
i += 2
|
|
202
|
+
textStart = i
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Unknown/malformed escape — emit just ESC as an esc token
|
|
207
|
+
tokens.push({ type: "esc", value: text[i] })
|
|
208
|
+
i++
|
|
209
|
+
textStart = i
|
|
210
|
+
continue
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
i++
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
flushText()
|
|
217
|
+
return tokens
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// =============================================================================
|
|
221
|
+
// CSI Parser
|
|
222
|
+
// =============================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Consume a CSI sequence starting after "ESC [" or the C1 CSI byte.
|
|
226
|
+
* Returns the index after the final byte.
|
|
227
|
+
*
|
|
228
|
+
* CSI format: parameter bytes (0x30–0x3F)*, intermediate bytes (0x20–0x2F)*, final byte (0x40–0x7E)
|
|
229
|
+
*/
|
|
230
|
+
function consumeCSI(text: string, i: number, len: number): number {
|
|
231
|
+
// Parameter bytes: 0x30–0x3F (digits, semicolons, colons, etc.)
|
|
232
|
+
while (i < len) {
|
|
233
|
+
const c = text.charCodeAt(i)
|
|
234
|
+
if (c < 0x30 || c > 0x3f) break
|
|
235
|
+
i++
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Intermediate bytes: 0x20–0x2F (space, !, ", #, etc.)
|
|
239
|
+
while (i < len) {
|
|
240
|
+
const c = text.charCodeAt(i)
|
|
241
|
+
if (c < 0x20 || c > 0x2f) break
|
|
242
|
+
i++
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Final byte: 0x40–0x7E
|
|
246
|
+
if (i < len) {
|
|
247
|
+
const c = text.charCodeAt(i)
|
|
248
|
+
if (c >= 0x40 && c <= 0x7e) {
|
|
249
|
+
i++
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return i
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// =============================================================================
|
|
257
|
+
// String Terminator Finder
|
|
258
|
+
// =============================================================================
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Find the String Terminator (ST) for DCS, PM, APC, SOS sequences.
|
|
262
|
+
* ST is ESC + '\\' (0x5C) or C1 ST (0x9C). Returns index after the ST.
|
|
263
|
+
* If no ST found, returns end of string (consuming the malformed sequence).
|
|
264
|
+
*/
|
|
265
|
+
function findST(text: string, i: number, len: number): number {
|
|
266
|
+
while (i < len) {
|
|
267
|
+
const code = text.charCodeAt(i)
|
|
268
|
+
// C1 ST (0x9C)
|
|
269
|
+
if (code === 0x9c) {
|
|
270
|
+
return i + 1
|
|
271
|
+
}
|
|
272
|
+
// ESC + '\' (7-bit ST)
|
|
273
|
+
if (code === ESC && i + 1 < len && text.charCodeAt(i + 1) === 0x5c) {
|
|
274
|
+
return i + 2 // past ESC + '\'
|
|
275
|
+
}
|
|
276
|
+
i++
|
|
277
|
+
}
|
|
278
|
+
return len
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Find the end of an OSC sequence.
|
|
283
|
+
* OSC is terminated by ST (ESC + '\\'), C1 ST (0x9C), or BEL (0x07).
|
|
284
|
+
* Returns index after the terminator.
|
|
285
|
+
*/
|
|
286
|
+
function findOSCEnd(text: string, i: number, len: number): number {
|
|
287
|
+
while (i < len) {
|
|
288
|
+
const code = text.charCodeAt(i)
|
|
289
|
+
// BEL terminator
|
|
290
|
+
if (code === 0x07) {
|
|
291
|
+
return i + 1
|
|
292
|
+
}
|
|
293
|
+
// C1 ST (0x9C)
|
|
294
|
+
if (code === 0x9c) {
|
|
295
|
+
return i + 1
|
|
296
|
+
}
|
|
297
|
+
// ST terminator (ESC + '\')
|
|
298
|
+
if (code === ESC && i + 1 < len && text.charCodeAt(i + 1) === 0x5c) {
|
|
299
|
+
return i + 2
|
|
300
|
+
}
|
|
301
|
+
i++
|
|
302
|
+
}
|
|
303
|
+
return len
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// =============================================================================
|
|
307
|
+
// Sanitizer
|
|
308
|
+
// =============================================================================
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check whether a CSI sequence is an SGR (Select Graphic Rendition) sequence.
|
|
312
|
+
*
|
|
313
|
+
* SGR sequences set text styling (colors, bold, underline, etc.) and are safe.
|
|
314
|
+
* They have the form: CSI <params> m
|
|
315
|
+
*
|
|
316
|
+
* A CSI is SGR when:
|
|
317
|
+
* - The final byte is 'm'
|
|
318
|
+
* - There are no intermediate bytes (0x20–0x2F)
|
|
319
|
+
* - Parameter bytes are only 0x30–0x3F
|
|
320
|
+
*/
|
|
321
|
+
export function isCSISGR(value: string): boolean {
|
|
322
|
+
// Must end with 'm'
|
|
323
|
+
if (value.length < 2 || value.charCodeAt(value.length - 1) !== 0x6d) {
|
|
324
|
+
return false
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Find start of parameters (skip ESC[ or C1 CSI)
|
|
328
|
+
let start: number
|
|
329
|
+
if (value.charCodeAt(0) === ESC) {
|
|
330
|
+
// ESC [ ... m
|
|
331
|
+
start = 2
|
|
332
|
+
} else {
|
|
333
|
+
// C1 CSI (0x9B) ... m
|
|
334
|
+
start = 1
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Everything between start and the final 'm' must be standard parameter bytes:
|
|
338
|
+
// digits (0x30–0x39), semicolons (0x3B), colons (0x3A).
|
|
339
|
+
// Private-use parameter prefixes (<, =, >, ? at 0x3C–0x3F) indicate non-SGR.
|
|
340
|
+
// Intermediate bytes (0x20–0x2F) also indicate non-SGR.
|
|
341
|
+
for (let i = start; i < value.length - 1; i++) {
|
|
342
|
+
const c = value.charCodeAt(i)
|
|
343
|
+
// Allow: digits 0-9 (0x30-0x39), colon (0x3A), semicolon (0x3B)
|
|
344
|
+
// Reject: < = > ? (0x3C-0x3F) — private-use parameter prefixes
|
|
345
|
+
// Reject: anything outside 0x30-0x3B (intermediates, etc.)
|
|
346
|
+
if (c < 0x30 || c > 0x3b) {
|
|
347
|
+
return false
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return true
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Sanitize a string by stripping dangerous ANSI escape sequences while
|
|
356
|
+
* preserving safe SGR styling codes and OSC sequences (hyperlinks, etc.).
|
|
357
|
+
*
|
|
358
|
+
* Safe (preserved):
|
|
359
|
+
* - Plain text
|
|
360
|
+
* - CSI SGR sequences (colors, bold, underline — final byte 'm', no intermediates)
|
|
361
|
+
* - OSC sequences (hyperlinks, window titles, etc.)
|
|
362
|
+
*
|
|
363
|
+
* Stripped:
|
|
364
|
+
* - Non-SGR CSI sequences (cursor movement, screen clearing, etc.)
|
|
365
|
+
* - DCS (Device Control String)
|
|
366
|
+
* - PM (Privacy Message)
|
|
367
|
+
* - APC (Application Program Command)
|
|
368
|
+
* - SOS (Start of String)
|
|
369
|
+
* - C1 control characters (0x80–0x9F)
|
|
370
|
+
* - Simple ESC sequences (cursor save/restore, etc.)
|
|
371
|
+
* - Malformed/incomplete escape sequences
|
|
372
|
+
*
|
|
373
|
+
* @param text - Input string that may contain ANSI escape sequences
|
|
374
|
+
* @returns Sanitized string with only safe sequences preserved
|
|
375
|
+
*
|
|
376
|
+
* @example
|
|
377
|
+
* ```ts
|
|
378
|
+
* // SGR preserved
|
|
379
|
+
* sanitizeAnsi('\x1b[31mred\x1b[0m') // '\x1b[31mred\x1b[0m'
|
|
380
|
+
*
|
|
381
|
+
* // Cursor movement stripped
|
|
382
|
+
* sanitizeAnsi('\x1b[2J\x1b[H') // ''
|
|
383
|
+
*
|
|
384
|
+
* // Mixed: only SGR kept
|
|
385
|
+
* sanitizeAnsi('\x1b[31m\x1b[2Jred\x1b[0m') // '\x1b[31mred\x1b[0m'
|
|
386
|
+
* ```
|
|
387
|
+
*/
|
|
388
|
+
export function sanitizeAnsi(text: string): string {
|
|
389
|
+
if (text.length === 0) return ""
|
|
390
|
+
|
|
391
|
+
const tokens = tokenizeAnsi(text)
|
|
392
|
+
let result = ""
|
|
393
|
+
|
|
394
|
+
for (const token of tokens) {
|
|
395
|
+
switch (token.type) {
|
|
396
|
+
case "text":
|
|
397
|
+
result += token.value
|
|
398
|
+
break
|
|
399
|
+
case "csi":
|
|
400
|
+
// Only keep SGR sequences (color/style codes)
|
|
401
|
+
if (isCSISGR(token.value)) {
|
|
402
|
+
result += token.value
|
|
403
|
+
}
|
|
404
|
+
break
|
|
405
|
+
case "osc":
|
|
406
|
+
// OSC sequences are safe (hyperlinks, titles, etc.)
|
|
407
|
+
result += token.value
|
|
408
|
+
break
|
|
409
|
+
// Strip everything else: esc, dcs, pm, apc, sos, c1
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return result
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// =============================================================================
|
|
417
|
+
// Colon-format SGR round-trip tracking
|
|
418
|
+
// =============================================================================
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* A colon→semicolon SGR replacement pair.
|
|
422
|
+
*/
|
|
423
|
+
export interface ColonSGRReplacement {
|
|
424
|
+
semicolonForm: string
|
|
425
|
+
colonForm: string
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Detect colon-format SGR sequences in an SGR token and return replacement pairs.
|
|
430
|
+
*
|
|
431
|
+
* Terminals use colon-separated parameters (e.g., `38:2::255:100:0`) for true color,
|
|
432
|
+
* but silvery's pipeline normalizes to semicolons (`38;2;255;100;0`). This function
|
|
433
|
+
* extracts the mapping so the original colon format can be restored after rendering.
|
|
434
|
+
*
|
|
435
|
+
* @param sgrSequence - A CSI SGR sequence (must end with 'm')
|
|
436
|
+
* @returns Array of replacement pairs, empty if no colon-format params found
|
|
437
|
+
*/
|
|
438
|
+
export function extractColonSGRReplacements(sgrSequence: string): ColonSGRReplacement[] {
|
|
439
|
+
const paramsMatch = sgrSequence.match(/\x1b\[([0-9;:]+)m/)
|
|
440
|
+
if (!paramsMatch) return []
|
|
441
|
+
|
|
442
|
+
const rawParams = paramsMatch[1]!
|
|
443
|
+
if (!rawParams.includes(":")) return []
|
|
444
|
+
|
|
445
|
+
const replacements: ColonSGRReplacement[] = []
|
|
446
|
+
const parts = rawParams.split(";")
|
|
447
|
+
for (const part of parts) {
|
|
448
|
+
if (!part.includes(":")) continue
|
|
449
|
+
const subs = part.split(":")
|
|
450
|
+
const code = Number(subs[0])
|
|
451
|
+
if ((code === 38 || code === 48) && Number(subs[1]) === 2) {
|
|
452
|
+
// True color colon format: code:2::R:G:B or code:2:R:G:B
|
|
453
|
+
// Extract R, G, B (skip empty colorspace ID)
|
|
454
|
+
const nums = subs.map((s) => (s === "" ? 0 : Number(s)))
|
|
455
|
+
const r = nums[3] ?? nums[2] ?? 0
|
|
456
|
+
const g = nums[4] ?? nums[3] ?? 0
|
|
457
|
+
const b = nums[5] ?? nums[4] ?? 0
|
|
458
|
+
const semicolonForm = `\x1b[${code};2;${r};${g};${b}m`
|
|
459
|
+
replacements.push({ semicolonForm, colonForm: `\x1b[${part}m` })
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return replacements
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Create a colon-format SGR tracker for round-trip preservation.
|
|
467
|
+
*
|
|
468
|
+
* Rendering is synchronous: sanitize → render → output in one call. The tracker
|
|
469
|
+
* accumulates colon→semicolon mappings during sanitization, then `restore()` applies
|
|
470
|
+
* them to the rendered output.
|
|
471
|
+
*
|
|
472
|
+
* @example
|
|
473
|
+
* ```ts
|
|
474
|
+
* const tracker = createColonSGRTracker()
|
|
475
|
+
* // During sanitization, register SGR tokens:
|
|
476
|
+
* tracker.register(sgrToken)
|
|
477
|
+
* // After rendering, restore original colon format:
|
|
478
|
+
* output = tracker.restore(output)
|
|
479
|
+
* // Optionally clear for reuse:
|
|
480
|
+
* tracker.clear()
|
|
481
|
+
* ```
|
|
482
|
+
*/
|
|
483
|
+
export function createColonSGRTracker(): {
|
|
484
|
+
register: (sgrSequence: string) => void
|
|
485
|
+
restore: (output: string) => string
|
|
486
|
+
clear: () => void
|
|
487
|
+
} {
|
|
488
|
+
const replacements: ColonSGRReplacement[] = []
|
|
489
|
+
|
|
490
|
+
return {
|
|
491
|
+
register(sgrSequence: string): void {
|
|
492
|
+
const found = extractColonSGRReplacements(sgrSequence)
|
|
493
|
+
for (const r of found) replacements.push(r)
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
restore(output: string): string {
|
|
497
|
+
if (replacements.length === 0) return output
|
|
498
|
+
let result = output
|
|
499
|
+
for (const { semicolonForm, colonForm } of replacements) {
|
|
500
|
+
result = result.replaceAll(semicolonForm, colonForm)
|
|
501
|
+
}
|
|
502
|
+
return result
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
clear(): void {
|
|
506
|
+
replacements.length = 0
|
|
507
|
+
},
|
|
508
|
+
}
|
|
509
|
+
}
|