Files
sproutclaw/packages/tui/src/terminal.ts
Mario Zechner c07126c0fd fix(tui): remove Kitty protocol query timeout
The 100ms timeout was causing Kitty protocol detection to fail when the
terminal response was delayed (e.g., due to event loop blocking during
startup). This resulted in shift+enter not working in some scenarios.

Changes:
- Remove timeout-based Kitty detection, process input immediately
- Detect Kitty response in stdinBuffer output (handles split data)
- Add modifyOtherKeys fallback for terminals without Kitty support
  (matches xterm format \x1b[27;modifier;keycode~)
2026-01-11 22:51:30 +01:00

231 lines
6.5 KiB
TypeScript

import { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js";
/**
* Minimal terminal interface for TUI
*/
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;
// 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
}
/**
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _kittyProtocolActive = false;
private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void;
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// 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~
process.stdout.write("\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");
}
// 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();
}
/**
* 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.
*/
private setupStdinBuffer(): void {
this.stdinBuffer = new StdinBuffer({ timeout: 10 });
// Kitty protocol response pattern: \x1b[?<flags>u
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
// Forward individual sequences to the input handler
this.stdinBuffer.on("data", (sequence) => {
// Check for Kitty protocol response (only if not already enabled)
if (!this._kittyProtocolActive) {
const match = sequence.match(kittyResponsePattern);
if (match) {
this._kittyProtocolActive = true;
setKittyProtocolActive(true);
// Enable Kitty keyboard protocol (push flags)
// Flag 1 = disambiguate escape codes
// Flag 2 = report event types (press/repeat/release)
process.stdout.write("\x1b[>3u");
return; // Don't forward protocol response to TUI
}
}
if (this.inputHandler) {
this.inputHandler(sequence);
}
});
// Re-wrap paste content with bracketed paste markers for existing editor handling
this.stdinBuffer.on("paste", (content) => {
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);
};
}
/**
* 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.
*/
private queryAndEnableKittyProtocol(): void {
this.setupStdinBuffer();
process.stdin.on("data", this.stdinDataHandler!);
process.stdout.write("\x1b[?u");
}
stop(): void {
// Disable bracketed paste mode
process.stdout.write("\x1b[?2004l");
// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it
if (this._kittyProtocolActive) {
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
// 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;
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = undefined;
}
// Restore raw mode state
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
}
write(data: string): void {
process.stdout.write(data);
}
get columns(): number {
return process.stdout.columns || 80;
}
get rows(): number {
return process.stdout.rows || 24;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
process.stdout.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
process.stdout.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
process.stdout.write("\x1b[?25l");
}
showCursor(): void {
process.stdout.write("\x1b[?25h");
}
clearLine(): void {
process.stdout.write("\x1b[K");
}
clearFromCursor(): void {
process.stdout.write("\x1b[J");
}
clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`);
}
}