/ ink / screen.ts
screen.ts
   1  import {
   2    type AnsiCode,
   3    ansiCodesToString,
   4    diffAnsiCodes,
   5  } from '@alcalzone/ansi-tokenize'
   6  import {
   7    type Point,
   8    type Rectangle,
   9    type Size,
  10    unionRect,
  11  } from './layout/geometry.js'
  12  import { BEL, ESC, SEP } from './termio/ansi.js'
  13  import * as warn from './warn.js'
  14  
  15  // --- Shared Pools (interning for memory efficiency) ---
  16  
  17  // Character string pool shared across all screens.
  18  // With a shared pool, interned char IDs are valid across screens,
  19  // so blitRegion can copy IDs directly (no re-interning) and
  20  // diffEach can compare IDs as integers (no string lookup).
  21  export class CharPool {
  22    private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty (spacer)
  23    private stringMap = new Map<string, number>([
  24      [' ', 0],
  25      ['', 1],
  26    ])
  27    private ascii: Int32Array = initCharAscii() // charCode → index, -1 = not interned
  28  
  29    intern(char: string): number {
  30      // ASCII fast-path: direct array lookup instead of Map.get
  31      if (char.length === 1) {
  32        const code = char.charCodeAt(0)
  33        if (code < 128) {
  34          const cached = this.ascii[code]!
  35          if (cached !== -1) return cached
  36          const index = this.strings.length
  37          this.strings.push(char)
  38          this.ascii[code] = index
  39          return index
  40        }
  41      }
  42      const existing = this.stringMap.get(char)
  43      if (existing !== undefined) return existing
  44      const index = this.strings.length
  45      this.strings.push(char)
  46      this.stringMap.set(char, index)
  47      return index
  48    }
  49  
  50    get(index: number): string {
  51      return this.strings[index] ?? ' '
  52    }
  53  }
  54  
  55  // Hyperlink string pool shared across all screens.
  56  // Index 0 = no hyperlink.
  57  export class HyperlinkPool {
  58    private strings: string[] = [''] // Index 0 = no hyperlink
  59    private stringMap = new Map<string, number>()
  60  
  61    intern(hyperlink: string | undefined): number {
  62      if (!hyperlink) return 0
  63      let id = this.stringMap.get(hyperlink)
  64      if (id === undefined) {
  65        id = this.strings.length
  66        this.strings.push(hyperlink)
  67        this.stringMap.set(hyperlink, id)
  68      }
  69      return id
  70    }
  71  
  72    get(id: number): string | undefined {
  73      return id === 0 ? undefined : this.strings[id]
  74    }
  75  }
  76  
  77  // SGR 7 (inverse) as an AnsiCode. endCode '\x1b[27m' flags VISIBLE_ON_SPACE
  78  // so bit 0 of the resulting styleId is set → renderer won't skip inverted
  79  // spaces as invisible.
  80  const INVERSE_CODE: AnsiCode = {
  81    type: 'ansi',
  82    code: '\x1b[7m',
  83    endCode: '\x1b[27m',
  84  }
  85  // Bold (SGR 1) — stacks cleanly, no reflow in monospace. endCode 22
  86  // also cancels dim (SGR 2); harmless here since we never add dim.
  87  const BOLD_CODE: AnsiCode = {
  88    type: 'ansi',
  89    code: '\x1b[1m',
  90    endCode: '\x1b[22m',
  91  }
  92  // Underline (SGR 4). Kept alongside yellow+bold — the underline is the
  93  // unambiguous visible-on-any-theme marker. Yellow-bg-via-inverse can
  94  // clash with existing bg colors (user-prompt style, tool chrome, syntax
  95  // bg). If you see underline but no yellow, the yellow is being lost in
  96  // the existing cell styling — the overlay IS finding the match.
  97  const UNDERLINE_CODE: AnsiCode = {
  98    type: 'ansi',
  99    code: '\x1b[4m',
 100    endCode: '\x1b[24m',
 101  }
 102  // fg→yellow (SGR 33). With inverse already in the stack, the terminal
 103  // swaps fg↔bg at render — so yellow-fg becomes yellow-BG. Original bg
 104  // becomes fg (readable on most themes: dark-bg → dark-text on yellow).
 105  // endCode 39 is 'default fg' — cancels any prior fg color cleanly.
 106  const YELLOW_FG_CODE: AnsiCode = {
 107    type: 'ansi',
 108    code: '\x1b[33m',
 109    endCode: '\x1b[39m',
 110  }
 111  
 112  export class StylePool {
 113    private ids = new Map<string, number>()
 114    private styles: AnsiCode[][] = []
 115    private transitionCache = new Map<number, string>()
 116    readonly none: number
 117  
 118    constructor() {
 119      this.none = this.intern([])
 120    }
 121  
 122    /**
 123     * Intern a style and return its ID. Bit 0 of the ID encodes whether the
 124     * style has a visible effect on space characters (background, inverse,
 125     * underline, etc.). Foreground-only styles get even IDs; styles visible
 126     * on spaces get odd IDs. This lets the renderer skip invisible spaces
 127     * with a single bitmask check on the packed word.
 128     */
 129    intern(styles: AnsiCode[]): number {
 130      const key = styles.length === 0 ? '' : styles.map(s => s.code).join('\0')
 131      let id = this.ids.get(key)
 132      if (id === undefined) {
 133        const rawId = this.styles.length
 134        this.styles.push(styles.length === 0 ? [] : styles)
 135        id =
 136          (rawId << 1) |
 137          (styles.length > 0 && hasVisibleSpaceEffect(styles) ? 1 : 0)
 138        this.ids.set(key, id)
 139      }
 140      return id
 141    }
 142  
 143    /** Recover styles from an encoded ID. Strips the bit-0 flag via >>> 1. */
 144    get(id: number): AnsiCode[] {
 145      return this.styles[id >>> 1] ?? []
 146    }
 147  
 148    /**
 149     * Returns the pre-serialized ANSI string to transition from one style to
 150     * another. Cached by (fromId, toId) — zero allocations after first call
 151     * for a given pair.
 152     */
 153    transition(fromId: number, toId: number): string {
 154      if (fromId === toId) return ''
 155      const key = fromId * 0x100000 + toId
 156      let str = this.transitionCache.get(key)
 157      if (str === undefined) {
 158        str = ansiCodesToString(diffAnsiCodes(this.get(fromId), this.get(toId)))
 159        this.transitionCache.set(key, str)
 160      }
 161      return str
 162    }
 163  
 164    /**
 165     * Intern a style that is `base + inverse`. Cached by base ID so
 166     * repeated calls for the same underlying style don't re-scan the
 167     * AnsiCode[] array. Used by the selection overlay.
 168     */
 169    private inverseCache = new Map<number, number>()
 170    withInverse(baseId: number): number {
 171      let id = this.inverseCache.get(baseId)
 172      if (id === undefined) {
 173        const baseCodes = this.get(baseId)
 174        // If already inverted, use as-is (avoids SGR 7 stacking)
 175        const hasInverse = baseCodes.some(c => c.endCode === '\x1b[27m')
 176        id = hasInverse ? baseId : this.intern([...baseCodes, INVERSE_CODE])
 177        this.inverseCache.set(baseId, id)
 178      }
 179      return id
 180    }
 181  
 182    /** Inverse + bold + yellow-bg-via-fg-swap for the CURRENT search match.
 183     *  OTHER matches are plain inverse — bg inherits from the theme. Current
 184     *  gets a distinct yellow bg (via fg-then-inverse swap) plus bold weight
 185     *  so it stands out in a sea of inverse. Underline was too subtle. Zero
 186     *  reflow risk: all pure SGR overlays, per-cell, post-layout. The yellow
 187     *  overrides any existing fg (syntax highlighting) on those cells — fine,
 188     *  the "you are here" signal IS the point, syntax color can yield. */
 189    private currentMatchCache = new Map<number, number>()
 190    withCurrentMatch(baseId: number): number {
 191      let id = this.currentMatchCache.get(baseId)
 192      if (id === undefined) {
 193        const baseCodes = this.get(baseId)
 194        // Filter BOTH fg + bg so yellow-via-inverse is unambiguous.
 195        // User-prompt cells have an explicit bg (grey box); with that bg
 196        // still set, inverse swaps yellow-fg↔grey-bg → grey-on-yellow on
 197        // SOME terminals, yellow-on-grey on others (inverse semantics vary
 198        // when both colors are explicit). Filtering both gives clean
 199        // yellow-bg + terminal-default-fg everywhere. Bold/dim/italic
 200        // coexist — keep those.
 201        const codes = baseCodes.filter(
 202          c => c.endCode !== '\x1b[39m' && c.endCode !== '\x1b[49m',
 203        )
 204        // fg-yellow FIRST so inverse swaps it to bg. Bold after inverse is
 205        // fine — SGR 1 is fg-attribute-only, order-independent vs 7.
 206        codes.push(YELLOW_FG_CODE)
 207        if (!baseCodes.some(c => c.endCode === '\x1b[27m'))
 208          codes.push(INVERSE_CODE)
 209        if (!baseCodes.some(c => c.endCode === '\x1b[22m')) codes.push(BOLD_CODE)
 210        // Underline as the unambiguous marker — yellow-bg can clash with
 211        // existing bg styling (user-prompt bg, syntax bg). If you see
 212        // underline but no yellow on a match, the overlay IS finding it;
 213        // the yellow is just losing a styling fight.
 214        if (!baseCodes.some(c => c.endCode === '\x1b[24m'))
 215          codes.push(UNDERLINE_CODE)
 216        id = this.intern(codes)
 217        this.currentMatchCache.set(baseId, id)
 218      }
 219      return id
 220    }
 221  
 222    /**
 223     * Selection overlay: REPLACE the cell's background with a solid color
 224     * while preserving its foreground (color, bold, italic, dim, underline).
 225     * Matches native terminal selection — a dedicated bg color, not SGR-7
 226     * inverse. Inverse swaps fg/bg per-cell, which fragments visually over
 227     * syntax-highlighted text (every fg color becomes a different bg stripe).
 228     *
 229     * Strips any existing bg (endCode 49m — REPLACES, so diff-added green
 230     * etc. don't bleed through) and any existing inverse (endCode 27m —
 231     * inverse on top of a solid bg would re-swap and look wrong).
 232     *
 233     * bg is set via setSelectionBg(); null → fallback to withInverse() so the
 234     * overlay still works before theme wiring sets a color (tests, first frame).
 235     * Cache is keyed by baseId only — setSelectionBg() clears it on change.
 236     */
 237    private selectionBgCode: AnsiCode | null = null
 238    private selectionBgCache = new Map<number, number>()
 239    setSelectionBg(bg: AnsiCode | null): void {
 240      if (this.selectionBgCode?.code === bg?.code) return
 241      this.selectionBgCode = bg
 242      this.selectionBgCache.clear()
 243    }
 244    withSelectionBg(baseId: number): number {
 245      const bg = this.selectionBgCode
 246      if (bg === null) return this.withInverse(baseId)
 247      let id = this.selectionBgCache.get(baseId)
 248      if (id === undefined) {
 249        // Keep everything except bg (49m) and inverse (27m). Fg, bold, dim,
 250        // italic, underline, strikethrough all preserved.
 251        const kept = this.get(baseId).filter(
 252          c => c.endCode !== '\x1b[49m' && c.endCode !== '\x1b[27m',
 253        )
 254        kept.push(bg)
 255        id = this.intern(kept)
 256        this.selectionBgCache.set(baseId, id)
 257      }
 258      return id
 259    }
 260  }
 261  
 262  // endCodes that produce visible effects on space characters
 263  const VISIBLE_ON_SPACE = new Set([
 264    '\x1b[49m', // background color
 265    '\x1b[27m', // inverse
 266    '\x1b[24m', // underline
 267    '\x1b[29m', // strikethrough
 268    '\x1b[55m', // overline
 269  ])
 270  
 271  function hasVisibleSpaceEffect(styles: AnsiCode[]): boolean {
 272    for (const style of styles) {
 273      if (VISIBLE_ON_SPACE.has(style.endCode)) return true
 274    }
 275    return false
 276  }
 277  
 278  /**
 279   * Cell width classification for handling double-wide characters (CJK, emoji,
 280   * etc.)
 281   *
 282   * We use explicit spacer cells rather than inferring width at render time. This
 283   * makes the data structure self-describing and simplifies cursor positioning
 284   * logic.
 285   *
 286   * @see https://mitchellh.com/writing/grapheme-clusters-in-terminals
 287   */
 288  // const enum is inlined at compile time - no runtime object, no property access
 289  export const enum CellWidth {
 290    // Not a wide character, cell width 1
 291    Narrow = 0,
 292    // Wide character, cell width 2. This cell contains the actual character.
 293    Wide = 1,
 294    // Spacer occupying the second visual column of a wide character. Do not render.
 295    SpacerTail = 2,
 296    // Spacer at the end of a soft-wrapped line indicating that a wide character
 297    // continues on the next line. Used for preserving wide character semantics
 298    // across line breaks during soft wrapping.
 299    SpacerHead = 3,
 300  }
 301  
 302  export type Hyperlink = string | undefined
 303  
 304  /**
 305   * Cell is a view type returned by cellAt(). Cells are stored as packed typed
 306   * arrays internally to avoid GC pressure from allocating objects per cell.
 307   */
 308  export type Cell = {
 309    char: string
 310    styleId: number
 311    width: CellWidth
 312    hyperlink: Hyperlink
 313  }
 314  
 315  // Constants for empty/spacer cells to enable fast comparisons
 316  // These are indices into the charStrings table, not codepoints
 317  const EMPTY_CHAR_INDEX = 0 // ' ' (space)
 318  const SPACER_CHAR_INDEX = 1 // '' (empty string for spacer cells)
 319  // Unwritten cells are [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0].
 320  // Since StylePool.none is always 0 (first intern), unwritten cells are
 321  // indistinguishable from explicitly-cleared cells in the packed array.
 322  // This is intentional: diffEach can compare raw ints with zero normalization.
 323  // isEmptyCellByIndex checks if both words are 0 to identify "never visually written" cells.
 324  
 325  function initCharAscii(): Int32Array {
 326    const table = new Int32Array(128)
 327    table.fill(-1)
 328    table[32] = EMPTY_CHAR_INDEX // ' ' (space)
 329    return table
 330  }
 331  
 332  // --- Packed cell layout ---
 333  // Each cell is 2 consecutive Int32 elements in the cells array:
 334  //   word0 (cells[ci]):     charId (full 32 bits)
 335  //   word1 (cells[ci + 1]): styleId[31:17] | hyperlinkId[16:2] | width[1:0]
 336  const STYLE_SHIFT = 17
 337  const HYPERLINK_SHIFT = 2
 338  const HYPERLINK_MASK = 0x7fff // 15 bits
 339  const WIDTH_MASK = 3 // 2 bits
 340  
 341  // Pack styleId, hyperlinkId, and width into a single Int32
 342  function packWord1(
 343    styleId: number,
 344    hyperlinkId: number,
 345    width: number,
 346  ): number {
 347    return (styleId << STYLE_SHIFT) | (hyperlinkId << HYPERLINK_SHIFT) | width
 348  }
 349  
 350  // Unwritten cell as BigInt64 — both words are 0, so the 64-bit value is 0n.
 351  // Used by BigInt64Array.fill() for bulk clears (resetScreen, clearRegion).
 352  // Not used for comparison — BigInt element reads cause heap allocation.
 353  const EMPTY_CELL_VALUE = 0n
 354  
 355  /**
 356   * Screen uses a packed Int32Array instead of Cell objects to eliminate GC
 357   * pressure. For a 200x120 screen, this avoids allocating 24,000 objects.
 358   *
 359   * Cell data is stored as 2 Int32s per cell in a single contiguous array:
 360   *   word0: charId (full 32 bits — index into CharPool)
 361   *   word1: styleId[31:17] | hyperlinkId[16:2] | width[1:0]
 362   *
 363   * This layout halves memory accesses in diffEach (2 int loads vs 4) and
 364   * enables future SIMD comparison via Bun.indexOfFirstDifference.
 365   */
 366  export type Screen = Size & {
 367    // Packed cell data — 2 Int32s per cell: [charId, packed(styleId|hyperlinkId|width)]
 368    // cells and cells64 are views over the same ArrayBuffer.
 369    cells: Int32Array
 370    cells64: BigInt64Array // 1 BigInt64 per cell — used for bulk fill in resetScreen/clearRegion
 371  
 372    // Shared pools — IDs are valid across all screens using the same pools
 373    charPool: CharPool
 374    hyperlinkPool: HyperlinkPool
 375  
 376    // Empty style ID for comparisons
 377    emptyStyleId: number
 378  
 379    /**
 380     * Bounding box of cells that were written to (not blitted) during rendering.
 381     * Used by diff() to limit iteration to only the region that could have changed.
 382     */
 383    damage: Rectangle | undefined
 384  
 385    /**
 386     * Per-cell noSelect bitmap — 1 byte per cell, 1 = exclude from text
 387     * selection (copy + highlight). Used by <NoSelect> to mark gutters
 388     * (line numbers, diff sigils) so click-drag over a diff yields clean
 389     * copyable code. Fully reset each frame in resetScreen; blitRegion
 390     * copies it alongside cells so the blit optimization preserves marks.
 391     */
 392    noSelect: Uint8Array
 393  
 394    /**
 395     * Per-ROW soft-wrap continuation marker. softWrap[r]=N>0 means row r
 396     * is a word-wrap continuation of row r-1 (the `\n` before it was
 397     * inserted by wrapAnsi, not in the source), and row r-1's written
 398     * content ends at absolute column N (exclusive — cells [0..N) are the
 399     * fragment, past N is unwritten padding). 0 means row r is NOT a
 400     * continuation (hard newline or first row). Selection copy checks
 401     * softWrap[r]>0 to join row r onto row r-1 without a newline, and
 402     * reads softWrap[r+1] to know row r's content end when row r+1
 403     * continues from it. The content-end column is needed because an
 404     * unwritten cell and a written-unstyled-space are indistinguishable in
 405     * the packed typed array (both all-zero) — without it we'd either drop
 406     * the word-separator space (trim) or include trailing padding (no
 407     * trim). This encoding (continuation-on-self, prev-content-end-here)
 408     * is chosen so shiftRows preserves the is-continuation semantics: when
 409     * row r scrolls off the top and row r+1 shifts to row r, sw[r] gets
 410     * old sw[r+1] — which correctly says the new row r is a continuation
 411     * of what's now in scrolledOffAbove. Reset each frame; copied by
 412     * blitRegion/shiftRows.
 413     */
 414    softWrap: Int32Array
 415  }
 416  
 417  function isEmptyCellByIndex(screen: Screen, index: number): boolean {
 418    // An empty/unwritten cell has both words === 0:
 419    // word0 = EMPTY_CHAR_INDEX (0), word1 = packWord1(emptyStyleId=0, 0, 0) = 0.
 420    const ci = index << 1
 421    return screen.cells[ci] === 0 && screen.cells[ci | 1] === 0
 422  }
 423  
 424  export function isEmptyCellAt(screen: Screen, x: number, y: number): boolean {
 425    if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return true
 426    return isEmptyCellByIndex(screen, y * screen.width + x)
 427  }
 428  
 429  /**
 430   * Check if a Cell (view object) represents an empty cell.
 431   */
 432  export function isCellEmpty(screen: Screen, cell: Cell): boolean {
 433    // Check if cell looks like an empty cell (space, empty style, narrow, no link).
 434    // Note: After cellAt mapping, unwritten cells have emptyStyleId, so this
 435    // returns true for both unwritten AND cleared cells. Use isEmptyCellAt
 436    // for the internal distinction.
 437    return (
 438      cell.char === ' ' &&
 439      cell.styleId === screen.emptyStyleId &&
 440      cell.width === CellWidth.Narrow &&
 441      !cell.hyperlink
 442    )
 443  }
 444  // Intern a hyperlink string and return its ID (0 = no hyperlink)
 445  function internHyperlink(screen: Screen, hyperlink: Hyperlink): number {
 446    return screen.hyperlinkPool.intern(hyperlink)
 447  }
 448  
 449  // ---
 450  
 451  export function createScreen(
 452    width: number,
 453    height: number,
 454    styles: StylePool,
 455    charPool: CharPool,
 456    hyperlinkPool: HyperlinkPool,
 457  ): Screen {
 458    // Warn if dimensions are not valid integers (likely bad yoga layout output)
 459    warn.ifNotInteger(width, 'createScreen width')
 460    warn.ifNotInteger(height, 'createScreen height')
 461  
 462    // Ensure width and height are valid integers to prevent crashes
 463    if (!Number.isInteger(width) || width < 0) {
 464      width = Math.max(0, Math.floor(width) || 0)
 465    }
 466    if (!Number.isInteger(height) || height < 0) {
 467      height = Math.max(0, Math.floor(height) || 0)
 468    }
 469  
 470    const size = width * height
 471  
 472    // Allocate one buffer, two views: Int32Array for per-word access,
 473    // BigInt64Array for bulk fill in resetScreen/clearRegion.
 474    // ArrayBuffer is zero-filled, which is exactly the empty cell value:
 475    // [EMPTY_CHAR_INDEX=0, packWord1(emptyStyleId=0,0,0)=0].
 476    const buf = new ArrayBuffer(size << 3) // 8 bytes per cell
 477    const cells = new Int32Array(buf)
 478    const cells64 = new BigInt64Array(buf)
 479  
 480    return {
 481      width,
 482      height,
 483      cells,
 484      cells64,
 485      charPool,
 486      hyperlinkPool,
 487      emptyStyleId: styles.none,
 488      damage: undefined,
 489      noSelect: new Uint8Array(size),
 490      softWrap: new Int32Array(height),
 491    }
 492  }
 493  
 494  /**
 495   * Reset an existing screen for reuse, avoiding allocation of new typed arrays.
 496   * Resizes if needed and clears all cells to empty/unwritten state.
 497   *
 498   * For double-buffering, this allows swapping between front and back buffers
 499   * without allocating new Screen objects each frame.
 500   */
 501  export function resetScreen(
 502    screen: Screen,
 503    width: number,
 504    height: number,
 505  ): void {
 506    // Warn if dimensions are not valid integers
 507    warn.ifNotInteger(width, 'resetScreen width')
 508    warn.ifNotInteger(height, 'resetScreen height')
 509  
 510    // Ensure width and height are valid integers to prevent crashes
 511    if (!Number.isInteger(width) || width < 0) {
 512      width = Math.max(0, Math.floor(width) || 0)
 513    }
 514    if (!Number.isInteger(height) || height < 0) {
 515      height = Math.max(0, Math.floor(height) || 0)
 516    }
 517  
 518    const size = width * height
 519  
 520    // Resize if needed (only grow, to avoid reallocations)
 521    if (screen.cells64.length < size) {
 522      const buf = new ArrayBuffer(size << 3)
 523      screen.cells = new Int32Array(buf)
 524      screen.cells64 = new BigInt64Array(buf)
 525      screen.noSelect = new Uint8Array(size)
 526    }
 527    if (screen.softWrap.length < height) {
 528      screen.softWrap = new Int32Array(height)
 529    }
 530  
 531    // Reset all cells — single fill call, no loop
 532    screen.cells64.fill(EMPTY_CELL_VALUE, 0, size)
 533    screen.noSelect.fill(0, 0, size)
 534    screen.softWrap.fill(0, 0, height)
 535  
 536    // Update dimensions
 537    screen.width = width
 538    screen.height = height
 539  
 540    // Shared pools accumulate — no clearing needed. Unique char/hyperlink sets are bounded.
 541  
 542    // Clear damage tracking
 543    screen.damage = undefined
 544  }
 545  
 546  /**
 547   * Re-intern a screen's char and hyperlink IDs into new pools.
 548   * Used for generational pool reset — after migrating, the screen's
 549   * typed arrays contain valid IDs for the new pools, and the old pools
 550   * can be GC'd.
 551   *
 552   * O(width * height) but only called occasionally (e.g., between conversation turns).
 553   */
 554  export function migrateScreenPools(
 555    screen: Screen,
 556    charPool: CharPool,
 557    hyperlinkPool: HyperlinkPool,
 558  ): void {
 559    const oldCharPool = screen.charPool
 560    const oldHyperlinkPool = screen.hyperlinkPool
 561    if (oldCharPool === charPool && oldHyperlinkPool === hyperlinkPool) return
 562  
 563    const size = screen.width * screen.height
 564    const cells = screen.cells
 565  
 566    // Re-intern chars and hyperlinks in a single pass, stride by 2
 567    for (let ci = 0; ci < size << 1; ci += 2) {
 568      // Re-intern charId (word0)
 569      const oldCharId = cells[ci]!
 570      cells[ci] = charPool.intern(oldCharPool.get(oldCharId))
 571  
 572      // Re-intern hyperlinkId (packed in word1)
 573      const word1 = cells[ci + 1]!
 574      const oldHyperlinkId = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
 575      if (oldHyperlinkId !== 0) {
 576        const oldStr = oldHyperlinkPool.get(oldHyperlinkId)
 577        const newHyperlinkId = hyperlinkPool.intern(oldStr)
 578        // Repack word1 with new hyperlinkId, preserving styleId and width
 579        const styleId = word1 >>> STYLE_SHIFT
 580        const width = word1 & WIDTH_MASK
 581        cells[ci + 1] = packWord1(styleId, newHyperlinkId, width)
 582      }
 583    }
 584  
 585    screen.charPool = charPool
 586    screen.hyperlinkPool = hyperlinkPool
 587  }
 588  
 589  /**
 590   * Get a Cell view at the given position. Returns a new object each call -
 591   * this is intentional as cells are stored packed, not as objects.
 592   */
 593  export function cellAt(screen: Screen, x: number, y: number): Cell | undefined {
 594    if (x < 0 || y < 0 || x >= screen.width || y >= screen.height)
 595      return undefined
 596    return cellAtIndex(screen, y * screen.width + x)
 597  }
 598  /**
 599   * Get a Cell view by pre-computed array index. Skips bounds checks and
 600   * index computation — caller must ensure index is valid.
 601   */
 602  export function cellAtIndex(screen: Screen, index: number): Cell {
 603    const ci = index << 1
 604    const word1 = screen.cells[ci + 1]!
 605    const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
 606    return {
 607      // Unwritten cells have charIndex=0 (EMPTY_CHAR_INDEX); charPool.get(0) returns ' '
 608      char: screen.charPool.get(screen.cells[ci]!),
 609      styleId: word1 >>> STYLE_SHIFT,
 610      width: word1 & WIDTH_MASK,
 611      hyperlink: hid === 0 ? undefined : screen.hyperlinkPool.get(hid),
 612    }
 613  }
 614  
 615  /**
 616   * Get a Cell at the given index, or undefined if it has no visible content.
 617   * Returns undefined for spacer cells (charId 1), empty unstyled spaces, and
 618   * fg-only styled spaces that match lastRenderedStyleId (cursor-forward
 619   * produces an identical visual result, avoiding a Cell allocation).
 620   *
 621   * @param lastRenderedStyleId - styleId of the last rendered cell on this
 622   *   line, or -1 if none yet.
 623   */
 624  export function visibleCellAtIndex(
 625    cells: Int32Array,
 626    charPool: CharPool,
 627    hyperlinkPool: HyperlinkPool,
 628    index: number,
 629    lastRenderedStyleId: number,
 630  ): Cell | undefined {
 631    const ci = index << 1
 632    const charId = cells[ci]!
 633    if (charId === 1) return undefined // spacer
 634    const word1 = cells[ci + 1]!
 635    // For spaces: 0x3fffc masks bits 2-17 (hyperlinkId + styleId visibility
 636    // bit). If zero, the space has no hyperlink and at most a fg-only style.
 637    // Then word1 >>> STYLE_SHIFT is the foreground style — skip if it's zero
 638    // (truly invisible) or matches the last rendered style on this line.
 639    if (charId === 0 && (word1 & 0x3fffc) === 0) {
 640      const fgStyle = word1 >>> STYLE_SHIFT
 641      if (fgStyle === 0 || fgStyle === lastRenderedStyleId) return undefined
 642    }
 643    const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
 644    return {
 645      char: charPool.get(charId),
 646      styleId: word1 >>> STYLE_SHIFT,
 647      width: word1 & WIDTH_MASK,
 648      hyperlink: hid === 0 ? undefined : hyperlinkPool.get(hid),
 649    }
 650  }
 651  
 652  /**
 653   * Write cell data into an existing Cell object to avoid allocation.
 654   * Caller must ensure index is valid.
 655   */
 656  function cellAtCI(screen: Screen, ci: number, out: Cell): void {
 657    const w1 = ci | 1
 658    const word1 = screen.cells[w1]!
 659    out.char = screen.charPool.get(screen.cells[ci]!)
 660    out.styleId = word1 >>> STYLE_SHIFT
 661    out.width = word1 & WIDTH_MASK
 662    const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
 663    out.hyperlink = hid === 0 ? undefined : screen.hyperlinkPool.get(hid)
 664  }
 665  
 666  export function charInCellAt(
 667    screen: Screen,
 668    x: number,
 669    y: number,
 670  ): string | undefined {
 671    if (x < 0 || y < 0 || x >= screen.width || y >= screen.height)
 672      return undefined
 673    const ci = (y * screen.width + x) << 1
 674    return screen.charPool.get(screen.cells[ci]!)
 675  }
 676  /**
 677   * Set a cell, optionally creating a spacer for wide characters.
 678   *
 679   * Wide characters (CJK, emoji) occupy 2 cells in the buffer:
 680   * 1. First cell: Contains the actual character with width = Wide
 681   * 2. Second cell: Spacer cell with width = SpacerTail (empty, not rendered)
 682   *
 683   * If the cell has width = Wide, this function automatically creates the
 684   * corresponding SpacerTail in the next column. This two-cell model keeps
 685   * the buffer aligned to visual columns, making cursor positioning
 686   * straightforward.
 687   *
 688   * TODO: When soft-wrapping is implemented, SpacerHead cells will be explicitly
 689   * placed by the wrapping logic at line-end positions where wide characters
 690   * wrap to the next line. This function doesn't need to handle SpacerHead
 691   * automatically - it will be set directly by the wrapping code.
 692   */
 693  export function setCellAt(
 694    screen: Screen,
 695    x: number,
 696    y: number,
 697    cell: Cell,
 698  ): void {
 699    if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return
 700    const ci = (y * screen.width + x) << 1
 701    const cells = screen.cells
 702  
 703    // When a Wide char is overwritten by a Narrow char, its SpacerTail remains
 704    // as a ghost cell that the diff/render pipeline skips, causing stale content
 705    // to leak through from previous frames.
 706    const prevWidth = cells[ci + 1]! & WIDTH_MASK
 707    if (prevWidth === CellWidth.Wide && cell.width !== CellWidth.Wide) {
 708      const spacerX = x + 1
 709      if (spacerX < screen.width) {
 710        const spacerCI = ci + 2
 711        if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
 712          cells[spacerCI] = EMPTY_CHAR_INDEX
 713          cells[spacerCI + 1] = packWord1(
 714            screen.emptyStyleId,
 715            0,
 716            CellWidth.Narrow,
 717          )
 718        }
 719      }
 720    }
 721    // Track cleared Wide position for damage expansion below
 722    let clearedWideX = -1
 723    if (
 724      prevWidth === CellWidth.SpacerTail &&
 725      cell.width !== CellWidth.SpacerTail
 726    ) {
 727      // Overwriting a SpacerTail: clear the orphaned Wide char at (x-1).
 728      // Keeping the wide character with Narrow width would cause the terminal
 729      // to still render it with width 2, desyncing the cursor model.
 730      if (x > 0) {
 731        const wideCI = ci - 2
 732        if ((cells[wideCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
 733          cells[wideCI] = EMPTY_CHAR_INDEX
 734          cells[wideCI + 1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
 735          clearedWideX = x - 1
 736        }
 737      }
 738    }
 739  
 740    // Pack cell data into cells array
 741    cells[ci] = internCharString(screen, cell.char)
 742    cells[ci + 1] = packWord1(
 743      cell.styleId,
 744      internHyperlink(screen, cell.hyperlink),
 745      cell.width,
 746    )
 747  
 748    // Track damage - expand bounds in place instead of allocating new objects
 749    // Include the main cell position and any cleared orphan cells
 750    const minX = clearedWideX >= 0 ? Math.min(x, clearedWideX) : x
 751    const damage = screen.damage
 752    if (damage) {
 753      const right = damage.x + damage.width
 754      const bottom = damage.y + damage.height
 755      if (minX < damage.x) {
 756        damage.width += damage.x - minX
 757        damage.x = minX
 758      } else if (x >= right) {
 759        damage.width = x - damage.x + 1
 760      }
 761      if (y < damage.y) {
 762        damage.height += damage.y - y
 763        damage.y = y
 764      } else if (y >= bottom) {
 765        damage.height = y - damage.y + 1
 766      }
 767    } else {
 768      screen.damage = { x: minX, y, width: x - minX + 1, height: 1 }
 769    }
 770  
 771    // If this is a wide character, create a spacer in the next column
 772    if (cell.width === CellWidth.Wide) {
 773      const spacerX = x + 1
 774      if (spacerX < screen.width) {
 775        const spacerCI = ci + 2
 776        // If the cell we're overwriting with our SpacerTail is itself Wide,
 777        // clear ITS SpacerTail at x+2 too. Otherwise the orphan SpacerTail
 778        // makes diffEach report it as `added` and log-update's skip-spacer
 779        // rule prevents clearing whatever prev content was at that column.
 780        // Scenario: [a, 💻, spacer] → [本, spacer, ORPHAN spacer] when
 781        // yoga squishes a💻 to height 0 and 本 renders at the same y.
 782        if ((cells[spacerCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
 783          const orphanCI = spacerCI + 2
 784          if (
 785            spacerX + 1 < screen.width &&
 786            (cells[orphanCI + 1]! & WIDTH_MASK) === CellWidth.SpacerTail
 787          ) {
 788            cells[orphanCI] = EMPTY_CHAR_INDEX
 789            cells[orphanCI + 1] = packWord1(
 790              screen.emptyStyleId,
 791              0,
 792              CellWidth.Narrow,
 793            )
 794          }
 795        }
 796        cells[spacerCI] = SPACER_CHAR_INDEX
 797        cells[spacerCI + 1] = packWord1(
 798          screen.emptyStyleId,
 799          0,
 800          CellWidth.SpacerTail,
 801        )
 802  
 803        // Expand damage to include SpacerTail so diff() scans it
 804        const d = screen.damage
 805        if (d && spacerX >= d.x + d.width) {
 806          d.width = spacerX - d.x + 1
 807        }
 808      }
 809    }
 810  }
 811  
 812  /**
 813   * Replace the styleId of a cell in-place without disturbing char, width,
 814   * or hyperlink. Preserves empty cells as-is (char stays ' '). Tracks damage
 815   * for the cell so diffEach picks up the change.
 816   */
 817  export function setCellStyleId(
 818    screen: Screen,
 819    x: number,
 820    y: number,
 821    styleId: number,
 822  ): void {
 823    if (x < 0 || y < 0 || x >= screen.width || y >= screen.height) return
 824    const ci = (y * screen.width + x) << 1
 825    const cells = screen.cells
 826    const word1 = cells[ci + 1]!
 827    const width = word1 & WIDTH_MASK
 828    // Skip spacer cells — inverse on the head cell visually covers both columns
 829    if (width === CellWidth.SpacerTail || width === CellWidth.SpacerHead) return
 830    const hid = (word1 >>> HYPERLINK_SHIFT) & HYPERLINK_MASK
 831    cells[ci + 1] = packWord1(styleId, hid, width)
 832    // Expand damage so diffEach scans this cell
 833    const d = screen.damage
 834    if (d) {
 835      screen.damage = unionRect(d, { x, y, width: 1, height: 1 })
 836    } else {
 837      screen.damage = { x, y, width: 1, height: 1 }
 838    }
 839  }
 840  
 841  /**
 842   * Intern a character string via the screen's shared CharPool.
 843   * Supports grapheme clusters like family emoji.
 844   */
 845  function internCharString(screen: Screen, char: string): number {
 846    return screen.charPool.intern(char)
 847  }
 848  
 849  /**
 850   * Bulk-copy a rectangular region from src to dst using TypedArray.set().
 851   * Single cells.set() call per row (or one call for contiguous blocks).
 852   * Damage is computed once for the whole region.
 853   *
 854   * Clamps negative regionX/regionY to 0 (matching clearRegion) — absolute-
 855   * positioned overlays in tiny terminals can compute negative screen coords.
 856   * maxX/maxY should already be clamped to both screen bounds by the caller.
 857   */
 858  export function blitRegion(
 859    dst: Screen,
 860    src: Screen,
 861    regionX: number,
 862    regionY: number,
 863    maxX: number,
 864    maxY: number,
 865  ): void {
 866    regionX = Math.max(0, regionX)
 867    regionY = Math.max(0, regionY)
 868    if (regionX >= maxX || regionY >= maxY) return
 869  
 870    const rowLen = maxX - regionX
 871    const srcStride = src.width << 1
 872    const dstStride = dst.width << 1
 873    const rowBytes = rowLen << 1 // 2 Int32s per cell
 874    const srcCells = src.cells
 875    const dstCells = dst.cells
 876    const srcNoSel = src.noSelect
 877    const dstNoSel = dst.noSelect
 878  
 879    // softWrap is per-row — copy the row range regardless of stride/width.
 880    // Partial-width blits still carry the row's wrap provenance since the
 881    // blitted content (a cached ink-text node) is what set the bit.
 882    dst.softWrap.set(src.softWrap.subarray(regionY, maxY), regionY)
 883  
 884    // Fast path: contiguous memory when copying full-width rows at same stride
 885    if (regionX === 0 && maxX === src.width && src.width === dst.width) {
 886      const srcStart = regionY * srcStride
 887      const totalBytes = (maxY - regionY) * srcStride
 888      dstCells.set(
 889        srcCells.subarray(srcStart, srcStart + totalBytes),
 890        srcStart, // srcStart === dstStart when strides match and regionX === 0
 891      )
 892      // noSelect is 1 byte/cell vs cells' 8 — same region, different scale
 893      const nsStart = regionY * src.width
 894      const nsLen = (maxY - regionY) * src.width
 895      dstNoSel.set(srcNoSel.subarray(nsStart, nsStart + nsLen), nsStart)
 896    } else {
 897      // Per-row copy for partial-width or mismatched-stride regions
 898      let srcRowCI = regionY * srcStride + (regionX << 1)
 899      let dstRowCI = regionY * dstStride + (regionX << 1)
 900      let srcRowNS = regionY * src.width + regionX
 901      let dstRowNS = regionY * dst.width + regionX
 902      for (let y = regionY; y < maxY; y++) {
 903        dstCells.set(srcCells.subarray(srcRowCI, srcRowCI + rowBytes), dstRowCI)
 904        dstNoSel.set(srcNoSel.subarray(srcRowNS, srcRowNS + rowLen), dstRowNS)
 905        srcRowCI += srcStride
 906        dstRowCI += dstStride
 907        srcRowNS += src.width
 908        dstRowNS += dst.width
 909      }
 910    }
 911  
 912    // Compute damage once for the whole region
 913    const regionRect = {
 914      x: regionX,
 915      y: regionY,
 916      width: rowLen,
 917      height: maxY - regionY,
 918    }
 919    if (dst.damage) {
 920      dst.damage = unionRect(dst.damage, regionRect)
 921    } else {
 922      dst.damage = regionRect
 923    }
 924  
 925    // Handle wide char at right edge: spacer might be outside blit region
 926    // but still within dst bounds. Per-row check only at the boundary column.
 927    if (maxX < dst.width) {
 928      let srcLastCI = (regionY * src.width + (maxX - 1)) << 1
 929      let dstSpacerCI = (regionY * dst.width + maxX) << 1
 930      let wroteSpacerOutsideRegion = false
 931      for (let y = regionY; y < maxY; y++) {
 932        if ((srcCells[srcLastCI + 1]! & WIDTH_MASK) === CellWidth.Wide) {
 933          dstCells[dstSpacerCI] = SPACER_CHAR_INDEX
 934          dstCells[dstSpacerCI + 1] = packWord1(
 935            dst.emptyStyleId,
 936            0,
 937            CellWidth.SpacerTail,
 938          )
 939          wroteSpacerOutsideRegion = true
 940        }
 941        srcLastCI += srcStride
 942        dstSpacerCI += dstStride
 943      }
 944      // Expand damage to include SpacerTail column if we wrote any
 945      if (wroteSpacerOutsideRegion && dst.damage) {
 946        const rightEdge = dst.damage.x + dst.damage.width
 947        if (rightEdge === maxX) {
 948          dst.damage = { ...dst.damage, width: dst.damage.width + 1 }
 949        }
 950      }
 951    }
 952  }
 953  
 954  /**
 955   * Bulk-clear a rectangular region of the screen.
 956   * Uses BigInt64Array.fill() for fast row clears.
 957   * Handles wide character boundary cleanup at region edges.
 958   */
 959  export function clearRegion(
 960    screen: Screen,
 961    regionX: number,
 962    regionY: number,
 963    regionWidth: number,
 964    regionHeight: number,
 965  ): void {
 966    const startX = Math.max(0, regionX)
 967    const startY = Math.max(0, regionY)
 968    const maxX = Math.min(regionX + regionWidth, screen.width)
 969    const maxY = Math.min(regionY + regionHeight, screen.height)
 970    if (startX >= maxX || startY >= maxY) return
 971  
 972    const cells = screen.cells
 973    const cells64 = screen.cells64
 974    const screenWidth = screen.width
 975    const rowBase = startY * screenWidth
 976    let damageMinX = startX
 977    let damageMaxX = maxX
 978  
 979    // EMPTY_CELL_VALUE (0n) matches the zero-initialized state:
 980    // word0=EMPTY_CHAR_INDEX(0), word1=packWord1(0,0,0)=0
 981    if (startX === 0 && maxX === screenWidth) {
 982      // Full-width: single fill, no boundary checks needed
 983      cells64.fill(
 984        EMPTY_CELL_VALUE,
 985        rowBase,
 986        rowBase + (maxY - startY) * screenWidth,
 987      )
 988    } else {
 989      // Partial-width: single loop handles boundary cleanup and fill per row.
 990      const stride = screenWidth << 1 // 2 Int32s per cell
 991      const rowLen = maxX - startX
 992      const checkLeft = startX > 0
 993      const checkRight = maxX < screenWidth
 994      let leftEdge = (rowBase + startX) << 1
 995      let rightEdge = (rowBase + maxX - 1) << 1
 996      let fillStart = rowBase + startX
 997  
 998      for (let y = startY; y < maxY; y++) {
 999        // Left boundary: if cell at startX is a SpacerTail, the Wide char
1000        // at startX-1 (outside the region) will be orphaned. Clear it.
1001        if (checkLeft) {
1002          // leftEdge points to word0 of cell at startX; +1 is its word1
1003          if ((cells[leftEdge + 1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
1004            // word1 of cell at startX-1 is leftEdge-1; word0 is leftEdge-2
1005            const prevW1 = leftEdge - 1
1006            if ((cells[prevW1]! & WIDTH_MASK) === CellWidth.Wide) {
1007              cells[prevW1 - 1] = EMPTY_CHAR_INDEX
1008              cells[prevW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
1009              damageMinX = startX - 1
1010            }
1011          }
1012        }
1013  
1014        // Right boundary: if cell at maxX-1 is Wide, its SpacerTail at maxX
1015        // (outside the region) will be orphaned. Clear it.
1016        if (checkRight) {
1017          // rightEdge points to word0 of cell at maxX-1; +1 is its word1
1018          if ((cells[rightEdge + 1]! & WIDTH_MASK) === CellWidth.Wide) {
1019            // word1 of cell at maxX is rightEdge+3 (+2 to next word0, +1 to word1)
1020            const nextW1 = rightEdge + 3
1021            if ((cells[nextW1]! & WIDTH_MASK) === CellWidth.SpacerTail) {
1022              cells[nextW1 - 1] = EMPTY_CHAR_INDEX
1023              cells[nextW1] = packWord1(screen.emptyStyleId, 0, CellWidth.Narrow)
1024              damageMaxX = maxX + 1
1025            }
1026          }
1027        }
1028  
1029        cells64.fill(EMPTY_CELL_VALUE, fillStart, fillStart + rowLen)
1030        leftEdge += stride
1031        rightEdge += stride
1032        fillStart += screenWidth
1033      }
1034    }
1035  
1036    // Update damage once for the whole region
1037    const regionRect = {
1038      x: damageMinX,
1039      y: startY,
1040      width: damageMaxX - damageMinX,
1041      height: maxY - startY,
1042    }
1043    if (screen.damage) {
1044      screen.damage = unionRect(screen.damage, regionRect)
1045    } else {
1046      screen.damage = regionRect
1047    }
1048  }
1049  
1050  /**
1051   * Shift full-width rows within [top, bottom] (inclusive, 0-indexed) by n.
1052   * n > 0 shifts UP (simulating CSI n S); n < 0 shifts DOWN (CSI n T).
1053   * Vacated rows are cleared. Does NOT update damage. Both cells and the
1054   * noSelect bitmap are shifted so text-selection markers stay aligned when
1055   * this is applied to next.screen during scroll fast path.
1056   */
1057  export function shiftRows(
1058    screen: Screen,
1059    top: number,
1060    bottom: number,
1061    n: number,
1062  ): void {
1063    if (n === 0 || top < 0 || bottom >= screen.height || top > bottom) return
1064    const w = screen.width
1065    const cells64 = screen.cells64
1066    const noSel = screen.noSelect
1067    const sw = screen.softWrap
1068    const absN = Math.abs(n)
1069    if (absN > bottom - top) {
1070      cells64.fill(EMPTY_CELL_VALUE, top * w, (bottom + 1) * w)
1071      noSel.fill(0, top * w, (bottom + 1) * w)
1072      sw.fill(0, top, bottom + 1)
1073      return
1074    }
1075    if (n > 0) {
1076      // SU: row top+n..bottom → top..bottom-n; clear bottom-n+1..bottom
1077      cells64.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
1078      noSel.copyWithin(top * w, (top + n) * w, (bottom + 1) * w)
1079      sw.copyWithin(top, top + n, bottom + 1)
1080      cells64.fill(EMPTY_CELL_VALUE, (bottom - n + 1) * w, (bottom + 1) * w)
1081      noSel.fill(0, (bottom - n + 1) * w, (bottom + 1) * w)
1082      sw.fill(0, bottom - n + 1, bottom + 1)
1083    } else {
1084      // SD: row top..bottom+n → top-n..bottom; clear top..top-n-1
1085      cells64.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
1086      noSel.copyWithin((top - n) * w, top * w, (bottom + n + 1) * w)
1087      sw.copyWithin(top - n, top, bottom + n + 1)
1088      cells64.fill(EMPTY_CELL_VALUE, top * w, (top - n) * w)
1089      noSel.fill(0, top * w, (top - n) * w)
1090      sw.fill(0, top, top - n)
1091    }
1092  }
1093  
1094  // Matches OSC 8 ; ; URI BEL
1095  const OSC8_REGEX = new RegExp(`^${ESC}\\]8${SEP}${SEP}([^${BEL}]*)${BEL}$`)
1096  // OSC8 prefix: ESC ] 8 ; — cheap check to skip regex for the vast majority of styles (SGR = ESC [)
1097  export const OSC8_PREFIX = `${ESC}]8${SEP}`
1098  
1099  export function extractHyperlinkFromStyles(
1100    styles: AnsiCode[],
1101  ): Hyperlink | null {
1102    for (const style of styles) {
1103      const code = style.code
1104      if (code.length < 5 || !code.startsWith(OSC8_PREFIX)) continue
1105      const match = code.match(OSC8_REGEX)
1106      if (match) {
1107        return match[1] || null
1108      }
1109    }
1110    return null
1111  }
1112  
1113  export function filterOutHyperlinkStyles(styles: AnsiCode[]): AnsiCode[] {
1114    return styles.filter(
1115      style =>
1116        !style.code.startsWith(OSC8_PREFIX) || !OSC8_REGEX.test(style.code),
1117    )
1118  }
1119  
1120  // ---
1121  
1122  /**
1123   * Returns an array of all changes between two screens. Used by tests.
1124   * Production code should use diffEach() to avoid allocations.
1125   */
1126  export function diff(
1127    prev: Screen,
1128    next: Screen,
1129  ): [point: Point, removed: Cell | undefined, added: Cell | undefined][] {
1130    const output: [Point, Cell | undefined, Cell | undefined][] = []
1131    diffEach(prev, next, (x, y, removed, added) => {
1132      // Copy cells since diffEach reuses the objects
1133      output.push([
1134        { x, y },
1135        removed ? { ...removed } : undefined,
1136        added ? { ...added } : undefined,
1137      ])
1138    })
1139    return output
1140  }
1141  
1142  type DiffCallback = (
1143    x: number,
1144    y: number,
1145    removed: Cell | undefined,
1146    added: Cell | undefined,
1147  ) => boolean | void
1148  
1149  /**
1150   * Like diff(), but calls a callback for each change instead of building an array.
1151   * Reuses two Cell objects to avoid per-change allocations. The callback must not
1152   * retain references to the Cell objects — their contents are overwritten each call.
1153   *
1154   * Returns true if the callback ever returned true (early exit signal).
1155   */
1156  export function diffEach(
1157    prev: Screen,
1158    next: Screen,
1159    cb: DiffCallback,
1160  ): boolean {
1161    const prevWidth = prev.width
1162    const nextWidth = next.width
1163    const prevHeight = prev.height
1164    const nextHeight = next.height
1165  
1166    let region: Rectangle
1167    if (prevWidth === 0 && prevHeight === 0) {
1168      region = { x: 0, y: 0, width: nextWidth, height: nextHeight }
1169    } else if (next.damage) {
1170      region = next.damage
1171      if (prev.damage) {
1172        region = unionRect(region, prev.damage)
1173      }
1174    } else if (prev.damage) {
1175      region = prev.damage
1176    } else {
1177      region = { x: 0, y: 0, width: 0, height: 0 }
1178    }
1179  
1180    if (prevHeight > nextHeight) {
1181      region = unionRect(region, {
1182        x: 0,
1183        y: nextHeight,
1184        width: prevWidth,
1185        height: prevHeight - nextHeight,
1186      })
1187    }
1188    if (prevWidth > nextWidth) {
1189      region = unionRect(region, {
1190        x: nextWidth,
1191        y: 0,
1192        width: prevWidth - nextWidth,
1193        height: prevHeight,
1194      })
1195    }
1196  
1197    const maxHeight = Math.max(prevHeight, nextHeight)
1198    const maxWidth = Math.max(prevWidth, nextWidth)
1199    const endY = Math.min(region.y + region.height, maxHeight)
1200    const endX = Math.min(region.x + region.width, maxWidth)
1201  
1202    if (prevWidth === nextWidth) {
1203      return diffSameWidth(prev, next, region.x, endX, region.y, endY, cb)
1204    }
1205    return diffDifferentWidth(prev, next, region.x, endX, region.y, endY, cb)
1206  }
1207  
1208  /**
1209   * Scan for the next cell that differs between two Int32Arrays.
1210   * Returns the number of matching cells before the first difference,
1211   * or `count` if all cells match. Tiny and pure for JIT inlining.
1212   */
1213  function findNextDiff(
1214    a: Int32Array,
1215    b: Int32Array,
1216    w0: number,
1217    count: number,
1218  ): number {
1219    for (let i = 0; i < count; i++, w0 += 2) {
1220      const w1 = w0 | 1
1221      if (a[w0] !== b[w0] || a[w1] !== b[w1]) return i
1222    }
1223    return count
1224  }
1225  
1226  /**
1227   * Diff one row where both screens are in bounds.
1228   * Scans for differences with findNextDiff, unpacks and calls cb for each.
1229   */
1230  function diffRowBoth(
1231    prevCells: Int32Array,
1232    nextCells: Int32Array,
1233    prev: Screen,
1234    next: Screen,
1235    ci: number,
1236    y: number,
1237    startX: number,
1238    endX: number,
1239    prevCell: Cell,
1240    nextCell: Cell,
1241    cb: DiffCallback,
1242  ): boolean {
1243    let x = startX
1244    while (x < endX) {
1245      const skip = findNextDiff(prevCells, nextCells, ci, endX - x)
1246      x += skip
1247      ci += skip << 1
1248      if (x >= endX) break
1249      cellAtCI(prev, ci, prevCell)
1250      cellAtCI(next, ci, nextCell)
1251      if (cb(x, y, prevCell, nextCell)) return true
1252      x++
1253      ci += 2
1254    }
1255    return false
1256  }
1257  
1258  /**
1259   * Emit removals for a row that only exists in prev (height shrank).
1260   * Cannot skip empty cells — the terminal still has content from the
1261   * previous frame that needs to be cleared.
1262   */
1263  function diffRowRemoved(
1264    prev: Screen,
1265    ci: number,
1266    y: number,
1267    startX: number,
1268    endX: number,
1269    prevCell: Cell,
1270    cb: DiffCallback,
1271  ): boolean {
1272    for (let x = startX; x < endX; x++, ci += 2) {
1273      cellAtCI(prev, ci, prevCell)
1274      if (cb(x, y, prevCell, undefined)) return true
1275    }
1276    return false
1277  }
1278  
1279  /**
1280   * Emit additions for a row that only exists in next (height grew).
1281   * Skips empty/unwritten cells.
1282   */
1283  function diffRowAdded(
1284    nextCells: Int32Array,
1285    next: Screen,
1286    ci: number,
1287    y: number,
1288    startX: number,
1289    endX: number,
1290    nextCell: Cell,
1291    cb: DiffCallback,
1292  ): boolean {
1293    for (let x = startX; x < endX; x++, ci += 2) {
1294      if (nextCells[ci] === 0 && nextCells[ci | 1] === 0) continue
1295      cellAtCI(next, ci, nextCell)
1296      if (cb(x, y, undefined, nextCell)) return true
1297    }
1298    return false
1299  }
1300  
1301  /**
1302   * Diff two screens with identical width.
1303   * Dispatches each row to a small, JIT-friendly function.
1304   */
1305  function diffSameWidth(
1306    prev: Screen,
1307    next: Screen,
1308    startX: number,
1309    endX: number,
1310    startY: number,
1311    endY: number,
1312    cb: DiffCallback,
1313  ): boolean {
1314    const prevCells = prev.cells
1315    const nextCells = next.cells
1316    const width = prev.width
1317    const prevHeight = prev.height
1318    const nextHeight = next.height
1319    const stride = width << 1
1320  
1321    const prevCell: Cell = {
1322      char: ' ',
1323      styleId: 0,
1324      width: CellWidth.Narrow,
1325      hyperlink: undefined,
1326    }
1327    const nextCell: Cell = {
1328      char: ' ',
1329      styleId: 0,
1330      width: CellWidth.Narrow,
1331      hyperlink: undefined,
1332    }
1333  
1334    const rowEndX = Math.min(endX, width)
1335    let rowCI = (startY * width + startX) << 1
1336  
1337    for (let y = startY; y < endY; y++) {
1338      const prevIn = y < prevHeight
1339      const nextIn = y < nextHeight
1340  
1341      if (prevIn && nextIn) {
1342        if (
1343          diffRowBoth(
1344            prevCells,
1345            nextCells,
1346            prev,
1347            next,
1348            rowCI,
1349            y,
1350            startX,
1351            rowEndX,
1352            prevCell,
1353            nextCell,
1354            cb,
1355          )
1356        )
1357          return true
1358      } else if (prevIn) {
1359        if (diffRowRemoved(prev, rowCI, y, startX, rowEndX, prevCell, cb))
1360          return true
1361      } else if (nextIn) {
1362        if (
1363          diffRowAdded(nextCells, next, rowCI, y, startX, rowEndX, nextCell, cb)
1364        )
1365          return true
1366      }
1367  
1368      rowCI += stride
1369    }
1370  
1371    return false
1372  }
1373  
1374  /**
1375   * Fallback: diff two screens with different widths (resize).
1376   * Separate indices for prev and next cells arrays.
1377   */
1378  function diffDifferentWidth(
1379    prev: Screen,
1380    next: Screen,
1381    startX: number,
1382    endX: number,
1383    startY: number,
1384    endY: number,
1385    cb: DiffCallback,
1386  ): boolean {
1387    const prevWidth = prev.width
1388    const nextWidth = next.width
1389    const prevCells = prev.cells
1390    const nextCells = next.cells
1391  
1392    const prevCell: Cell = {
1393      char: ' ',
1394      styleId: 0,
1395      width: CellWidth.Narrow,
1396      hyperlink: undefined,
1397    }
1398    const nextCell: Cell = {
1399      char: ' ',
1400      styleId: 0,
1401      width: CellWidth.Narrow,
1402      hyperlink: undefined,
1403    }
1404  
1405    const prevStride = prevWidth << 1
1406    const nextStride = nextWidth << 1
1407    let prevRowCI = (startY * prevWidth + startX) << 1
1408    let nextRowCI = (startY * nextWidth + startX) << 1
1409  
1410    for (let y = startY; y < endY; y++) {
1411      const prevIn = y < prev.height
1412      const nextIn = y < next.height
1413      const prevEndX = prevIn ? Math.min(endX, prevWidth) : startX
1414      const nextEndX = nextIn ? Math.min(endX, nextWidth) : startX
1415      const bothEndX = Math.min(prevEndX, nextEndX)
1416  
1417      let prevCI = prevRowCI
1418      let nextCI = nextRowCI
1419  
1420      for (let x = startX; x < bothEndX; x++) {
1421        if (
1422          prevCells[prevCI] === nextCells[nextCI] &&
1423          prevCells[prevCI + 1] === nextCells[nextCI + 1]
1424        ) {
1425          prevCI += 2
1426          nextCI += 2
1427          continue
1428        }
1429        cellAtCI(prev, prevCI, prevCell)
1430        cellAtCI(next, nextCI, nextCell)
1431        prevCI += 2
1432        nextCI += 2
1433        if (cb(x, y, prevCell, nextCell)) return true
1434      }
1435  
1436      if (prevEndX > bothEndX) {
1437        prevCI = prevRowCI + ((bothEndX - startX) << 1)
1438        for (let x = bothEndX; x < prevEndX; x++) {
1439          cellAtCI(prev, prevCI, prevCell)
1440          prevCI += 2
1441          if (cb(x, y, prevCell, undefined)) return true
1442        }
1443      }
1444  
1445      if (nextEndX > bothEndX) {
1446        nextCI = nextRowCI + ((bothEndX - startX) << 1)
1447        for (let x = bothEndX; x < nextEndX; x++) {
1448          if (nextCells[nextCI] === 0 && nextCells[nextCI | 1] === 0) {
1449            nextCI += 2
1450            continue
1451          }
1452          cellAtCI(next, nextCI, nextCell)
1453          nextCI += 2
1454          if (cb(x, y, undefined, nextCell)) return true
1455        }
1456      }
1457  
1458      prevRowCI += prevStride
1459      nextRowCI += nextStride
1460    }
1461  
1462    return false
1463  }
1464  
1465  /**
1466   * Mark a rectangular region as noSelect (exclude from text selection).
1467   * Clamps to screen bounds. Called from output.ts when a <NoSelect> box
1468   * renders. No damage tracking — noSelect doesn't affect terminal output,
1469   * only getSelectedText/applySelectionOverlay which read it directly.
1470   */
1471  export function markNoSelectRegion(
1472    screen: Screen,
1473    x: number,
1474    y: number,
1475    width: number,
1476    height: number,
1477  ): void {
1478    const maxX = Math.min(x + width, screen.width)
1479    const maxY = Math.min(y + height, screen.height)
1480    const noSel = screen.noSelect
1481    const stride = screen.width
1482    for (let row = Math.max(0, y); row < maxY; row++) {
1483      const rowStart = row * stride
1484      noSel.fill(1, rowStart + Math.max(0, x), rowStart + maxX)
1485    }
1486  }