feat(tui): query Kitty keyboard protocol support before enabling

- Query terminal with CSI ? u before enabling Kitty protocol
- Only enable protocol if terminal responds (100ms timeout)
- Mode-aware key parsing: when Kitty active, interpret legacy
  sequences as custom terminal mappings (e.g. \x1b\r = shift+enter)
- Add Terminal Setup section to README with Ghostty/wezterm config

fixes #439
This commit is contained in:
Mario Zechner 2026-01-05 22:52:13 +01:00
parent 0b9e3ada0c
commit c5d54a8413
5 changed files with 186 additions and 18 deletions

View file

@ -30,7 +30,7 @@ export {
setEditorKeybindings,
} from "./keybindings.js";
// Keyboard input handling
export { Key, type KeyId, matchesKey, parseKey } from "./keys.js";
export { isKittyProtocolActive, Key, type KeyId, matchesKey, parseKey, setKittyProtocolActive } from "./keys.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support

View file

@ -13,8 +13,31 @@
* - matchesKey(data, keyId) - Check if input matches a key identifier
* - parseKey(data) - Parse input and return the key identifier
* - Key - Helper object for creating typed key identifiers
* - setKittyProtocolActive(active) - Set global Kitty protocol state
* - isKittyProtocolActive() - Query global Kitty protocol state
*/
// =============================================================================
// Global Kitty Protocol State
// =============================================================================
let _kittyProtocolActive = false;
/**
* Set the global Kitty keyboard protocol state.
* Called by ProcessTerminal after detecting protocol support.
*/
export function setKittyProtocolActive(active: boolean): void {
_kittyProtocolActive = active;
}
/**
* Query whether Kitty keyboard protocol is currently active.
*/
export function isKittyProtocolActive(): boolean {
return _kittyProtocolActive;
}
// =============================================================================
// Type-Safe Key Identifiers
// =============================================================================
@ -402,17 +425,35 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
case "enter":
case "return":
if (shift && !ctrl && !alt) {
return (
// CSI u sequences (standard Kitty protocol)
if (
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
matchesKittySequence(data, CODEPOINTS.kpEnter, 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"
if (_kittyProtocolActive) {
return data === "\x1b\r" || data === "\n";
}
return false;
}
if (alt && !ctrl && !shift) {
return (
data === "\x1b\r" ||
// CSI u sequences (standard Kitty protocol)
if (
matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
matchesKittySequence(data, CODEPOINTS.kpEnter, 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) {
return data === "\x1b\r";
}
return false;
}
if (modifier === 0) {
return (
@ -577,14 +618,22 @@ export function parseKey(data: string): string | undefined {
}
}
// Legacy sequences
// Mode-aware legacy sequences
// When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
// - \x1b\r = shift+enter (Kitty mapping), not alt+enter
// - \n = shift+enter (Ghostty mapping)
if (_kittyProtocolActive) {
if (data === "\x1b\r" || data === "\n") return "shift+enter";
}
// Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
if (data === "\x1b") return "escape";
if (data === "\t") return "tab";
if (data === "\r" || data === "\x1bOM") return "enter";
if (data === " ") return "space";
if (data === "\x7f" || data === "\x08") return "backspace";
if (data === "\x1b[Z") return "shift+tab";
if (data === "\x1b\r") return "alt+enter";
if (!_kittyProtocolActive && data === "\x1b\r") return "alt+enter";
if (data === "\x1b\x7f") return "alt+backspace";
if (data === "\x1b[A") return "up";
if (data === "\x1b[B") return "down";

View file

@ -1,3 +1,5 @@
import { setKittyProtocolActive } from "./keys.js";
/**
* Minimal terminal interface for TUI
*/
@ -15,6 +17,9 @@ export interface Terminal {
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
@ -38,6 +43,11 @@ export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _kittyProtocolActive = false;
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
@ -54,23 +64,104 @@ export class ProcessTerminal implements Terminal {
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
process.stdout.write("\x1b[?2004h");
// Enable Kitty keyboard protocol (disambiguate escape codes)
// This makes terminals like Ghostty, Kitty, WezTerm send enhanced key sequences
// e.g., Shift+Enter becomes \x1b[13;2u instead of just \r
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
process.stdout.write("\x1b[>1u");
// Set up event handlers
process.stdin.on("data", this.inputHandler);
// Set up resize handler immediately
process.stdout.on("resize", this.resizeHandler);
// 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 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.
*
* Non-supporting terminals won't respond, so we use a timeout.
*/
private queryAndEnableKittyProtocol(): void {
const QUERY_TIMEOUT_MS = 100;
let resolved = false;
let buffer = "";
// Kitty protocol response pattern: \x1b[?<flags>u
const kittyResponsePattern = /\x1b\[\?(\d+)u/;
const queryHandler = (data: string) => {
if (resolved) {
// Query phase done, forward to user handler
if (this.inputHandler) {
this.inputHandler(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
process.stdout.write("\x1b[>1u");
// Remove the response from buffer, forward any remaining input
const remaining = buffer.replace(kittyResponsePattern, "");
if (remaining && this.inputHandler) {
this.inputHandler(remaining);
}
// Replace with user handler
process.stdin.removeListener("data", queryHandler);
if (this.inputHandler) {
process.stdin.on("data", this.inputHandler);
}
}
};
// Temporarily intercept input for the query
process.stdin.on("data", queryHandler);
// Send query
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
if (buffer && this.inputHandler) {
this.inputHandler(buffer);
}
// Replace with user handler
process.stdin.removeListener("data", queryHandler);
if (this.inputHandler) {
process.stdin.on("data", this.inputHandler);
}
}
}, QUERY_TIMEOUT_MS);
}
stop(): void {
// Disable bracketed paste mode
process.stdout.write("\x1b[?2004l");
// Disable Kitty keyboard protocol (pop the flags we pushed)
process.stdout.write("\x1b[<u");
// 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);
}
// Remove event handlers
if (this.inputHandler) {