Something went wrong. Try again.
source dump of claude code forked from oppi.li/claude-code
Something went wrong. Try again.
15 kB · 484 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485import type { FocusManager } from './focus.js'import { createLayoutNode } from './layout/engine.js'import type { LayoutNode } from './layout/node.js'import { LayoutDisplay, LayoutMeasureMode } from './layout/node.js'import measureText from './measure-text.js'import { addPendingClear, nodeCache } from './node-cache.js'import squashTextNodes from './squash-text-nodes.js'import type { Styles, TextStyles } from './styles.js'import { expandTabs } from './tabstops.js'import wrapText from './wrap-text.js'
type InkNode = { parentNode: DOMElement | undefined yogaNode?: LayoutNode style: Styles}
export type TextName = '#text'export type ElementNames = | 'ink-root' | 'ink-box' | 'ink-text' | 'ink-virtual-text' | 'ink-link' | 'ink-progress' | 'ink-raw-ansi'
export type NodeNames = ElementNames | TextName
// eslint-disable-next-line @typescript-eslint/naming-conventionexport type DOMElement = { nodeName: ElementNames attributes: Record<string, DOMNodeAttribute> childNodes: DOMNode[] textStyles?: TextStyles
// Internal properties onComputeLayout?: () => void onRender?: () => void onImmediateRender?: () => void // Used to skip empty renders during React 19's effect double-invoke in test mode hasRenderedContent?: boolean
// When true, this node needs re-rendering dirty: boolean // Set by the reconciler's hideInstance/unhideInstance; survives style updates. isHidden?: boolean // Event handlers set by the reconciler for the capture/bubble dispatcher. // Stored separately from attributes so handler identity changes don't // mark dirty and defeat the blit optimization. _eventHandlers?: Record<string, unknown>
// Scroll state for overflow: 'scroll' boxes. scrollTop is the number of // rows the content is scrolled down by. scrollHeight/scrollViewportHeight // are computed at render time and stored for imperative access. stickyScroll // auto-pins scrollTop to the bottom when content grows. scrollTop?: number // Accumulated scroll delta not yet applied to scrollTop. The renderer // drains this at SCROLL_MAX_PER_FRAME rows/frame so fast flicks show // intermediate frames instead of one big jump. Direction reversal // naturally cancels (pure accumulator, no target tracking). pendingScrollDelta?: number // Render-time clamp bounds for virtual scroll. useVirtualScroll writes // the currently-mounted children's coverage span; render-node-to-output // clamps scrollTop to stay within it. Prevents blank screen when // scrollTo's direct write races past React's async re-render — instead // of painting spacer (blank), the renderer holds at the edge of mounted // content until React catches up (next commit updates these bounds and // the clamp releases). Undefined = no clamp (sticky-scroll, cold start). scrollClampMin?: number scrollClampMax?: number scrollHeight?: number scrollViewportHeight?: number scrollViewportTop?: number stickyScroll?: boolean // Set by ScrollBox.scrollToElement; render-node-to-output reads // el.yogaNode.getComputedTop() (FRESH — same Yoga pass as scrollHeight) // and sets scrollTop = top + offset, then clears this. Unlike an // imperative scrollTo(N) which bakes in a number that's stale by the // time the throttled render fires, the element ref defers the position // read to paint time. One-shot. scrollAnchor?: { el: DOMElement; offset: number } // Only set on ink-root. The document owns focus — any node can // reach it by walking parentNode, like browser getRootNode(). focusManager?: FocusManager // React component stack captured at createInstance time (reconciler.ts), // e.g. ['ToolUseLoader', 'Messages', 'REPL']. Only populated when // CLAUDE_CODE_DEBUG_REPAINTS is set. Used by findOwnerChainAtRow to // attribute scrollback-diff full-resets to the component that caused them. debugOwnerChain?: string[]} & InkNode
export type TextNode = { nodeName: TextName nodeValue: string} & InkNode
// eslint-disable-next-line @typescript-eslint/naming-conventionexport type DOMNode<T = { nodeName: NodeNames }> = T extends { nodeName: infer U} ? U extends '#text' ? TextNode : DOMElement : never
// eslint-disable-next-line @typescript-eslint/naming-conventionexport type DOMNodeAttribute = boolean | string | number
export const createNode = (nodeName: ElementNames): DOMElement => { const needsYogaNode = nodeName !== 'ink-virtual-text' && nodeName !== 'ink-link' && nodeName !== 'ink-progress' const node: DOMElement = { nodeName, style: {}, attributes: {}, childNodes: [], parentNode: undefined, yogaNode: needsYogaNode ? createLayoutNode() : undefined, dirty: false, }
if (nodeName === 'ink-text') { node.yogaNode?.setMeasureFunc(measureTextNode.bind(null, node)) } else if (nodeName === 'ink-raw-ansi') { node.yogaNode?.setMeasureFunc(measureRawAnsiNode.bind(null, node)) }
return node}
export const appendChildNode = ( node: DOMElement, childNode: DOMElement,): void => { if (childNode.parentNode) { removeChildNode(childNode.parentNode, childNode) }
childNode.parentNode = node node.childNodes.push(childNode)
if (childNode.yogaNode) { node.yogaNode?.insertChild( childNode.yogaNode, node.yogaNode.getChildCount(), ) }
markDirty(node)}
export const insertBeforeNode = ( node: DOMElement, newChildNode: DOMNode, beforeChildNode: DOMNode,): void => { if (newChildNode.parentNode) { removeChildNode(newChildNode.parentNode, newChildNode) }
newChildNode.parentNode = node
const index = node.childNodes.indexOf(beforeChildNode)
if (index >= 0) { // Calculate yoga index BEFORE modifying childNodes. // We can't use DOM index directly because some children (like ink-progress, // ink-link, ink-virtual-text) don't have yogaNodes, so DOM indices don't // match yoga indices. let yogaIndex = 0 if (newChildNode.yogaNode && node.yogaNode) { for (let i = 0; i < index; i++) { if (node.childNodes[i]?.yogaNode) { yogaIndex++ } } }
node.childNodes.splice(index, 0, newChildNode)
if (newChildNode.yogaNode && node.yogaNode) { node.yogaNode.insertChild(newChildNode.yogaNode, yogaIndex) }
markDirty(node) return }
node.childNodes.push(newChildNode)
if (newChildNode.yogaNode) { node.yogaNode?.insertChild( newChildNode.yogaNode, node.yogaNode.getChildCount(), ) }
markDirty(node)}
export const removeChildNode = ( node: DOMElement, removeNode: DOMNode,): void => { if (removeNode.yogaNode) { removeNode.parentNode?.yogaNode?.removeChild(removeNode.yogaNode) }
// Collect cached rects from the removed subtree so they can be cleared collectRemovedRects(node, removeNode)
removeNode.parentNode = undefined
const index = node.childNodes.indexOf(removeNode) if (index >= 0) { node.childNodes.splice(index, 1) }
markDirty(node)}
function collectRemovedRects( parent: DOMElement, removed: DOMNode, underAbsolute = false,): void { if (removed.nodeName === '#text') return const elem = removed as DOMElement // If this node or any ancestor in the removed subtree was absolute, // its painted pixels may overlap non-siblings — flag for global blit // disable. Normal-flow removals only affect direct siblings, which // hasRemovedChild already handles. const isAbsolute = underAbsolute || elem.style.position === 'absolute' const cached = nodeCache.get(elem) if (cached) { addPendingClear(parent, cached, isAbsolute) nodeCache.delete(elem) } for (const child of elem.childNodes) { collectRemovedRects(parent, child, isAbsolute) }}
export const setAttribute = ( node: DOMElement, key: string, value: DOMNodeAttribute,): void => { // Skip 'children' - React handles children via appendChild/removeChild, // not attributes. React always passes a new children reference, so // tracking it as an attribute would mark everything dirty every render. if (key === 'children') { return } // Skip if unchanged if (node.attributes[key] === value) { return } node.attributes[key] = value markDirty(node)}
export const setStyle = (node: DOMNode, style: Styles): void => { // Compare style properties to avoid marking dirty unnecessarily. // React creates new style objects on every render even when unchanged. if (stylesEqual(node.style, style)) { return } node.style = style markDirty(node)}
export const setTextStyles = ( node: DOMElement, textStyles: TextStyles,): void => { // Same dirty-check guard as setStyle: React (and buildTextStyles in Text.tsx) // allocate a new textStyles object on every render even when values are // unchanged, so compare by value to avoid markDirty -> yoga re-measurement // on every Text re-render. if (shallowEqual(node.textStyles, textStyles)) { return } node.textStyles = textStyles markDirty(node)}
function stylesEqual(a: Styles, b: Styles): boolean { return shallowEqual(a, b)}
function shallowEqual<T extends object>( a: T | undefined, b: T | undefined,): boolean { // Fast path: same object reference (or both undefined) if (a === b) return true if (a === undefined || b === undefined) return false
// Get all keys from both objects const aKeys = Object.keys(a) as (keyof T)[] const bKeys = Object.keys(b) as (keyof T)[]
// Different number of properties if (aKeys.length !== bKeys.length) return false
// Compare each property for (const key of aKeys) { if (a[key] !== b[key]) return false }
return true}
export const createTextNode = (text: string): TextNode => { const node: TextNode = { nodeName: '#text', nodeValue: text, yogaNode: undefined, parentNode: undefined, style: {}, }
setTextNodeValue(node, text)
return node}
const measureTextNode = function ( node: DOMNode, width: number, widthMode: LayoutMeasureMode,): { width: number; height: number } { const rawText = node.nodeName === '#text' ? node.nodeValue : squashTextNodes(node)
// Expand tabs for measurement (worst case: 8 spaces each). // Actual tab expansion happens in output.ts based on screen position. const text = expandTabs(rawText)
const dimensions = measureText(text, width)
// Text fits into container, no need to wrap if (dimensions.width <= width) { return dimensions }
// This is happening when <Box> is shrinking child nodes and layout asks // if we can fit this text node in a <1px space, so we just say "no" if (dimensions.width >= 1 && width > 0 && width < 1) { return dimensions }
// For text with embedded newlines (pre-wrapped content), avoid re-wrapping // at measurement width when layout is asking for intrinsic size (Undefined mode). // This prevents height inflation during min/max size checks. // // However, when layout provides an actual constraint (Exactly or AtMost mode), // we must respect it and measure at that width. Otherwise, if the actual // rendering width is smaller than the natural width, the text will wrap to // more lines than layout expects, causing content to be truncated. if (text.includes('\n') && widthMode === LayoutMeasureMode.Undefined) { const effectiveWidth = Math.max(width, dimensions.width) return measureText(text, effectiveWidth) }
const textWrap = node.style?.textWrap ?? 'wrap' const wrappedText = wrapText(text, width, textWrap)
return measureText(wrappedText, width)}
// ink-raw-ansi nodes hold pre-rendered ANSI strings with known dimensions.// No stringWidth, no wrapping, no tab expansion — the producer (e.g. ColorDiff)// already wrapped to the target width and each line is exactly one terminal row.const measureRawAnsiNode = function (node: DOMElement): { width: number height: number} { return { width: node.attributes['rawWidth'] as number, height: node.attributes['rawHeight'] as number, }}
/** * Mark a node and all its ancestors as dirty for re-rendering. * Also marks yoga dirty for text remeasurement if this is a text node. */export const markDirty = (node?: DOMNode): void => { let current: DOMNode | undefined = node let markedYoga = false
while (current) { if (current.nodeName !== '#text') { ;(current as DOMElement).dirty = true // Only mark yoga dirty on leaf nodes that have measure functions if ( !markedYoga && (current.nodeName === 'ink-text' || current.nodeName === 'ink-raw-ansi') && current.yogaNode ) { current.yogaNode.markDirty() markedYoga = true } } current = current.parentNode }}
// Walk to root and call its onRender (the throttled scheduleRender). Use for// DOM-level mutations (scrollTop changes) that should trigger an Ink frame// without going through React's reconciler. Pair with markDirty() so the// renderer knows which subtree to re-evaluate.export const scheduleRenderFrom = (node?: DOMNode): void => { let cur: DOMNode | undefined = node while (cur?.parentNode) cur = cur.parentNode if (cur && cur.nodeName !== '#text') (cur as DOMElement).onRender?.()}
export const setTextNodeValue = (node: TextNode, text: string): void => { if (typeof text !== 'string') { text = String(text) }
// Skip if unchanged if (node.nodeValue === text) { return }
node.nodeValue = text markDirty(node)}
function isDOMElement(node: DOMElement | TextNode): node is DOMElement { return node.nodeName !== '#text'}
// Clear yogaNode references recursively before freeing.// freeRecursive() frees the node and ALL its children, so we must clear// all yogaNode references to prevent dangling pointers.export const clearYogaNodeReferences = (node: DOMElement | TextNode): void => { if ('childNodes' in node) { for (const child of node.childNodes) { clearYogaNodeReferences(child) } } node.yogaNode = undefined}
/** * Find the React component stack responsible for content at screen row `y`. * * DFS the DOM tree accumulating yoga offsets. Returns the debugOwnerChain of * the deepest node whose bounding box contains `y`. Called from ink.tsx when * log-update triggers a full reset, to attribute the flicker to its source. * * Only useful when CLAUDE_CODE_DEBUG_REPAINTS is set (otherwise chains are * undefined and this returns []). */export function findOwnerChainAtRow(root: DOMElement, y: number): string[] { let best: string[] = [] walk(root, 0) return best
function walk(node: DOMElement, offsetY: number): void { const yoga = node.yogaNode if (!yoga || yoga.getDisplay() === LayoutDisplay.None) return
const top = offsetY + yoga.getComputedTop() const height = yoga.getComputedHeight() if (y < top || y >= top + height) return
if (node.debugOwnerChain) best = node.debugOwnerChain
for (const child of node.childNodes) { if (isDOMElement(child)) walk(child, top) } }}