From cbd20b58a6acb74d81083c9babc978f7079adc8a Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 19 Dec 2025 21:00:20 +0100 Subject: [PATCH] fix(tui): add isHome, isEnd, isDelete helpers for Kitty protocol Extended the Kitty protocol parser to handle functional keys with ~ terminator (Delete, Insert, PageUp, PageDown) and Home/End keys. Updated editor.ts and input.ts to use the new helpers. --- packages/tui/src/components/editor.ts | 12 ++--- packages/tui/src/components/input.ts | 3 +- packages/tui/src/index.ts | 3 ++ packages/tui/src/keys.ts | 73 +++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) diff --git a/packages/tui/src/components/editor.ts b/packages/tui/src/components/editor.ts index 8ef933c7..452fd308 100644 --- a/packages/tui/src/components/editor.ts +++ b/packages/tui/src/components/editor.ts @@ -16,8 +16,11 @@ import { isCtrlRight, isCtrlU, isCtrlW, + isDelete, + isEnd, isEnter, isEscape, + isHome, isShiftEnter, isTab, } from "../keys.js"; @@ -459,16 +462,13 @@ export class Editor implements Component { this.handleBackspace(); } // Line navigation shortcuts (Home/End keys) - else if (data === "\x1b[H" || data === "\x1b[1~" || data === "\x1b[7~") { - // Home key + else if (isHome(data)) { this.moveToLineStart(); - } else if (data === "\x1b[F" || data === "\x1b[4~" || data === "\x1b[8~") { - // End key + } else if (isEnd(data)) { this.moveToLineEnd(); } // Forward delete (Fn+Backspace or Delete key) - else if (data === "\x1b[3~") { - // Delete key + else if (isDelete(data)) { this.handleForwardDelete(); } // Word navigation (Option/Alt + Arrow or Ctrl + Arrow) diff --git a/packages/tui/src/components/input.ts b/packages/tui/src/components/input.ts index 1dfb8eed..e99ad8ec 100644 --- a/packages/tui/src/components/input.ts +++ b/packages/tui/src/components/input.ts @@ -8,6 +8,7 @@ import { isCtrlK, isCtrlU, isCtrlW, + isDelete, isEnter, } from "../keys.js"; import type { Component } from "../tui.js"; @@ -118,7 +119,7 @@ export class Input implements Component { return; } - if (data === "\x1b[3~") { + if (isDelete(data)) { // Delete - delete grapheme at cursor (handles emojis, etc.) if (this.cursor < this.value.length) { const afterCursor = this.value.slice(this.cursor); diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index e35779ae..fdf04af8 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -41,8 +41,11 @@ export { isCtrlT, isCtrlU, isCtrlW, + isDelete, + isEnd, isEnter, isEscape, + isHome, isShiftEnter, isShiftTab, isTab, diff --git a/packages/tui/src/keys.ts b/packages/tui/src/keys.ts index 49e0d6ce..aca6838a 100644 --- a/packages/tui/src/keys.ts +++ b/packages/tui/src/keys.ts @@ -77,6 +77,16 @@ interface ParsedKittySequence { * * Returns null if not a valid Kitty sequence. */ +// Virtual codepoints for functional keys (negative to avoid conflicts) +const FUNCTIONAL_CODEPOINTS = { + delete: -10, + insert: -11, + pageUp: -12, + pageDown: -13, + home: -14, + end: -15, +} as const; + function parseKittySequence(data: string): ParsedKittySequence | null { // Match CSI u format: \x1b[u or \x1b[;u const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?u$/); @@ -96,6 +106,35 @@ function parseKittySequence(data: string): ParsedKittySequence | null { return { codepoint, modifier: modValue - 1 }; } + // Match functional keys with ~ terminator: \x1b[~ or \x1b[;~ + // DELETE=3, INSERT=2, PAGEUP=5, PAGEDOWN=6, etc. + const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?~$/); + if (funcMatch) { + const keyNum = parseInt(funcMatch[1]!, 10); + const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1; + // Map functional key numbers to virtual codepoints + const funcCodes: Record = { + 2: FUNCTIONAL_CODEPOINTS.insert, + 3: FUNCTIONAL_CODEPOINTS.delete, + 5: FUNCTIONAL_CODEPOINTS.pageUp, + 6: FUNCTIONAL_CODEPOINTS.pageDown, + 7: FUNCTIONAL_CODEPOINTS.home, // Alternative home + 8: FUNCTIONAL_CODEPOINTS.end, // Alternative end + }; + const codepoint = funcCodes[keyNum]; + if (codepoint !== undefined) { + return { codepoint, modifier: modValue - 1 }; + } + } + + // Match Home/End with modifier: \x1b[1;H/F + const homeEndMatch = data.match(/^\x1b\[1;(\d+)([HF])$/); + if (homeEndMatch) { + const modValue = parseInt(homeEndMatch[1]!, 10); + const codepoint = homeEndMatch[2] === "H" ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end; + return { codepoint, modifier: modValue - 1 }; + } + return null; } @@ -408,3 +447,37 @@ export function isCtrlLeft(data: string): boolean { export function isCtrlRight(data: string): boolean { return data === "\x1b[1;5C" || matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl); } + +/** + * Check if input matches Home key. + * Handles legacy formats and Kitty protocol with lock key modifiers. + */ +export function isHome(data: string): boolean { + return ( + data === "\x1b[H" || + data === "\x1b[1~" || + data === "\x1b[7~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) + ); +} + +/** + * Check if input matches End key. + * Handles legacy formats and Kitty protocol with lock key modifiers. + */ +export function isEnd(data: string): boolean { + return ( + data === "\x1b[F" || + data === "\x1b[4~" || + data === "\x1b[8~" || + matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) + ); +} + +/** + * Check if input matches Delete key (forward delete). + * Handles legacy format and Kitty protocol with lock key modifiers. + */ +export function isDelete(data: string): boolean { + return data === "\x1b[3~" || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0); +}