773 lines
26 KiB
TypeScript
773 lines
26 KiB
TypeScript
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
|
|
*/
|
|
|
|
// Track active terminal for emergency cleanup on crash
|
|
let activeTerminal: ProcessTerminal | null = null;
|
|
// Track if a terminal was ever started (for emergency restore logic)
|
|
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) {
|
|
// Blind restore only if we know a terminal was started but lost track of it
|
|
// This avoids writing escape sequences for non-TUI commands (grep, commit, etc.)
|
|
process.stdout.write(
|
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
"\x1b[>4;0m" + // Disable modifyOtherKeys fallback
|
|
"\x1b[?25h", // Show cursor
|
|
);
|
|
if (process.stdin.setRawMode) {
|
|
process.stdin.setRawMode(false);
|
|
}
|
|
}
|
|
} catch {
|
|
// Terminal may already be dead during crash cleanup - ignore errors
|
|
}
|
|
}
|
|
/** Terminal-reported appearance (dark/light mode). */
|
|
export type TerminalAppearance = "dark" | "light";
|
|
export interface Terminal {
|
|
// Start the terminal with input and resize handlers
|
|
start(onInput: (data: string) => void, onResize: () => void): void;
|
|
|
|
// Stop the terminal and restore state
|
|
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 output to terminal
|
|
write(data: string): void;
|
|
|
|
// Get terminal dimensions
|
|
get columns(): number;
|
|
get rows(): number;
|
|
|
|
// Whether Kitty keyboard protocol is active
|
|
get kittyProtocolActive(): boolean;
|
|
|
|
// Cursor positioning (relative to current position)
|
|
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
|
|
|
|
// Cursor visibility
|
|
hideCursor(): void; // Hide the cursor
|
|
showCursor(): void; // Show the cursor
|
|
|
|
// Clear operations
|
|
clearLine(): void; // Clear current line
|
|
clearFromCursor(): void; // Clear from cursor to end of screen
|
|
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
|
|
|
|
// Title operations
|
|
setTitle(title: string): void; // Set terminal window title
|
|
|
|
// Progress indicator (OSC 9;4)
|
|
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;
|
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
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;
|
|
|
|
// Register for emergency cleanup
|
|
activeTerminal = this;
|
|
terminalEverStarted = true;
|
|
|
|
// Save previous state and enable raw mode
|
|
this.#wasRaw = process.stdin.isRaw || false;
|
|
if (process.stdin.setRawMode) {
|
|
process.stdin.setRawMode(true);
|
|
}
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.resume();
|
|
|
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
this.#safeWrite("\x1b[?2004h");
|
|
|
|
// Set up resize handler immediately
|
|
process.stdout.on("resize", this.#resizeHandler);
|
|
|
|
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
if (process.platform !== "win32") {
|
|
process.kill(process.pid, "SIGWINCH");
|
|
}
|
|
|
|
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
// events that lose modifier information. Must run after setRawMode(true)
|
|
// since that resets console mode flags.
|
|
this.#enableWindowsVTInput();
|
|
// Query and enable Kitty keyboard protocol
|
|
// The query handler intercepts input temporarily, then installs the user's handler
|
|
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
|
|
this.#queryAndEnableKittyProtocol();
|
|
|
|
// Query terminal background color via OSC 11 for dark/light detection.
|
|
// Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
|
|
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
// the terminal does not support OSC 11. This avoids indefinite hangs.
|
|
// Technique used by Neovim, bat, fish, and terminal-colorsaurus.
|
|
this.#queryBackgroundColor();
|
|
|
|
// Subscribe to Mode 2031 appearance change notifications.
|
|
// When the terminal reports a change, we re-query OSC 11 to get the
|
|
// actual background color (following Neovim convention) with 100ms debounce.
|
|
this.#safeWrite("\x1b[?2031h");
|
|
|
|
// Start periodic OSC 11 re-query for terminals without Mode 2031
|
|
// (Warp, Alacritty, WezTerm, iTerm2). Self-disables once Mode 2031 fires.
|
|
// Windows Terminal under WSL has been observed to close the hosting tab
|
|
// after repeated OSC 11/DA1 probes. Keep the initial/event-driven probes,
|
|
// but avoid background polling there.
|
|
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 {
|
|
// bun:ffi unavailable or console API unsupported; keep startup non-fatal.
|
|
}
|
|
}
|
|
|
|
#restoreWindowsVTInput(): void {
|
|
if (process.platform !== "win32") return;
|
|
const restore = this.#windowsVTInputRestore;
|
|
this.#windowsVTInputRestore = undefined;
|
|
if (!restore) return;
|
|
try {
|
|
restore();
|
|
} catch {
|
|
// Ignore restore errors during terminal teardown.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
|
|
// Kitty protocol response pattern: \x1b[?<flags>u
|
|
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
|
|
|
|
// Mode 2031 DSR response: \x1b[?997;{1=dark,2=light}n
|
|
const appearanceDsrPattern = /^\x1b\[\?997;([12])n$/;
|
|
|
|
// OSC 11 response: \x1b]11;rgb:RR/GG/BB or rgba:RR/GG/BB, terminated by BEL or ST.
|
|
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\\)$/;
|
|
|
|
// DA1 (Primary Device Attributes) response: \x1b[?...c
|
|
const da1ResponsePattern = /^\x1b\[\?[\d;]*c$/;
|
|
|
|
// Private CSI partial: \x1b[?<digits/semicolons>... — incomplete probe response
|
|
// that the StdinBuffer flushed before the terminator arrived (split across
|
|
// stdin reads). Used to reassemble DA1, kitty, and Mode 2031 replies.
|
|
const privateCsiPartialPattern = /^\x1b\[\?[\d;]*$/;
|
|
|
|
// Forward individual sequences to the input handler
|
|
this.#stdinBuffer.on("data", (sequence: string) => {
|
|
// Reassemble split private CSI responses (DA1, kitty keyboard, Mode 2031).
|
|
// When the terminal writes the response slowly enough that the StdinBuffer's
|
|
// flush timeout elapses mid-sequence, the prefix `\x1b[?<digits>` arrives as
|
|
// one event and the tail `;...<terminator>` arrives as individual character
|
|
// events that would otherwise leak into the prompt as keystrokes. See #1238.
|
|
if (
|
|
this.#privateCsiResponseBuffer ||
|
|
(privateCsiPartialPattern.test(sequence) && this.#da1SentinelOwners.length > 0)
|
|
) {
|
|
if (this.#privateCsiResponseBuffer && sequence.startsWith("\x1b")) {
|
|
// New escape arrived mid-reassembly — abandon partial and re-process the new sequence.
|
|
this.#privateCsiResponseBuffer = "";
|
|
} else {
|
|
this.#privateCsiResponseBuffer += sequence;
|
|
// Cap accumulator to defend against runaway partials if the terminator never arrives.
|
|
if (this.#privateCsiResponseBuffer.length > 256) {
|
|
this.#privateCsiResponseBuffer = "";
|
|
return;
|
|
}
|
|
const lastChar = this.#privateCsiResponseBuffer.at(-1)!;
|
|
const lastCode = lastChar.charCodeAt(0);
|
|
if (lastCode >= 0x40 && lastCode <= 0x7e) {
|
|
// Terminator byte arrived. Fall through to the pattern checks with the
|
|
// reassembled sequence so the existing DA1/kitty/Mode 2031 handlers run.
|
|
sequence = this.#privateCsiResponseBuffer;
|
|
this.#privateCsiResponseBuffer = "";
|
|
} else if (!privateCsiPartialPattern.test(this.#privateCsiResponseBuffer)) {
|
|
// Diverged from a valid private CSI prefix (unexpected byte). Drop the
|
|
// probe noise we ate; do not forward to the input handler.
|
|
this.#privateCsiResponseBuffer = "";
|
|
return;
|
|
} else {
|
|
// Still accumulating.
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// DA1 response: swallow our sentinel reply regardless of whether OSC 11
|
|
// already succeeded. Other terminal probes should never see these replies.
|
|
if (da1ResponsePattern.test(sequence) && this.#da1SentinelOwners.length > 0) {
|
|
const owner = this.#da1SentinelOwners.shift()!;
|
|
if (owner === "osc11") {
|
|
if (this.#osc11Pending) {
|
|
// DA1 arrived before the OSC 11 reply: terminal does not support OSC 11.
|
|
this.#osc11Pending = false;
|
|
this.#osc11ResponseBuffer = "";
|
|
}
|
|
// Start a queued OSC 11 query once the prior cycle is fully drained.
|
|
if (
|
|
this.#osc11QueryQueued &&
|
|
!this.#osc11Pending &&
|
|
!this.#da1SentinelOwners.includes("osc11") &&
|
|
!this.#dead
|
|
) {
|
|
this.#osc11QueryQueued = false;
|
|
this.#startOsc11Query();
|
|
}
|
|
} else {
|
|
// Keyboard probe sentinel: kitty reply never arrived → fall back to modifyOtherKeys.
|
|
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;
|
|
}
|
|
// Any reply to `\x1b[?u` means the terminal speaks the kitty keyboard
|
|
// protocol. The reported flag value is the *current* stack-top — fresh
|
|
// terminals report 0 — so support is implied by the reply itself, not by
|
|
// the flag value. Pick the level we want; `\x1b[>Nu` pushes one frame
|
|
// that shutdown's single `\x1b[<u` pop balances.
|
|
const reportedFlags = parseInt(match[1]!, 10);
|
|
this.#kittyProtocolActive = true;
|
|
setKittyProtocolActive(true);
|
|
if (reportedFlags >= 3) {
|
|
// Already enriched (Ghostty/foot may keep flags from a parent app).
|
|
// Push level-2 to lock in event reporting.
|
|
this.#safeWrite("\x1b[>7u");
|
|
} else {
|
|
// Level 1 (disambiguate escape codes) — enough for Shift+Enter
|
|
// without the modifyOtherKeys fallback that caused regression #3259.
|
|
this.#safeWrite("\x1b[>1u");
|
|
}
|
|
return;
|
|
}
|
|
|
|
// OSC 11 replies can be split if the stdin buffer flushes a partial sequence.
|
|
// Accumulate fragments until the BEL/ST terminator arrives, then parse once.
|
|
// If a new escape sequence arrives (not the ST terminator), abort buffering
|
|
// and forward it as normal input so user keystrokes are never swallowed.
|
|
if (this.#osc11Pending && (this.#osc11ResponseBuffer || sequence.startsWith("\x1b]11;"))) {
|
|
if (this.#osc11ResponseBuffer && sequence.startsWith("\x1b") && sequence !== "\x1b\\") {
|
|
// New escape sequence arrived mid-buffer — not an OSC 11 continuation.
|
|
this.#osc11ResponseBuffer = "";
|
|
// Fall through to normal input handling below.
|
|
} 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;
|
|
}
|
|
}
|
|
|
|
// Mode 2031 change notification: re-query OSC 11 with 100ms debounce
|
|
// (Neovim convention — coalesces rapid notifications during transitions)
|
|
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);
|
|
}
|
|
});
|
|
|
|
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
|
this.#stdinBuffer.on("paste", (content: string) => {
|
|
if (this.#inputHandler) {
|
|
this.#inputHandler(`\x1b[200~${content}\x1b[201~`);
|
|
}
|
|
});
|
|
|
|
// Handler that pipes stdin data through the buffer
|
|
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;
|
|
// Queue if an OSC 11 query is in flight or its DA1 sentinel hasn't been
|
|
// consumed yet. Starting a new query while a DA1 is outstanding would
|
|
// increment the sentinel counter, and the old DA1 arrival would then
|
|
// prematurely clear the new query's pending state.
|
|
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"); // OSC 11 query (BEL terminated)
|
|
this.#safeWrite("\x1b[c"); // DA1 sentinel
|
|
}
|
|
/**
|
|
* 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 {
|
|
/* ignore callback errors */
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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!);
|
|
// Progressive enhancement query: CSI ?u asks the terminal for its current
|
|
// kitty keyboard flags (no side effect on the stack); the DA1 sentinel
|
|
// guarantees a reply even from terminals that ignore CSI ?u.
|
|
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) {
|
|
// Disable Kitty keyboard protocol first so any late key releases
|
|
// do not generate new Kitty escape sequences.
|
|
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 {
|
|
// Unregister from emergency cleanup
|
|
if (activeTerminal === this) {
|
|
activeTerminal = null;
|
|
}
|
|
|
|
if (this.#clearProgressTimer()) {
|
|
this.#safeWrite(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
|
}
|
|
|
|
// Disable bracketed paste mode
|
|
this.#safeWrite("\x1b[?2004l");
|
|
|
|
// Disable Mode 2031 appearance change notifications
|
|
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;
|
|
|
|
// Disable Kitty keyboard protocol if not already done by drainInput()
|
|
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();
|
|
// Clean up StdinBuffer
|
|
if (this.#stdinBuffer) {
|
|
this.#stdinBuffer.destroy();
|
|
this.#stdinBuffer = undefined;
|
|
}
|
|
|
|
// Remove event handlers
|
|
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;
|
|
}
|
|
|
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
// where Ctrl+D could close the parent shell over SSH.
|
|
process.stdin.pause();
|
|
|
|
// Restore raw mode state
|
|
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 {
|
|
// Ignore logging errors
|
|
}
|
|
}
|
|
}
|
|
|
|
#safeWrite(data: string): void {
|
|
if (this.#dead) return;
|
|
// Skip control sequences when stdout isn't a TTY (piped output, tests, log
|
|
// files). They serve no purpose there and would surface as visible noise.
|
|
if (!process.stdout.isTTY) return;
|
|
try {
|
|
process.stdout.write(data);
|
|
} catch (err) {
|
|
// Any write failure means terminal is dead - no recovery possible
|
|
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) {
|
|
// Move down
|
|
this.#safeWrite(`\x1b[${lines}B`);
|
|
} else if (lines < 0) {
|
|
// Move up
|
|
this.#safeWrite(`\x1b[${-lines}A`);
|
|
}
|
|
// lines === 0: no movement
|
|
}
|
|
|
|
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"); // Move to home (1,1) and clear from cursor to end
|
|
}
|
|
|
|
setTitle(title: string): void {
|
|
// OSC 0;title BEL - set terminal window title
|
|
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;
|
|
}
|
|
}
|