/ ink / render-node-to-output.ts
render-node-to-output.ts
   1  import indentString from 'indent-string'
   2  import { applyTextStyles } from './colorize.js'
   3  import type { DOMElement } from './dom.js'
   4  import getMaxWidth from './get-max-width.js'
   5  import type { Rectangle } from './layout/geometry.js'
   6  import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js'
   7  import { nodeCache, pendingClears } from './node-cache.js'
   8  import type Output from './output.js'
   9  import renderBorder from './render-border.js'
  10  import type { Screen } from './screen.js'
  11  import {
  12    type StyledSegment,
  13    squashTextNodesToSegments,
  14  } from './squash-text-nodes.js'
  15  import type { Color } from './styles.js'
  16  import { isXtermJs } from './terminal.js'
  17  import { widestLine } from './widest-line.js'
  18  import wrapText from './wrap-text.js'
  19  
  20  // Matches detectXtermJsWheel() in ScrollKeybindingHandler.tsx — the curve
  21  // and drain must agree on terminal detection. TERM_PROGRAM check is the sync
  22  // fallback; isXtermJs() is the authoritative XTVERSION-probe result.
  23  function isXtermJsHost(): boolean {
  24    return process.env.TERM_PROGRAM === 'vscode' || isXtermJs()
  25  }
  26  
  27  // Per-frame scratch: set when any node's yoga position/size differs from
  28  // its cached value, or a child was removed. Read by ink.tsx to decide
  29  // whether the full-damage sledgehammer (PR #20120) is needed this frame.
  30  // Applies on both alt-screen and main-screen. Steady-state frames
  31  // (spinner tick, clock tick, text append into a fixed-height box) don't
  32  // shift layout → narrow damage bounds → O(changed cells) diff instead of
  33  // O(rows×cols).
  34  let layoutShifted = false
  35  
  36  export function resetLayoutShifted(): void {
  37    layoutShifted = false
  38  }
  39  
  40  export function didLayoutShift(): boolean {
  41    return layoutShifted
  42  }
  43  
  44  // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
  45  // between frames (and nothing else moved), log-update.ts can emit a
  46  // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
  47  // viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 =
  48  // content moved up (scrollTop increased, CSI n S).
  49  export type ScrollHint = { top: number; bottom: number; delta: number }
  50  let scrollHint: ScrollHint | null = null
  51  
  52  // Rects of position:absolute nodes from the PREVIOUS frame, used by
  53  // ScrollBox's blit+shift third-pass repair (see usage site). Recorded at
  54  // three paths — full-render nodeCache.set, node-level blit early-return,
  55  // blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls
  56  // still have the rect.
  57  let absoluteRectsPrev: Rectangle[] = []
  58  let absoluteRectsCur: Rectangle[] = []
  59  
  60  export function resetScrollHint(): void {
  61    scrollHint = null
  62    absoluteRectsPrev = absoluteRectsCur
  63    absoluteRectsCur = []
  64  }
  65  
  66  export function getScrollHint(): ScrollHint | null {
  67    return scrollHint
  68  }
  69  
  70  // The ScrollBox DOM node (if any) with pendingScrollDelta left after this
  71  // frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT
  72  // frame's root blit check fails and we descend to continue draining.
  73  // Without this, after the scrollbox's dirty flag is cleared (line ~721),
  74  // the next frame blits root and never reaches the scrollbox — drain stalls.
  75  let scrollDrainNode: DOMElement | null = null
  76  
  77  export function resetScrollDrainNode(): void {
  78    scrollDrainNode = null
  79  }
  80  
  81  export function getScrollDrainNode(): DOMElement | null {
  82    return scrollDrainNode
  83  }
  84  
  85  // At-bottom follow scroll event this frame. When streaming content
  86  // triggers scrollTop = maxScroll, the ScrollBox records the delta +
  87  // viewport bounds here. ink.tsx consumes it post-render to translate any active
  88  // text selection by -delta so the highlight stays anchored to the TEXT
  89  // (native terminal behavior — the selection walks up the screen as content
  90  // scrolls, eventually clipping at the top). The frontFrame screen buffer
  91  // still holds the old content at that point — captureScrolledRows reads
  92  // from it before the front/back swap to preserve the text for copy.
  93  export type FollowScroll = {
  94    delta: number
  95    viewportTop: number
  96    viewportBottom: number
  97  }
  98  let followScroll: FollowScroll | null = null
  99  
 100  export function consumeFollowScroll(): FollowScroll | null {
 101    const f = followScroll
 102    followScroll = null
 103    return f
 104  }
 105  
 106  // ── Native terminal drain (iTerm2/Ghostty/etc. — proportional events) ──
 107  // Minimum rows applied per frame. Above this, drain is proportional (~3/4
 108  // of remaining) so big bursts catch up in log₄ frames while the tail
 109  // decelerates smoothly. Hard cap is innerHeight-1 so DECSTBM hint fires.
 110  const SCROLL_MIN_PER_FRAME = 4
 111  
 112  // ── xterm.js (VS Code) smooth drain ──
 113  // Low pending (≤5) drains ALL in one frame — slow wheel clicks should be
 114  // instant (click → visible jump → done), not micro-stutter 1-row frames.
 115  // Higher pending drains at a small fixed step so fast-scroll animation
 116  // stays smooth (no big jumps). Pending >MAX snaps excess.
 117  const SCROLL_INSTANT_THRESHOLD = 5 // ≤ this: drain all at once
 118  const SCROLL_HIGH_PENDING = 12 // threshold for HIGH step
 119  const SCROLL_STEP_MED = 2 // pending (INSTANT, HIGH): catch-up
 120  const SCROLL_STEP_HIGH = 3 // pending ≥ HIGH: fast flick
 121  const SCROLL_MAX_PENDING = 30 // snap excess beyond this
 122  
 123  // xterm.js adaptive drain. Returns rows applied; mutates pendingScrollDelta.
 124  function drainAdaptive(
 125    node: DOMElement,
 126    pending: number,
 127    innerHeight: number,
 128  ): number {
 129    const sign = pending > 0 ? 1 : -1
 130    let abs = Math.abs(pending)
 131    let applied = 0
 132    // Snap excess beyond animation window so big flicks don't coast.
 133    if (abs > SCROLL_MAX_PENDING) {
 134      applied += sign * (abs - SCROLL_MAX_PENDING)
 135      abs = SCROLL_MAX_PENDING
 136    }
 137    // ≤5: drain all (slow click = instant). Above: small fixed step.
 138    const step =
 139      abs <= SCROLL_INSTANT_THRESHOLD
 140        ? abs
 141        : abs < SCROLL_HIGH_PENDING
 142          ? SCROLL_STEP_MED
 143          : SCROLL_STEP_HIGH
 144    applied += sign * step
 145    const rem = abs - step
 146    // Cap total at innerHeight-1 so DECSTBM blit+shift fast path fires
 147    // (matches drainProportional). Excess stays in pendingScrollDelta.
 148    const cap = Math.max(1, innerHeight - 1)
 149    const totalAbs = Math.abs(applied)
 150    if (totalAbs > cap) {
 151      const excess = totalAbs - cap
 152      node.pendingScrollDelta = sign * (rem + excess)
 153      return sign * cap
 154    }
 155    node.pendingScrollDelta = rem > 0 ? sign * rem : undefined
 156    return applied
 157  }
 158  
 159  // Native proportional drain. step = max(MIN, floor(abs*3/4)), capped at
 160  // innerHeight-1 so DECSTBM + blit+shift fast path fire.
 161  function drainProportional(
 162    node: DOMElement,
 163    pending: number,
 164    innerHeight: number,
 165  ): number {
 166    const abs = Math.abs(pending)
 167    const cap = Math.max(1, innerHeight - 1)
 168    const step = Math.min(cap, Math.max(SCROLL_MIN_PER_FRAME, (abs * 3) >> 2))
 169    if (abs <= step) {
 170      node.pendingScrollDelta = undefined
 171      return pending
 172    }
 173    const applied = pending > 0 ? step : -step
 174    node.pendingScrollDelta = pending - applied
 175    return applied
 176  }
 177  
 178  // OSC 8 hyperlink escape sequences. Empty params (;;) — ansi-tokenize only
 179  // recognizes this exact prefix. The id= param (for grouping wrapped lines)
 180  // is added at terminal-output time in termio/osc.ts link().
 181  const OSC = '\u001B]'
 182  const BEL = '\u0007'
 183  
 184  function wrapWithOsc8Link(text: string, url: string): string {
 185    return `${OSC}8;;${url}${BEL}${text}${OSC}8;;${BEL}`
 186  }
 187  
 188  /**
 189   * Build a mapping from each character position in the plain text to its segment index.
 190   * Returns an array where charToSegment[i] is the segment index for character i.
 191   */
 192  function buildCharToSegmentMap(segments: StyledSegment[]): number[] {
 193    const map: number[] = []
 194    for (let i = 0; i < segments.length; i++) {
 195      const len = segments[i]!.text.length
 196      for (let j = 0; j < len; j++) {
 197        map.push(i)
 198      }
 199    }
 200    return map
 201  }
 202  
 203  /**
 204   * Apply styles to wrapped text by mapping each character back to its original segment.
 205   * This preserves per-segment styles even when text wraps across lines.
 206   *
 207   * @param trimEnabled - Whether whitespace trimming is enabled (wrap-trim mode).
 208   *   When true, we skip whitespace in the original that was trimmed from the output.
 209   *   When false (wrap mode), all whitespace is preserved so no skipping is needed.
 210   */
 211  function applyStylesToWrappedText(
 212    wrappedPlain: string,
 213    segments: StyledSegment[],
 214    charToSegment: number[],
 215    originalPlain: string,
 216    trimEnabled: boolean = false,
 217  ): string {
 218    const lines = wrappedPlain.split('\n')
 219    const resultLines: string[] = []
 220  
 221    let charIndex = 0
 222    for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
 223      const line = lines[lineIdx]!
 224  
 225      // In trim mode, skip leading whitespace that was trimmed from this line.
 226      // Only skip if the original has whitespace but the output line doesn't start
 227      // with whitespace (meaning it was trimmed). If both have whitespace, the
 228      // whitespace was preserved and we shouldn't skip.
 229      if (trimEnabled && line.length > 0) {
 230        const lineStartsWithWhitespace = /\s/.test(line[0]!)
 231        const originalHasWhitespace =
 232          charIndex < originalPlain.length && /\s/.test(originalPlain[charIndex]!)
 233  
 234        // Only skip if original has whitespace but line doesn't
 235        if (originalHasWhitespace && !lineStartsWithWhitespace) {
 236          while (
 237            charIndex < originalPlain.length &&
 238            /\s/.test(originalPlain[charIndex]!)
 239          ) {
 240            charIndex++
 241          }
 242        }
 243      }
 244  
 245      let styledLine = ''
 246      let runStart = 0
 247      let runSegmentIndex = charToSegment[charIndex] ?? 0
 248  
 249      for (let i = 0; i < line.length; i++) {
 250        const currentSegmentIndex = charToSegment[charIndex] ?? runSegmentIndex
 251  
 252        if (currentSegmentIndex !== runSegmentIndex) {
 253          // Flush the current run
 254          const runText = line.slice(runStart, i)
 255          const segment = segments[runSegmentIndex]
 256          if (segment) {
 257            let styled = applyTextStyles(runText, segment.styles)
 258            if (segment.hyperlink) {
 259              styled = wrapWithOsc8Link(styled, segment.hyperlink)
 260            }
 261            styledLine += styled
 262          } else {
 263            styledLine += runText
 264          }
 265          runStart = i
 266          runSegmentIndex = currentSegmentIndex
 267        }
 268  
 269        charIndex++
 270      }
 271  
 272      // Flush the final run
 273      const runText = line.slice(runStart)
 274      const segment = segments[runSegmentIndex]
 275      if (segment) {
 276        let styled = applyTextStyles(runText, segment.styles)
 277        if (segment.hyperlink) {
 278          styled = wrapWithOsc8Link(styled, segment.hyperlink)
 279        }
 280        styledLine += styled
 281      } else {
 282        styledLine += runText
 283      }
 284  
 285      resultLines.push(styledLine)
 286  
 287      // Skip newline character in original that corresponds to this line break.
 288      // This is needed when the original text contains actual newlines (not just
 289      // wrapping-inserted newlines). Without this, charIndex gets out of sync
 290      // because the newline is in originalPlain/charToSegment but not in the
 291      // split lines.
 292      if (charIndex < originalPlain.length && originalPlain[charIndex] === '\n') {
 293        charIndex++
 294      }
 295  
 296      // In trim mode, skip whitespace that was replaced by newline when wrapping.
 297      // We skip whitespace in the original until we reach a character that matches
 298      // the first character of the next line. This handles cases like:
 299      // - "AB   \tD" wrapped to "AB\n\tD" - skip spaces until we hit the tab
 300      // In non-trim mode, whitespace is preserved so no skipping is needed.
 301      if (trimEnabled && lineIdx < lines.length - 1) {
 302        const nextLine = lines[lineIdx + 1]!
 303        const nextLineFirstChar = nextLine.length > 0 ? nextLine[0] : null
 304  
 305        // Skip whitespace until we hit a char that matches the next line's first char
 306        while (
 307          charIndex < originalPlain.length &&
 308          /\s/.test(originalPlain[charIndex]!)
 309        ) {
 310          // Stop if we found the character that starts the next line
 311          if (
 312            nextLineFirstChar !== null &&
 313            originalPlain[charIndex] === nextLineFirstChar
 314          ) {
 315            break
 316          }
 317          charIndex++
 318        }
 319      }
 320    }
 321  
 322    return resultLines.join('\n')
 323  }
 324  
 325  /**
 326   * Wrap text and record which output lines are soft-wrap continuations
 327   * (i.e. the `\n` before them was inserted by word-wrap, not in the
 328   * source). wrapAnsi already processes each input line independently, so
 329   * wrapping per-input-line here gives identical output to a single
 330   * whole-string wrap while letting us mark per-piece provenance.
 331   * Truncate modes never add newlines (cli-truncate is whole-string) so
 332   * they fall through with softWrap undefined — no tracking, no behavior
 333   * change from the pre-softWrap path.
 334   */
 335  function wrapWithSoftWrap(
 336    plainText: string,
 337    maxWidth: number,
 338    textWrap: Parameters<typeof wrapText>[2],
 339  ): { wrapped: string; softWrap: boolean[] | undefined } {
 340    if (textWrap !== 'wrap' && textWrap !== 'wrap-trim') {
 341      return {
 342        wrapped: wrapText(plainText, maxWidth, textWrap),
 343        softWrap: undefined,
 344      }
 345    }
 346    const origLines = plainText.split('\n')
 347    const outLines: string[] = []
 348    const softWrap: boolean[] = []
 349    for (const orig of origLines) {
 350      const pieces = wrapText(orig, maxWidth, textWrap).split('\n')
 351      for (let i = 0; i < pieces.length; i++) {
 352        outLines.push(pieces[i]!)
 353        softWrap.push(i > 0)
 354      }
 355    }
 356    return { wrapped: outLines.join('\n'), softWrap }
 357  }
 358  
 359  // If parent container is `<Box>`, text nodes will be treated as separate nodes in
 360  // the tree and will have their own coordinates in the layout.
 361  // To ensure text nodes are aligned correctly, take X and Y of the first text node
 362  // and use it as offset for the rest of the nodes
 363  // Only first node is taken into account, because other text nodes can't have margin or padding,
 364  // so their coordinates will be relative to the first node anyway
 365  function applyPaddingToText(
 366    node: DOMElement,
 367    text: string,
 368    softWrap?: boolean[],
 369  ): string {
 370    const yogaNode = node.childNodes[0]?.yogaNode
 371  
 372    if (yogaNode) {
 373      const offsetX = yogaNode.getComputedLeft()
 374      const offsetY = yogaNode.getComputedTop()
 375      text = '\n'.repeat(offsetY) + indentString(text, offsetX)
 376      if (softWrap && offsetY > 0) {
 377        // Prepend `false` for each padding line so indices stay aligned
 378        // with text.split('\n'). Mutate in place — caller owns the array.
 379        softWrap.unshift(...Array<boolean>(offsetY).fill(false))
 380      }
 381    }
 382  
 383    return text
 384  }
 385  
 386  // After nodes are laid out, render each to output object, which later gets rendered to terminal
 387  function renderNodeToOutput(
 388    node: DOMElement,
 389    output: Output,
 390    {
 391      offsetX = 0,
 392      offsetY = 0,
 393      prevScreen,
 394      skipSelfBlit = false,
 395      inheritedBackgroundColor,
 396    }: {
 397      offsetX?: number
 398      offsetY?: number
 399      prevScreen: Screen | undefined
 400      // Force this node to descend instead of blitting its own rect, while
 401      // still passing prevScreen to children. Used for non-opaque absolute
 402      // overlays over a dirty clipped region: the overlay's full rect has
 403      // transparent gaps (stale underlying content in prevScreen), but its
 404      // opaque descendants' narrower rects are safe to blit.
 405      skipSelfBlit?: boolean
 406      inheritedBackgroundColor?: Color
 407    },
 408  ): void {
 409    const { yogaNode } = node
 410  
 411    if (yogaNode) {
 412      if (yogaNode.getDisplay() === LayoutDisplay.None) {
 413        // Clear old position if node was visible before becoming hidden
 414        if (node.dirty) {
 415          const cached = nodeCache.get(node)
 416          if (cached) {
 417            output.clear({
 418              x: Math.floor(cached.x),
 419              y: Math.floor(cached.y),
 420              width: Math.floor(cached.width),
 421              height: Math.floor(cached.height),
 422            })
 423            // Drop descendants' cache too — hideInstance's markDirty walks UP
 424            // only, so descendants' .dirty stays false. Their nodeCache entries
 425            // survive with pre-hide rects. On unhide, if position didn't shift,
 426            // the blit check at line ~432 passes and copies EMPTY cells from
 427            // prevScreen (cleared here) → content vanishes.
 428            dropSubtreeCache(node)
 429            layoutShifted = true
 430          }
 431        }
 432        return
 433      }
 434  
 435      // Left and top positions in Yoga are relative to their parent node
 436      const x = offsetX + yogaNode.getComputedLeft()
 437      const yogaTop = yogaNode.getComputedTop()
 438      let y = offsetY + yogaTop
 439      const width = yogaNode.getComputedWidth()
 440      const height = yogaNode.getComputedHeight()
 441  
 442      // Absolute-positioned overlays (e.g. autocomplete menus with bottom='100%')
 443      // can compute negative screen y when they extend above the viewport. Without
 444      // clamping, setCellAt drops cells at y<0, clipping the TOP of the content
 445      // (best matches in an autocomplete). By clamping to 0, we shift the element
 446      // down so the top rows are visible and the bottom overflows below — the
 447      // opaque prop ensures it paints over whatever is underneath.
 448      if (y < 0 && node.style.position === 'absolute') {
 449        y = 0
 450      }
 451  
 452      // Check if we can skip this subtree (clean node with unchanged layout).
 453      // Blit cells from previous screen instead of re-rendering.
 454      const cached = nodeCache.get(node)
 455      if (
 456        !node.dirty &&
 457        !skipSelfBlit &&
 458        node.pendingScrollDelta === undefined &&
 459        cached &&
 460        cached.x === x &&
 461        cached.y === y &&
 462        cached.width === width &&
 463        cached.height === height &&
 464        prevScreen
 465      ) {
 466        const fx = Math.floor(x)
 467        const fy = Math.floor(y)
 468        const fw = Math.floor(width)
 469        const fh = Math.floor(height)
 470        output.blit(prevScreen, fx, fy, fw, fh)
 471        if (node.style.position === 'absolute') {
 472          absoluteRectsCur.push(cached)
 473        }
 474        // Absolute descendants can paint outside this node's layout bounds
 475        // (e.g. a slash menu with position='absolute' bottom='100%' floats
 476        // above). If a dirty clipped sibling re-rendered and overwrote those
 477        // cells, the blit above only restored this node's own rect — the
 478        // absolute descendants' cells are lost. Re-blit them from prevScreen
 479        // so the overlays survive.
 480        blitEscapingAbsoluteDescendants(node, output, prevScreen, fx, fy, fw, fh)
 481        return
 482      }
 483  
 484      // Clear stale content from the old position when re-rendering.
 485      // Dirty: content changed. Moved: position/size changed (e.g., sibling
 486      // above changed height), old cells still on the terminal.
 487      const positionChanged =
 488        cached !== undefined &&
 489        (cached.x !== x ||
 490          cached.y !== y ||
 491          cached.width !== width ||
 492          cached.height !== height)
 493      if (positionChanged) {
 494        layoutShifted = true
 495      }
 496      if (cached && (node.dirty || positionChanged)) {
 497        output.clear(
 498          {
 499            x: Math.floor(cached.x),
 500            y: Math.floor(cached.y),
 501            width: Math.floor(cached.width),
 502            height: Math.floor(cached.height),
 503          },
 504          node.style.position === 'absolute',
 505        )
 506      }
 507  
 508      // Read before deleting — hasRemovedChild disables prevScreen blitting
 509      // for siblings to prevent stale overflow content from being restored.
 510      const clears = pendingClears.get(node)
 511      const hasRemovedChild = clears !== undefined
 512      if (hasRemovedChild) {
 513        layoutShifted = true
 514        for (const rect of clears) {
 515          output.clear({
 516            x: Math.floor(rect.x),
 517            y: Math.floor(rect.y),
 518            width: Math.floor(rect.width),
 519            height: Math.floor(rect.height),
 520          })
 521        }
 522        pendingClears.delete(node)
 523      }
 524  
 525      // Yoga squeezed this node to zero height (overflow in a height-constrained
 526      // parent) AND a sibling lands at the same y. Skip rendering — both would
 527      // write to the same row; if the sibling's content is shorter, this node's
 528      // tail chars ghost (e.g. "false" + "true" = "truee"). The clear above
 529      // already handled the visible→squeezed transition.
 530      //
 531      // The sibling-overlap check is load-bearing: Yoga's pixel-grid rounding
 532      // can give a box h=0 while still leaving a row for it (next sibling at
 533      // y+1, not y). HelpV2's third shortcuts column hits this — skipping
 534      // unconditionally drops "ctrl + z to suspend" from /help output.
 535      if (height === 0 && siblingSharesY(node, yogaNode)) {
 536        nodeCache.set(node, { x, y, width, height, top: yogaTop })
 537        node.dirty = false
 538        return
 539      }
 540  
 541      if (node.nodeName === 'ink-raw-ansi') {
 542        // Pre-rendered ANSI content. The producer already wrapped to width and
 543        // emitted terminal-ready escape codes. Skip squash, measure, wrap, and
 544        // style re-application — output.write() parses ANSI directly into cells.
 545        const text = node.attributes['rawText'] as string
 546        if (text) {
 547          output.write(x, y, text)
 548        }
 549      } else if (node.nodeName === 'ink-text') {
 550        const segments = squashTextNodesToSegments(
 551          node,
 552          inheritedBackgroundColor
 553            ? { backgroundColor: inheritedBackgroundColor }
 554            : undefined,
 555        )
 556  
 557        // First, get plain text to check if wrapping is needed
 558        const plainText = segments.map(s => s.text).join('')
 559  
 560        if (plainText.length > 0) {
 561          // Upstream Ink uses getMaxWidth(yogaNode) unclamped here. That
 562          // width comes from Yoga's AtMost pass and can exceed the actual
 563          // screen space (see getMaxWidth docstring). Yoga's height for this
 564          // node already reflects the constrained Exactly pass, so clamping
 565          // the wrap width here keeps line count consistent with layout.
 566          // Without this, characters past the screen edge are dropped by
 567          // setCellAt's bounds check.
 568          const maxWidth = Math.min(getMaxWidth(yogaNode), output.width - x)
 569          const textWrap = node.style.textWrap ?? 'wrap'
 570  
 571          // Check if wrapping is needed
 572          const needsWrapping = widestLine(plainText) > maxWidth
 573  
 574          let text: string
 575          let softWrap: boolean[] | undefined
 576          if (needsWrapping && segments.length === 1) {
 577            // Single segment: wrap plain text first, then apply styles to each line
 578            const segment = segments[0]!
 579            const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
 580            softWrap = w.softWrap
 581            text = w.wrapped
 582              .split('\n')
 583              .map(line => {
 584                let styled = applyTextStyles(line, segment.styles)
 585                // Apply OSC 8 hyperlink per-line so each line is independently
 586                // clickable. output.ts splits on newlines and tokenizes each
 587                // line separately, so a single wrapper around the whole block
 588                // would only apply the hyperlink to the first line.
 589                if (segment.hyperlink) {
 590                  styled = wrapWithOsc8Link(styled, segment.hyperlink)
 591                }
 592                return styled
 593              })
 594              .join('\n')
 595          } else if (needsWrapping) {
 596            // Multiple segments with wrapping: wrap plain text first, then re-apply
 597            // each segment's styles based on character positions. This preserves
 598            // per-segment styles even when text wraps across lines.
 599            const w = wrapWithSoftWrap(plainText, maxWidth, textWrap)
 600            softWrap = w.softWrap
 601            const charToSegment = buildCharToSegmentMap(segments)
 602            text = applyStylesToWrappedText(
 603              w.wrapped,
 604              segments,
 605              charToSegment,
 606              plainText,
 607              textWrap === 'wrap-trim',
 608            )
 609            // Hyperlinks are handled per-run in applyStylesToWrappedText via
 610            // wrapWithOsc8Link, similar to how styles are applied per-run.
 611          } else {
 612            // No wrapping needed: apply styles directly
 613            text = segments
 614              .map(segment => {
 615                let styledText = applyTextStyles(segment.text, segment.styles)
 616                if (segment.hyperlink) {
 617                  styledText = wrapWithOsc8Link(styledText, segment.hyperlink)
 618                }
 619                return styledText
 620              })
 621              .join('')
 622          }
 623  
 624          text = applyPaddingToText(node, text, softWrap)
 625  
 626          output.write(x, y, text, softWrap)
 627        }
 628      } else if (node.nodeName === 'ink-box') {
 629        const boxBackgroundColor =
 630          node.style.backgroundColor ?? inheritedBackgroundColor
 631  
 632        // Mark this box's region as non-selectable (fullscreen text
 633        // selection). noSelect ops are applied AFTER blits/writes in
 634        // output.get(), so this wins regardless of what's rendered into
 635        // the region — including blits from prevScreen when the box is
 636        // clean (the op is emitted on both the dirty-render path here
 637        // AND on the blit fast-path at line ~235 since blitRegion copies
 638        // the noSelect bitmap alongside cells).
 639        //
 640        // 'from-left-edge' extends the exclusion from col 0 so any
 641        // upstream indentation (tool prefix, tree lines) is covered too
 642        // — a multi-row drag over a diff gutter shouldn't pick up the
 643        // `  ⎿  ` prefix on row 0 or the blank cells under it on row 1+.
 644        if (node.style.noSelect) {
 645          const boxX = Math.floor(x)
 646          const fromEdge = node.style.noSelect === 'from-left-edge'
 647          output.noSelect({
 648            x: fromEdge ? 0 : boxX,
 649            y: Math.floor(y),
 650            width: fromEdge ? boxX + Math.floor(width) : Math.floor(width),
 651            height: Math.floor(height),
 652          })
 653        }
 654  
 655        const overflowX = node.style.overflowX ?? node.style.overflow
 656        const overflowY = node.style.overflowY ?? node.style.overflow
 657        const clipHorizontally = overflowX === 'hidden' || overflowX === 'scroll'
 658        const clipVertically = overflowY === 'hidden' || overflowY === 'scroll'
 659        const isScrollY = overflowY === 'scroll'
 660  
 661        const needsClip = clipHorizontally || clipVertically
 662        let y1: number | undefined
 663        let y2: number | undefined
 664        if (needsClip) {
 665          const x1 = clipHorizontally
 666            ? x + yogaNode.getComputedBorder(LayoutEdge.Left)
 667            : undefined
 668  
 669          const x2 = clipHorizontally
 670            ? x +
 671              yogaNode.getComputedWidth() -
 672              yogaNode.getComputedBorder(LayoutEdge.Right)
 673            : undefined
 674  
 675          y1 = clipVertically
 676            ? y + yogaNode.getComputedBorder(LayoutEdge.Top)
 677            : undefined
 678  
 679          y2 = clipVertically
 680            ? y +
 681              yogaNode.getComputedHeight() -
 682              yogaNode.getComputedBorder(LayoutEdge.Bottom)
 683            : undefined
 684  
 685          output.clip({ x1, x2, y1, y2 })
 686        }
 687  
 688        if (isScrollY) {
 689          // Scroll containers follow the ScrollBox component structure:
 690          // a single content-wrapper child with flexShrink:0 (doesn't shrink
 691          // to fit), whose children are the scrollable items. scrollHeight
 692          // comes from the wrapper's intrinsic Yoga height. The wrapper is
 693          // rendered with its Y translated by -scrollTop; its children are
 694          // culled against the visible window.
 695          const padTop = yogaNode.getComputedPadding(LayoutEdge.Top)
 696          const innerHeight = Math.max(
 697            0,
 698            (y2 ?? y + height) -
 699              (y1 ?? y) -
 700              padTop -
 701              yogaNode.getComputedPadding(LayoutEdge.Bottom),
 702          )
 703  
 704          const content = node.childNodes.find(c => (c as DOMElement).yogaNode) as
 705            | DOMElement
 706            | undefined
 707          const contentYoga = content?.yogaNode
 708          // scrollHeight is the intrinsic height of the content wrapper.
 709          // Do NOT add getComputedTop() — that's the wrapper's offset
 710          // within the viewport (equal to the scroll container's
 711          // paddingTop), and innerHeight already subtracts padding, so
 712          // including it double-counts padding and inflates maxScroll.
 713          const scrollHeight = contentYoga?.getComputedHeight() ?? 0
 714          // Capture previous scroll bounds BEFORE overwriting — the at-bottom
 715          // follow check compares against last frame's max.
 716          const prevScrollHeight = node.scrollHeight ?? scrollHeight
 717          const prevInnerHeight = node.scrollViewportHeight ?? innerHeight
 718          node.scrollHeight = scrollHeight
 719          node.scrollViewportHeight = innerHeight
 720          // Absolute screen-buffer row where the scrollable area (inside
 721          // padding) begins. Exposed via ScrollBoxHandle.getViewportTop() so
 722          // drag-to-scroll can detect when the drag leaves the scroll viewport.
 723          node.scrollViewportTop = (y1 ?? y) + padTop
 724  
 725          const maxScroll = Math.max(0, scrollHeight - innerHeight)
 726          // scrollAnchor: scroll so the anchored element's top is at the
 727          // viewport top (plus offset). Yoga is FRESH — same calculateLayout
 728          // pass that just produced scrollHeight. Deterministic alternative
 729          // to scrollTo(N) which bakes a number that's stale by the throttled
 730          // render; the element ref defers the read to now. One-shot snap.
 731          // A prior eased-seek version (proportional drain over ~5 frames)
 732          // moved scrollTop without firing React's notify → parent's quantized
 733          // store snapshot never updated → StickyTracker got stale range props
 734          // → firstVisible wrong. Also: SCROLL_MIN_PER_FRAME=4 with snap-at-1
 735          // ping-ponged forever at delta=2. Smooth needs drain-end notify
 736          // plumbing; shipping instant first. stickyScroll overrides.
 737          if (node.scrollAnchor) {
 738            const anchorTop = node.scrollAnchor.el.yogaNode?.getComputedTop()
 739            if (anchorTop != null) {
 740              node.scrollTop = anchorTop + node.scrollAnchor.offset
 741              node.pendingScrollDelta = undefined
 742            }
 743            node.scrollAnchor = undefined
 744          }
 745          // At-bottom follow. Positional: if scrollTop was at (or past) the
 746          // previous max, pin to the new max. Scroll away → stop following;
 747          // scroll back (or scrollToBottom/sticky attr) → resume. The sticky
 748          // flag is OR'd in for cold start (scrollTop=0 before first layout)
 749          // and scrollToBottom-from-far-away (flag set before scrollTop moves)
 750          // — the imperative field takes precedence over the attribute so
 751          // scrollTo/scrollBy can break stickiness. pendingDelta<0 guard:
 752          // don't cancel an in-flight scroll-up when content races in.
 753          // Capture scrollTop before follow so ink.tsx can translate any
 754          // active text selection by the same delta (native terminal behavior:
 755          // view keeps scrolling, highlight walks up with the text).
 756          const scrollTopBeforeFollow = node.scrollTop ?? 0
 757          const sticky =
 758            node.stickyScroll ?? Boolean(node.attributes['stickyScroll'])
 759          const prevMaxScroll = Math.max(0, prevScrollHeight - prevInnerHeight)
 760          // Positional check only valid when content grew — virtualization can
 761          // transiently SHRINK scrollHeight (tail unmount + stale heightCache
 762          // spacer) making scrollTop >= prevMaxScroll true by artifact, not
 763          // because the user was at bottom.
 764          const grew = scrollHeight >= prevScrollHeight
 765          const atBottom =
 766            sticky || (grew && scrollTopBeforeFollow >= prevMaxScroll)
 767          if (atBottom && (node.pendingScrollDelta ?? 0) >= 0) {
 768            node.scrollTop = maxScroll
 769            node.pendingScrollDelta = undefined
 770            // Sync flag so useVirtualScroll's isSticky() agrees with positional
 771            // state — sticky-broken-but-at-bottom (wheel tremor, click-select
 772            // at max) otherwise leaves useVirtualScroll's clamp holding the
 773            // viewport short of new streaming content. scrollTo/scrollBy set
 774            // false; this restores true, same as scrollToBottom() would.
 775            // Only restore when (a) positionally at bottom and (b) the flag
 776            // was explicitly broken (===false) by scrollTo/scrollBy. When
 777            // undefined (never set by user action) leave it alone — setting it
 778            // would make the sticky flag sticky-by-default and lock out
 779            // direct scrollTop writes (e.g. the alt-screen-perf test).
 780            if (
 781              node.stickyScroll === false &&
 782              scrollTopBeforeFollow >= prevMaxScroll
 783            ) {
 784              node.stickyScroll = true
 785            }
 786          }
 787          const followDelta = (node.scrollTop ?? 0) - scrollTopBeforeFollow
 788          if (followDelta > 0) {
 789            const vpTop = node.scrollViewportTop ?? 0
 790            followScroll = {
 791              delta: followDelta,
 792              viewportTop: vpTop,
 793              viewportBottom: vpTop + innerHeight - 1,
 794            }
 795          }
 796          // Drain pendingScrollDelta. Native terminals (proportional burst
 797          // events) use proportional drain; xterm.js (VS Code, sparse events +
 798          // app-side accel curve) uses adaptive small-step drain. isXtermJs()
 799          // depends on the async XTVERSION probe, but by the time this runs
 800          // (pendingScrollDelta is only set by wheel events, >>50ms after
 801          // startup) the probe has resolved — same timing guarantee the
 802          // wheel-accel curve relies on.
 803          let cur = node.scrollTop ?? 0
 804          const pending = node.pendingScrollDelta
 805          const cMin = node.scrollClampMin
 806          const cMax = node.scrollClampMax
 807          const haveClamp = cMin !== undefined && cMax !== undefined
 808          if (pending !== undefined && pending !== 0) {
 809            // Drain continues even past the clamp — the render-clamp below
 810            // holds the VISUAL at the mounted edge regardless. Hard-stopping
 811            // here caused stop-start jutter: drain hits edge → pause → React
 812            // commits → clamp widens → drain resumes → edge again. Letting
 813            // scrollTop advance smoothly while the clamp lags gives continuous
 814            // visual scroll at React's commit rate (the clamp catches up each
 815            // commit). But THROTTLE the drain when already past the clamp so
 816            // scrollTop doesn't race 5000 rows ahead of the mounted range
 817            // (slide-cap would then take 200 commits to catch up = long
 818            // perceived stall at the edge). Past-clamp drain caps at ~4 rows/
 819            // frame, roughly matching React's slide rate so the gap stays
 820            // bounded and catch-up is quick once input stops.
 821            const pastClamp =
 822              haveClamp &&
 823              ((pending < 0 && cur < cMin) || (pending > 0 && cur > cMax))
 824            const eff = pastClamp ? Math.min(4, innerHeight >> 3) : innerHeight
 825            cur += isXtermJsHost()
 826              ? drainAdaptive(node, pending, eff)
 827              : drainProportional(node, pending, eff)
 828          } else if (pending === 0) {
 829            // Opposite scrollBy calls cancelled to zero — clear so we don't
 830            // schedule an infinite loop of no-op drain frames.
 831            node.pendingScrollDelta = undefined
 832          }
 833          let scrollTop = Math.max(0, Math.min(cur, maxScroll))
 834          // Virtual-scroll clamp: if scrollTop raced past the currently-mounted
 835          // range (burst PageUp before React re-renders), render at the EDGE of
 836          // the mounted children instead of blank spacer. Do NOT write back to
 837          // node.scrollTop — the clamped value is for this paint only; the real
 838          // scrollTop stays so React's next commit sees the target and mounts
 839          // the right range. Not scheduling scrollDrainNode here keeps the
 840          // clamp passive — React's commit → resetAfterCommit → onRender will
 841          // paint again with fresh bounds.
 842          const clamped = haveClamp
 843            ? Math.max(cMin, Math.min(scrollTop, cMax))
 844            : scrollTop
 845          node.scrollTop = scrollTop
 846          // Clamp hitting top/bottom consumes any remainder. Set drainPending
 847          // only after clamp so a wasted no-op frame isn't scheduled.
 848          if (scrollTop !== cur) node.pendingScrollDelta = undefined
 849          if (node.pendingScrollDelta !== undefined) scrollDrainNode = node
 850          scrollTop = clamped
 851  
 852          if (content && contentYoga) {
 853            // Compute content wrapper's absolute render position with scroll
 854            // offset applied, then render its children with culling.
 855            const contentX = x + contentYoga.getComputedLeft()
 856            const contentY = y + contentYoga.getComputedTop() - scrollTop
 857            // layoutShifted detection gap: when scrollTop moves by >= viewport
 858            // height (batched PageUps, fast wheel), every visible child gets
 859            // culled (cache dropped) and every newly-visible child has no
 860            // cache — so the children's positionChanged check can't fire.
 861            // The content wrapper's cached y (which encodes -scrollTop) is
 862            // the only node that survives to witness the scroll.
 863            const contentCached = nodeCache.get(content)
 864            let hint: ScrollHint | null = null
 865            if (contentCached && contentCached.y !== contentY) {
 866              // delta = newScrollTop - oldScrollTop (positive = scrolled down).
 867              // Capture a DECSTBM hint if the container itself didn't move
 868              // and the shift fits within the viewport — otherwise the full
 869              // rewrite is needed anyway, and layoutShifted stays the fallback.
 870              const delta = contentCached.y - contentY
 871              const regionTop = Math.floor(y + contentYoga.getComputedTop())
 872              const regionBottom = regionTop + innerHeight - 1
 873              if (
 874                cached?.y === y &&
 875                cached.height === height &&
 876                innerHeight > 0 &&
 877                Math.abs(delta) < innerHeight
 878              ) {
 879                hint = { top: regionTop, bottom: regionBottom, delta }
 880                scrollHint = hint
 881              } else {
 882                layoutShifted = true
 883              }
 884            }
 885            // Fast path: scroll (hint captured) with usable prevScreen.
 886            // Blit prevScreen's scroll region into next.screen, shift in-place
 887            // by delta (mirrors DECSTBM), then render ONLY the edge rows. The
 888            // nested clip keeps child writes out of stable rows — a tall child
 889            // that spans edge+stable still renders but stable cells are
 890            // clipped, preserving the blit. Avoids re-rendering every visible
 891            // child (expensive for long syntax-highlighted transcripts).
 892            //
 893            // When content.dirty (e.g. streaming text at the bottom of the
 894            // scroll), we still use the fast path — the dirty child is almost
 895            // always in the edge rows (the bottom, where new content appears).
 896            // After edge rendering, any dirty children in stable rows are
 897            // re-rendered in a second pass to avoid showing stale blitted
 898            // content.
 899            //
 900            // Guard: the fast path only handles pure scroll or bottom-append.
 901            // Child removal/insertion changes the content height in a way that
 902            // doesn't match the scroll delta — fall back to the full path so
 903            // removed children don't leave stale cells and shifted siblings
 904            // render at their new positions.
 905            const scrollHeight = contentYoga.getComputedHeight()
 906            const prevHeight = contentCached?.height ?? scrollHeight
 907            const heightDelta = scrollHeight - prevHeight
 908            const safeForFastPath =
 909              !hint ||
 910              heightDelta === 0 ||
 911              (hint.delta > 0 && heightDelta === hint.delta)
 912            // scrollHint is set above when hint is captured. If safeForFastPath
 913            // is false the full path renders a next.screen that doesn't match
 914            // the DECSTBM shift — emitting DECSTBM leaves stale rows (seen as
 915            // content bleeding through during scroll-up + streaming). Clear it.
 916            if (!safeForFastPath) scrollHint = null
 917            if (hint && prevScreen && safeForFastPath) {
 918              const { top, bottom, delta } = hint
 919              const w = Math.floor(width)
 920              output.blit(prevScreen, Math.floor(x), top, w, bottom - top + 1)
 921              output.shift(top, bottom, delta)
 922              // Edge rows: new content entering the viewport.
 923              const edgeTop = delta > 0 ? bottom - delta + 1 : top
 924              const edgeBottom = delta > 0 ? bottom : top - delta - 1
 925              output.clear({
 926                x: Math.floor(x),
 927                y: edgeTop,
 928                width: w,
 929                height: edgeBottom - edgeTop + 1,
 930              })
 931              output.clip({
 932                x1: undefined,
 933                x2: undefined,
 934                y1: edgeTop,
 935                y2: edgeBottom + 1,
 936              })
 937              // Snapshot dirty children before the first pass — the first
 938              // pass clears dirty flags, and edge-spanning children would be
 939              // missed by the second pass without this snapshot.
 940              const dirtyChildren = content.dirty
 941                ? new Set(content.childNodes.filter(c => (c as DOMElement).dirty))
 942                : null
 943              renderScrolledChildren(
 944                content,
 945                output,
 946                contentX,
 947                contentY,
 948                hasRemovedChild,
 949                undefined,
 950                // Cull to edge in child-local coords (inverse of contentY offset).
 951                edgeTop - contentY,
 952                edgeBottom + 1 - contentY,
 953                boxBackgroundColor,
 954                true,
 955              )
 956              output.unclip()
 957  
 958              // Second pass: re-render children in stable rows whose screen
 959              // position doesn't match where the shift put their old pixels.
 960              // Covers TWO cases:
 961              //   1. Dirty children — their content changed, blitted pixels are
 962              //      stale regardless of position.
 963              //   2. Clean children BELOW a middle-growth point — when a dirty
 964              //      sibling above them grows, their yogaTop increases but
 965              //      scrollTop increases by the same amount (sticky), so their
 966              //      screenY is CONSTANT. The shift moved their old pixels to
 967              //      screenY-delta (wrong); they should stay at screenY. Without
 968              //      this, the spinner/tmux-monitor ghost at shifted positions
 969              //      during streaming (e.g. triple spinner, pill duplication).
 970              //   For bottom-append (the common case), all clean children are
 971              //   ABOVE the growth point; their screenY decreased by delta and
 972              //   the shift put them at the right place — skipped here, fast
 973              //   path preserved.
 974              if (dirtyChildren) {
 975                const edgeTopLocal = edgeTop - contentY
 976                const edgeBottomLocal = edgeBottom + 1 - contentY
 977                const spaces = ' '.repeat(w)
 978                // Track cumulative height change of children iterated so far.
 979                // A clean child's yogaTop is unchanged iff this is zero (no
 980                // sibling above it grew/shrank/mounted). When zero, the skip
 981                // check cached.y−delta === screenY reduces to delta === delta
 982                // (tautology) → skip without yoga reads. Restores O(dirty)
 983                // that #24536 traded away: for bottom-append the dirty child
 984                // is last (all clean children skip); for virtual-scroll range
 985                // shift the topSpacer shrink + new-item heights self-balance
 986                // to zero before reaching the clean block. Middle-growth
 987                // leaves shift non-zero → clean children after the growth
 988                // point fall through to yoga + the fine-grained check below,
 989                // preserving the ghost-box fix.
 990                let cumHeightShift = 0
 991                for (const childNode of content.childNodes) {
 992                  const childElem = childNode as DOMElement
 993                  const isDirty = dirtyChildren.has(childNode)
 994                  if (!isDirty && cumHeightShift === 0) {
 995                    if (nodeCache.has(childElem)) continue
 996                    // Uncached = culled last frame, now re-entering. blit
 997                    // never painted it → fall through to yoga + render.
 998                    // Height unchanged (clean), so cumHeightShift stays 0.
 999                  }
1000                  const cy = childElem.yogaNode
1001                  if (!cy) continue
1002                  const childTop = cy.getComputedTop()
1003                  const childH = cy.getComputedHeight()
1004                  const childBottom = childTop + childH
1005                  if (isDirty) {
1006                    const prev = nodeCache.get(childElem)
1007                    cumHeightShift += childH - (prev ? prev.height : 0)
1008                  }
1009                  // Skip culled children (outside viewport)
1010                  if (
1011                    childBottom <= scrollTop ||
1012                    childTop >= scrollTop + innerHeight
1013                  )
1014                    continue
1015                  // Skip children entirely within edge rows (already rendered)
1016                  if (childTop >= edgeTopLocal && childBottom <= edgeBottomLocal)
1017                    continue
1018                  const screenY = Math.floor(contentY + childTop)
1019                  // Clean children reaching here have cumHeightShift ≠ 0 OR
1020                  // no cache. Re-check precisely: cached.y − delta is where
1021                  // the shift left old pixels; if it equals new screenY the
1022                  // blit is correct (shift re-balanced at this child, or
1023                  // yogaTop happens to net out). No cache → blit never
1024                  // painted it → render.
1025                  if (!isDirty) {
1026                    const childCached = nodeCache.get(childElem)
1027                    if (
1028                      childCached &&
1029                      Math.floor(childCached.y) - delta === screenY
1030                    ) {
1031                      continue
1032                    }
1033                  }
1034                  // Wipe this child's region with spaces to overwrite stale
1035                  // blitted content — output.clear() only expands damage and
1036                  // cannot zero cells that the blit already wrote.
1037                  const screenBottom = Math.min(
1038                    Math.floor(contentY + childBottom),
1039                    Math.floor((y1 ?? y) + padTop + innerHeight),
1040                  )
1041                  if (screenY < screenBottom) {
1042                    const fill = Array(screenBottom - screenY)
1043                      .fill(spaces)
1044                      .join('\n')
1045                    output.write(Math.floor(x), screenY, fill)
1046                    output.clip({
1047                      x1: undefined,
1048                      x2: undefined,
1049                      y1: screenY,
1050                      y2: screenBottom,
1051                    })
1052                    renderNodeToOutput(childElem, output, {
1053                      offsetX: contentX,
1054                      offsetY: contentY,
1055                      prevScreen: undefined,
1056                      inheritedBackgroundColor: boxBackgroundColor,
1057                    })
1058                    output.unclip()
1059                  }
1060                }
1061              }
1062  
1063              // Third pass: repair rows where shifted copies of absolute
1064              // overlays landed. The blit copied prevScreen cells INCLUDING
1065              // overlay pixels (overlays render AFTER this ScrollBox so they
1066              // painted into prevScreen's scroll region). After shift, those
1067              // pixels sit at (rect.y - delta) — neither edge render nor the
1068              // overlay's own re-render covers them. Wipe and re-render
1069              // ScrollBox content so the diff writes correct cells.
1070              const spaces = absoluteRectsPrev.length ? ' '.repeat(w) : ''
1071              for (const r of absoluteRectsPrev) {
1072                if (r.y >= bottom + 1 || r.y + r.height <= top) continue
1073                const shiftedTop = Math.max(top, Math.floor(r.y) - delta)
1074                const shiftedBottom = Math.min(
1075                  bottom + 1,
1076                  Math.floor(r.y + r.height) - delta,
1077                )
1078                // Skip if entirely within edge rows (already rendered).
1079                if (shiftedTop >= edgeTop && shiftedBottom <= edgeBottom + 1)
1080                  continue
1081                if (shiftedTop >= shiftedBottom) continue
1082                const fill = Array(shiftedBottom - shiftedTop)
1083                  .fill(spaces)
1084                  .join('\n')
1085                output.write(Math.floor(x), shiftedTop, fill)
1086                output.clip({
1087                  x1: undefined,
1088                  x2: undefined,
1089                  y1: shiftedTop,
1090                  y2: shiftedBottom,
1091                })
1092                renderScrolledChildren(
1093                  content,
1094                  output,
1095                  contentX,
1096                  contentY,
1097                  hasRemovedChild,
1098                  undefined,
1099                  shiftedTop - contentY,
1100                  shiftedBottom - contentY,
1101                  boxBackgroundColor,
1102                  true,
1103                )
1104                output.unclip()
1105              }
1106            } else {
1107              // Full path. Two sub-cases:
1108              //
1109              // Scrolled without a usable hint (big jump, container moved):
1110              // child positions in prevScreen are stale. Clear the viewport
1111              // and disable blit so children don't restore shifted content.
1112              //
1113              // No scroll (spinner tick, content edit): child positions in
1114              // prevScreen are still valid. Skip the viewport clear and pass
1115              // prevScreen so unchanged children blit. Dirty children already
1116              // self-clear via their own cached-rect clear. Without this, a
1117              // spinner inside ScrollBox forces a full-content rewrite every
1118              // frame — on wide terminals over tmux (no BSU/ESU) the
1119              // bandwidth crosses the chunk boundary and the frame tears.
1120              const scrolled = contentCached && contentCached.y !== contentY
1121              if (scrolled && y1 !== undefined && y2 !== undefined) {
1122                output.clear({
1123                  x: Math.floor(x),
1124                  y: Math.floor(y1),
1125                  width: Math.floor(width),
1126                  height: Math.floor(y2 - y1),
1127                })
1128              }
1129              // positionChanged (ScrollBox height shrunk — pill mount) means a
1130              // child spanning the old bottom edge would blit its full cached
1131              // rect past the new clip. output.ts clips blits now, but also
1132              // disable prevScreen here so the partial-row child re-renders at
1133              // correct bounds instead of blitting a clipped (truncated) old
1134              // rect.
1135              renderScrolledChildren(
1136                content,
1137                output,
1138                contentX,
1139                contentY,
1140                hasRemovedChild,
1141                scrolled || positionChanged ? undefined : prevScreen,
1142                scrollTop,
1143                scrollTop + innerHeight,
1144                boxBackgroundColor,
1145              )
1146            }
1147            nodeCache.set(content, {
1148              x: contentX,
1149              y: contentY,
1150              width: contentYoga.getComputedWidth(),
1151              height: contentYoga.getComputedHeight(),
1152            })
1153            content.dirty = false
1154          }
1155        } else {
1156          // Fill interior with background color before rendering children.
1157          // This covers padding areas and empty space; child text inherits
1158          // the color via inheritedBackgroundColor so written cells also
1159          // get the background.
1160          // Disable prevScreen for children: the fill overwrites the entire
1161          // interior each render, so child blits from prevScreen would restore
1162          // stale cells (wrong bg if it changed) on top of the fresh fill.
1163          const ownBackgroundColor = node.style.backgroundColor
1164          if (ownBackgroundColor || node.style.opaque) {
1165            const borderLeft = yogaNode.getComputedBorder(LayoutEdge.Left)
1166            const borderRight = yogaNode.getComputedBorder(LayoutEdge.Right)
1167            const borderTop = yogaNode.getComputedBorder(LayoutEdge.Top)
1168            const borderBottom = yogaNode.getComputedBorder(LayoutEdge.Bottom)
1169            const innerWidth = Math.floor(width) - borderLeft - borderRight
1170            const innerHeight = Math.floor(height) - borderTop - borderBottom
1171            if (innerWidth > 0 && innerHeight > 0) {
1172              const spaces = ' '.repeat(innerWidth)
1173              const fillLine = ownBackgroundColor
1174                ? applyTextStyles(spaces, { backgroundColor: ownBackgroundColor })
1175                : spaces
1176              const fill = Array(innerHeight).fill(fillLine).join('\n')
1177              output.write(x + borderLeft, y + borderTop, fill)
1178            }
1179          }
1180  
1181          renderChildren(
1182            node,
1183            output,
1184            x,
1185            y,
1186            hasRemovedChild,
1187            // backgroundColor and opaque both disable child blit: the fill
1188            // overwrites the entire interior each render, so any child whose
1189            // layout position shifted would blit stale cells from prevScreen
1190            // on top of the fresh fill. Previously opaque kept blit enabled
1191            // on the assumption that plain-space fill + unchanged children =
1192            // valid composite, but children CAN reposition (ScrollBox remeasure
1193            // on re-render → /permissions body blanked on Down arrow, #25436).
1194            ownBackgroundColor || node.style.opaque ? undefined : prevScreen,
1195            boxBackgroundColor,
1196          )
1197        }
1198  
1199        if (needsClip) {
1200          output.unclip()
1201        }
1202  
1203        // Render border AFTER children to ensure it's not overwritten by child
1204        // clearing operations. When a child shrinks, it clears its old area,
1205        // which may overlap with where the parent's border now is.
1206        renderBorder(x, y, node, output)
1207      } else if (node.nodeName === 'ink-root') {
1208        renderChildren(
1209          node,
1210          output,
1211          x,
1212          y,
1213          hasRemovedChild,
1214          prevScreen,
1215          inheritedBackgroundColor,
1216        )
1217      }
1218  
1219      // Cache layout bounds for dirty tracking
1220      const rect = { x, y, width, height, top: yogaTop }
1221      nodeCache.set(node, rect)
1222      if (node.style.position === 'absolute') {
1223        absoluteRectsCur.push(rect)
1224      }
1225      node.dirty = false
1226    }
1227  }
1228  
1229  // Overflow contamination: content overflows right/down, so clean siblings
1230  // AFTER a dirty/removed sibling can contain stale overflow in prevScreen.
1231  // Disable blit for siblings after a dirty child — but still pass prevScreen
1232  // TO the dirty child itself so its clean descendants can blit. The dirty
1233  // child's own blit check already fails (node.dirty=true at line 216), so
1234  // passing prevScreen only benefits its subtree.
1235  // For removed children we don't know their original position, so
1236  // conservatively disable blit for all.
1237  //
1238  // Clipped children (overflow hidden/scroll on both axes) cannot overflow
1239  // onto later siblings — their content is confined to their layout bounds.
1240  // Skip the contamination guard for them so later siblings can still blit.
1241  // Without this, a spinner inside a ScrollBox dirties the wrapper on every
1242  // tick and the bottom prompt section never blits → 100% writes every frame.
1243  //
1244  // Exception: absolute-positioned clipped children may have layout bounds
1245  // that overlap arbitrary siblings, so the clipping does not help.
1246  //
1247  // Overlap contamination (seenDirtyClipped): a later ABSOLUTE sibling whose
1248  // rect sits inside a dirty clipped child's bounds would blit stale cells
1249  // from prevScreen — the clipped child just rewrote those cells this frame.
1250  // The clipsBothAxes skip only protects against OVERFLOW (clipped child
1251  // painting outside its bounds), not overlap (absolute sibling painting
1252  // inside them). For non-opaque absolute siblings, skipSelfBlit forces
1253  // descent (the full-width rect has transparent gaps → stale blit) while
1254  // still passing prevScreen so opaque descendants can blit their narrower
1255  // rects (NewMessagesPill's inner Text with backgroundColor). Opaque
1256  // absolute siblings fill their entire rect — direct blit is safe.
1257  function renderChildren(
1258    node: DOMElement,
1259    output: Output,
1260    offsetX: number,
1261    offsetY: number,
1262    hasRemovedChild: boolean,
1263    prevScreen: Screen | undefined,
1264    inheritedBackgroundColor: Color | undefined,
1265  ): void {
1266    let seenDirtyChild = false
1267    let seenDirtyClipped = false
1268    for (const childNode of node.childNodes) {
1269      const childElem = childNode as DOMElement
1270      // Capture dirty before rendering — renderNodeToOutput clears the flag
1271      const wasDirty = childElem.dirty
1272      const isAbsolute = childElem.style.position === 'absolute'
1273      renderNodeToOutput(childElem, output, {
1274        offsetX,
1275        offsetY,
1276        prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
1277        // Short-circuits on seenDirtyClipped (false in the common case) so
1278        // the opaque/bg reads don't happen per-child per-frame.
1279        skipSelfBlit:
1280          seenDirtyClipped &&
1281          isAbsolute &&
1282          !childElem.style.opaque &&
1283          childElem.style.backgroundColor === undefined,
1284        inheritedBackgroundColor,
1285      })
1286      if (wasDirty && !seenDirtyChild) {
1287        if (!clipsBothAxes(childElem) || isAbsolute) {
1288          seenDirtyChild = true
1289        } else {
1290          seenDirtyClipped = true
1291        }
1292      }
1293    }
1294  }
1295  
1296  function clipsBothAxes(node: DOMElement): boolean {
1297    const ox = node.style.overflowX ?? node.style.overflow
1298    const oy = node.style.overflowY ?? node.style.overflow
1299    return (
1300      (ox === 'hidden' || ox === 'scroll') && (oy === 'hidden' || oy === 'scroll')
1301    )
1302  }
1303  
1304  // When Yoga squeezes a box to h=0, the ghost only happens if a sibling
1305  // lands at the same computed top — then both write to that row and the
1306  // shorter content leaves the longer's tail visible. Yoga's pixel-grid
1307  // rounding can give h=0 while still advancing the next sibling's top
1308  // (HelpV2's third shortcuts column), so h=0 alone isn't sufficient.
1309  function siblingSharesY(node: DOMElement, yogaNode: LayoutNode): boolean {
1310    const parent = node.parentNode
1311    if (!parent) return false
1312    const myTop = yogaNode.getComputedTop()
1313    const siblings = parent.childNodes
1314    const idx = siblings.indexOf(node)
1315    for (let i = idx + 1; i < siblings.length; i++) {
1316      const sib = (siblings[i] as DOMElement).yogaNode
1317      if (!sib) continue
1318      return sib.getComputedTop() === myTop
1319    }
1320    // No next sibling with a yoga node — check previous. A run of h=0 boxes
1321    // at the tail would all share y with each other.
1322    for (let i = idx - 1; i >= 0; i--) {
1323      const sib = (siblings[i] as DOMElement).yogaNode
1324      if (!sib) continue
1325      return sib.getComputedTop() === myTop
1326    }
1327    return false
1328  }
1329  
1330  // When a node blits, its absolute-positioned descendants that paint outside
1331  // the node's layout bounds are NOT covered by the blit (which only copies
1332  // the node's own rect). If a dirty sibling re-rendered and overwrote those
1333  // cells, we must re-blit them from prevScreen so the overlays survive.
1334  // Example: PromptInputFooter's slash menu uses position='absolute' bottom='100%'
1335  // to float above the prompt; a spinner tick in the ScrollBox above re-renders
1336  // and overwrites those cells. Without this, the menu vanishes on the next frame.
1337  function blitEscapingAbsoluteDescendants(
1338    node: DOMElement,
1339    output: Output,
1340    prevScreen: Screen,
1341    px: number,
1342    py: number,
1343    pw: number,
1344    ph: number,
1345  ): void {
1346    const pr = px + pw
1347    const pb = py + ph
1348    for (const child of node.childNodes) {
1349      if (child.nodeName === '#text') continue
1350      const elem = child as DOMElement
1351      if (elem.style.position === 'absolute') {
1352        const cached = nodeCache.get(elem)
1353        if (cached) {
1354          absoluteRectsCur.push(cached)
1355          const cx = Math.floor(cached.x)
1356          const cy = Math.floor(cached.y)
1357          const cw = Math.floor(cached.width)
1358          const ch = Math.floor(cached.height)
1359          // Only blit rects that extend outside the parent's layout bounds —
1360          // cells within the parent rect are already covered by the parent blit.
1361          if (cx < px || cy < py || cx + cw > pr || cy + ch > pb) {
1362            output.blit(prevScreen, cx, cy, cw, ch)
1363          }
1364        }
1365      }
1366      // Recurse — absolute descendants can be nested arbitrarily deep
1367      blitEscapingAbsoluteDescendants(elem, output, prevScreen, px, py, pw, ph)
1368    }
1369  }
1370  
1371  // Render children of a scroll container with viewport culling.
1372  // scrollTopY..scrollBottomY are the visible window in CHILD-LOCAL Yoga coords
1373  // (i.e. what getComputedTop() returns). Children entirely outside this window
1374  // are skipped; their nodeCache entry is deleted so if they re-enter the
1375  // viewport later they don't emit a stale clear for a position now occupied
1376  // by a sibling.
1377  function renderScrolledChildren(
1378    node: DOMElement,
1379    output: Output,
1380    offsetX: number,
1381    offsetY: number,
1382    hasRemovedChild: boolean,
1383    prevScreen: Screen | undefined,
1384    scrollTopY: number,
1385    scrollBottomY: number,
1386    inheritedBackgroundColor: Color | undefined,
1387    // When true (DECSTBM fast path), culled children keep their cache —
1388    // the blit+shift put stable rows in next.screen so stale cache is
1389    // never read. Avoids walking O(total_children * subtree_depth) per frame.
1390    preserveCulledCache = false,
1391  ): void {
1392    let seenDirtyChild = false
1393    // Track cumulative height shift of dirty children iterated so far. When
1394    // zero, a clean child's yogaTop is unchanged (no sibling above it grew),
1395    // so cached.top is fresh and the cull check skips yoga. Bottom-append
1396    // has the dirty child last → all prior clean children hit cache →
1397    // O(dirty) not O(mounted). Middle-growth leaves shift non-zero after
1398    // the dirty child → subsequent children yoga-read (needed for correct
1399    // culling since their yogaTop shifted).
1400    let cumHeightShift = 0
1401    for (const childNode of node.childNodes) {
1402      const childElem = childNode as DOMElement
1403      const cy = childElem.yogaNode
1404      if (cy) {
1405        const cached = nodeCache.get(childElem)
1406        let top: number
1407        let height: number
1408        if (
1409          cached?.top !== undefined &&
1410          !childElem.dirty &&
1411          cumHeightShift === 0
1412        ) {
1413          top = cached.top
1414          height = cached.height
1415        } else {
1416          top = cy.getComputedTop()
1417          height = cy.getComputedHeight()
1418          if (childElem.dirty) {
1419            cumHeightShift += height - (cached ? cached.height : 0)
1420          }
1421          // Refresh cached top so next frame's cumShift===0 path stays
1422          // correct. For culled children with preserveCulledCache=true this
1423          // is the ONLY refresh point — without it, a middle-growth frame
1424          // leaves stale tops that misfire next frame.
1425          if (cached) cached.top = top
1426        }
1427        const bottom = top + height
1428        if (bottom <= scrollTopY || top >= scrollBottomY) {
1429          // Culled — outside visible window. Drop stale cache entries from
1430          // the subtree so when this child re-enters it doesn't fire clears
1431          // at positions now occupied by siblings. The viewport-clear on
1432          // scroll-change handles the visible-area repaint.
1433          if (!preserveCulledCache) dropSubtreeCache(childElem)
1434          continue
1435        }
1436      }
1437      const wasDirty = childElem.dirty
1438      renderNodeToOutput(childElem, output, {
1439        offsetX,
1440        offsetY,
1441        prevScreen: hasRemovedChild || seenDirtyChild ? undefined : prevScreen,
1442        inheritedBackgroundColor,
1443      })
1444      if (wasDirty) {
1445        seenDirtyChild = true
1446      }
1447    }
1448  }
1449  
1450  function dropSubtreeCache(node: DOMElement): void {
1451    nodeCache.delete(node)
1452    for (const child of node.childNodes) {
1453      if (child.nodeName !== '#text') {
1454        dropSubtreeCache(child as DOMElement)
1455      }
1456    }
1457  }
1458  
1459  // Exported for testing
1460  export { buildCharToSegmentMap, applyStylesToWrappedText }
1461  
1462  export default renderNodeToOutput