source dump of claude code
at main 484 lines 15 kB view raw
1import type { FocusManager } from './focus.js' 2import { createLayoutNode } from './layout/engine.js' 3import type { LayoutNode } from './layout/node.js' 4import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js' 5import measureText from './measure-text.js' 6import { addPendingClear, nodeCache } from './node-cache.js' 7import squashTextNodes from './squash-text-nodes.js' 8import type { Styles, TextStyles } from './styles.js' 9import { expandTabs } from './tabstops.js' 10import wrapText from './wrap-text.js' 11 12type InkNode = { 13 parentNode: DOMElement | undefined 14 yogaNode?: LayoutNode 15 style: Styles 16} 17 18export type TextName = '#text' 19export type ElementNames = 20 | 'ink-root' 21 | 'ink-box' 22 | 'ink-text' 23 | 'ink-virtual-text' 24 | 'ink-link' 25 | 'ink-progress' 26 | 'ink-raw-ansi' 27 28export type NodeNames = ElementNames | TextName 29 30// eslint-disable-next-line @typescript-eslint/naming-convention 31export type DOMElement = { 32 nodeName: ElementNames 33 attributes: Record<string, DOMNodeAttribute> 34 childNodes: DOMNode[] 35 textStyles?: TextStyles 36 37 // Internal properties 38 onComputeLayout?: () => void 39 onRender?: () => void 40 onImmediateRender?: () => void 41 // Used to skip empty renders during React 19's effect double-invoke in test mode 42 hasRenderedContent?: boolean 43 44 // When true, this node needs re-rendering 45 dirty: boolean 46 // Set by the reconciler's hideInstance/unhideInstance; survives style updates. 47 isHidden?: boolean 48 // Event handlers set by the reconciler for the capture/bubble dispatcher. 49 // Stored separately from attributes so handler identity changes don't 50 // mark dirty and defeat the blit optimization. 51 _eventHandlers?: Record<string, unknown> 52 53 // Scroll state for overflow: 'scroll' boxes. scrollTop is the number of 54 // rows the content is scrolled down by. scrollHeight/scrollViewportHeight 55 // are computed at render time and stored for imperative access. stickyScroll 56 // auto-pins scrollTop to the bottom when content grows. 57 scrollTop?: number 58 // Accumulated scroll delta not yet applied to scrollTop. The renderer 59 // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show 60 // intermediate frames instead of one big jump. Direction reversal 61 // naturally cancels (pure accumulator, no target tracking). 62 pendingScrollDelta?: number 63 // Render-time clamp bounds for virtual scroll. useVirtualScroll writes 64 // the currently-mounted children's coverage span; render-node-to-output 65 // clamps scrollTop to stay within it. Prevents blank screen when 66 // scrollTo's direct write races past React's async re-render — instead 67 // of painting spacer (blank), the renderer holds at the edge of mounted 68 // content until React catches up (next commit updates these bounds and 69 // the clamp releases). Undefined = no clamp (sticky-scroll, cold start). 70 scrollClampMin?: number 71 scrollClampMax?: number 72 scrollHeight?: number 73 scrollViewportHeight?: number 74 scrollViewportTop?: number 75 stickyScroll?: boolean 76 // Set by ScrollBox.scrollToElement; render-node-to-output reads 77 // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) 78 // and sets scrollTop = top + offset, then clears this. Unlike an 79 // imperative scrollTo(N) which bakes in a number that's stale by the 80 // time the throttled render fires, the element ref defers the position 81 // read to paint time. One-shot. 82 scrollAnchor?: { el: DOMElement; offset: number } 83 // Only set on ink-root. The document owns focus — any node can 84 // reach it by walking parentNode, like browser getRootNode(). 85 focusManager?: FocusManager 86 // React component stack captured at createInstance time (reconciler.ts), 87 // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when 88 // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to 89 // attribute scrollback-diff full-resets to the component that caused them. 90 debugOwnerChain?: string[] 91} & InkNode 92 93export type TextNode = { 94 nodeName: TextName 95 nodeValue: string 96} & InkNode 97 98// eslint-disable-next-line @typescript-eslint/naming-convention 99export type DOMNode<T = { nodeName: NodeNames }> = T extends { 100 nodeName: infer U 101} 102 ? U extends '#text' 103 ? TextNode 104 : DOMElement 105 : never 106 107// eslint-disable-next-line @typescript-eslint/naming-convention 108export type DOMNodeAttribute = boolean | string | number 109 110export const createNode = (nodeName: ElementNames): DOMElement => { 111 const needsYogaNode = 112 nodeName !== 'ink-virtual-text' && 113 nodeName !== 'ink-link' && 114 nodeName !== 'ink-progress' 115 const node: DOMElement = { 116 nodeName, 117 style: {}, 118 attributes: {}, 119 childNodes: [], 120 parentNode: undefined, 121 yogaNode: needsYogaNode ? createLayoutNode() : undefined, 122 dirty: false, 123 } 124 125 if (nodeName === 'ink-text') { 126 node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) 127 } else if (nodeName === 'ink-raw-ansi') { 128 node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node)) 129 } 130 131 return node 132} 133 134export const appendChildNode = ( 135 node: DOMElement, 136 childNode: DOMElement, 137): void => { 138 if (childNode.parentNode) { 139 removeChildNode(childNode.parentNode, childNode) 140 } 141 142 childNode.parentNode = node 143 node.childNodes.push(childNode) 144 145 if (childNode.yogaNode) { 146 node.yogaNode?.insertChild( 147 childNode.yogaNode, 148 node.yogaNode.getChildCount(), 149 ) 150 } 151 152 markDirty(node) 153} 154 155export const insertBeforeNode = ( 156 node: DOMElement, 157 newChildNode: DOMNode, 158 beforeChildNode: DOMNode, 159): void => { 160 if (newChildNode.parentNode) { 161 removeChildNode(newChildNode.parentNode, newChildNode) 162 } 163 164 newChildNode.parentNode = node 165 166 const index = node.childNodes.indexOf(beforeChildNode) 167 168 if (index >= 0) { 169 // Calculate yoga index BEFORE modifying childNodes. 170 // We can't use DOM index directly because some children (like ink-progress, 171 // ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't 172 // match yoga indices. 173 let yogaIndex = 0 174 if (newChildNode.yogaNode && node.yogaNode) { 175 for (let i = 0; i < index; i++) { 176 if (node.childNodes[i]?.yogaNode) { 177 yogaIndex++ 178 } 179 } 180 } 181 182 node.childNodes.splice(index, 0, newChildNode) 183 184 if (newChildNode.yogaNode && node.yogaNode) { 185 node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex) 186 } 187 188 markDirty(node) 189 return 190 } 191 192 node.childNodes.push(newChildNode) 193 194 if (newChildNode.yogaNode) { 195 node.yogaNode?.insertChild( 196 newChildNode.yogaNode, 197 node.yogaNode.getChildCount(), 198 ) 199 } 200 201 markDirty(node) 202} 203 204export const removeChildNode = ( 205 node: DOMElement, 206 removeNode: DOMNode, 207): void => { 208 if (removeNode.yogaNode) { 209 removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) 210 } 211 212 // Collect cached rects from the removed subtree so they can be cleared 213 collectRemovedRects(node, removeNode) 214 215 removeNode.parentNode = undefined 216 217 const index = node.childNodes.indexOf(removeNode) 218 if (index >= 0) { 219 node.childNodes.splice(index, 1) 220 } 221 222 markDirty(node) 223} 224 225function collectRemovedRects( 226 parent: DOMElement, 227 removed: DOMNode, 228 underAbsolute = false, 229): void { 230 if (removed.nodeName === '#text') return 231 const elem = removed as DOMElement 232 // If this node or any ancestor in the removed subtree was absolute, 233 // its painted pixels may overlap non-siblings — flag for global blit 234 // disable. Normal-flow removals only affect direct siblings, which 235 // hasRemovedChild already handles. 236 const isAbsolute = underAbsolute || elem.style.position === 'absolute' 237 const cached = nodeCache.get(elem) 238 if (cached) { 239 addPendingClear(parent, cached, isAbsolute) 240 nodeCache.delete(elem) 241 } 242 for (const child of elem.childNodes) { 243 collectRemovedRects(parent, child, isAbsolute) 244 } 245} 246 247export const setAttribute = ( 248 node: DOMElement, 249 key: string, 250 value: DOMNodeAttribute, 251): void => { 252 // Skip 'children' - React handles children via appendChild/removeChild, 253 // not attributes. React always passes a new children reference, so 254 // tracking it as an attribute would mark everything dirty every render. 255 if (key === 'children') { 256 return 257 } 258 // Skip if unchanged 259 if (node.attributes[key] === value) { 260 return 261 } 262 node.attributes[key] = value 263 markDirty(node) 264} 265 266export const setStyle = (node: DOMNode, style: Styles): void => { 267 // Compare style properties to avoid marking dirty unnecessarily. 268 // React creates new style objects on every render even when unchanged. 269 if (stylesEqual(node.style, style)) { 270 return 271 } 272 node.style = style 273 markDirty(node) 274} 275 276export const setTextStyles = ( 277 node: DOMElement, 278 textStyles: TextStyles, 279): void => { 280 // Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx) 281 // allocate a new textStyles object on every render even when values are 282 // unchanged, so compare by value to avoid markDirty -> yoga re-measurement 283 // on every Text re-render. 284 if (shallowEqual(node.textStyles, textStyles)) { 285 return 286 } 287 node.textStyles = textStyles 288 markDirty(node) 289} 290 291function stylesEqual(a: Styles, b: Styles): boolean { 292 return shallowEqual(a, b) 293} 294 295function shallowEqual<T extends object>( 296 a: T | undefined, 297 b: T | undefined, 298): boolean { 299 // Fast path: same object reference (or both undefined) 300 if (a === b) return true 301 if (a === undefined || b === undefined) return false 302 303 // Get all keys from both objects 304 const aKeys = Object.keys(a) as (keyof T)[] 305 const bKeys = Object.keys(b) as (keyof T)[] 306 307 // Different number of properties 308 if (aKeys.length !== bKeys.length) return false 309 310 // Compare each property 311 for (const key of aKeys) { 312 if (a[key] !== b[key]) return false 313 } 314 315 return true 316} 317 318export const createTextNode = (text: string): TextNode => { 319 const node: TextNode = { 320 nodeName: '#text', 321 nodeValue: text, 322 yogaNode: undefined, 323 parentNode: undefined, 324 style: {}, 325 } 326 327 setTextNodeValue(node, text) 328 329 return node 330} 331 332const measureTextNode = function ( 333 node: DOMNode, 334 width: number, 335 widthMode: LayoutMeasureMode, 336): { width: number; height: number } { 337 const rawText = 338 node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node) 339 340 // Expand tabs for measurement (worst case: 8 spaces each). 341 // Actual tab expansion happens in output.ts based on screen position. 342 const text = expandTabs(rawText) 343 344 const dimensions = measureText(text, width) 345 346 // Text fits into container, no need to wrap 347 if (dimensions.width <= width) { 348 return dimensions 349 } 350 351 // This is happening when <Box> is shrinking child nodes and layout asks 352 // if we can fit this text node in a <1px space, so we just say "no" 353 if (dimensions.width >= 1 && width > 0 && width < 1) { 354 return dimensions 355 } 356 357 // For text with embedded newlines (pre-wrapped content), avoid re-wrapping 358 // at measurement width when layout is asking for intrinsic size (Undefined mode). 359 // This prevents height inflation during min/max size checks. 360 // 361 // However, when layout provides an actual constraint (Exactly or AtMost mode), 362 // we must respect it and measure at that width. Otherwise, if the actual 363 // rendering width is smaller than the natural width, the text will wrap to 364 // more lines than layout expects, causing content to be truncated. 365 if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) { 366 const effectiveWidth = Math.max(width, dimensions.width) 367 return measureText(text, effectiveWidth) 368 } 369 370 const textWrap = node.style?.textWrap ?? 'wrap' 371 const wrappedText = wrapText(text, width, textWrap) 372 373 return measureText(wrappedText, width) 374} 375 376// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions. 377// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff) 378// already wrapped to the target width and each line is exactly one terminal row. 379const measureRawAnsiNode = function (node: DOMElement): { 380 width: number 381 height: number 382} { 383 return { 384 width: node.attributes['rawWidth'] as number, 385 height: node.attributes['rawHeight'] as number, 386 } 387} 388 389/** 390 * Mark a node and all its ancestors as dirty for re-rendering. 391 * Also marks yoga dirty for text remeasurement if this is a text node. 392 */ 393export const markDirty = (node?: DOMNode): void => { 394 let current: DOMNode | undefined = node 395 let markedYoga = false 396 397 while (current) { 398 if (current.nodeName !== '#text') { 399 ;(current as DOMElement).dirty = true 400 // Only mark yoga dirty on leaf nodes that have measure functions 401 if ( 402 !markedYoga && 403 (current.nodeName === 'ink-text' || 404 current.nodeName === 'ink-raw-ansi') && 405 current.yogaNode 406 ) { 407 current.yogaNode.markDirty() 408 markedYoga = true 409 } 410 } 411 current = current.parentNode 412 } 413} 414 415// Walk to root and call its onRender (the throttled scheduleRender). Use for 416// DOM-level mutations (scrollTop changes) that should trigger an Ink frame 417// without going through React's reconciler. Pair with markDirty() so the 418// renderer knows which subtree to re-evaluate. 419export const scheduleRenderFrom = (node?: DOMNode): void => { 420 let cur: DOMNode | undefined = node 421 while (cur?.parentNode) cur = cur.parentNode 422 if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.() 423} 424 425export const setTextNodeValue = (node: TextNode, text: string): void => { 426 if (typeof text !== 'string') { 427 text = String(text) 428 } 429 430 // Skip if unchanged 431 if (node.nodeValue === text) { 432 return 433 } 434 435 node.nodeValue = text 436 markDirty(node) 437} 438 439function isDOMElement(node: DOMElement | TextNode): node is DOMElement { 440 return node.nodeName !== '#text' 441} 442 443// Clear yogaNode references recursively before freeing. 444// freeRecursive() frees the node and ALL its children, so we must clear 445// all yogaNode references to prevent dangling pointers. 446export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { 447 if ('childNodes' in node) { 448 for (const child of node.childNodes) { 449 clearYogaNodeReferences(child) 450 } 451 } 452 node.yogaNode = undefined 453} 454 455/** 456 * Find the React component stack responsible for content at screen row `y`. 457 * 458 * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of 459 * the deepest node whose bounding box contains `y`. Called from ink.tsx when 460 * log-update triggers a full reset, to attribute the flicker to its source. 461 * 462 * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are 463 * undefined and this returns []). 464 */ 465export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { 466 let best: string[] = [] 467 walk(root, 0) 468 return best 469 470 function walk(node: DOMElement, offsetY: number): void { 471 const yoga = node.yogaNode 472 if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return 473 474 const top = offsetY + yoga.getComputedTop() 475 const height = yoga.getComputedHeight() 476 if (y < top || y >= top + height) return 477 478 if (node.debugOwnerChain) best = node.debugOwnerChain 479 480 for (const child of node.childNodes) { 481 if (isDOMElement(child)) walk(child, top) 482 } 483 } 484}