source dump of claude code
at main 797 lines 26 kB view raw
1import { 2 type AnsiCode, 3 type StyledChar, 4 styledCharsFromTokens, 5 tokenize, 6} from '@alcalzone/ansi-tokenize' 7import { logForDebugging } from '../utils/debug.js' 8import { getGraphemeSegmenter } from '../utils/intl.js' 9import sliceAnsi from '../utils/sliceAnsi.js' 10import { reorderBidi } from './bidi.js' 11import { type Rectangle, unionRect } from './layout/geometry.js' 12import { 13 blitRegion, 14 CellWidth, 15 extractHyperlinkFromStyles, 16 filterOutHyperlinkStyles, 17 markNoSelectRegion, 18 OSC8_PREFIX, 19 resetScreen, 20 type Screen, 21 type StylePool, 22 setCellAt, 23 shiftRows, 24} from './screen.js' 25import { stringWidth } from './stringWidth.js' 26import { widestLine } from './widest-line.js' 27 28/** 29 * A grapheme cluster with precomputed terminal width, styleId, and hyperlink. 30 * Built once per unique line (cached via charCache), so the per-char hot loop 31 * is just property reads + setCellAt — no stringWidth, no style interning, 32 * no hyperlink extraction per frame. 33 * 34 * styleId is safe to cache: StylePool is session-lived (never reset). 35 * hyperlink is stored as a string (not interned ID) since hyperlinkPool 36 * resets every 5 min; setCellAt interns it per-frame (cheap Map.get). 37 */ 38type ClusteredChar = { 39 value: string 40 width: number 41 styleId: number 42 hyperlink: string | undefined 43} 44 45/** 46 * Collects write/blit/clear/clip operations from the render tree, then 47 * applies them to a Screen buffer in `get()`. The Screen is what gets 48 * diffed against the previous frame to produce terminal updates. 49 */ 50 51type Options = { 52 width: number 53 height: number 54 stylePool: StylePool 55 /** 56 * Screen to render into. Will be reset before use. 57 * For double-buffering, pass a reusable screen. Otherwise create a new one. 58 */ 59 screen: Screen 60} 61 62export type Operation = 63 | WriteOperation 64 | ClipOperation 65 | UnclipOperation 66 | BlitOperation 67 | ClearOperation 68 | NoSelectOperation 69 | ShiftOperation 70 71type WriteOperation = { 72 type: 'write' 73 x: number 74 y: number 75 text: string 76 /** 77 * Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true 78 * means line i is a continuation of line i-1 (the `\n` before it was 79 * inserted by word-wrap, not in the source). Index 0 is always false. 80 * Undefined means the producer didn't track wrapping (e.g. fills, 81 * raw-ansi) — the screen's per-row bitmap is left untouched. 82 */ 83 softWrap?: boolean[] 84} 85 86type ClipOperation = { 87 type: 'clip' 88 clip: Clip 89} 90 91export type Clip = { 92 x1: number | undefined 93 x2: number | undefined 94 y1: number | undefined 95 y2: number | undefined 96} 97 98/** 99 * Intersect two clips. `undefined` on an axis means unbounded; the other 100 * clip's bound wins. If both are bounded, take the tighter constraint 101 * (max of mins, min of maxes). If the resulting region is empty 102 * (x1 >= x2 or y1 >= y2), writes clipped by it will be dropped. 103 */ 104function intersectClip(parent: Clip | undefined, child: Clip): Clip { 105 if (!parent) return child 106 return { 107 x1: maxDefined(parent.x1, child.x1), 108 x2: minDefined(parent.x2, child.x2), 109 y1: maxDefined(parent.y1, child.y1), 110 y2: minDefined(parent.y2, child.y2), 111 } 112} 113 114function maxDefined( 115 a: number | undefined, 116 b: number | undefined, 117): number | undefined { 118 if (a === undefined) return b 119 if (b === undefined) return a 120 return Math.max(a, b) 121} 122 123function minDefined( 124 a: number | undefined, 125 b: number | undefined, 126): number | undefined { 127 if (a === undefined) return b 128 if (b === undefined) return a 129 return Math.min(a, b) 130} 131 132type UnclipOperation = { 133 type: 'unclip' 134} 135 136type BlitOperation = { 137 type: 'blit' 138 src: Screen 139 x: number 140 y: number 141 width: number 142 height: number 143} 144 145type ShiftOperation = { 146 type: 'shift' 147 top: number 148 bottom: number 149 n: number 150} 151 152type ClearOperation = { 153 type: 'clear' 154 region: Rectangle 155 /** 156 * Set when the clear is for an absolute-positioned node's old bounds. 157 * Absolute nodes overlay normal-flow siblings, so their stale paint is 158 * what an earlier sibling's clean-subtree blit wrongly restores from 159 * prevScreen. Normal-flow siblings' clears don't have this problem — 160 * their old position can't have been painted on top of a sibling. 161 */ 162 fromAbsolute?: boolean 163} 164 165type NoSelectOperation = { 166 type: 'noSelect' 167 region: Rectangle 168} 169 170export default class Output { 171 width: number 172 height: number 173 private readonly stylePool: StylePool 174 private screen: Screen 175 176 private readonly operations: Operation[] = [] 177 178 private charCache: Map<string, ClusteredChar[]> = new Map() 179 180 constructor(options: Options) { 181 const { width, height, stylePool, screen } = options 182 183 this.width = width 184 this.height = height 185 this.stylePool = stylePool 186 this.screen = screen 187 188 resetScreen(screen, width, height) 189 } 190 191 /** 192 * Reuse this Output for a new frame. Zeroes the screen buffer, clears 193 * the operation list (backing storage is retained), and caps charCache 194 * growth. Preserving charCache across frames is the main win — most 195 * lines don't change between renders, so tokenize + grapheme clustering 196 * becomes a cache hit. 197 */ 198 reset(width: number, height: number, screen: Screen): void { 199 this.width = width 200 this.height = height 201 this.screen = screen 202 this.operations.length = 0 203 resetScreen(screen, width, height) 204 if (this.charCache.size > 16384) this.charCache.clear() 205 } 206 207 /** 208 * Copy cells from a source screen region (blit = block image transfer). 209 */ 210 blit(src: Screen, x: number, y: number, width: number, height: number): void { 211 this.operations.push({ type: 'blit', src, x, y, width, height }) 212 } 213 214 /** 215 * Shift full-width rows within [top, bottom] by n. n > 0 = up. Mirrors 216 * what DECSTBM + SU/SD does to the terminal. Paired with blit() to reuse 217 * prevScreen content during pure scroll, avoiding full child re-render. 218 */ 219 shift(top: number, bottom: number, n: number): void { 220 this.operations.push({ type: 'shift', top, bottom, n }) 221 } 222 223 /** 224 * Clear a region by writing empty cells. Used when a node shrinks to 225 * ensure stale content from the previous frame is removed. 226 */ 227 clear(region: Rectangle, fromAbsolute?: boolean): void { 228 this.operations.push({ type: 'clear', region, fromAbsolute }) 229 } 230 231 /** 232 * Mark a region as non-selectable (excluded from fullscreen text 233 * selection copy + highlight). Used by <NoSelect> to fence off 234 * gutters (line numbers, diff sigils). Applied AFTER blit/write so 235 * the mark wins regardless of what's blitted into the region. 236 */ 237 noSelect(region: Rectangle): void { 238 this.operations.push({ type: 'noSelect', region }) 239 } 240 241 write(x: number, y: number, text: string, softWrap?: boolean[]): void { 242 if (!text) { 243 return 244 } 245 246 this.operations.push({ 247 type: 'write', 248 x, 249 y, 250 text, 251 softWrap, 252 }) 253 } 254 255 clip(clip: Clip) { 256 this.operations.push({ 257 type: 'clip', 258 clip, 259 }) 260 } 261 262 unclip() { 263 this.operations.push({ 264 type: 'unclip', 265 }) 266 } 267 268 get(): Screen { 269 const screen = this.screen 270 const screenWidth = this.width 271 const screenHeight = this.height 272 273 // Track blit vs write cell counts for debugging 274 let blitCells = 0 275 let writeCells = 0 276 277 // Pass 1: expand damage to cover clear regions. The buffer is freshly 278 // zeroed by resetScreen, so this pass only marks damage so diff() 279 // checks these regions against the previous frame. 280 // 281 // Also collect clears from absolute-positioned nodes. An absolute 282 // node overlays normal-flow siblings; when it shrinks, its clear is 283 // pushed AFTER those siblings' clean-subtree blits (DOM order). The 284 // blit copies the absolute node's own stale paint from prevScreen, 285 // and since clear is damage-only, the ghost survives diff. Normal- 286 // flow clears don't need this — a normal-flow node's old position 287 // can't have been painted on top of a sibling's current position. 288 const absoluteClears: Rectangle[] = [] 289 for (const operation of this.operations) { 290 if (operation.type !== 'clear') continue 291 const { x, y, width, height } = operation.region 292 const startX = Math.max(0, x) 293 const startY = Math.max(0, y) 294 const maxX = Math.min(x + width, screenWidth) 295 const maxY = Math.min(y + height, screenHeight) 296 if (startX >= maxX || startY >= maxY) continue 297 const rect = { 298 x: startX, 299 y: startY, 300 width: maxX - startX, 301 height: maxY - startY, 302 } 303 screen.damage = screen.damage ? unionRect(screen.damage, rect) : rect 304 if (operation.fromAbsolute) absoluteClears.push(rect) 305 } 306 307 const clips: Clip[] = [] 308 309 for (const operation of this.operations) { 310 switch (operation.type) { 311 case 'clear': 312 // handled in pass 1 313 continue 314 315 case 'clip': 316 // Intersect with the parent clip (if any) so nested 317 // overflow:hidden boxes can't write outside their ancestor's 318 // clip region. Without this, a message with overflow:hidden at 319 // the bottom of a scrollbox pushes its OWN clip (based on its 320 // layout bounds, already translated by -scrollTop) which can 321 // extend below the scrollbox viewport — writes escape into 322 // the sibling bottom section's rows. 323 clips.push(intersectClip(clips.at(-1), operation.clip)) 324 continue 325 326 case 'unclip': 327 clips.pop() 328 continue 329 330 case 'blit': { 331 // Bulk-copy cells from source screen region using TypedArray.set(). 332 // Tracking damage ensures diff() checks blitted cells for stale content 333 // when a parent blits an area that previously contained child content. 334 const { 335 src, 336 x: regionX, 337 y: regionY, 338 width: regionWidth, 339 height: regionHeight, 340 } = operation 341 // Intersect with active clip — a child's clean-blit passes its full 342 // cached rect, but the parent ScrollBox may have shrunk (pill mount). 343 // Without this, the blit writes past the ScrollBox's new bottom edge 344 // into the pill's row. 345 const clip = clips.at(-1) 346 const startX = Math.max(regionX, clip?.x1 ?? 0) 347 const startY = Math.max(regionY, clip?.y1 ?? 0) 348 const maxY = Math.min( 349 regionY + regionHeight, 350 screenHeight, 351 src.height, 352 clip?.y2 ?? Infinity, 353 ) 354 const maxX = Math.min( 355 regionX + regionWidth, 356 screenWidth, 357 src.width, 358 clip?.x2 ?? Infinity, 359 ) 360 if (startX >= maxX || startY >= maxY) continue 361 // Skip rows covered by an absolute-positioned node's clear. 362 // Absolute nodes overlay normal-flow siblings, so prevScreen in 363 // that region holds the absolute node's stale paint — blitting 364 // it back would ghost. See absoluteClears collection above. 365 if (absoluteClears.length === 0) { 366 blitRegion(screen, src, startX, startY, maxX, maxY) 367 blitCells += (maxY - startY) * (maxX - startX) 368 continue 369 } 370 let rowStart = startY 371 for (let row = startY; row <= maxY; row++) { 372 const excluded = 373 row < maxY && 374 absoluteClears.some( 375 r => 376 row >= r.y && 377 row < r.y + r.height && 378 startX >= r.x && 379 maxX <= r.x + r.width, 380 ) 381 if (excluded || row === maxY) { 382 if (row > rowStart) { 383 blitRegion(screen, src, startX, rowStart, maxX, row) 384 blitCells += (row - rowStart) * (maxX - startX) 385 } 386 rowStart = row + 1 387 } 388 } 389 continue 390 } 391 392 case 'shift': { 393 shiftRows(screen, operation.top, operation.bottom, operation.n) 394 continue 395 } 396 397 case 'write': { 398 const { text, softWrap } = operation 399 let { x, y } = operation 400 let lines = text.split('\n') 401 let swFrom = 0 402 let prevContentEnd = 0 403 404 const clip = clips.at(-1) 405 406 if (clip) { 407 const clipHorizontally = 408 typeof clip?.x1 === 'number' && typeof clip?.x2 === 'number' 409 410 const clipVertically = 411 typeof clip?.y1 === 'number' && typeof clip?.y2 === 'number' 412 413 // If text is positioned outside of clipping area altogether, 414 // skip to the next operation to avoid unnecessary calculations 415 if (clipHorizontally) { 416 const width = widestLine(text) 417 418 if (x + width <= clip.x1! || x >= clip.x2!) { 419 continue 420 } 421 } 422 423 if (clipVertically) { 424 const height = lines.length 425 426 if (y + height <= clip.y1! || y >= clip.y2!) { 427 continue 428 } 429 } 430 431 if (clipHorizontally) { 432 lines = lines.map(line => { 433 const from = x < clip.x1! ? clip.x1! - x : 0 434 const width = stringWidth(line) 435 const to = x + width > clip.x2! ? clip.x2! - x : width 436 let sliced = sliceAnsi(line, from, to) 437 // Wide chars (CJK, emoji) occupy 2 cells. When `to` lands 438 // on the first cell of a wide char, sliceAnsi includes the 439 // entire glyph and the result overflows clip.x2 by one cell, 440 // writing a SpacerTail into the adjacent sibling. Re-slice 441 // one cell earlier; wide chars are exactly 2 cells, so a 442 // single retry always fits. 443 if (stringWidth(sliced) > to - from) { 444 sliced = sliceAnsi(line, from, to - 1) 445 } 446 return sliced 447 }) 448 449 if (x < clip.x1!) { 450 x = clip.x1! 451 } 452 } 453 454 if (clipVertically) { 455 const from = y < clip.y1! ? clip.y1! - y : 0 456 const height = lines.length 457 const to = y + height > clip.y2! ? clip.y2! - y : height 458 459 // If the first visible line is a soft-wrap continuation, we 460 // need the clipped previous line's content end so 461 // screen.softWrap[lineY] correctly records the join point 462 // even though that line's cells were never written. 463 if (softWrap && from > 0 && softWrap[from] === true) { 464 prevContentEnd = x + stringWidth(lines[from - 1]!) 465 } 466 467 lines = lines.slice(from, to) 468 swFrom = from 469 470 if (y < clip.y1!) { 471 y = clip.y1! 472 } 473 } 474 } 475 476 const swBits = screen.softWrap 477 let offsetY = 0 478 479 for (const line of lines) { 480 const lineY = y + offsetY 481 // Line can be outside screen if `text` is taller than screen height 482 if (lineY >= screenHeight) { 483 break 484 } 485 const contentEnd = writeLineToScreen( 486 screen, 487 line, 488 x, 489 lineY, 490 screenWidth, 491 this.stylePool, 492 this.charCache, 493 ) 494 writeCells += contentEnd - x 495 // See Screen.softWrap docstring for the encoding. contentEnd 496 // from writeLineToScreen is tab-expansion-aware, unlike 497 // x+stringWidth(line) which treats tabs as width 0. 498 if (softWrap) { 499 const isSW = softWrap[swFrom + offsetY] === true 500 swBits[lineY] = isSW ? prevContentEnd : 0 501 prevContentEnd = contentEnd 502 } 503 offsetY++ 504 } 505 continue 506 } 507 } 508 } 509 510 // noSelect ops go LAST so they win over blits (which copy noSelect 511 // from prevScreen) and writes (which don't touch noSelect). This way 512 // a <NoSelect> box correctly fences its region even when the parent 513 // blits, and moving a <NoSelect> between frames correctly clears the 514 // old region (resetScreen already zeroed the bitmap). 515 for (const operation of this.operations) { 516 if (operation.type === 'noSelect') { 517 const { x, y, width, height } = operation.region 518 markNoSelectRegion(screen, x, y, width, height) 519 } 520 } 521 522 // Log blit/write ratio for debugging - high write count suggests blitting isn't working 523 const totalCells = blitCells + writeCells 524 if (totalCells > 1000 && writeCells > blitCells) { 525 logForDebugging( 526 `High write ratio: blit=${blitCells}, write=${writeCells} (${((writeCells / totalCells) * 100).toFixed(1)}% writes), screen=${screenHeight}x${screenWidth}`, 527 ) 528 } 529 530 return screen 531 } 532} 533 534function stylesEqual(a: AnsiCode[], b: AnsiCode[]): boolean { 535 if (a === b) return true // Reference equality fast path 536 const len = a.length 537 if (len !== b.length) return false 538 if (len === 0) return true // Both empty 539 for (let i = 0; i < len; i++) { 540 if (a[i]!.code !== b[i]!.code) return false 541 } 542 return true 543} 544 545/** 546 * Convert a string with ANSI codes into styled characters with proper grapheme 547 * clustering. Fixes ansi-tokenize splitting grapheme clusters (like family 548 * emojis) into individual code points. 549 * 550 * Also precomputes styleId + hyperlink per style run (not per char) — an 551 * 80-char line with 3 style runs does 3 intern calls instead of 80. 552 */ 553function styledCharsWithGraphemeClustering( 554 chars: StyledChar[], 555 stylePool: StylePool, 556): ClusteredChar[] { 557 const charCount = chars.length 558 if (charCount === 0) return [] 559 560 const result: ClusteredChar[] = [] 561 const bufferChars: string[] = [] 562 let bufferStyles: AnsiCode[] = chars[0]!.styles 563 564 for (let i = 0; i < charCount; i++) { 565 const char = chars[i]! 566 const styles = char.styles 567 568 // Different styles means we need to flush and start new buffer 569 if (bufferChars.length > 0 && !stylesEqual(styles, bufferStyles)) { 570 flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result) 571 bufferChars.length = 0 572 } 573 574 bufferChars.push(char.value) 575 bufferStyles = styles 576 } 577 578 // Final flush 579 if (bufferChars.length > 0) { 580 flushBuffer(bufferChars.join(''), bufferStyles, stylePool, result) 581 } 582 583 return result 584} 585 586function flushBuffer( 587 buffer: string, 588 styles: AnsiCode[], 589 stylePool: StylePool, 590 out: ClusteredChar[], 591): void { 592 // Compute styleId + hyperlink ONCE for the whole style run. 593 // Every grapheme in this buffer shares the same styles. 594 // 595 // Extract and track hyperlinks separately, filter from styles. 596 // Always check for OSC 8 codes to filter, not just when a URL is 597 // extracted. The tokenizer treats OSC 8 close codes (empty URL) as 598 // active styles, so they must be filtered even when no hyperlink 599 // URL is present. 600 const hyperlink = extractHyperlinkFromStyles(styles) ?? undefined 601 const hasOsc8Styles = 602 hyperlink !== undefined || 603 styles.some( 604 s => 605 s.code.length >= OSC8_PREFIX.length && s.code.startsWith(OSC8_PREFIX), 606 ) 607 const filteredStyles = hasOsc8Styles 608 ? filterOutHyperlinkStyles(styles) 609 : styles 610 const styleId = stylePool.intern(filteredStyles) 611 612 for (const { segment: grapheme } of getGraphemeSegmenter().segment(buffer)) { 613 out.push({ 614 value: grapheme, 615 width: stringWidth(grapheme), 616 styleId, 617 hyperlink, 618 }) 619 } 620} 621 622/** 623 * Write a single line's characters into the screen buffer. 624 * Extracted from Output.get() so JSC can optimize this tight, 625 * monomorphic loop independently — better register allocation, 626 * setCellAt inlining, and type feedback than when buried inside 627 * a 300-line dispatch function. 628 * 629 * Returns the end column (x + visual width, including tab expansion) so 630 * the caller can record it in screen.softWrap without re-walking the 631 * line via stringWidth(). Caller computes the debug cell-count as end-x. 632 */ 633function writeLineToScreen( 634 screen: Screen, 635 line: string, 636 x: number, 637 y: number, 638 screenWidth: number, 639 stylePool: StylePool, 640 charCache: Map<string, ClusteredChar[]>, 641): number { 642 let characters = charCache.get(line) 643 if (!characters) { 644 characters = reorderBidi( 645 styledCharsWithGraphemeClustering( 646 styledCharsFromTokens(tokenize(line)), 647 stylePool, 648 ), 649 ) 650 charCache.set(line, characters) 651 } 652 653 let offsetX = x 654 655 for (let charIdx = 0; charIdx < characters.length; charIdx++) { 656 const character = characters[charIdx]! 657 const codePoint = character.value.codePointAt(0) 658 659 // Handle C0 control characters (0x00-0x1F) that cause cursor movement 660 // mismatches. stringWidth treats these as width 0, but terminals may 661 // move the cursor differently. 662 if (codePoint !== undefined && codePoint <= 0x1f) { 663 // Tab (0x09): expand to spaces to reach next tab stop 664 if (codePoint === 0x09) { 665 const tabWidth = 8 666 const spacesToNextStop = tabWidth - (offsetX % tabWidth) 667 for (let i = 0; i < spacesToNextStop && offsetX < screenWidth; i++) { 668 setCellAt(screen, offsetX, y, { 669 char: ' ', 670 styleId: stylePool.none, 671 width: CellWidth.Narrow, 672 hyperlink: undefined, 673 }) 674 offsetX++ 675 } 676 } 677 // ESC (0x1B): skip incomplete escape sequences that ansi-tokenize 678 // didn't recognize. ansi-tokenize only parses SGR sequences (ESC[...m) 679 // and OSC 8 hyperlinks (ESC]8;;url BEL). Other sequences like cursor 680 // movement, screen clearing, or terminal title become individual char 681 // tokens that we need to skip here. 682 else if (codePoint === 0x1b) { 683 const nextChar = characters[charIdx + 1]?.value 684 const nextCode = nextChar?.codePointAt(0) 685 if ( 686 nextChar === '(' || 687 nextChar === ')' || 688 nextChar === '*' || 689 nextChar === '+' 690 ) { 691 // Charset selection: ESC ( X, ESC ) X, etc. 692 // Skip the intermediate char and the charset designator 693 charIdx += 2 694 } else if (nextChar === '[') { 695 // CSI sequence: ESC [ ... final-byte 696 // Final byte is in range 0x40-0x7E (@, A-Z, [\]^_`, a-z, {|}~) 697 // Examples: ESC[2J (clear), ESC[?25l (cursor hide), ESC[H (home) 698 charIdx++ // skip the [ 699 while (charIdx < characters.length - 1) { 700 charIdx++ 701 const c = characters[charIdx]?.value.codePointAt(0) 702 // Final byte terminates the sequence 703 if (c !== undefined && c >= 0x40 && c <= 0x7e) { 704 break 705 } 706 } 707 } else if ( 708 nextChar === ']' || 709 nextChar === 'P' || 710 nextChar === '_' || 711 nextChar === '^' || 712 nextChar === 'X' 713 ) { 714 // String-based sequences terminated by BEL (0x07) or ST (ESC \): 715 // - OSC: ESC ] ... (Operating System Command) 716 // - DCS: ESC P ... (Device Control String) 717 // - APC: ESC _ ... (Application Program Command) 718 // - PM: ESC ^ ... (Privacy Message) 719 // - SOS: ESC X ... (Start of String) 720 charIdx++ // skip the introducer char 721 while (charIdx < characters.length - 1) { 722 charIdx++ 723 const c = characters[charIdx]?.value 724 // BEL (0x07) terminates the sequence 725 if (c === '\x07') { 726 break 727 } 728 // ST (String Terminator) is ESC \ 729 // When we see ESC, check if next char is backslash 730 if (c === '\x1b') { 731 const nextC = characters[charIdx + 1]?.value 732 if (nextC === '\\') { 733 charIdx++ // skip the backslash too 734 break 735 } 736 } 737 } 738 } else if ( 739 nextCode !== undefined && 740 nextCode >= 0x30 && 741 nextCode <= 0x7e 742 ) { 743 // Single-character escape sequences: ESC followed by 0x30-0x7E 744 // (excluding the multi-char introducers already handled above) 745 // - Fp range (0x30-0x3F): ESC 7 (save cursor), ESC 8 (restore) 746 // - Fe range (0x40-0x5F): ESC D (index), ESC M (reverse index) 747 // - Fs range (0x60-0x7E): ESC c (reset) 748 charIdx++ // skip the command char 749 } 750 } 751 // Carriage return (0x0D): would move cursor to column 0, skip it 752 // Backspace (0x08): would move cursor left, skip it 753 // Bell (0x07), vertical tab (0x0B), form feed (0x0C): skip 754 // All other control chars (0x00-0x06, 0x0E-0x1F): skip 755 // Note: newline (0x0A) is already handled by line splitting 756 continue 757 } 758 759 // Zero-width characters (combining marks, ZWNJ, ZWS, etc.) 760 // don't occupy terminal cells — storing them as Narrow cells 761 // desyncs the virtual cursor from the real terminal cursor. 762 // Width was computed once during clustering (cached via charCache). 763 const charWidth = character.width 764 if (charWidth === 0) { 765 continue 766 } 767 768 const isWideCharacter = charWidth >= 2 769 770 // Wide char at last column can't fit — terminal would wrap it to 771 // the next line, desyncing our cursor model. Place a SpacerHead 772 // to mark the blank column, matching terminal behavior. 773 if (isWideCharacter && offsetX + 2 > screenWidth) { 774 setCellAt(screen, offsetX, y, { 775 char: ' ', 776 styleId: stylePool.none, 777 width: CellWidth.SpacerHead, 778 hyperlink: undefined, 779 }) 780 offsetX++ 781 continue 782 } 783 784 // styleId + hyperlink were precomputed during clustering (once per 785 // style run, cached via charCache). Hot loop is now just property 786 // reads — no intern, no extract, no filter per frame. 787 setCellAt(screen, offsetX, y, { 788 char: character.value, 789 styleId: character.styleId, 790 width: isWideCharacter ? CellWidth.Wide : CellWidth.Narrow, 791 hyperlink: character.hyperlink, 792 }) 793 offsetX += isWideCharacter ? 2 : 1 794 } 795 796 return offsetX 797}