mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 05:02:07 +00:00
feat(tui): add OverlayOptions API and fix width overflow crash
Adds positioning/sizing options for overlays and fixes crash when compositing lines with complex ANSI sequences exceed terminal width.
This commit is contained in:
parent
842a65f06a
commit
0c0aac6599
7 changed files with 962 additions and 28 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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<number>();
|
||||
|
||||
// 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;
|
||||
|
|
|
|||
538
packages/tui/test/overlay-options.test.ts
Normal file
538
packages/tui/test/overlay-options.test.ts
Normal file
|
|
@ -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<void> {
|
||||
tui.requestRender(true);
|
||||
await new Promise<void>((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();
|
||||
});
|
||||
});
|
||||
});
|
||||
57
packages/tui/test/overlay-short-content.test.ts
Normal file
57
packages/tui/test/overlay-short-content.test.ts
Normal file
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue