Files
oh-my-pi/packages/tui/test/virtual-terminal.ts
T
can1357 99aac72ce5 fix(tui): fixed IME cursor sticking to viewport bottom after resize
- Adjusted `#emitViewportRepaint` to use `viewportTop + height - 1` as the cursor target row instead of clamping to `lines.length - 1`.
- This kept `#cursorControlSequence` row-delta math consistent with the hardware cursor after blank padding rows, preventing the IME cursor from staying stuck at the viewport bottom on height-grow resizes.
- Added a regression test and terminal cursor accessor to verify the focused `CURSOR_MARKER` row remains active after terminal expansion.
2026-05-26 09:33:07 +02:00

228 lines
5.2 KiB
TypeScript

import type { Terminal, TerminalAppearance } from "@oh-my-pi/pi-tui/terminal";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
/**
* Virtual terminal for testing using xterm.js for accurate terminal emulation
*/
export class VirtualTerminal implements Terminal {
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
constructor(columns = 80, rows = 24) {
this._columns = columns;
this._rows = rows;
// Create xterm instance with specified dimensions
this.xterm = new XtermTerminal({
cols: columns,
rows: rows,
// Disable all interactive features for testing
disableStdin: true,
allowProposedApi: true,
});
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Enable bracketed paste mode for consistency with ProcessTerminal
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no stdin to drain
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
write(data: string): void {
this.xterm.write(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
// Virtual terminal always reports Kitty protocol as active for testing
return true;
}
get appearance(): TerminalAppearance | undefined {
return undefined;
}
onAppearanceChange(_callback: (appearance: TerminalAppearance) => void): void {
// No-op for virtual terminal
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
this.xterm.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
this.xterm.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
this.xterm.write("\x1b[?25l");
}
showCursor(): void {
this.xterm.write("\x1b[?25h");
}
clearLine(): void {
this.xterm.write("\x1b[K");
}
clearFromCursor(): void {
this.xterm.write("\x1b[J");
}
clearScreen(): void {
this.xterm.write("\x1b[H\x1b[0J"); // Move to home (1,1) and clear from cursor to end
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
this.xterm.write(`\x1b]0;${title}\x07`);
}
setProgress(active: boolean): void {
// OSC 9;4 progress sequence; no-op in tests beyond writing through to xterm.
this.xterm.write(active ? "\x1b]9;4;3\x07" : "\x1b]9;4;0;\x07");
}
/** Wait for TUI's throttled render pipeline to settle (matches the 16ms frame budget). */
async waitForRender(): Promise<void> {
await new Promise<void>(resolve => process.nextTick(resolve));
await new Promise<void>(resolve => setTimeout(resolve, 20));
await this.flush();
}
// Test-specific methods not in Terminal interface
/**
* Simulate keyboard input
*/
sendInput(data: string): void {
if (this.inputHandler) {
this.inputHandler(data);
}
}
/**
* Resize the terminal
*/
resize(columns: number, rows: number): void {
this._columns = columns;
this._rows = rows;
this.xterm.resize(columns, rows);
if (this.resizeHandler) {
this.resizeHandler();
}
}
/**
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
*/
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>(resolve => {
this.xterm.write("", () => resolve());
});
}
/**
* Flush and get viewport - convenience method for tests
*/
async flushAndGetViewport(): Promise<string[]> {
await this.flush();
return this.getViewport();
}
/**
* Get the visible viewport (what's currently on screen)
* Note: You should use getViewportAfterWrite() for testing after writing data
*/
getViewport(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get only the visible lines (viewport)
for (let i = 0; i < this.xterm.rows; i++) {
const line = buffer.getLine(buffer.viewportY + i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the entire scroll buffer
*/
getScrollBuffer(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get all lines in the buffer (including scrollback)
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the hardware cursor position within the visible viewport.
* Both coordinates are 0-indexed; row is relative to the top of the viewport.
*/
getCursor(): { row: number; col: number } {
const buffer = this.xterm.buffer.active;
return { row: buffer.cursorY, col: buffer.cursorX };
}
/**
* Clear the terminal viewport
*/
clear(): void {
this.xterm.clear();
}
/**
* Reset the terminal completely
*/
reset(): void {
this.xterm.reset();
}
}