source dump of claude code
at main 178 lines 7.7 kB view raw
1import { logForDebugging } from 'src/utils/debug.js' 2import { type DOMElement, markDirty } from './dom.js' 3import type { Frame } from './frame.js' 4import { consumeAbsoluteRemovedFlag } from './node-cache.js' 5import Output from './output.js' 6import renderNodeToOutput, { 7 getScrollDrainNode, 8 getScrollHint, 9 resetLayoutShifted, 10 resetScrollDrainNode, 11 resetScrollHint, 12} from './render-node-to-output.js' 13import { createScreen, type StylePool } from './screen.js' 14 15export type RenderOptions = { 16 frontFrame: Frame 17 backFrame: Frame 18 isTTY: boolean 19 terminalWidth: number 20 terminalRows: number 21 altScreen: boolean 22 // True when the previous frame's screen buffer was mutated post-render 23 // (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT), 24 // or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would 25 // copy stale inverted cells, blanks, or nothing. When false, blit is safe. 26 prevFrameContaminated: boolean 27} 28 29export type Renderer = (options: RenderOptions) => Frame 30 31export default function createRenderer( 32 node: DOMElement, 33 stylePool: StylePool, 34): Renderer { 35 // Reuse Output across frames so charCache (tokenize + grapheme clustering) 36 // persists — most lines don't change between renders. 37 let output: Output | undefined 38 return options => { 39 const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } = 40 options 41 const prevScreen = frontFrame.screen 42 const backScreen = backFrame.screen 43 // Read pools from the back buffer's screen — pools may be replaced 44 // between frames (generational reset), so we can't capture them in the closure 45 const charPool = backScreen.charPool 46 const hyperlinkPool = backScreen.hyperlinkPool 47 48 // Return empty frame if yoga node doesn't exist or layout hasn't been computed yet. 49 // getComputedHeight() returns NaN before calculateLayout() is called. 50 // Also check for invalid dimensions (negative, Infinity) that would cause RangeError 51 // when creating arrays. 52 const computedHeight = node.yogaNode?.getComputedHeight() 53 const computedWidth = node.yogaNode?.getComputedWidth() 54 const hasInvalidHeight = 55 computedHeight === undefined || 56 !Number.isFinite(computedHeight) || 57 computedHeight < 0 58 const hasInvalidWidth = 59 computedWidth === undefined || 60 !Number.isFinite(computedWidth) || 61 computedWidth < 0 62 63 if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) { 64 // Log to help diagnose root cause (visible with --debug flag) 65 if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) { 66 logForDebugging( 67 `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` + 68 `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`, 69 ) 70 } 71 return { 72 screen: createScreen( 73 terminalWidth, 74 0, 75 stylePool, 76 charPool, 77 hyperlinkPool, 78 ), 79 viewport: { width: terminalWidth, height: terminalRows }, 80 cursor: { x: 0, y: 0, visible: true }, 81 } 82 } 83 84 const width = Math.floor(node.yogaNode.getComputedWidth()) 85 const yogaHeight = Math.floor(node.yogaNode.getComputedHeight()) 86 // Alt-screen: the screen buffer IS the alt buffer — always exactly 87 // terminalRows tall. <AlternateScreen> wraps children in <Box 88 // height={rows} flexShrink={0}>, so yogaHeight should equal 89 // terminalRows. But if something renders as a SIBLING of that Box 90 // (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight 91 // exceeds rows and every assumption below (viewport +1 hack, cursor.y 92 // clamp, log-update's heightDelta===0 fast path) breaks, desyncing 93 // virtual/physical cursors. Clamping here enforces the invariant: 94 // overflow writes land at y >= screen.height and setCellAt drops 95 // them. The sibling is invisible (obvious, easy to find) instead of 96 // corrupting the whole terminal. 97 const height = options.altScreen ? terminalRows : yogaHeight 98 if (options.altScreen && yogaHeight > terminalRows) { 99 logForDebugging( 100 `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows}` + 101 `something is rendering outside <AlternateScreen>. Overflow clipped.`, 102 { level: 'warn' }, 103 ) 104 } 105 const screen = 106 backScreen ?? 107 createScreen(width, height, stylePool, charPool, hyperlinkPool) 108 if (output) { 109 output.reset(width, height, screen) 110 } else { 111 output = new Output({ width, height, stylePool, screen }) 112 } 113 114 resetLayoutShifted() 115 resetScrollHint() 116 resetScrollDrainNode() 117 118 // prevFrameContaminated: selection overlay mutated the returned screen 119 // buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it 120 // with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame 121 // would copy stale inverted cells / blanks / nothing. When clean, blit 122 // restores the O(unchanged) fast path for steady-state frames (spinner 123 // tick, text stream). 124 // Removing an absolute-positioned node poisons prevScreen: it may 125 // have painted over non-siblings (e.g. an overlay over a ScrollBox 126 // earlier in tree order), so their blits would restore the removed 127 // node's pixels. hasRemovedChild only shields direct siblings. 128 // Normal-flow removals don't paint cross-subtree and are fine. 129 const absoluteRemoved = consumeAbsoluteRemovedFlag() 130 renderNodeToOutput(node, output, { 131 prevScreen: 132 absoluteRemoved || options.prevFrameContaminated 133 ? undefined 134 : prevScreen, 135 }) 136 137 const renderedScreen = output.get() 138 139 // Drain continuation: render cleared scrollbox.dirty, so next frame's 140 // root blit would skip the subtree. markDirty walks ancestors so the 141 // next frame descends. Done AFTER render so the clear-dirty at the end 142 // of renderNodeToOutput doesn't overwrite this. 143 const drainNode = getScrollDrainNode() 144 if (drainNode) markDirty(drainNode) 145 146 return { 147 scrollHint: options.altScreen ? getScrollHint() : null, 148 scrollDrainPending: drainNode !== null, 149 screen: renderedScreen, 150 viewport: { 151 width: terminalWidth, 152 // Alt screen: fake viewport.height = rows + 1 so that 153 // shouldClearScreen()'s `screen.height >= viewport.height` check 154 // (which treats exactly-filling content as "overflows" for 155 // scrollback purposes) never fires. Alt-screen content is always 156 // exactly `rows` tall (via <Box height={rows}>) but never 157 // scrolls — the cursor.y clamp below keeps the cursor-restore 158 // from emitting an LF. With the standard diff path, every frame 159 // is incremental; no fullResetSequence_CAUSES_FLICKER. 160 height: options.altScreen ? terminalRows + 1 : terminalRows, 161 }, 162 cursor: { 163 x: 0, 164 // In the alt screen, keep the cursor inside the viewport. When 165 // screen.height === terminalRows exactly (content fills the alt 166 // screen), cursor.y = screen.height would trigger log-update's 167 // cursor-restore LF at the last row, scrolling one row off the top 168 // of the alt buffer and desyncing the diff's cursor model. The 169 // cursor is hidden so its position only matters for diff coords. 170 y: options.altScreen 171 ? Math.max(0, Math.min(screen.height, terminalRows) - 1) 172 : screen.height, 173 // Hide cursor when there's dynamic output to render (only in TTY mode) 174 visible: !isTTY || screen.height === 0, 175 }, 176 } 177 } 178}