/ ink / output.ts
output.ts
  1  import {
  2    type AnsiCode,
  3    type StyledChar,
  4    styledCharsFromTokens,
  5    tokenize,
  6  } from '@alcalzone/ansi-tokenize'
  7  import { logForDebugging } from '../utils/debug.js'
  8  import { getGraphemeSegmenter } from '../utils/intl.js'
  9  import sliceAnsi from '../utils/sliceAnsi.js'
 10  import { reorderBidi } from './bidi.js'
 11  import { type Rectangle, unionRect } from './layout/geometry.js'
 12  import {
 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'
 25  import { stringWidth } from './stringWidth.js'
 26  import { 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   */
 38  type 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  
 51  type 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  
 62  export type Operation =
 63    | WriteOperation
 64    | ClipOperation
 65    | UnclipOperation
 66    | BlitOperation
 67    | ClearOperation
 68    | NoSelectOperation
 69    | ShiftOperation
 70  
 71  type 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  
 86  type ClipOperation = {
 87    type: 'clip'
 88    clip: Clip
 89  }
 90  
 91  export 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   */
104  function 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  
114  function 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  
123  function 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  
132  type UnclipOperation = {
133    type: 'unclip'
134  }
135  
136  type BlitOperation = {
137    type: 'blit'
138    src: Screen
139    x: number
140    y: number
141    width: number
142    height: number
143  }
144  
145  type ShiftOperation = {
146    type: 'shift'
147    top: number
148    bottom: number
149    n: number
150  }
151  
152  type 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  
165  type NoSelectOperation = {
166    type: 'noSelect'
167    region: Rectangle
168  }
169  
170  export 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  
534  function 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   */
553  function 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  
586  function 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   */
633  function 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  }