diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 9730dd2d..fe6b79e5 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -6,6 +6,10 @@ - `PI_NO_HARDWARE_CURSOR=1` environment variable to disable hardware cursor positioning for terminals with limited escape sequence support (e.g., IntelliJ IDEA's built-in terminal) +### Fixed + +- Decode Kitty CSI-u printable sequences in the editor so shifted symbol keys (e.g., `@`, `?`) work in terminals that enable Kitty keyboard protocol + ## [0.47.0] - 2026-01-16 ### Breaking Changes diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 171174dc..494fe91a 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -187,6 +187,44 @@ function wordWrapLine(line: string, maxWidth: number): TextChunk[] { return chunks.length > 0 ? chunks : [{ text: "", startIndex: 0, endIndex: 0 }]; } +// Kitty CSI-u sequences for printable keys, including optional shifted/base codepoints. +const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/; +const KITTY_MOD_SHIFT = 1; +const KITTY_MOD_ALT = 2; +const KITTY_MOD_CTRL = 4; + +// Decode a printable CSI-u sequence, preferring the shifted key when present. +function decodeKittyPrintable(data: string): string | undefined { + const match = data.match(KITTY_CSI_U_REGEX); + if (!match) return undefined; + + // CSI-u groups: [:[:]];u + const codepoint = Number.parseInt(match[1] ?? "", 10); + if (!Number.isFinite(codepoint)) return undefined; + + const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined; + const modValue = match[4] ? Number.parseInt(match[4], 10) : 1; + // Modifiers are 1-indexed in CSI-u; normalize to our bitmask. + const modifier = Number.isFinite(modValue) ? modValue - 1 : 0; + + // Ignore CSI-u sequences used for Alt/Ctrl shortcuts. + if (modifier & (KITTY_MOD_ALT | KITTY_MOD_CTRL)) return undefined; + + // Prefer the shifted keycode when Shift is held. + let effectiveCodepoint = codepoint; + if (modifier & KITTY_MOD_SHIFT && typeof shiftedKey === "number") { + effectiveCodepoint = shiftedKey; + } + // Drop control characters or invalid codepoints. + if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined; + + try { + return String.fromCodePoint(effectiveCodepoint); + } catch { + return undefined; + } +} + interface EditorState { lines: string[]; cursorLine: number; @@ -676,6 +714,12 @@ export class Editor implements Component, Focusable { return; } + const kittyPrintable = decodeKittyPrintable(data); + if (kittyPrintable !== undefined) { + this.insertCharacter(kittyPrintable); + return; + } + // Regular characters if (data.charCodeAt(0) >= 32) { this.insertCharacter(data);