import { dlopen, FFIType, ptr } from "bun:ffi";
import * as fs from "node:fs";
import { $env, logger } from "@oh-my-pi/pi-utils";
import { setKittyProtocolActive } from "./keys";
import { StdinBuffer } from "./stdin-buffer";
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
* Minimal terminal interface for TUI
*/
let activeTerminal: ProcessTerminal | null = null;
let terminalEverStarted = false;
const STD_INPUT_HANDLE = -10;
const STD_OUTPUT_HANDLE = -11;
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
* Emergency terminal restore - call this from signal/crash handlers
* Resets terminal state without requiring access to the ProcessTerminal instance
*/
export function emergencyTerminalRestore(): void {
try {
const terminal = activeTerminal;
if (terminal) {
terminal.stop();
terminal.showCursor();
} else if (terminalEverStarted) {
process.stdout.write(
"\x1b[?2004l" +
"\x1b[?2031l" +
"\x1b[<u" +
"\x1b[>4;0m" +
"\x1b[?25h",
);
if (process.stdin.setRawMode) {
process.stdin.setRawMode(false);
}
}
} catch {
}
}
export type TerminalAppearance = "dark" | "light";
export interface Terminal {
start(onInput: (data: string) => void, onResize: () => void): void;
stop(): void;
* Drain stdin before exiting to prevent Kitty key release events from
* leaking to the parent shell over slow SSH connections.
* @param maxMs - Maximum time to drain (default: 1000ms)
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
*/
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
write(data: string): void;
get columns(): number;
get rows(): number;
get kittyProtocolActive(): boolean;
moveBy(lines: number): void;
hideCursor(): void;
showCursor(): void;
clearLine(): void;
clearFromCursor(): void;
clearScreen(): void;
setTitle(title: string): void;
setProgress(active: boolean): void;
* Returns whether the native terminal viewport is at the scrollback tail when
* the host exposes that state. `undefined` means the terminal cannot report it.
*/
isNativeViewportAtBottom?(): boolean | undefined;
* Register a callback for terminal appearance (dark/light) changes.
* Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
* Fires when the detected appearance changes, including the initial detection.
*/
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void;
get appearance(): TerminalAppearance | undefined;
}
function isWindowsSubsystemForLinux(): boolean {
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
}
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
#wasRaw = false;
#inputHandler?: (data: string) => void;
#resizeHandler?: () => void;
#kittyProtocolActive = false;
#modifyOtherKeysActive = false;
#modifyOtherKeysTimeout?: Timer;
#stdinBuffer?: StdinBuffer;
#stdinDataHandler?: (data: string) => void;
#dead = false;
#writeLogPath = $env.PI_TUI_WRITE_LOG || "";
#windowsVTInputRestore?: () => void;
#appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
#appearance: TerminalAppearance | undefined;
#osc11Pending = false;
#osc11QueryQueued = false;
#osc11ResponseBuffer = "";
#privateCsiResponseBuffer = "";
#da1SentinelOwners: ("keyboard" | "osc11")[] = [];
#osc11PollTimer?: Timer;
#mode2031DebounceTimer?: Timer;
#progressTimer?: ReturnType<typeof setInterval>;
get kittyProtocolActive(): boolean {
return this.#kittyProtocolActive;
}
get appearance(): TerminalAppearance | undefined {
return this.#appearance;
}
onAppearanceChange(callback: (appearance: TerminalAppearance) => void): void {
this.#appearanceCallbacks.push(callback);
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.#inputHandler = onInput;
this.#resizeHandler = onResize;
activeTerminal = this;
terminalEverStarted = true;
this.#wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
this.#safeWrite("\x1b[?2004h");
process.stdout.on("resize", this.#resizeHandler);
if (process.platform !== "win32") {
process.kill(process.pid, "SIGWINCH");
}
this.#enableWindowsVTInput();
this.#queryAndEnableKittyProtocol();
this.#queryBackgroundColor();
this.#safeWrite("\x1b[?2031h");
if (!isWindowsSubsystemForLinux()) {
this.#startOsc11Poll();
}
}
* Returns true when Windows' active console viewport is at the scrollback tail.
* POSIX terminals do not expose native scrollback position through a standard API.
*/
isNativeViewportAtBottom(): boolean | undefined {
if (process.platform !== "win32") return undefined;
try {
const kernel32 = dlopen("kernel32.dll", {
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
GetConsoleScreenBufferInfo: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
});
try {
const handle = kernel32.symbols.GetStdHandle(STD_OUTPUT_HANDLE);
const info = new Uint8Array(22);
const infoPtr = ptr(info);
if (!infoPtr || !kernel32.symbols.GetConsoleScreenBufferInfo(handle, infoPtr)) return undefined;
const viewBottom = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(16, true);
const bufferHeight = new DataView(info.buffer, info.byteOffset, info.byteLength).getInt16(2, true);
return viewBottom >= bufferHeight - 1;
} finally {
kernel32.close();
}
} catch {
return undefined;
}
}
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT to the stdin console mode
* so modified keys (for example Shift+Tab) arrive as VT escape sequences.
*/
#enableWindowsVTInput(): void {
if (process.platform !== "win32") return;
this.#restoreWindowsVTInput();
try {
const kernel32 = dlopen("kernel32.dll", {
GetStdHandle: { args: [FFIType.i32], returns: FFIType.ptr },
GetConsoleMode: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.bool },
SetConsoleMode: { args: [FFIType.ptr, FFIType.u32], returns: FFIType.bool },
});
const handle = kernel32.symbols.GetStdHandle(STD_INPUT_HANDLE);
const mode = new Uint32Array(1);
const modePtr = ptr(mode);
if (!modePtr || !kernel32.symbols.GetConsoleMode(handle, modePtr)) {
kernel32.close();
return;
}
const originalMode = mode[0]!;
const vtMode = originalMode | ENABLE_VIRTUAL_TERMINAL_INPUT;
if (vtMode !== originalMode && !kernel32.symbols.SetConsoleMode(handle, vtMode)) {
kernel32.close();
return;
}
this.#windowsVTInputRestore = () => {
try {
kernel32.symbols.SetConsoleMode(handle, originalMode);
} finally {
kernel32.close();
}
};
} catch {
}
}
#restoreWindowsVTInput(): void {
if (process.platform !== "win32") return;
const restore = this.#windowsVTInputRestore;
this.#windowsVTInputRestore = undefined;
if (!restore) return;
try {
restore();
} catch {
}
}
* Set up StdinBuffer to split batched input into individual sequences.
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
*
* Also watches for Kitty protocol response and enables it when detected.
* This is done here (after stdinBuffer parsing) rather than on raw stdin
* to handle the case where the response arrives split across multiple events.
*/
#setupStdinBuffer(): void {
this.#stdinBuffer = new StdinBuffer({ timeout: 10 });
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
const osc11ResponsePattern =
/^\x1b\]11;rgba?:([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})\/([0-9a-fA-F]{1,4})(?:\x07|\x1b\\)$/;
const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
const privateCsiPartialPattern = /^\x1b\[\?[\d;]*$/;
this.#stdinBuffer.on("data", (sequence: string) => {
if (
this.#privateCsiResponseBuffer ||
(privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
) {
if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
this.#privateCsiResponseBuffer = "";
} else {
this.#privateCsiResponseBuffer += sequence;
if (this.#privateCsiResponseBuffer.length > 256) {
this.#privateCsiResponseBuffer = "";
return;
}
const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
const lastCode = lastChar.charCodeAt(0);
if (lastCode >= 0x40 && lastCode <= 0x7e) {
sequence = this.#privateCsiResponseBuffer;
this.#privateCsiResponseBuffer = "";
} else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
this.#privateCsiResponseBuffer = "";
return;
} else {
return;
}
}
}
if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
const owner = this.#da1SentinelOwners.shift()!;
if (owner === "osc11") {
if (this.#osc11Pending) {
this.#osc11Pending = false;
this.#osc11ResponseBuffer = "";
}
if (
this.#osc11QueryQueued &&
!this.#osc11Pending &&
!this.#da1SentinelOwners.includes("osc11") &&
!this.#dead
) {
this.#osc11QueryQueued = false;
this.#startOsc11Query();
}
} else {
if (!this.#kittyProtocolActive && !this.#modifyOtherKeysActive && this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
this.#safeWrite("\x1b[>4;2m");
this.#modifyOtherKeysActive = true;
}
}
return;
}
const match = sequence.match(kittyResponsePattern);
if (match && !this.#modifyOtherKeysActive) {
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
const reportedFlags = parseInt(match[1]!, 10);
this.#kittyProtocolActive = true;
setKittyProtocolActive(true);
if (reportedFlags >= 3) {
this.#safeWrite("\x1b[>7u");
} else {
this.#safeWrite("\x1b[>1u");
}
return;
}
if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
this.#osc11ResponseBuffer = "";
} else {
this.#osc11ResponseBuffer += sequence;
const osc11Match = this.#osc11ResponseBuffer.match(osc11ResponsePattern);
if (!osc11Match) return;
const [, rHex, gHex, bHex] = osc11Match;
this.#osc11Pending = false;
this.#osc11ResponseBuffer = "";
this.#handleOsc11Response(rHex!, gHex!, bHex!);
return;
}
}
const appearanceMatch = sequence.match(appearanceDsrPattern);
if (appearanceMatch) {
this.#stopOsc11Poll();
if (this.#mode2031DebounceTimer) clearTimeout(this.#mode2031DebounceTimer);
this.#mode2031DebounceTimer = setTimeout(() => {
this.#mode2031DebounceTimer = undefined;
this.#queryBackgroundColor();
}, 100);
return;
}
if (this.#inputHandler) {
this.#inputHandler(sequence);
}
});
this.#stdinBuffer.on("paste", (content: string) => {
if (this.#inputHandler) {
this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
}
});
this.#stdinDataHandler = (data: string) => {
this.#stdinBuffer!.process(data);
};
}
* Send OSC 11 background color query followed by DA1 sentinel.
* DA1 avoids indefinite hangs: if DA1 response arrives before OSC 11,
* the terminal does not support OSC 11.
*/
#queryBackgroundColor(): void {
if (this.#dead) return;
if (this.#osc11Pending || this.#da1SentinelOwners.includes("osc11")) {
this.#osc11QueryQueued = true;
return;
}
this.#startOsc11Query();
}
#startOsc11Query(): void {
this.#osc11Pending = true;
this.#osc11ResponseBuffer = "";
this.#da1SentinelOwners.push("osc11");
this.#safeWrite("\x1b]11;?\x07");
this.#safeWrite("\x1b[c");
}
* Parse an OSC 11 background color response and compute BT.601 luminance.
* Handles 1-, 2-, 3-, and 4-digit XParseColor hex components.
*/
#handleOsc11Response(rHex: string, gHex: string, bHex: string): void {
const normalize = (hex: string): number => {
const value = parseInt(hex, 16);
if (Number.isNaN(value)) return 0;
const max = 16 ** hex.length - 1;
return max > 0 ? value / max : 0;
};
const luminance = 0.299 * normalize(rHex) + 0.587 * normalize(gHex) + 0.114 * normalize(bHex);
const mode: TerminalAppearance = luminance < 0.5 ? "dark" : "light";
if (mode === this.#appearance) return;
this.#appearance = mode;
for (const cb of this.#appearanceCallbacks) {
try {
cb(mode);
} catch {
}
}
}
* Start periodic OSC 11 re-queries for terminals without Mode 2031 (Warp, Alacritty, WezTerm).
* Self-disables once Mode 2031 fires (push-based is better than polling).
*/
#startOsc11Poll(): void {
this.#stopOsc11Poll();
this.#osc11PollTimer = setInterval(() => {
if (this.#dead) {
this.#stopOsc11Poll();
return;
}
this.#queryBackgroundColor();
}, 2_000);
this.#osc11PollTimer.unref();
}
#stopOsc11Poll(): void {
if (this.#osc11PollTimer) {
clearInterval(this.#osc11PollTimer);
this.#osc11PollTimer = undefined;
}
}
* Query terminal for Kitty keyboard protocol support and enable if available.
*
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
* it supports the protocol and we enable it with CSI > 1 u.
*
* The response is detected in setupStdinBuffer's data handler, which properly
* handles the case where the response arrives split across multiple stdin events.
*/
#queryAndEnableKittyProtocol(): void {
this.#setupStdinBuffer();
process.stdin.on("data", this.#stdinDataHandler!);
this.#da1SentinelOwners.push("keyboard");
this.#safeWrite("\x1b[?u\x1b[c");
this.#modifyOtherKeysTimeout = setTimeout(() => {
this.#modifyOtherKeysTimeout = undefined;
if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
return;
}
this.#safeWrite("\x1b[>4;2m");
this.#modifyOtherKeysActive = true;
}, 150);
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this.#kittyProtocolActive) {
this.#safeWrite("\x1b[<u");
this.#kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
if (this.#modifyOtherKeysActive) {
this.#safeWrite("\x1b[>4;0m");
this.#modifyOtherKeysActive = false;
}
const previousHandler = this.#inputHandler;
this.#inputHandler = undefined;
let lastDataTime = Date.now();
const onData = () => {
lastDataTime = Date.now();
};
process.stdin.on("data", onData);
const endTime = Date.now() + maxMs;
try {
while (true) {
const now = Date.now();
const timeLeft = endTime - now;
if (timeLeft <= 0) break;
if (now - lastDataTime >= idleMs) break;
await new Promise(resolve => setTimeout(resolve, Math.min(idleMs, timeLeft)));
}
} finally {
process.stdin.removeListener("data", onData);
this.#inputHandler = previousHandler;
}
}
stop(): void {
if (activeTerminal === this) {
activeTerminal = null;
}
if (this.#clearProgressTimer()) {
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
}
this.#safeWrite("\x1b[?2004l");
this.#safeWrite("\x1b[?2031l");
this.#stopOsc11Poll();
if (this.#mode2031DebounceTimer) {
clearTimeout(this.#mode2031DebounceTimer);
this.#mode2031DebounceTimer = undefined;
}
this.#appearanceCallbacks = [];
this.#osc11Pending = false;
this.#osc11QueryQueued = false;
this.#osc11ResponseBuffer = "";
this.#privateCsiResponseBuffer = "";
this.#da1SentinelOwners.length = 0;
if (this.#kittyProtocolActive) {
this.#safeWrite("\x1b[<u");
this.#kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this.#modifyOtherKeysTimeout) {
clearTimeout(this.#modifyOtherKeysTimeout);
this.#modifyOtherKeysTimeout = undefined;
}
if (this.#modifyOtherKeysActive) {
this.#safeWrite("\x1b[>4;0m");
this.#modifyOtherKeysActive = false;
}
this.#restoreWindowsVTInput();
if (this.#stdinBuffer) {
this.#stdinBuffer.destroy();
this.#stdinBuffer = undefined;
}
if (this.#stdinDataHandler) {
process.stdin.removeListener("data", this.#stdinDataHandler);
this.#stdinDataHandler = undefined;
}
this.#inputHandler = undefined;
this.#appearance = undefined;
if (this.#resizeHandler) {
process.stdout.removeListener("resize", this.#resizeHandler);
this.#resizeHandler = undefined;
}
process.stdin.pause();
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.#wasRaw);
}
}
write(data: string): void {
this.#safeWrite(data);
if (this.#writeLogPath) {
try {
fs.appendFileSync(this.#writeLogPath, data, { encoding: "utf8" });
} catch {
}
}
}
#safeWrite(data: string): void {
if (this.#dead) return;
if (!process.stdout.isTTY) return;
try {
process.stdout.write(data);
} catch (err) {
this.#dead = true;
logger.warn("terminal is dead - no recovery possible", { error: err, data });
}
}
get columns(): number {
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
}
get rows(): number {
return process.stdout.rows || Number(Bun.env.LINES) || 24;
}
moveBy(lines: number): void {
if (lines > 0) {
this.#safeWrite(`\x1b[${lines}B`);
} else if (lines < 0) {
this.#safeWrite(`\x1b[${-lines}A`);
}
}
hideCursor(): void {
this.#safeWrite("\x1b[?25l");
}
showCursor(): void {
this.#safeWrite("\x1b[?25h");
}
clearLine(): void {
this.#safeWrite("\x1b[K");
}
clearFromCursor(): void {
this.#safeWrite("\x1b[J");
}
clearScreen(): void {
this.#safeWrite("\x1b[H\x1b[0J");
}
setTitle(title: string): void {
this.#safeWrite(`\x1b]0;${title}\x07`);
}
setProgress(active: boolean): void {
if (active) {
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
if (!this.#progressTimer) {
this.#progressTimer = setInterval(() => {
this.#safeWrite(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
this.#progressTimer.unref?.();
}
} else {
this.#clearProgressTimer();
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
}
}
#clearProgressTimer(): boolean {
if (!this.#progressTimer) return false;
clearInterval(this.#progressTimer);
this.#progressTimer = undefined;
return true;
}
}