From c07126c0fdef6b8a14a0f91a06c6138561c9f5fe Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 11 Jan 2026 22:51:30 +0100 Subject: [PATCH] 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~) --- packages/tui/src/keys.ts | 23 +++++++++ packages/tui/src/terminal.ts | 98 +++++++++--------------------------- 2 files changed, 48 insertions(+), 73 deletions(-) diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 21c96508..039946ca 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -437,6 +437,21 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM return parsed.codepoint === expectedCodepoint && actualMod === expectedMod; } +/** + * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~ + * This is used by terminals when Kitty protocol is not enabled. + * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc. + */ +function matchesModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { + const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/); + if (!match) return false; + const modValue = parseInt(match[1]!, 10); + const keycode = parseInt(match[2]!, 10); + // Convert from 1-indexed xterm format to our 0-indexed format + const actualMod = modValue - 1; + return keycode === expectedKeycode && actualMod === expectedModifier; +} + // ============================================================================= // Generic Key Matching // ============================================================================= @@ -515,6 +530,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean { ) { return true; } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) { + return true; + } // When Kitty protocol is active, legacy sequences are custom terminal mappings // \x1b\r = Kitty's "map shift+enter send_text all \e\r" // \n = Ghostty's "keybind = shift+enter=text:\n" @@ -531,6 +550,10 @@ export function matchesKey(data: string, keyId: KeyId): boolean { ) { return true; } + // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled) + if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) { + return true; + } // \x1b\r is alt+enter only in legacy mode (no Kitty protocol) // When Kitty protocol is active, alt+enter comes as CSI u sequence if (!_kittyProtocolActive) { diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 772668e3..7727832e 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -85,13 +85,34 @@ export class ProcessTerminal implements Terminal { /** * Set up StdinBuffer to split batched input into individual sequences. * This ensures components receive single events, making matchesKey/isKeyRelease work correctly. - * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query. + * + * 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[?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); } @@ -105,7 +126,6 @@ export class ProcessTerminal implements Terminal { }); // Handler that pipes stdin data through the buffer - // Registration happens after Kitty protocol query completes this.stdinDataHandler = (data: string) => { this.stdinBuffer!.process(data); }; @@ -117,81 +137,13 @@ export class ProcessTerminal implements Terminal { * Sends CSI ? u to query current flags. If terminal responds with CSI ? u, * it supports the protocol and we enable it with CSI > 1 u. * - * Non-supporting terminals won't respond, so we use a timeout. + * 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 { - const QUERY_TIMEOUT_MS = 100; - let resolved = false; - let buffer = ""; - - // Kitty protocol response pattern: \x1b[?u - const kittyResponsePattern = /\x1b\[\?(\d+)u/; - - const queryHandler = (data: string) => { - if (resolved) { - // Query phase done, forward to StdinBuffer - if (this.stdinBuffer) { - this.stdinBuffer.process(data); - } - return; - } - - buffer += data; - - // Check if we have a Kitty protocol response - const match = buffer.match(kittyResponsePattern); - if (match) { - resolved = true; - 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"); - - // Remove the response from buffer, forward any remaining input through StdinBuffer - const remaining = buffer.replace(kittyResponsePattern, ""); - if (remaining && this.stdinBuffer) { - this.stdinBuffer.process(remaining); - } - - // Replace query handler with StdinBuffer handler - process.stdin.removeListener("data", queryHandler); - if (this.stdinDataHandler) { - process.stdin.on("data", this.stdinDataHandler); - } - } - }; - - // Set up StdinBuffer before query (it will receive input after query completes) this.setupStdinBuffer(); - - // Temporarily intercept input for the query (before StdinBuffer) - process.stdin.on("data", queryHandler); - - // Send query + process.stdin.on("data", this.stdinDataHandler!); process.stdout.write("\x1b[?u"); - - // Timeout: if no response, terminal doesn't support Kitty protocol - setTimeout(() => { - if (!resolved) { - resolved = true; - this._kittyProtocolActive = false; - setKittyProtocolActive(false); - - // Forward any buffered input that wasn't a Kitty response through StdinBuffer - if (buffer && this.stdinBuffer) { - this.stdinBuffer.process(buffer); - } - - // Replace query handler with StdinBuffer handler - process.stdin.removeListener("data", queryHandler); - if (this.stdinDataHandler) { - process.stdin.on("data", this.stdinDataHandler); - } - } - }, QUERY_TIMEOUT_MS); } stop(): void {