diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index edd62bb4..157864ef 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,10 +6,13 @@ - `SettingsListOptions` with `enableSearch` for fuzzy filtering in `SettingsList` ([#643](https://github.com/badlogic/pi-mono/pull/643) by [@ninlds](https://github.com/ninlds)) - `pageUp` and `pageDown` key support with `selectPageUp`/`selectPageDown` editor actions ([#662](https://github.com/badlogic/pi-mono/pull/662) by [@aliou](https://github.com/aliou)) +- `OverlayOptions` API for overlay positioning and sizing: `width`, `widthPercent`, `minWidth`, `maxHeight`, `maxHeightPercent`, `anchor`, `offsetX`, `offsetY`, `rowPercent`, `colPercent`, `row`, `col`, `margin` +- New exported types: `OverlayAnchor`, `OverlayMargin`, `OverlayOptions` ### Fixed - Numbered list items showing "1." for all items when code blocks break list continuity ([#660](https://github.com/badlogic/pi-mono/pull/660) by [@ogulcancelik](https://github.com/ogulcancelik)) +- Overlay compositing crash when rendered lines exceed terminal width due to complex ANSI/OSC sequences (e.g., hyperlinks in subagent output) ## [0.43.0] - 2026-01-11 diff --git a/packages/tui/README.md b/packages/tui/README.md index 4f14a2fb..41e35883 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -56,6 +56,56 @@ tui.requestRender(); // Request a re-render tui.onDebug = () => console.log("Debug triggered"); ``` +### Overlays + +Overlays render components on top of existing content without replacing it. Useful for dialogs, menus, and modal UI. + +```typescript +// Show overlay with default options (centered, max 80 cols) +tui.showOverlay(component); + +// Show overlay with custom positioning and sizing +tui.showOverlay(component, { + // Sizing + width: 60, // Fixed width in columns + widthPercent: 80, // Width as percentage of terminal (0-100) + minWidth: 40, // Minimum width floor + maxHeight: 20, // Maximum height in rows + maxHeightPercent: 50, // Maximum height as percentage of terminal + + // Anchor-based positioning (default: 'center') + anchor: 'bottom-right', // Position relative to anchor point + offsetX: 2, // Horizontal offset from anchor + offsetY: -1, // Vertical offset from anchor + + // Percentage-based positioning (alternative to anchor) + rowPercent: 25, // Vertical position (0=top, 100=bottom) + colPercent: 50, // Horizontal position (0=left, 100=right) + + // Absolute positioning (overrides anchor/percent) + row: 5, // Exact row position + col: 10, // Exact column position + + // Margin from terminal edges + margin: 2, // All sides + margin: { top: 1, right: 2, bottom: 1, left: 2 } +}); + +// Hide topmost overlay +tui.hideOverlay(); + +// Check if any overlay is active +tui.hasOverlay(); +``` + +**Anchor values**: `'center'`, `'top-left'`, `'top-right'`, `'bottom-left'`, `'bottom-right'`, `'top-center'`, `'bottom-center'`, `'left-center'`, `'right-center'` + +**Resolution order**: +1. `width` takes precedence over `widthPercent` +2. `minWidth` is applied as a floor after width calculation +3. For position: `row`/`col` > `rowPercent`/`colPercent` > `anchor` +4. `margin` clamps final position to stay within terminal bounds + ### Component Interface All components implement: diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index b1b3cdff..ba158353 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -72,6 +72,13 @@ export { setCellDimensions, type TerminalCapabilities, } from "./terminal-image.js"; -export { type Component, Container, TUI } from "./tui.js"; +export { + type Component, + Container, + type OverlayAnchor, + type OverlayMargin, + type OverlayOptions, + TUI, +} from "./tui.js"; // Utilities export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js"; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 648b0ff4..c9e42f4f 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -41,6 +41,73 @@ export interface Component { export { visibleWidth }; +/** + * Anchor position for overlays + */ +export type OverlayAnchor = + | "center" + | "top-left" + | "top-right" + | "bottom-left" + | "bottom-right" + | "top-center" + | "bottom-center" + | "left-center" + | "right-center"; + +/** + * Margin configuration for overlays + */ +export interface OverlayMargin { + top?: number; + right?: number; + bottom?: number; + left?: number; +} + +/** + * Options for overlay positioning and sizing + */ +export interface OverlayOptions { + // === Sizing (absolute) === + /** Fixed width in columns */ + width?: number; + /** Minimum width in columns */ + minWidth?: number; + /** Maximum height in rows */ + maxHeight?: number; + + // === Sizing (relative to terminal) === + /** Width as percentage of terminal width (0-100) */ + widthPercent?: number; + /** Maximum height as percentage of terminal height (0-100) */ + maxHeightPercent?: number; + + // === Positioning - anchor-based === + /** Anchor point for positioning (default: 'center') */ + anchor?: OverlayAnchor; + /** Horizontal offset from anchor position (positive = right) */ + offsetX?: number; + /** Vertical offset from anchor position (positive = down) */ + offsetY?: number; + + // === Positioning - percentage-based (alternative to anchor) === + /** Vertical position as percentage (0 = top, 100 = bottom) */ + rowPercent?: number; + /** Horizontal position as percentage (0 = left, 100 = right) */ + colPercent?: number; + + // === Positioning - absolute (low-level) === + /** Absolute row position (overrides anchor/percent) */ + row?: number; + /** Absolute column position (overrides anchor/percent) */ + col?: number; + + // === Margin from terminal edges === + /** Margin from terminal edges. Number applies to all sides. */ + margin?: OverlayMargin | number; +} + /** * Container - a component that contains other components */ @@ -96,7 +163,7 @@ export class TUI extends Container { // Overlay stack for modal components rendered on top of base content private overlayStack: { component: Component; - options?: { row?: number; col?: number; width?: number }; + options?: OverlayOptions; preFocus: Component | null; }[] = []; @@ -109,8 +176,8 @@ export class TUI extends Container { this.focusedComponent = component; } - /** Show an overlay component centered (or at specified position). */ - showOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void { + /** Show an overlay component with configurable positioning and sizing. */ + showOverlay(component: Component, options?: OverlayOptions): void { this.overlayStack.push({ component, options, preFocus: this.focusedComponent }); this.setFocus(component); this.terminal.hideCursor(); @@ -260,30 +327,202 @@ export class TUI extends Container { return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); } + /** + * 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: number; + if (opt.width !== undefined) { + width = opt.width; + } else if (opt.widthPercent !== undefined) { + width = Math.floor((termWidth * opt.widthPercent) / 100); + } else { + width = 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: number | undefined; + if (opt.maxHeight !== undefined) { + maxHeight = opt.maxHeight; + } else if (opt.maxHeightPercent !== undefined) { + maxHeight = Math.floor((termHeight * opt.maxHeightPercent) / 100); + } + // 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; + + // Absolute positioning takes precedence + if (opt.row !== undefined) { + row = opt.row; + } else if (opt.rowPercent !== undefined) { + // Percentage: 0 = top, 100 = bottom + const maxRow = Math.max(0, availHeight - effectiveHeight); + row = marginTop + Math.floor((maxRow * opt.rowPercent) / 100); + } else { + // Anchor-based (default: center) + const anchor = opt.anchor ?? "center"; + row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); + } + + if (opt.col !== undefined) { + col = opt.col; + } else if (opt.colPercent !== undefined) { + // Percentage: 0 = left, 100 = right + const maxCol = Math.max(0, availWidth - width); + col = marginLeft + Math.floor((maxCol * opt.colPercent) / 100); + } 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 (in stack order, later = on top). */ private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) return lines; const result = [...lines]; - const viewportStart = Math.max(0, result.length - termHeight); + + // Pre-render all overlays and calculate positions + const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; + let minLinesNeeded = result.length; for (const { component, options } of this.overlayStack) { - const w = - options?.width !== undefined - ? Math.max(1, Math.min(options.width, termWidth - 4)) - : Math.max(1, Math.min(80, termWidth - 4)); - const overlayLines = component.render(w); - const h = overlayLines.length; + // 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); - const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h)); - const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w)); + // Render component at calculated width + let overlayLines = component.render(width); - for (let i = 0; i < h; i++) { + // 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); + } + + // Extend result with empty lines if content is too short for overlay placement + while (result.length < minLinesNeeded) { + result.push(""); + } + + const viewportStart = Math.max(0, result.length - termHeight); + + // Track which lines were modified for final verification + const modifiedLines = new Set(); + + // 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) { - result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth); + // 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); + modifiedLines.add(idx); } } } + + // Final verification: ensure no composited line exceeds terminal width + // This is a belt-and-suspenders safeguard - compositeLineAt should already + // guarantee this, but we verify here to prevent crashes from any edge cases + // Only check lines that were actually modified (optimization) + for (const idx of modifiedLines) { + const lineWidth = visibleWidth(result[idx]); + if (lineWidth > termWidth) { + result[idx] = sliceByColumn(result[idx], 0, termWidth, true); + } + } + return result; } @@ -308,8 +547,8 @@ export class TUI extends Container { const afterStart = startCol + overlayWidth; const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); - // Extract overlay with width tracking - const overlay = sliceWithWidth(overlayLine, 0, overlayWidth); + // 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); @@ -319,7 +558,7 @@ export class TUI extends Container { const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); const afterPad = Math.max(0, afterTarget - base.afterWidth); - // Compose result - widths are tracked so no final visibleWidth check needed + // Compose result const r = TUI.SEGMENT_RESET; const result = base.before + @@ -331,9 +570,18 @@ export class TUI extends Container { base.after + " ".repeat(afterPad); - // Only truncate if wide char at after boundary caused overflow (rare) - const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth); - return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true); + // 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); } private doRender(): void { @@ -362,8 +610,8 @@ export class TUI extends Container { } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); - // After rendering N lines, cursor is at end of last line (line N-1) - this.cursorRow = newLines.length - 1; + // After rendering N lines, cursor is at end of last line (clamp to 0 for empty) + this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; return; @@ -379,7 +627,7 @@ export class TUI extends Container { } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; + this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; return; @@ -410,8 +658,8 @@ export class TUI extends Container { if (firstChanged >= newLines.length) { if (this.previousLines.length > newLines.length) { let buffer = "\x1b[?2026h"; - // Move to end of new content - const targetRow = newLines.length - 1; + // Move to end of new content (clamp to 0 for empty content) + const targetRow = Math.max(0, newLines.length - 1); const lineDiff = targetRow - this.cursorRow; if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; @@ -424,7 +672,7 @@ export class TUI extends Container { buffer += `\x1b[${extraLines}A`; buffer += "\x1b[?2026l"; this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; + this.cursorRow = targetRow; } this.previousLines = newLines; this.previousWidth = width; @@ -446,7 +694,7 @@ export class TUI extends Container { } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); - this.cursorRow = newLines.length - 1; + this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; return; diff --git a/packages/tui/test/overlay-options.test.ts b/packages/tui/test/overlay-options.test.ts new file mode 100644 index 00000000..b5dbc5c4 --- /dev/null +++ b/packages/tui/test/overlay-options.test.ts @@ -0,0 +1,538 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component } from "../src/tui.js"; +import { TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class StaticOverlay implements Component { + constructor( + private lines: string[], + public requestedWidth?: number, + ) {} + + render(width: number): string[] { + // Store the width we were asked to render at for verification + this.requestedWidth = width; + return this.lines; + } + + invalidate(): void {} +} + +class EmptyContent implements Component { + render(): string[] { + return []; + } + invalidate(): void {} +} + +async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise { + tui.requestRender(true); + await new Promise((resolve) => process.nextTick(resolve)); + await terminal.flush(); +} + +describe("TUI overlay options", () => { + describe("width overflow protection", () => { + it("should truncate overlay lines that exceed declared width", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Overlay declares width 20 but renders lines much wider + const overlay = new StaticOverlay(["X".repeat(100)]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash, and no line should exceed terminal width + const viewport = terminal.getViewport(); + for (const line of viewport) { + // visibleWidth not available here, but line length is a rough check + // The important thing is it didn't crash + assert.ok(line !== undefined); + } + tui.stop(); + }); + + it("should handle overlay with complex ANSI sequences without crashing", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Simulate complex ANSI content like the crash log showed + const complexLine = + "\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" + + "\x1b]8;;http://example.com\x07link\x1b]8;;\x07" + + " more content ".repeat(10); + const overlay = new StaticOverlay([complexLine, complexLine, complexLine]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 60 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay composited on styled base content", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Base content with styling + class StyledContent implements Component { + render(width: number): string[] { + const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`; + return [styledLine, styledLine, styledLine]; + } + invalidate(): void {} + } + + const overlay = new StaticOverlay(["OVERLAY"]); + + tui.addChild(new StyledContent()); + tui.showOverlay(overlay, { width: 20, anchor: "center" }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash and overlay should be visible + const viewport = terminal.getViewport(); + const hasOverlay = viewport.some((line) => line?.includes("OVERLAY")); + assert.ok(hasOverlay, "Overlay should be visible"); + tui.stop(); + }); + + it("should handle wide characters at overlay boundary", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Wide chars (each takes 2 columns) at the edge of declared width + const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars + const overlay = new StaticOverlay([wideCharLine]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay positioned at terminal edge", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + // Overlay positioned at right edge with content that exceeds declared width + const overlay = new StaticOverlay(["X".repeat(50)]); + + tui.addChild(new EmptyContent()); + // Position at col 60 with width 20 - should fit exactly at right edge + tui.showOverlay(overlay, { col: 60, width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + + it("should handle overlay on base content with OSC sequences", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Base content with OSC 8 hyperlinks (like file paths in agent output) + class HyperlinkContent implements Component { + render(width: number): string[] { + const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`; + const line = `See ${link} for details ${"X".repeat(width - 30)}`; + return [line, line, line]; + } + invalidate(): void {} + } + + const overlay = new StaticOverlay(["OVERLAY-TEXT"]); + + tui.addChild(new HyperlinkContent()); + tui.showOverlay(overlay, { anchor: "center", width: 20 }); + tui.start(); + await renderAndFlush(tui, terminal); + + // Should not crash - this was the original bug scenario + const viewport = terminal.getViewport(); + assert.ok(viewport.length > 0); + tui.stop(); + }); + }); + + describe("widthPercent", () => { + it("should render overlay at percentage of terminal width", async () => { + const terminal = new VirtualTerminal(100, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["test"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { widthPercent: 50 }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(overlay.requestedWidth, 50); + tui.stop(); + }); + + it("should respect minWidth when widthPercent results in smaller width", async () => { + const terminal = new VirtualTerminal(100, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["test"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { widthPercent: 10, minWidth: 30 }); + tui.start(); + await renderAndFlush(tui, terminal); + + assert.strictEqual(overlay.requestedWidth, 30); + tui.stop(); + }); + }); + + describe("anchor positioning", () => { + it("should position overlay at top-left", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["TOP-LEFT"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.startsWith("TOP-LEFT"), `Expected TOP-LEFT at start, got: ${viewport[0]}`); + tui.stop(); + }); + + it("should position overlay at bottom-right", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["BTM-RIGHT"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on last row, ending at last column + const lastRow = viewport[23]; + assert.ok(lastRow?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on last row, got: ${lastRow}`); + assert.ok(lastRow?.trimEnd().endsWith("BTM-RIGHT"), `Expected BTM-RIGHT at end, got: ${lastRow}`); + tui.stop(); + }); + + it("should position overlay at top-center", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["CENTERED"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-center", width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on first row, centered horizontally + const firstRow = viewport[0]; + assert.ok(firstRow?.includes("CENTERED"), `Expected CENTERED on first row, got: ${firstRow}`); + // Check it's roughly centered (col 35 for width 10 in 80 col terminal) + const colIndex = firstRow?.indexOf("CENTERED") ?? -1; + assert.ok(colIndex >= 30 && colIndex <= 40, `Expected centered, got col ${colIndex}`); + tui.stop(); + }); + }); + + describe("margin", () => { + it("should clamp negative margins to zero", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["NEG-MARGIN"]); + + tui.addChild(new EmptyContent()); + // Negative margins should be treated as 0 + tui.showOverlay(overlay, { + anchor: "top-left", + width: 12, + margin: { top: -5, left: -10, right: 0, bottom: 0 }, + }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be at row 0, col 0 (negative margins clamped to 0) + assert.ok(viewport[0]?.startsWith("NEG-MARGIN"), `Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`); + tui.stop(); + }); + + it("should respect margin as number", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["MARGIN"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Should be on row 5 (not 0) due to margin + assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0"); + assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4"); + assert.ok(viewport[5]?.includes("MARGIN"), `Expected MARGIN on row 5, got: ${viewport[5]}`); + // Should start at col 5 (not 0) + const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1; + assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`); + tui.stop(); + }); + + it("should respect margin object", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["MARGIN"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { + anchor: "top-left", + width: 10, + margin: { top: 2, left: 3, right: 0, bottom: 0 }, + }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[2]?.includes("MARGIN"), `Expected MARGIN on row 2, got: ${viewport[2]}`); + const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1; + assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("offset", () => { + it("should apply offsetX and offsetY from anchor position", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["OFFSET"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { anchor: "top-left", width: 10, offsetX: 10, offsetY: 5 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[5]?.includes("OFFSET"), `Expected OFFSET on row 5, got: ${viewport[5]}`); + const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1; + assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("percentage positioning", () => { + it("should position with rowPercent and colPercent", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["PCT"]); + + tui.addChild(new EmptyContent()); + // 50% should center both ways + tui.showOverlay(overlay, { width: 10, rowPercent: 50, colPercent: 50 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Find the row with PCT + let foundRow = -1; + for (let i = 0; i < viewport.length; i++) { + if (viewport[i]?.includes("PCT")) { + foundRow = i; + break; + } + } + // Should be roughly centered vertically (row ~11-12 for 24 row terminal) + assert.ok(foundRow >= 10 && foundRow <= 13, `Expected centered row, got ${foundRow}`); + tui.stop(); + }); + + it("rowPercent 0 should position at top", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["TOP"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 10, rowPercent: 0 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("TOP"), `Expected TOP on row 0, got: ${viewport[0]}`); + tui.stop(); + }); + + it("rowPercent 100 should position at bottom", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["BOTTOM"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { width: 10, rowPercent: 100 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[23]?.includes("BOTTOM"), `Expected BOTTOM on last row, got: ${viewport[23]}`); + tui.stop(); + }); + }); + + describe("maxHeight", () => { + it("should truncate overlay to maxHeight", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { maxHeight: 3 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + const content = viewport.join("\n"); + assert.ok(content.includes("Line 1"), "Should include Line 1"); + assert.ok(content.includes("Line 2"), "Should include Line 2"); + assert.ok(content.includes("Line 3"), "Should include Line 3"); + assert.ok(!content.includes("Line 4"), "Should NOT include Line 4"); + assert.ok(!content.includes("Line 5"), "Should NOT include Line 5"); + tui.stop(); + }); + + it("should truncate overlay to maxHeightPercent", async () => { + const terminal = new VirtualTerminal(80, 10); + const tui = new TUI(terminal); + // 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines + const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]); + + tui.addChild(new EmptyContent()); + tui.showOverlay(overlay, { maxHeightPercent: 50 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + const content = viewport.join("\n"); + assert.ok(content.includes("L1"), "Should include L1"); + assert.ok(content.includes("L5"), "Should include L5"); + assert.ok(!content.includes("L6"), "Should NOT include L6"); + tui.stop(); + }); + }); + + describe("absolute positioning", () => { + it("row and col should override anchor", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + const overlay = new StaticOverlay(["ABSOLUTE"]); + + tui.addChild(new EmptyContent()); + // Even with bottom-right anchor, row/col should win + tui.showOverlay(overlay, { anchor: "bottom-right", row: 3, col: 5, width: 10 }); + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + assert.ok(viewport[3]?.includes("ABSOLUTE"), `Expected ABSOLUTE on row 3, got: ${viewport[3]}`); + const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1; + assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`); + tui.stop(); + }); + }); + + describe("stacked overlays", () => { + it("should render multiple overlays with later ones on top", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // First overlay at top-left + const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 20 }); + + // Second overlay at top-left (should cover part of first) + const overlay2 = new StaticOverlay(["SECOND"]); + tui.showOverlay(overlay2, { anchor: "top-left", width: 10 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Second overlay should be visible (on top) + assert.ok(viewport[0]?.includes("SECOND"), `Expected SECOND on row 0, got: ${viewport[0]}`); + // Part of first overlay might still be visible after SECOND + // FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show + tui.stop(); + }); + + it("should handle overlays at different positions without interference", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // Overlay at top-left + const overlay1 = new StaticOverlay(["TOP-LEFT"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 15 }); + + // Overlay at bottom-right + const overlay2 = new StaticOverlay(["BTM-RIGHT"]); + tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + const viewport = terminal.getViewport(); + // Both should be visible + assert.ok(viewport[0]?.includes("TOP-LEFT"), `Expected TOP-LEFT on row 0, got: ${viewport[0]}`); + assert.ok(viewport[23]?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on row 23, got: ${viewport[23]}`); + tui.stop(); + }); + + it("should properly hide overlays in stack order", async () => { + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + tui.addChild(new EmptyContent()); + + // Show two overlays + const overlay1 = new StaticOverlay(["FIRST"]); + tui.showOverlay(overlay1, { anchor: "top-left", width: 10 }); + + const overlay2 = new StaticOverlay(["SECOND"]); + tui.showOverlay(overlay2, { anchor: "top-left", width: 10 }); + + tui.start(); + await renderAndFlush(tui, terminal); + + // Second should be visible + let viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("SECOND"), "SECOND should be visible initially"); + + // Hide top overlay + tui.hideOverlay(); + await renderAndFlush(tui, terminal); + + // First should now be visible + viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("FIRST"), "FIRST should be visible after hiding SECOND"); + + tui.stop(); + }); + }); +}); diff --git a/packages/tui/test/overlay-short-content.test.ts b/packages/tui/test/overlay-short-content.test.ts new file mode 100644 index 00000000..13c07fe3 --- /dev/null +++ b/packages/tui/test/overlay-short-content.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { type Component, TUI } from "../src/tui.js"; +import { VirtualTerminal } from "./virtual-terminal.js"; + +class SimpleContent implements Component { + constructor(private lines: string[]) {} + render(): string[] { + return this.lines; + } + invalidate() {} +} + +class SimpleOverlay implements Component { + render(): string[] { + return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"]; + } + invalidate() {} +} + +describe("TUI overlay with short content", () => { + it("should render overlay when content is shorter than terminal height", async () => { + // Terminal has 24 rows, but content only has 3 lines + const terminal = new VirtualTerminal(80, 24); + const tui = new TUI(terminal); + + // Only 3 lines of content + tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"])); + + // Show overlay centered - should be around row 10 in a 24-row terminal + const overlay = new SimpleOverlay(); + tui.showOverlay(overlay); + + // Trigger render + tui.start(); + await new Promise((r) => process.nextTick(r)); + await terminal.flush(); + + const viewport = terminal.getViewport(); + const hasOverlay = viewport.some((line) => line.includes("OVERLAY")); + + console.log("Terminal rows:", terminal.rows); + console.log("Content lines: 3"); + console.log("Overlay visible:", hasOverlay); + + if (!hasOverlay) { + console.log("\nViewport contents:"); + for (let i = 0; i < viewport.length; i++) { + console.log(` [${i}]: "${viewport[i]}"`); + } + } + + assert.ok(hasOverlay, "Overlay should be visible when content is shorter than terminal"); + + tui.stop(); + }); +}); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 75469904..e22a349e 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -165,4 +165,35 @@ describe("TUI differential rendering", () => { tui.stop(); }); + + it("handles transition from content to empty and back to content", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui = new TUI(terminal); + const component = new TestComponent(); + tui.addChild(component); + + // Start with content + component.lines = ["Line 0", "Line 1", "Line 2"]; + tui.start(); + await terminal.flush(); + + let viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered"); + + // Clear to empty + component.lines = []; + tui.requestRender(); + await terminal.flush(); + + // Add content back - this should work correctly even after empty state + component.lines = ["New Line 0", "New Line 1"]; + tui.requestRender(); + await terminal.flush(); + + viewport = terminal.getViewport(); + assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`); + assert.ok(viewport[1]?.includes("New Line 1"), `New content line 1: ${viewport[1]}`); + + tui.stop(); + }); });