Files
claude-code-v2.1.88/ink/renderer.ts
T
2026-04-25 06:45:36 +09:00

179 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { logForDebugging } from 'src/utils/debug.js'
import { type DOMElement, markDirty } from './dom.js'
import type { Frame } from './frame.js'
import { consumeAbsoluteRemovedFlag } from './node-cache.js'
import Output from './output.js'
import renderNodeToOutput, {
getScrollDrainNode,
getScrollHint,
resetLayoutShifted,
resetScrollDrainNode,
resetScrollHint,
} from './render-node-to-output.js'
import { createScreen, type StylePool } from './screen.js'
export type RenderOptions = {
frontFrame: Frame
backFrame: Frame
isTTY: boolean
terminalWidth: number
terminalRows: number
altScreen: boolean
// True when the previous frame's screen buffer was mutated post-render
// (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
// or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
// copy stale inverted cells, blanks, or nothing. When false, blit is safe.
prevFrameContaminated: boolean
}
export type Renderer = (options: RenderOptions) => Frame
export default function createRenderer(
node: DOMElement,
stylePool: StylePool,
): Renderer {
// Reuse Output across frames so charCache (tokenize + grapheme clustering)
// persists — most lines don't change between renders.
let output: Output | undefined
return options => {
const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
options
const prevScreen = frontFrame.screen
const backScreen = backFrame.screen
// Read pools from the back buffer's screen — pools may be replaced
// between frames (generational reset), so we can't capture them in the closure
const charPool = backScreen.charPool
const hyperlinkPool = backScreen.hyperlinkPool
// Return empty frame if yoga node doesn't exist or layout hasn't been computed yet.
// getComputedHeight() returns NaN before calculateLayout() is called.
// Also check for invalid dimensions (negative, Infinity) that would cause RangeError
// when creating arrays.
const computedHeight = node.yogaNode?.getComputedHeight()
const computedWidth = node.yogaNode?.getComputedWidth()
const hasInvalidHeight =
computedHeight === undefined ||
!Number.isFinite(computedHeight) ||
computedHeight < 0
const hasInvalidWidth =
computedWidth === undefined ||
!Number.isFinite(computedWidth) ||
computedWidth < 0
if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
// Log to help diagnose root cause (visible with --debug flag)
if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
logForDebugging(
`Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
`childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
)
}
return {
screen: createScreen(
terminalWidth,
0,
stylePool,
charPool,
hyperlinkPool,
),
viewport: { width: terminalWidth, height: terminalRows },
cursor: { x: 0, y: 0, visible: true },
}
}
const width = Math.floor(node.yogaNode.getComputedWidth())
const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
// Alt-screen: the screen buffer IS the alt buffer — always exactly
// terminalRows tall. <AlternateScreen> wraps children in <Box
// height={rows} flexShrink={0}>, so yogaHeight should equal
// terminalRows. But if something renders as a SIBLING of that Box
// (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight
// exceeds rows and every assumption below (viewport +1 hack, cursor.y
// clamp, log-update's heightDelta===0 fast path) breaks, desyncing
// virtual/physical cursors. Clamping here enforces the invariant:
// overflow writes land at y >= screen.height and setCellAt drops
// them. The sibling is invisible (obvious, easy to find) instead of
// corrupting the whole terminal.
const height = options.altScreen ? terminalRows : yogaHeight
if (options.altScreen && yogaHeight > terminalRows) {
logForDebugging(
`alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows}` +
`something is rendering outside <AlternateScreen>. Overflow clipped.`,
{ level: 'warn' },
)
}
const screen =
backScreen ??
createScreen(width, height, stylePool, charPool, hyperlinkPool)
if (output) {
output.reset(width, height, screen)
} else {
output = new Output({ width, height, stylePool, screen })
}
resetLayoutShifted()
resetScrollHint()
resetScrollDrainNode()
// prevFrameContaminated: selection overlay mutated the returned screen
// buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it
// with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame
// would copy stale inverted cells / blanks / nothing. When clean, blit
// restores the O(unchanged) fast path for steady-state frames (spinner
// tick, text stream).
// Removing an absolute-positioned node poisons prevScreen: it may
// have painted over non-siblings (e.g. an overlay over a ScrollBox
// earlier in tree order), so their blits would restore the removed
// node's pixels. hasRemovedChild only shields direct siblings.
// Normal-flow removals don't paint cross-subtree and are fine.
const absoluteRemoved = consumeAbsoluteRemovedFlag()
renderNodeToOutput(node, output, {
prevScreen:
absoluteRemoved || options.prevFrameContaminated
? undefined
: prevScreen,
})
const renderedScreen = output.get()
// Drain continuation: render cleared scrollbox.dirty, so next frame's
// root blit would skip the subtree. markDirty walks ancestors so the
// next frame descends. Done AFTER render so the clear-dirty at the end
// of renderNodeToOutput doesn't overwrite this.
const drainNode = getScrollDrainNode()
if (drainNode) markDirty(drainNode)
return {
scrollHint: options.altScreen ? getScrollHint() : null,
scrollDrainPending: drainNode !== null,
screen: renderedScreen,
viewport: {
width: terminalWidth,
// Alt screen: fake viewport.height = rows + 1 so that
// shouldClearScreen()'s `screen.height >= viewport.height` check
// (which treats exactly-filling content as "overflows" for
// scrollback purposes) never fires. Alt-screen content is always
// exactly `rows` tall (via <Box height={rows}>) but never
// scrolls — the cursor.y clamp below keeps the cursor-restore
// from emitting an LF. With the standard diff path, every frame
// is incremental; no fullResetSequence_CAUSES_FLICKER.
height: options.altScreen ? terminalRows + 1 : terminalRows,
},
cursor: {
x: 0,
// In the alt screen, keep the cursor inside the viewport. When
// screen.height === terminalRows exactly (content fills the alt
// screen), cursor.y = screen.height would trigger log-update's
// cursor-restore LF at the last row, scrolling one row off the top
// of the alt buffer and desyncing the diff's cursor model. The
// cursor is hidden so its position only matters for diff coords.
y: options.altScreen
? Math.max(0, Math.min(screen.height, terminalRows) - 1)
: screen.height,
// Hide cursor when there's dynamic output to render (only in TTY mode)
visible: !isTTY || screen.height === 0,
},
}
}
}