source dump of claude code
at main 1462 lines 63 kB view raw
1import indentString from 'indent-string' 2import { applyTextStyles } from './colorize.js' 3import type { DOMElement } from './dom.js' 4import getMaxWidth from './get-max-width.js' 5import type { Rectangle } from './layout/geometry.js' 6import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js' 7import { nodeCache, pendingClears } from './node-cache.js' 8import type Output from './output.js' 9import renderBorder from './render-border.js' 10import type { Screen } from './screen.js' 11import { 12 type StyledSegment, 13 squashTextNodesToSegments, 14} from './squash-text-nodes.js' 15import type { Color } from './styles.js' 16import { isXtermJs } from './terminal.js' 17import { widestLine } from './widest-line.js' 18import 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. 23function 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). 34let layoutShifted = false 35 36export function resetLayoutShifted(): void { 37 layoutShifted = false 38} 39 40export 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). 49export type ScrollHint = { top: number; bottom: number; delta: number } 50let 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. 57let absoluteRectsPrev: Rectangle[] = [] 58let absoluteRectsCur: Rectangle[] = [] 59 60export function resetScrollHint(): void { 61 scrollHint = null 62 absoluteRectsPrev = absoluteRectsCur 63 absoluteRectsCur = [] 64} 65 66export 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. 75let scrollDrainNode: DOMElement | null = null 76 77export function resetScrollDrainNode(): void { 78 scrollDrainNode = null 79} 80 81export 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. 93export type FollowScroll = { 94 delta: number 95 viewportTop: number 96 viewportBottom: number 97} 98let followScroll: FollowScroll | null = null 99 100export 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. 110const 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. 117const SCROLL_INSTANT_THRESHOLD = 5 // ≤ this: drain all at once 118const SCROLL_HIGH_PENDING = 12 // threshold for HIGH step 119const SCROLL_STEP_MED = 2 // pending (INSTANT, HIGH): catch-up 120const SCROLL_STEP_HIGH = 3 // pending ≥ HIGH: fast flick 121const SCROLL_MAX_PENDING = 30 // snap excess beyond this 122 123// xterm.js adaptive drain. Returns rows applied; mutates pendingScrollDelta. 124function 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. 161function 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(). 181const OSC = '\u001B]' 182const BEL = '\u0007' 183 184function 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 */ 192function 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 */ 211function 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 */ 335function 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 365function 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 387function 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. 1257function 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 1296function 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. 1309function 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. 1337function 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. 1377function 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 1450function 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 1460export { buildCharToSegmentMap, applyStylesToWrappedText } 1461 1462export default renderNodeToOutput