From aa85e99dc030adb324414a30e6db9ed155deb9a1 Mon Sep 17 00:00:00 2001 From: Juan Francisco Lebrero <101231690+frizynn@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:35:00 -0300 Subject: [PATCH] refactor: extract overlay layout and compositing from TUI into separate module (#1482) Move overlay positioning (resolveOverlayLayout, resolveAnchorRow/Col), line compositing (compositeLineAt, compositeOverlays, applyLineResets), cursor extraction, and size parsing into overlay-layout.ts. These are pure functions with no TUI state dependencies, reducing tui.ts from 1,200 to 899 lines. --- packages/pi-tui/src/overlay-layout.ts | 372 ++++++++++++++++++++++++++ packages/pi-tui/src/tui.ts | 323 +--------------------- 2 files changed, 383 insertions(+), 312 deletions(-) create mode 100644 packages/pi-tui/src/overlay-layout.ts diff --git a/packages/pi-tui/src/overlay-layout.ts b/packages/pi-tui/src/overlay-layout.ts new file mode 100644 index 000000000..1896c5bba --- /dev/null +++ b/packages/pi-tui/src/overlay-layout.ts @@ -0,0 +1,372 @@ +/** + * Overlay layout resolution, compositing, and rendering utilities. + * + * Extracted from tui.ts — these are pure functions that compute overlay + * positions and composite overlay content onto base terminal lines. + */ + +import type { OverlayAnchor, OverlayOptions, SizeValue } from "./tui.js"; +import { extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "./utils.js"; +import { isImageLine } from "./terminal-image.js"; +import { CURSOR_MARKER } from "./tui.js"; + +// ─── Size parsing ─────────────────────────────────────────────────────────── + +/** Parse a SizeValue into absolute value given a reference size */ +export function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { + if (value === undefined) return undefined; + if (typeof value === "number") return value; + // Parse percentage string like "50%" + const match = value.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + return Math.floor((referenceSize * parseFloat(match[1])) / 100); + } + return undefined; +} + +// ─── Anchor resolution ────────────────────────────────────────────────────── + +export function resolveAnchorRow( + anchor: OverlayAnchor, + height: number, + availHeight: number, + marginTop: number, +): number { + switch (anchor) { + case "top-left": + case "top-center": + case "top-right": + return marginTop; + case "bottom-left": + case "bottom-center": + case "bottom-right": + return marginTop + availHeight - height; + case "left-center": + case "center": + case "right-center": + return marginTop + Math.floor((availHeight - height) / 2); + } +} + +export function resolveAnchorCol( + anchor: OverlayAnchor, + width: number, + availWidth: number, + marginLeft: number, +): number { + switch (anchor) { + case "top-left": + case "left-center": + case "bottom-left": + return marginLeft; + case "top-right": + case "right-center": + case "bottom-right": + return marginLeft + availWidth - width; + case "top-center": + case "center": + case "bottom-center": + return marginLeft + Math.floor((availWidth - width) / 2); + } +} + +// ─── Overlay layout resolution ────────────────────────────────────────────── + +export interface OverlayLayout { + width: number; + row: number; + col: number; + maxHeight: number | undefined; +} + +/** + * Resolve overlay layout from options. + * Returns { width, row, col, maxHeight } for rendering. + */ +export function resolveOverlayLayout( + options: OverlayOptions | undefined, + overlayHeight: number, + termWidth: number, + termHeight: number, +): OverlayLayout { + const opt = options ?? {}; + + // Parse margin (clamp to non-negative) + const margin = + typeof opt.margin === "number" + ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } + : (opt.margin ?? {}); + const marginTop = Math.max(0, margin.top ?? 0); + const marginRight = Math.max(0, margin.right ?? 0); + const marginBottom = Math.max(0, margin.bottom ?? 0); + const marginLeft = Math.max(0, margin.left ?? 0); + + // Available space after margins + const availWidth = Math.max(1, termWidth - marginLeft - marginRight); + const availHeight = Math.max(1, termHeight - marginTop - marginBottom); + + // === Resolve width === + let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); + // Apply minWidth + if (opt.minWidth !== undefined) { + width = Math.max(width, opt.minWidth); + } + // Clamp to available space + width = Math.max(1, Math.min(width, availWidth)); + + // === Resolve maxHeight === + let maxHeight = parseSizeValue(opt.maxHeight, termHeight); + // Clamp to available space + if (maxHeight !== undefined) { + maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); + } + + // Effective overlay height (may be clamped by maxHeight) + const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; + + // === Resolve position === + let row: number; + let col: number; + + if (opt.row !== undefined) { + if (typeof opt.row === "string") { + // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) + const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxRow = Math.max(0, availHeight - effectiveHeight); + const percent = parseFloat(match[1]) / 100; + row = marginTop + Math.floor(maxRow * percent); + } else { + // Invalid format, fall back to center + row = resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); + } + } else { + // Absolute row position + row = opt.row; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + row = resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); + } + + if (opt.col !== undefined) { + if (typeof opt.col === "string") { + // Percentage: 0% = left, 100% = right (overlay stays within bounds) + const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); + if (match) { + const maxCol = Math.max(0, availWidth - width); + const percent = parseFloat(match[1]) / 100; + col = marginLeft + Math.floor(maxCol * percent); + } else { + // Invalid format, fall back to center + col = resolveAnchorCol("center", width, availWidth, marginLeft); + } + } else { + // Absolute column position + col = opt.col; + } + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + col = resolveAnchorCol(anchor, width, availWidth, marginLeft); + } + + // Apply offsets + if (opt.offsetY !== undefined) row += opt.offsetY; + if (opt.offsetX !== undefined) col += opt.offsetX; + + // Clamp to terminal bounds (respecting margins) + row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); + col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); + + return { width, row, col, maxHeight }; +} + +// ─── Line compositing ─────────────────────────────────────────────────────── + +const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + +/** Append reset sequences to each non-image line. */ +export function applyLineResets(lines: string[]): string[] { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (!isImageLine(line)) { + lines[i] = line + SEGMENT_RESET; + } + } + return lines; +} + +/** Splice overlay content into a base line at a specific column. Single-pass optimized. */ +export function compositeLineAt( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + if (isImageLine(baseLine)) return baseLine; + + // Single pass through baseLine extracts both before and after segments + const afterStart = startCol + overlayWidth; + const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); + + // Extract overlay with width tracking (strict=true to exclude wide chars at boundary) + const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); + + // Pad segments to target widths + const beforePad = Math.max(0, startCol - base.beforeWidth); + const overlayPad = Math.max(0, overlayWidth - overlay.width); + const actualBeforeWidth = Math.max(startCol, base.beforeWidth); + const actualOverlayWidth = Math.max(overlayWidth, overlay.width); + const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); + const afterPad = Math.max(0, afterTarget - base.afterWidth); + + // Compose result + const r = SEGMENT_RESET; + const result = + base.before + + " ".repeat(beforePad) + + r + + overlay.text + + " ".repeat(overlayPad) + + r + + base.after + + " ".repeat(afterPad); + + // CRITICAL: Always verify and truncate to terminal width. + // This is the final safeguard against width overflow which would crash the TUI. + // Width tracking can drift from actual visible width due to: + // - Complex ANSI/OSC sequences (hyperlinks, colors) + // - Wide characters at segment boundaries + // - Edge cases in segment extraction + const resultWidth = visibleWidth(result); + if (resultWidth <= totalWidth) { + return result; + } + // Truncate with strict=true to ensure we don't exceed totalWidth + return sliceByColumn(result, 0, totalWidth, true); +} + +// ─── Overlay compositing ──────────────────────────────────────────────────── + +export interface OverlayEntry { + component: { render(width: number): string[]; invalidate?(): void }; + options?: OverlayOptions; + hidden: boolean; + focusOrder: number; +} + +/** Check if an overlay entry is currently visible */ +export function isOverlayVisible( + entry: OverlayEntry, + termWidth: number, + termHeight: number, +): boolean { + if (entry.hidden) return false; + if (entry.options?.visible) { + return entry.options.visible(termWidth, termHeight); + } + return true; +} + +/** + * Composite all visible overlays into content lines. + * Sorted by focusOrder (higher = on top). + */ +export function compositeOverlays( + lines: string[], + overlayStack: OverlayEntry[], + termWidth: number, + termHeight: number, + maxLinesRendered: number, +): string[] { + if (overlayStack.length === 0) return lines; + const result = [...lines]; + + // Pre-render all visible overlays and calculate positions + const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; + let minLinesNeeded = result.length; + + const visibleEntries = overlayStack.filter((e) => isOverlayVisible(e, termWidth, termHeight)); + visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); + for (const entry of visibleEntries) { + const { component, options } = entry; + + // Get layout with height=0 first to determine width and maxHeight + // (width and maxHeight don't depend on overlay height) + const { width, maxHeight } = resolveOverlayLayout(options, 0, termWidth, termHeight); + + // Render component at calculated width + let overlayLines = component.render(width); + + // Apply maxHeight if specified + if (maxHeight !== undefined && overlayLines.length > maxHeight) { + overlayLines = overlayLines.slice(0, maxHeight); + } + + // Get final row/col with actual overlay height + const { row, col } = resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); + + rendered.push({ overlayLines, row, col, w: width }); + minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); + } + + // Ensure result covers the terminal working area to keep overlay positioning stable across resizes. + // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent. + const workingHeight = Math.max(maxLinesRendered, minLinesNeeded); + + // Extend result with empty lines if content is too short for overlay placement or working area + while (result.length < workingHeight) { + result.push(""); + } + + const viewportStart = Math.max(0, workingHeight - termHeight); + + // Composite each overlay + for (const { overlayLines, row, col, w } of rendered) { + for (let i = 0; i < overlayLines.length; i++) { + const idx = viewportStart + row + i; + if (idx >= 0 && idx < result.length) { + // Defensive: truncate overlay line to declared width before compositing + // (components should already respect width, but this ensures it) + const truncatedOverlayLine = + visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; + result[idx] = compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); + } + } + } + + return result; +} + +// ─── Cursor extraction ────────────────────────────────────────────────────── + +/** + * Find and extract cursor position from rendered lines. + * Searches for CURSOR_MARKER, calculates its position, and strips it from the output. + * Only scans the bottom terminal height lines (visible viewport). + * @param lines - Rendered lines to search (mutated to strip marker) + * @param height - Terminal height (visible viewport size) + * @returns Cursor position { row, col } or null if no marker found + */ +export function extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { + // Only scan the bottom `height` lines (visible viewport) + const viewportTop = Math.max(0, lines.length - height); + for (let row = lines.length - 1; row >= viewportTop; row--) { + const line = lines[row]; + const markerIndex = line.indexOf(CURSOR_MARKER); + if (markerIndex !== -1) { + // Calculate visual column (width of text before marker) + const beforeMarker = line.slice(0, markerIndex); + const col = visibleWidth(beforeMarker); + + // Strip marker from the line + lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); + + return { row, col }; + } + } + return null; +} diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 863cddb1f..460218de6 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -6,9 +6,15 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { isKeyRelease, matchesKey } from "./keys.js"; +import { + applyLineResets, + compositeOverlays, + extractCursorPosition, + isOverlayVisible as isOverlayEntryVisible, +} from "./overlay-layout.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js"; -import { extractSegments, sliceByColumn, sliceWithWidth, truncateToWidth, visibleWidth } from "./utils.js"; +import { truncateToWidth, visibleWidth } from "./utils.js"; /** * Component interface - all components must implement this @@ -95,18 +101,6 @@ export interface OverlayMargin { /** Value that can be absolute (number) or percentage (string like "50%") */ export type SizeValue = number | `${number}%`; -/** Parse a SizeValue into absolute value given a reference size */ -function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { - if (value === undefined) return undefined; - if (typeof value === "number") return value; - // Parse percentage string like "50%" - const match = value.match(/^(\d+(?:\.\d+)?)%$/); - if (match) { - return Math.floor((referenceSize * parseFloat(match[1])) / 100); - } - return undefined; -} - /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). @@ -383,11 +377,7 @@ export class TUI extends Container { /** Check if an overlay entry is currently visible */ private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { - if (entry.hidden) return false; - if (entry.options?.visible) { - return entry.options.visible(this.terminal.columns, this.terminal.rows); - } - return true; + return isOverlayEntryVisible(entry, this.terminal.columns, this.terminal.rows); } /** Find the topmost visible capturing overlay, if any */ @@ -584,297 +574,6 @@ export class TUI extends Container { return result; } - /** - * Resolve overlay layout from options. - * Returns { width, row, col, maxHeight } for rendering. - */ - private resolveOverlayLayout( - options: OverlayOptions | undefined, - overlayHeight: number, - termWidth: number, - termHeight: number, - ): { width: number; row: number; col: number; maxHeight: number | undefined } { - const opt = options ?? {}; - - // Parse margin (clamp to non-negative) - const margin = - typeof opt.margin === "number" - ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } - : (opt.margin ?? {}); - const marginTop = Math.max(0, margin.top ?? 0); - const marginRight = Math.max(0, margin.right ?? 0); - const marginBottom = Math.max(0, margin.bottom ?? 0); - const marginLeft = Math.max(0, margin.left ?? 0); - - // Available space after margins - const availWidth = Math.max(1, termWidth - marginLeft - marginRight); - const availHeight = Math.max(1, termHeight - marginTop - marginBottom); - - // === Resolve width === - let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); - // Apply minWidth - if (opt.minWidth !== undefined) { - width = Math.max(width, opt.minWidth); - } - // Clamp to available space - width = Math.max(1, Math.min(width, availWidth)); - - // === Resolve maxHeight === - let maxHeight = parseSizeValue(opt.maxHeight, termHeight); - // Clamp to available space - if (maxHeight !== undefined) { - maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); - } - - // Effective overlay height (may be clamped by maxHeight) - const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; - - // === Resolve position === - let row: number; - let col: number; - - if (opt.row !== undefined) { - if (typeof opt.row === "string") { - // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) - const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); - if (match) { - const maxRow = Math.max(0, availHeight - effectiveHeight); - const percent = parseFloat(match[1]) / 100; - row = marginTop + Math.floor(maxRow * percent); - } else { - // Invalid format, fall back to center - row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); - } - } else { - // Absolute row position - row = opt.row; - } - } else { - // Anchor-based (default: center) - const anchor = opt.anchor ?? "center"; - row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); - } - - if (opt.col !== undefined) { - if (typeof opt.col === "string") { - // Percentage: 0% = left, 100% = right (overlay stays within bounds) - const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); - if (match) { - const maxCol = Math.max(0, availWidth - width); - const percent = parseFloat(match[1]) / 100; - col = marginLeft + Math.floor(maxCol * percent); - } else { - // Invalid format, fall back to center - col = this.resolveAnchorCol("center", width, availWidth, marginLeft); - } - } else { - // Absolute column position - col = opt.col; - } - } else { - // Anchor-based (default: center) - const anchor = opt.anchor ?? "center"; - col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); - } - - // Apply offsets - if (opt.offsetY !== undefined) row += opt.offsetY; - if (opt.offsetX !== undefined) col += opt.offsetX; - - // Clamp to terminal bounds (respecting margins) - row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); - col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); - - return { width, row, col, maxHeight }; - } - - private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { - switch (anchor) { - case "top-left": - case "top-center": - case "top-right": - return marginTop; - case "bottom-left": - case "bottom-center": - case "bottom-right": - return marginTop + availHeight - height; - case "left-center": - case "center": - case "right-center": - return marginTop + Math.floor((availHeight - height) / 2); - } - } - - private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { - switch (anchor) { - case "top-left": - case "left-center": - case "bottom-left": - return marginLeft; - case "top-right": - case "right-center": - case "bottom-right": - return marginLeft + availWidth - width; - case "top-center": - case "center": - case "bottom-center": - return marginLeft + Math.floor((availWidth - width) / 2); - } - } - - /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ - private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { - if (this.overlayStack.length === 0) return lines; - const result = [...lines]; - - // Pre-render all visible overlays and calculate positions - const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; - let minLinesNeeded = result.length; - - const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); - visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); - for (const entry of visibleEntries) { - const { component, options } = entry; - - // Get layout with height=0 first to determine width and maxHeight - // (width and maxHeight don't depend on overlay height) - const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); - - // Render component at calculated width - let overlayLines = component.render(width); - - // Apply maxHeight if specified - if (maxHeight !== undefined && overlayLines.length > maxHeight) { - overlayLines = overlayLines.slice(0, maxHeight); - } - - // Get final row/col with actual overlay height - const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); - - rendered.push({ overlayLines, row, col, w: width }); - minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); - } - - // Ensure result covers the terminal working area to keep overlay positioning stable across resizes. - // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent. - const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded); - - // Extend result with empty lines if content is too short for overlay placement or working area - while (result.length < workingHeight) { - result.push(""); - } - - const viewportStart = Math.max(0, workingHeight - termHeight); - - // Composite each overlay - for (const { overlayLines, row, col, w } of rendered) { - for (let i = 0; i < overlayLines.length; i++) { - const idx = viewportStart + row + i; - if (idx >= 0 && idx < result.length) { - // Defensive: truncate overlay line to declared width before compositing - // (components should already respect width, but this ensures it) - const truncatedOverlayLine = - visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; - result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); - } - } - } - - return result; - } - - private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - - private applyLineResets(lines: string[]): string[] { - const reset = TUI.SEGMENT_RESET; - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (!isImageLine(line)) { - lines[i] = line + reset; - } - } - return lines; - } - - /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ - private compositeLineAt( - baseLine: string, - overlayLine: string, - startCol: number, - overlayWidth: number, - totalWidth: number, - ): string { - if (isImageLine(baseLine)) return baseLine; - - // Single pass through baseLine extracts both before and after segments - const afterStart = startCol + overlayWidth; - const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); - - // Extract overlay with width tracking (strict=true to exclude wide chars at boundary) - const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); - - // Pad segments to target widths - const beforePad = Math.max(0, startCol - base.beforeWidth); - const overlayPad = Math.max(0, overlayWidth - overlay.width); - const actualBeforeWidth = Math.max(startCol, base.beforeWidth); - const actualOverlayWidth = Math.max(overlayWidth, overlay.width); - const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); - const afterPad = Math.max(0, afterTarget - base.afterWidth); - - // Compose result - const r = TUI.SEGMENT_RESET; - const result = - base.before + - " ".repeat(beforePad) + - r + - overlay.text + - " ".repeat(overlayPad) + - r + - base.after + - " ".repeat(afterPad); - - // CRITICAL: Always verify and truncate to terminal width. - // This is the final safeguard against width overflow which would crash the TUI. - // Width tracking can drift from actual visible width due to: - // - Complex ANSI/OSC sequences (hyperlinks, colors) - // - Wide characters at segment boundaries - // - Edge cases in segment extraction - const resultWidth = visibleWidth(result); - if (resultWidth <= totalWidth) { - return result; - } - // Truncate with strict=true to ensure we don't exceed totalWidth - return sliceByColumn(result, 0, totalWidth, true); - } - - /** - * Find and extract cursor position from rendered lines. - * Searches for CURSOR_MARKER, calculates its position, and strips it from the output. - * Only scans the bottom terminal height lines (visible viewport). - * @param lines - Rendered lines to search - * @param height - Terminal height (visible viewport size) - * @returns Cursor position { row, col } or null if no marker found - */ - private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { - // Only scan the bottom `height` lines (visible viewport) - const viewportTop = Math.max(0, lines.length - height); - for (let row = lines.length - 1; row >= viewportTop; row--) { - const line = lines[row]; - const markerIndex = line.indexOf(CURSOR_MARKER); - if (markerIndex !== -1) { - // Calculate visual column (width of text before marker) - const beforeMarker = line.slice(0, markerIndex); - const col = visibleWidth(beforeMarker); - - // Strip marker from the line - lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); - - return { row, col }; - } - } - return null; - } - private doRender(): void { if (this.stopped) return; const width = this.terminal.columns; @@ -893,13 +592,13 @@ export class TUI extends Container { // Composite overlays into the rendered lines (before differential compare) if (this.overlayStack.length > 0) { - newLines = this.compositeOverlays(newLines, width, height); + newLines = compositeOverlays(newLines, this.overlayStack, width, height, this.maxLinesRendered); } // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); + const cursorPos = extractCursorPosition(newLines, height); - newLines = this.applyLineResets(newLines); + newLines = applyLineResets(newLines); // Width or height changed - need full re-render const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;