import { CURSOR_MARKER, truncateToWidth, visibleWidth, type Component, type TUI, type Terminal, } from "@earendil-works/pi-tui"; export type SplitRendererDiagnostic = (event: string, fields: Record) => void; export interface SplitRendererOptions { enabled: boolean; minimumHistoryRows: number; lowerPaneMaxPercent: number; diagnostic?: SplitRendererDiagnostic; } export interface SplitRendererPatchStatus { installed: boolean; active: boolean; reason: string; } interface CursorPosition { row: number; col: number; } interface TuiWithInternals { children: Component[]; terminal: Terminal; previousLines: string[]; previousWidth: number; previousHeight: number; cursorRow: number; hardwareCursorRow: number; clearOnShrink: boolean; maxLinesRendered: number; previousViewportTop: number; fullRedrawCount: number; stopped: boolean; overlayStack: unknown[]; hasOverlay?: () => boolean; extractCursorPosition?: (lines: string[], height: number) => CursorPosition | null; applyLineResets?: (lines: string[]) => string[]; positionHardwareCursor?: (cursorPos: CursorPosition | null, totalLines: number) => void; } type DoRender = (this: TUI) => void; interface PatchedTuiPrototype { doRender?: DoRender; __piPinnedInputOriginalDoRender?: DoRender; __piPinnedInputPatched?: boolean; } interface ChildRange { start: number; end: number; } interface RenderedChildren { lines: string[]; ranges: ChildRange[]; } interface SplitLayout { lines: string[]; footerStartLine: number; lowerPaneRows: number; historyRows: number; historyViewportTop: number; screenLines: string[]; } interface ViewportMetadata { footerStartLine: number; lowerPaneRows: number; historyRows: number; historyViewportTop: number; logicalLineCount: number; } interface HistoryViewportState { viewportTop: number; followBottom: boolean; lastHistoryLineCount: number; lastHistoryLine: string; unseenOutput: boolean; } interface LineSpan { start: number; endExclusive: number; } export interface HistoryViewportScrollResult { handled: boolean; changed: boolean; viewportTop?: number; followBottom?: boolean; } interface UnsupportedLayout { reason: string; fields?: Record; } const DEFAULT_OPTIONS: SplitRendererOptions = { enabled: false, minimumHistoryRows: 3, lowerPaneMaxPercent: 60, }; export const NEW_OUTPUT_MARKER = "↓ new output"; const LOWER_PANE_CHILD_COUNT = 5; /** Hide auxiliary lower-pane rows in this order: widget-above, widget-below, then status. */ const AUXILIARY_HIDE_PRIORITY = [1, 2, 0] as const; const SIXEL_RENDER_ROW_MARKER = "\x1b_Gm=0;\x1b\\"; const INLINE_IMAGE_PROTOCOL_MARKERS = [ SIXEL_RENDER_ROW_MARKER, "\x1b_G", // Kitty graphics APC. "\x1b]1337;File=", // iTerm2 inline image OSC. "\x1bP", // Sixel DCS. ] as const; const CURSOR_UP_ROWS_PATTERN = /\x1b\[(\d+)A/g; let options: SplitRendererOptions = { ...DEFAULT_OPTIONS }; let patchInstalled = false; let lastPatchReason = "not-installed"; const viewportMetadata = new WeakMap(); const historyViewportState = new WeakMap(); function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(value, max)); } function getTuiInternals(tui: TUI): TuiWithInternals { return tui as unknown as TuiWithInternals; } function hasRequiredInternals(tui: TuiWithInternals): boolean { return Array.isArray(tui.children) && Array.isArray(tui.previousLines) && Array.isArray(tui.overlayStack) && typeof tui.extractCursorPosition === "function" && typeof tui.applyLineResets === "function" && typeof tui.positionHardwareCursor === "function" && typeof tui.terminal?.write === "function" && typeof tui.terminal.columns === "number" && typeof tui.terminal.rows === "number"; } function getVisibleOverlayState(tui: TuiWithInternals): boolean { if (typeof tui.hasOverlay === "function") { return tui.hasOverlay(); } return Array.isArray(tui.overlayStack) && tui.overlayStack.length > 0; } function getUnsupportedTerminalReason(tui: TuiWithInternals): UnsupportedLayout | undefined { const width = tui.terminal.columns; const height = tui.terminal.rows; if (!Number.isFinite(width) || !Number.isFinite(height)) { return { reason: "invalid-terminal-dimensions", fields: { width, height } }; } if (width < 20 || height < 8) { return { reason: "terminal-too-small", fields: { width, height } }; } if (process.env.TERM === "dumb") { return { reason: "dumb-terminal" }; } if (process.env.PI_PINNED_INPUT_DISABLE_SPLIT_RENDERER === "1") { return { reason: "disabled-by-environment" }; } return undefined; } function findLowerPaneStartIndex(tui: TuiWithInternals): number { return tui.children.length >= LOWER_PANE_CHILD_COUNT ? tui.children.length - LOWER_PANE_CHILD_COUNT : -1; } function isPlainContainer(component: Component): component is Component & { children: Component[] } { return component.constructor?.name === "Container" && Array.isArray((component as { children?: unknown }).children); } function renderComponent(component: Component, width: number): string[] { if (!isPlainContainer(component)) { return component.render(width); } const lines: string[] = []; for (const child of component.children) { lines.push(...renderComponent(child, width)); } return lines; } function renderChildren(tui: TuiWithInternals, width: number): RenderedChildren { const ranges: ChildRange[] = []; const lines: string[] = []; for (const child of tui.children) { const start = lines.length; lines.push(...renderComponent(child, width)); ranges.push({ start, end: lines.length }); } return { lines, ranges }; } function maximumHistoryViewportTop(historyLineCount: number, historyRows: number): number { return Math.max(0, historyLineCount - historyRows); } function saveHistoryViewportState( tui: object, state: Pick & Partial>, ): HistoryViewportState { const previous = historyViewportState.get(tui); const next: HistoryViewportState = { viewportTop: state.viewportTop, followBottom: state.followBottom, unseenOutput: state.unseenOutput, lastHistoryLineCount: state.lastHistoryLineCount ?? previous?.lastHistoryLineCount ?? 0, lastHistoryLine: state.lastHistoryLine ?? previous?.lastHistoryLine ?? "", }; historyViewportState.set(tui, next); return next; } function getHistoryViewportTop( tui: object, historyLineCount: number, historyRows: number, ): { viewportTop: number; followBottom: boolean } { const maximumViewportTop = maximumHistoryViewportTop(historyLineCount, historyRows); const state = historyViewportState.get(tui); if (!state || state.followBottom) { return saveHistoryViewportState(tui, { viewportTop: maximumViewportTop, followBottom: true, unseenOutput: false, lastHistoryLineCount: historyLineCount, lastHistoryLine: "", }); } const viewportTop = clamp(state.viewportTop, 0, maximumViewportTop); return saveHistoryViewportState(tui, { viewportTop, followBottom: viewportTop >= maximumViewportTop, unseenOutput: state.unseenOutput || historyLineCount > state.lastHistoryLineCount, lastHistoryLineCount: historyLineCount, }); } function getInlineImageMoveUpRows(line: string): number { if (!isInlineImageProtocolLine(line)) { return 0; } let rows = 0; for (const match of line.matchAll(CURSOR_UP_ROWS_PATTERN)) { rows = Math.max(rows, Number.parseInt(match[1] ?? "0", 10)); } return rows; } function countPrecedingBlankSpacerRows(lines: readonly string[], row: number, limit: number): number { let spacerRows = 0; while (spacerRows < limit) { const candidateRow = row - spacerRows - 1; if (candidateRow < 0 || (lines[candidateRow] ?? "") !== "") { break; } spacerRows += 1; } return spacerRows; } function isInlineImageProtocolLine(line: string): boolean { return INLINE_IMAGE_PROTOCOL_MARKERS.some((marker) => line.includes(marker)); } function getInlineImageSpanEndingAt(lines: readonly string[], row: number): LineSpan | undefined { const line = lines[row] ?? ""; if (!isInlineImageProtocolLine(line)) { return undefined; } const spacerRows = countPrecedingBlankSpacerRows(lines, row, getInlineImageMoveUpRows(line)); return { start: row - spacerRows, endExclusive: row + 1 }; } function collectInlineImageSpans(lines: readonly string[]): LineSpan[] { const spans: LineSpan[] = []; for (let row = 0; row < lines.length; row += 1) { const span = getInlineImageSpanEndingAt(lines, row); if (span) { spans.push(span); } } return spans; } function findContainingLineSpan(spans: readonly LineSpan[], row: number): LineSpan | undefined { return spans.find((span) => span.start <= row && row < span.endExclusive); } function lineSpanContentMatches( previousLines: readonly string[], previousSpan: LineSpan, nextLines: readonly string[], nextSpan: LineSpan, ): boolean { const previousSpanRows = previousSpan.endExclusive - previousSpan.start; const nextSpanRows = nextSpan.endExclusive - nextSpan.start; if (previousSpanRows !== nextSpanRows) { return false; } for (let offset = 0; offset < previousSpanRows; offset += 1) { if ((previousLines[previousSpan.start + offset] ?? "") !== (nextLines[nextSpan.start + offset] ?? "")) { return false; } } return true; } function alignViewportTopToInlineImageSpans( historyLines: readonly string[], viewportTop: number, historyRows: number, ): { viewportTop: number; unsupportedSpan?: LineSpan } { const maximumViewportTop = Math.max(0, historyLines.length - historyRows); const spans = collectInlineImageSpans(historyLines); let nextViewportTop = clamp(viewportTop, 0, maximumViewportTop); for (let iteration = 0; iteration <= spans.length; iteration += 1) { const viewportBottom = nextViewportTop + historyRows; const oversizedSpan = spans.find( (span) => span.endExclusive - span.start > historyRows && span.start < viewportBottom && nextViewportTop < span.endExclusive, ); if (oversizedSpan) { return { viewportTop: nextViewportTop, unsupportedSpan: oversizedSpan }; } const leadingSpan = spans.find((span) => span.start < nextViewportTop && nextViewportTop < span.endExclusive); if (leadingSpan) { nextViewportTop = leadingSpan.start; continue; } const trailingSpan = spans.find((span) => span.start < viewportBottom && viewportBottom < span.endExclusive); if (trailingSpan) { nextViewportTop = clamp(trailingSpan.endExclusive - historyRows, 0, maximumViewportTop); continue; } break; } return { viewportTop: nextViewportTop }; } function createScreenLines( tui: object, historyLines: readonly string[], lowerPaneLines: readonly string[], historyRows: number, ): { screenLines: string[]; historyViewportTop: number } | UnsupportedLayout { const { viewportTop, followBottom } = getHistoryViewportTop(tui, historyLines.length, historyRows); const alignedViewport = alignViewportTopToInlineImageSpans(historyLines, viewportTop, historyRows); if (alignedViewport.unsupportedSpan) { return { reason: "history-inline-image-span-too-tall", fields: { historyRows, viewportTop, spanStart: alignedViewport.unsupportedSpan.start, spanEndExclusive: alignedViewport.unsupportedSpan.endExclusive, spanRows: alignedViewport.unsupportedSpan.endExclusive - alignedViewport.unsupportedSpan.start, }, }; } const historyViewportTop = alignedViewport.viewportTop; const maximumViewportTop = maximumHistoryViewportTop(historyLines.length, historyRows); const previousState = historyViewportState.get(tui); const nextFollowBottom = followBottom || historyViewportTop >= maximumViewportTop; const lastHistoryLine = historyLines.at(-1) ?? ""; const state = saveHistoryViewportState(tui, { viewportTop: historyViewportTop, followBottom: nextFollowBottom, unseenOutput: !nextFollowBottom && ( (previousState?.unseenOutput ?? false) || (previousState?.lastHistoryLine ?? lastHistoryLine) !== lastHistoryLine ), lastHistoryLineCount: historyLines.length, lastHistoryLine, }); const visibleHistory = historyLines.slice(historyViewportTop, historyViewportTop + historyRows); if (state.unseenOutput && visibleHistory.length > 0) { const imageSpans = collectInlineImageSpans(historyLines); let markerRow = visibleHistory.length - 1; while ( markerRow >= 0 && findContainingLineSpan(imageSpans, historyViewportTop + markerRow) ) markerRow -= 1; if (markerRow >= 0) visibleHistory[markerRow] = NEW_OUTPUT_MARKER; } const screenLines = [...visibleHistory]; while (screenLines.length < historyRows) { screenLines.push(""); } screenLines.push(...lowerPaneLines); return { screenLines, historyViewportTop }; } function normalizeVisibleLine(line: string, width: number): string { if (isInlineImageProtocolLine(line)) { return line; } return visibleWidth(line) > width ? truncateToWidth(line, width, "") : line; } function normalizeVisibleLines(lines: readonly string[], width: number): string[] { return lines.map((line) => normalizeVisibleLine(line, width)); } function buildSplitLayout(tui: TuiWithInternals, width: number, height: number): SplitLayout | UnsupportedLayout { const footerStartIndex = findLowerPaneStartIndex(tui); if (footerStartIndex < 0) { return { reason: "unknown-layout", fields: { childCount: tui.children.length, expectedLowerPaneChildCount: LOWER_PANE_CHILD_COUNT, }, }; } const historyLines = tui.children.slice(0, footerStartIndex).flatMap((child) => renderComponent(child, width)); const lowerChildren = tui.children.slice(footerStartIndex); const [status, widgetAbove, editor, widgetBelow, footer] = lowerChildren; if (!status || !widgetAbove || !editor || !widgetBelow || !footer) { return { reason: "missing-lower-pane-components" }; } const renderedStatus = renderComponent(status, width); const renderedAbove = renderComponent(widgetAbove, width); const renderedBelow = renderComponent(widgetBelow, width); const renderedFooter = renderComponent(footer, width); const hasBudgetedEditor = setEditorMaximumRows(editor, 3); const minimumRenderedEditor = renderComponent(editor, width); const lowerPaneLimit = Math.min( height - options.minimumHistoryRows, Math.floor(height * (options.lowerPaneMaxPercent / 100)), ); const visibleAuxiliary = [true, true, true]; const auxiliaryRows = [renderedStatus.length, renderedAbove.length, renderedBelow.length]; const countFixedRows = () => renderedFooter.length + auxiliaryRows.reduce( (total, rows, itemIndex) => total + (visibleAuxiliary[itemIndex] ? rows : 0), 0, ); for (const index of AUXILIARY_HIDE_PRIORITY) { if (countFixedRows() + minimumRenderedEditor.length <= lowerPaneLimit) break; visibleAuxiliary[index] = false; } const fixedRows = countFixedRows(); const editorBudget = Math.max(minimumRenderedEditor.length, lowerPaneLimit - fixedRows); if (hasBudgetedEditor) setEditorMaximumRows(editor, editorBudget); const renderedEditor = hasBudgetedEditor ? renderComponent(editor, width) : minimumRenderedEditor; const lowerPaneLines = [ ...(visibleAuxiliary[0] ? renderedStatus : []), ...(visibleAuxiliary[1] ? renderedAbove : []), ...renderedEditor, ...(visibleAuxiliary[2] ? renderedBelow : []), ...renderedFooter, ]; const footerStartLine = historyLines.length; const lines = [...historyLines, ...lowerPaneLines]; const lowerPaneRows = lowerPaneLines.length; if (lowerPaneRows <= 0) { return { reason: "empty-lower-pane", fields: { footerStartIndex, footerStartLine } }; } if (lowerPaneRows >= height) { return { reason: "lower-pane-too-tall", fields: { lowerPaneRows, height } }; } const historyRows = height - lowerPaneRows; if (historyRows < options.minimumHistoryRows) { return { reason: "history-pane-too-small", fields: { historyRows, lowerPaneRows, height, minimumHistoryRows: options.minimumHistoryRows }, }; } const screen = createScreenLines(tui, historyLines, lowerPaneLines, historyRows); if (isUnsupportedLayout(screen)) { return screen; } const { screenLines, historyViewportTop } = screen; return { lines, footerStartLine, lowerPaneRows, historyRows, historyViewportTop, screenLines, }; } function setEditorMaximumRows(component: Component, rows: number): boolean { const budgeted = component as Component & { setLowerPaneMaximumRows?: (maximumRows: number) => void }; if (typeof budgeted.setLowerPaneMaximumRows === "function") { budgeted.setLowerPaneMaximumRows(rows); return true; } if (!isPlainContainer(component)) return false; return component.children.some((child) => setEditorMaximumRows(child, rows)); } function isUnsupportedLayout(layout: object): layout is UnsupportedLayout { return "reason" in layout && typeof (layout as { reason?: unknown }).reason === "string"; } function extractCursorPosition(lines: string[], height: number): CursorPosition | null { const viewportTop = Math.max(0, lines.length - height); for (let row = lines.length - 1; row >= viewportTop; row -= 1) { const line = lines[row] ?? ""; const markerIndex = line.indexOf(CURSOR_MARKER); if (markerIndex === -1) { continue; } const beforeMarker = line.slice(0, markerIndex); const col = visibleWidth(beforeMarker); lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); return { row, col }; } return null; } function beginSynchronizedOutput(): string { return "\x1b[?2026h"; } function endSynchronizedOutput(): string { return "\x1b[?2026l"; } function clearViewportForStartupRedrawCompatibility(): string { return "\x1b[H\x1b[2J"; } function moveTo(row: number, column: number): string { return `\x1b[${row};${column}H`; } function clearWholeLine(): string { return "\x1b[2K"; } function clearToLineEnd(): string { return "\x1b[K"; } function clearToLineEndIfNeeded(line: string, width: number): string { if (isInlineImageProtocolLine(line)) { return ""; } return visibleWidth(line) < width ? clearToLineEnd() : ""; } function logDiagnostic(event: string, fields: Record): void { options.diagnostic?.(event, fields); } function rememberMetadata(tui: object, layout: SplitLayout): void { viewportMetadata.set(tui, { footerStartLine: layout.footerStartLine, lowerPaneRows: layout.lowerPaneRows, historyRows: layout.historyRows, historyViewportTop: layout.historyViewportTop, logicalLineCount: layout.lines.length, }); } function updateRenderState( tui: TuiWithInternals, screenLines: string[], width: number, height: number, hardwareCursorRow?: number, ): void { tui.cursorRow = Math.max(0, screenLines.length - 1); if (hardwareCursorRow !== undefined) { tui.hardwareCursorRow = clamp(hardwareCursorRow, 0, Math.max(0, screenLines.length - 1)); } tui.maxLinesRendered = Math.max(tui.maxLinesRendered, screenLines.length); tui.previousViewportTop = 0; tui.previousLines = screenLines; tui.previousWidth = width; tui.previousHeight = height; } function collectInlineImageSpanSets( previousLines: readonly string[], screenLines: readonly string[], ): { previousSpans: LineSpan[]; nextSpans: LineSpan[]; bothEmpty: boolean } { const previousSpans = collectInlineImageSpans(previousLines); const nextSpans = collectInlineImageSpans(screenLines); return { previousSpans, nextSpans, bothEmpty: previousSpans.length === 0 && nextSpans.length === 0 }; } function expandRowsToRenderForInlineImageSpans( previousLines: readonly string[], screenLines: readonly string[], rowsToRender: readonly number[], ): number[] { if (rowsToRender.length === 0) { return []; } const { previousSpans, nextSpans, bothEmpty } = collectInlineImageSpanSets(previousLines, screenLines); if (bothEmpty) { return [...rowsToRender]; } const expandedRows = new Set(rowsToRender); for (const row of rowsToRender) { for (const spans of [previousSpans, nextSpans]) { const span = findContainingLineSpan(spans, row); if (!span) { continue; } for (let spanRow = span.start; spanRow < span.endExclusive; spanRow += 1) { expandedRows.add(spanRow); } } } return [...expandedRows].sort((left, right) => left - right); } function getRowsToRender( previousLines: readonly string[], screenLines: readonly string[], forceFullRender: boolean, ): number[] { if (forceFullRender) { return screenLines.map((_line, index) => index); } const rows: number[] = []; const rowCount = Math.max(previousLines.length, screenLines.length); for (let row = 0; row < rowCount; row += 1) { if ((previousLines[row] ?? "") !== (screenLines[row] ?? "")) { rows.push(row); } } return expandRowsToRenderForInlineImageSpans(previousLines, screenLines, rows); } function renderBoundedViewport( tui: TuiWithInternals, layout: SplitLayout, cursorPos: CursorPosition | null, width: number, height: number, clear: boolean, ): void { tui.fullRedrawCount += clear ? 1 : 0; const rowsToRender = getRowsToRender(tui.previousLines, layout.screenLines, clear); let hardwareCursorRow = tui.hardwareCursorRow; if (rowsToRender.length > 0) { let buffer = beginSynchronizedOutput(); if (clear) { buffer += `\x1b[r${clearViewportForStartupRedrawCompatibility()}`; } for (const screenRow of rowsToRender) { const line = layout.screenLines[screenRow] ?? ""; buffer += clear ? `${moveTo(screenRow + 1, 1)}${clearWholeLine()}${line}` : `${moveTo(screenRow + 1, 1)}${line}${clearToLineEndIfNeeded(line, width)}`; hardwareCursorRow = screenRow; } buffer += endSynchronizedOutput(); tui.terminal.write(buffer); } updateRenderState(tui, layout.screenLines, width, height, hardwareCursorRow); tui.positionHardwareCursor?.(cursorPos, layout.screenLines.length); rememberMetadata(tui, layout); } function forceOriginalRenderer( tui: TuiWithInternals, originalDoRender: DoRender, thisArg: TUI, reason: string, fields: Record = {}, ): void { const leavingSplitRenderer = viewportMetadata.has(thisArg); viewportMetadata.delete(thisArg); logDiagnostic("fallback", { reason, width: tui.terminal?.columns, height: tui.terminal?.rows, previousScreenRows: tui.previousLines.length, leavingSplitRenderer, ...fields, }); if (leavingSplitRenderer) { tui.previousLines = []; tui.previousWidth = -1; tui.previousHeight = -1; tui.cursorRow = 0; tui.hardwareCursorRow = 0; tui.previousViewportTop = 0; } originalDoRender.call(thisArg); } function handOffToOriginalRenderer(tui: TuiWithInternals, originalDoRender: DoRender, thisArg: TUI): void { if (viewportMetadata.has(thisArg)) { forceOriginalRenderer(tui, originalDoRender, thisArg, "split-renderer-disabled"); return; } originalDoRender.call(thisArg); } function shouldForceFullClearForInlineImageSpans( previousLines: readonly string[], screenLines: readonly string[], ): boolean { const { previousSpans, nextSpans, bothEmpty } = collectInlineImageSpanSets(previousLines, screenLines); if (bothEmpty) { return false; } if (previousSpans.length !== nextSpans.length) { return true; } return previousSpans.some((span, index) => { const nextSpan = nextSpans[index]; return !nextSpan || span.start !== nextSpan.start || span.endExclusive !== nextSpan.endExclusive || !lineSpanContentMatches(previousLines, span, screenLines, nextSpan); }); } function shouldClearViewport( tui: TuiWithInternals, width: number, height: number, screenLines: readonly string[], ): boolean { return tui.previousLines.length === 0 || tui.previousWidth !== width || tui.previousHeight !== height || !viewportMetadata.has(tui as unknown as object) || shouldForceFullClearForInlineImageSpans(tui.previousLines, screenLines); } function patchedDoRender(this: TUI): void { const tui = getTuiInternals(this); const prototype = Object.getPrototypeOf(this) as PatchedTuiPrototype | null; const originalDoRender = prototype?.__piPinnedInputOriginalDoRender; if (!originalDoRender) { return; } if (!options.enabled || tui.stopped) { handOffToOriginalRenderer(tui, originalDoRender, this); return; } if (!hasRequiredInternals(tui)) { forceOriginalRenderer(tui, originalDoRender, this, "missing-required-tui-internals", { hasChildren: Array.isArray(tui.children), hasPreviousLines: Array.isArray(tui.previousLines), hasOverlayStack: Array.isArray(tui.overlayStack), }); return; } const unsupportedTerminal = getUnsupportedTerminalReason(tui); if (unsupportedTerminal) { forceOriginalRenderer(tui, originalDoRender, this, unsupportedTerminal.reason, unsupportedTerminal.fields); return; } if (getVisibleOverlayState(tui)) { forceOriginalRenderer(tui, originalDoRender, this, "visible-overlay", { overlayCount: tui.overlayStack.length, }); return; } const width = tui.terminal.columns; const height = tui.terminal.rows; const layout = buildSplitLayout(tui, width, height); if (isUnsupportedLayout(layout)) { forceOriginalRenderer(tui, originalDoRender, this, layout.reason, layout.fields); return; } const cursorPos = extractCursorPosition(layout.screenLines, height); const appliedLines = tui.applyLineResets?.(layout.screenLines) ?? layout.screenLines; const appliedLayout: SplitLayout = { ...layout, screenLines: normalizeVisibleLines(appliedLines, width), }; renderBoundedViewport( tui, appliedLayout, cursorPos, width, height, shouldClearViewport(tui, width, height, appliedLayout.screenLines), ); } export function configureSplitRenderer(nextOptions: SplitRendererOptions): void { options = { enabled: nextOptions.enabled, minimumHistoryRows: Math.max(1, Math.floor(nextOptions.minimumHistoryRows)), lowerPaneMaxPercent: clamp(Math.floor(nextOptions.lowerPaneMaxPercent), 40, 90), diagnostic: nextOptions.diagnostic, }; } function resolveRuntimeTuiPrototype(runtimeTui: TUI | undefined): PatchedTuiPrototype | undefined { if (!runtimeTui || typeof runtimeTui !== "object") { return undefined; } const prototype = Object.getPrototypeOf(runtimeTui) as PatchedTuiPrototype | null; if (!prototype || typeof prototype !== "object") { return undefined; } return prototype; } export function applySplitRendererPatch( nextOptions: SplitRendererOptions, runtimeTui?: TUI, ): SplitRendererPatchStatus { configureSplitRenderer(nextOptions); const prototype = resolveRuntimeTuiPrototype(runtimeTui); if (!prototype) { lastPatchReason = "awaiting-runtime-tui-instance"; return { installed: patchInstalled, active: patchInstalled && options.enabled, reason: lastPatchReason }; } if (prototype.__piPinnedInputPatched) { if (typeof prototype.__piPinnedInputOriginalDoRender !== "function") { patchInstalled = false; lastPatchReason = "missing-original-doRender"; return { installed: false, active: false, reason: lastPatchReason }; } prototype.doRender = patchedDoRender; patchInstalled = true; lastPatchReason = "already-installed"; return { installed: true, active: options.enabled, reason: lastPatchReason }; } if (typeof prototype.doRender !== "function") { patchInstalled = false; lastPatchReason = "missing-runtime-TUI.prototype.doRender"; return { installed: false, active: false, reason: lastPatchReason }; } prototype.__piPinnedInputOriginalDoRender = prototype.doRender; prototype.doRender = patchedDoRender; prototype.__piPinnedInputPatched = true; patchInstalled = true; lastPatchReason = "installed"; return { installed: true, active: options.enabled, reason: lastPatchReason }; } function getCurrentViewportTop( tui: object, historyLineCount: number, historyRows: number, ): { currentViewportTop: number; maximumViewportTop: number } { const maximumViewportTop = maximumHistoryViewportTop(historyLineCount, historyRows); const currentState = historyViewportState.get(tui); const currentViewportTop = currentState?.followBottom === false ? currentState.viewportTop : maximumViewportTop; return { currentViewportTop, maximumViewportTop }; } function updateViewportTop( runtimeTui: TUI, tui: object, currentViewportTop: number, viewportTop: number, maximumViewportTop: number, ): HistoryViewportScrollResult { const currentState = historyViewportState.get(tui); const followBottom = viewportTop >= maximumViewportTop; const changed = viewportTop !== currentViewportTop || currentState?.followBottom !== followBottom; saveHistoryViewportState(tui, { viewportTop, followBottom, unseenOutput: followBottom ? false : (currentState?.unseenOutput ?? false), lastHistoryLineCount: currentState?.lastHistoryLineCount ?? metadataLineCount(tui), }); if (changed) { runtimeTui.requestRender(); } return { handled: true, changed, viewportTop, followBottom }; } function metadataLineCount(tui: object): number { return viewportMetadata.get(tui)?.footerStartLine ?? 0; } export function scrollHistoryViewport( runtimeTui: TUI | undefined, deltaRows: number, ): HistoryViewportScrollResult { if (!runtimeTui || !Number.isFinite(deltaRows) || deltaRows === 0) { return { handled: false, changed: false }; } const tui = runtimeTui as unknown as object; const metadata = viewportMetadata.get(tui); if (!metadata) { return { handled: false, changed: false }; } const { currentViewportTop, maximumViewportTop } = getCurrentViewportTop( tui, metadata.footerStartLine, metadata.historyRows, ); const viewportTop = clamp(currentViewportTop + Math.trunc(deltaRows), 0, maximumViewportTop); return updateViewportTop(runtimeTui, tui, currentViewportTop, viewportTop, maximumViewportTop); } export function resetHistoryViewport(runtimeTui?: TUI): void { if (!runtimeTui) { return; } historyViewportState.delete(runtimeTui as unknown as object); viewportMetadata.delete(runtimeTui as unknown as object); } export function getSplitRendererPatchStatus(): SplitRendererPatchStatus { return { installed: patchInstalled, active: patchInstalled && options.enabled, reason: lastPatchReason, }; }