| 214 | * Calculate the visible width of a string in terminal columns. |
| 215 | */ |
| 216 | export function visibleWidth(str: string): number { |
| 217 | if (str.length === 0) { |
| 218 | return 0; |
| 219 | } |
| 220 | |
| 221 | // Fast path: pure ASCII printable |
| 222 | if (isPrintableAscii(str)) { |
| 223 | return str.length; |
| 224 | } |
| 225 | |
| 226 | // Check cache |
| 227 | const cached = widthCache.get(str); |
| 228 | if (cached !== undefined) { |
| 229 | return cached; |
| 230 | } |
| 231 | |
| 232 | // Normalize: tabs to 3 spaces, strip ANSI escape codes |
| 233 | let clean = str; |
| 234 | if (str.includes("\t")) { |
| 235 | clean = clean.replace(/\t/g, " "); |
| 236 | } |
| 237 | if (clean.includes("\x1b")) { |
| 238 | // Strip supported ANSI/OSC/APC escape sequences in one pass. |
| 239 | // This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers, |
| 240 | // and APC sequences like CURSOR_MARKER. |
| 241 | let stripped = ""; |
| 242 | let i = 0; |
| 243 | while (i < clean.length) { |
| 244 | const ansi = extractAnsiCode(clean, i); |
| 245 | if (ansi) { |
| 246 | i += ansi.length; |
| 247 | continue; |
| 248 | } |
| 249 | stripped += clean[i]; |
| 250 | i++; |
| 251 | } |
| 252 | clean = stripped; |
| 253 | } |
| 254 | |
| 255 | // Calculate width |
| 256 | let width = 0; |
| 257 | for (const { segment } of graphemeSegmenter.segment(clean)) { |
| 258 | width += graphemeWidth(segment); |
| 259 | } |
| 260 | |
| 261 | // Cache result |
| 262 | if (widthCache.size >= WIDTH_CACHE_SIZE) { |
| 263 | const firstKey = widthCache.keys().next().value; |
| 264 | if (firstKey !== undefined) { |
| 265 | widthCache.delete(firstKey); |
| 266 | } |
| 267 | } |
| 268 | widthCache.set(str, width); |
| 269 | |
| 270 | return width; |
| 271 | } |
| 272 | |
| 273 | /** |