source dump of claude code
at main 231 lines 8.6 kB view raw
1import noop from 'lodash-es/noop.js' 2import type { ReactElement } from 'react' 3import { LegacyRoot } from 'react-reconciler/constants.js' 4import { logForDebugging } from '../utils/debug.js' 5import { createNode, type DOMElement } from './dom.js' 6import { FocusManager } from './focus.js' 7import Output from './output.js' 8import reconciler from './reconciler.js' 9import renderNodeToOutput, { 10 resetLayoutShifted, 11} from './render-node-to-output.js' 12import { 13 CellWidth, 14 CharPool, 15 cellAtIndex, 16 createScreen, 17 HyperlinkPool, 18 type Screen, 19 StylePool, 20 setCellStyleId, 21} from './screen.js' 22 23/** Position of a match within a rendered message, relative to the message's 24 * own bounding box (row 0 = message top). Stable across scroll — to 25 * highlight on the real screen, add the message's screen-row offset. */ 26export type MatchPosition = { 27 row: number 28 col: number 29 /** Number of CELLS the match spans (= query.length for ASCII, more 30 * for wide chars in the query). */ 31 len: number 32} 33 34// Shared across calls. Pools accumulate style/char interns — reusing them 35// means later calls hit cache more. Root/container reuse saves the 36// createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling — 37// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork. 38let root: DOMElement | undefined 39let container: ReturnType<typeof reconciler.createContainer> | undefined 40let stylePool: StylePool | undefined 41let charPool: CharPool | undefined 42let hyperlinkPool: HyperlinkPool | undefined 43let output: Output | undefined 44 45const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 } 46const LOG_EVERY = 20 47 48/** Render a React element (wrapped in all contexts the component needs — 49 * caller's job) to an isolated Screen buffer at the given width. Returns 50 * the Screen + natural height (from yoga). Used for search: render ONE 51 * message, scan its Screen for the query, get exact (row, col) positions. 52 * 53 * ~1-3ms per call (yoga alloc + calculateLayout + paint). The 54 * flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine 55 * for on-demand single-message rendering, pathological for render-all- 56 * 8k-upfront. Cache per (msg, query, width) upstream. 57 * 58 * Unmounts between calls. Root/container/pools persist for reuse. */ 59export function renderToScreen( 60 el: ReactElement, 61 width: number, 62): { screen: Screen; height: number } { 63 if (!root) { 64 root = createNode('ink-root') 65 root.focusManager = new FocusManager(() => false) 66 stylePool = new StylePool() 67 charPool = new CharPool() 68 hyperlinkPool = new HyperlinkPool() 69 // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11 70 container = reconciler.createContainer( 71 root, 72 LegacyRoot, 73 null, 74 false, 75 null, 76 'search-render', 77 noop, 78 noop, 79 noop, 80 noop, 81 ) 82 } 83 84 const t0 = performance.now() 85 // @ts-expect-error updateContainerSync exists but not in @types 86 reconciler.updateContainerSync(el, container, null, noop) 87 // @ts-expect-error flushSyncWork exists but not in @types 88 reconciler.flushSyncWork() 89 const t1 = performance.now() 90 91 // Yoga layout. Root might not have a yogaNode if the tree is empty. 92 root.yogaNode?.setWidth(width) 93 root.yogaNode?.calculateLayout(width) 94 const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0) 95 const t2 = performance.now() 96 97 // Paint to a fresh Screen. Width = given, height = yoga's natural. 98 // No alt-screen, no prevScreen (every call is fresh). 99 const screen = createScreen( 100 width, 101 Math.max(1, height), // avoid 0-height Screen (createScreen may choke) 102 stylePool!, 103 charPool!, 104 hyperlinkPool!, 105 ) 106 if (!output) { 107 output = new Output({ width, height, stylePool: stylePool!, screen }) 108 } else { 109 output.reset(width, height, screen) 110 } 111 resetLayoutShifted() 112 renderNodeToOutput(root, output, { prevScreen: undefined }) 113 // renderNodeToOutput queues writes into Output; .get() flushes the 114 // queue into the Screen's cell arrays. Without this the screen is 115 // blank (constructor-zero). 116 const rendered = output.get() 117 const t3 = performance.now() 118 119 // Unmount so next call gets a fresh tree. Leaves root/container/pools. 120 // @ts-expect-error updateContainerSync exists but not in @types 121 reconciler.updateContainerSync(null, container, null, noop) 122 // @ts-expect-error flushSyncWork exists but not in @types 123 reconciler.flushSyncWork() 124 125 timing.reconcile += t1 - t0 126 timing.yoga += t2 - t1 127 timing.paint += t3 - t2 128 if (++timing.calls % LOG_EVERY === 0) { 129 const total = timing.reconcile + timing.yoga + timing.paint + timing.scan 130 logForDebugging( 131 `renderToScreen: ${timing.calls} calls · ` + 132 `reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` + 133 `paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` + 134 `total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call`, 135 ) 136 } 137 138 return { screen: rendered, height } 139} 140 141/** Scan a Screen buffer for all occurrences of query. Returns positions 142 * relative to the buffer (row 0 = buffer top). Same cell-skip logic as 143 * applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions 144 * match what the overlay highlight would find. Case-insensitive. 145 * 146 * For the side-render use: this Screen is the FULL message (natural 147 * height, not viewport-clipped). Positions are stable — to highlight 148 * on the real screen, add the message's screen offset (lo). */ 149export function scanPositions(screen: Screen, query: string): MatchPosition[] { 150 const lq = query.toLowerCase() 151 if (!lq) return [] 152 const qlen = lq.length 153 const w = screen.width 154 const h = screen.height 155 const noSelect = screen.noSelect 156 const positions: MatchPosition[] = [] 157 158 const t0 = performance.now() 159 for (let row = 0; row < h; row++) { 160 const rowOff = row * w 161 // Same text-build as applySearchHighlight. Keep in sync — or extract 162 // to a shared helper (TODO once both are stable). codeUnitToCell 163 // maps indexOf positions (code units in the LOWERCASED text) to cell 164 // indices in colOf — surrogate pairs (emoji) and multi-unit lowercase 165 // (Turkish İ → i + U+0307) make text.length > colOf.length. 166 let text = '' 167 const colOf: number[] = [] 168 const codeUnitToCell: number[] = [] 169 for (let col = 0; col < w; col++) { 170 const idx = rowOff + col 171 const cell = cellAtIndex(screen, idx) 172 if ( 173 cell.width === CellWidth.SpacerTail || 174 cell.width === CellWidth.SpacerHead || 175 noSelect[idx] === 1 176 ) { 177 continue 178 } 179 const lc = cell.char.toLowerCase() 180 const cellIdx = colOf.length 181 for (let i = 0; i < lc.length; i++) { 182 codeUnitToCell.push(cellIdx) 183 } 184 text += lc 185 colOf.push(col) 186 } 187 // Non-overlapping — same advance as applySearchHighlight. 188 let pos = text.indexOf(lq) 189 while (pos >= 0) { 190 const startCi = codeUnitToCell[pos]! 191 const endCi = codeUnitToCell[pos + qlen - 1]! 192 const col = colOf[startCi]! 193 const endCol = colOf[endCi]! + 1 194 positions.push({ row, col, len: endCol - col }) 195 pos = text.indexOf(lq, pos + qlen) 196 } 197 } 198 timing.scan += performance.now() - t0 199 200 return positions 201} 202 203/** Write CURRENT (yellow+bold+underline) at positions[currentIdx] + 204 * rowOffset. OTHER positions are NOT styled here — the scan-highlight 205 * (applySearchHighlight with null hint) does inverse for all visible 206 * matches, including these. Two-layer: scan = 'you could go here', 207 * position = 'you ARE here'. Writing inverse again here would be a 208 * no-op (withInverse idempotent) but wasted work. 209 * 210 * Positions are message-relative (row 0 = message top). rowOffset = 211 * message's current screen-top (lo). Clips outside [0, height). */ 212export function applyPositionedHighlight( 213 screen: Screen, 214 stylePool: StylePool, 215 positions: MatchPosition[], 216 rowOffset: number, 217 currentIdx: number, 218): boolean { 219 if (currentIdx < 0 || currentIdx >= positions.length) return false 220 const p = positions[currentIdx]! 221 const row = p.row + rowOffset 222 if (row < 0 || row >= screen.height) return false 223 const transform = (id: number) => stylePool.withCurrentMatch(id) 224 const rowOff = row * screen.width 225 for (let col = p.col; col < p.col + p.len; col++) { 226 if (col < 0 || col >= screen.width) continue 227 const cell = cellAtIndex(screen, rowOff + col) 228 setCellStyleId(screen, col, row, transform(cell.styleId)) 229 } 230 return true 231}