From e6aa5759d6991536692c623f3938d7b2def04b95 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 4 Dec 2025 00:34:39 -0600 Subject: [PATCH] fix: support application cursor mode (DECCKM) for arrow keys Arrow keys now correctly send SS3 sequences (ESC O A/B/C/D) when application cursor mode is enabled (DEC mode 1), and CSI sequences (ESC [ A/B/C/D) when in normal mode. This fixes arrow key navigation in applications like htop that enable application cursor mode. Changes: - Rewrote Key enum to match Ghostty's internal values (was using USB HID codes) - Arrow keys now go through the Ghostty encoder instead of fast path - Sync encoder CURSOR_KEY_APPLICATION option with terminal mode 1 state - Add getModeCallback to InputHandler for querying terminal mode state - Add tests for both normal and application cursor modes The Key enum change also fixes the encoder for all special keys (F-keys, navigation keys, etc.) which were previously producing incorrect or empty output due to the enum value mismatch. --- lib/input-handler.test.ts | 54 +++++++ lib/input-handler.ts | 33 ++-- lib/terminal.ts | 6 +- lib/types.ts | 325 +++++++++++++++++++++++--------------- 4 files changed, 270 insertions(+), 148 deletions(-) diff --git a/lib/input-handler.test.ts b/lib/input-handler.test.ts index 4052d44..e7c2af9 100644 --- a/lib/input-handler.test.ts +++ b/lib/input-handler.test.ts @@ -492,6 +492,60 @@ describe('InputHandler', () => { expect(dataReceived.length).toBe(1); expect(dataReceived[0]).toMatch(/\x1b(\[C|OC)/); }); + + test('sends CSI sequences in normal cursor mode (mode 1 off)', () => { + // Create handler with getMode callback that returns false (normal mode) + const handler = new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + undefined, + (_mode: number) => false // Normal cursor mode + ); + + simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp')); + simulateKey(container, createKeyEvent('ArrowDown', 'ArrowDown')); + simulateKey(container, createKeyEvent('ArrowLeft', 'ArrowLeft')); + simulateKey(container, createKeyEvent('ArrowRight', 'ArrowRight')); + + expect(dataReceived.length).toBe(4); + // Normal mode: CSI sequences (ESC[A, ESC[B, ESC[D, ESC[C) + expect(dataReceived[0]).toBe('\x1b[A'); + expect(dataReceived[1]).toBe('\x1b[B'); + expect(dataReceived[2]).toBe('\x1b[D'); + expect(dataReceived[3]).toBe('\x1b[C'); + }); + + test('sends SS3 sequences in application cursor mode (mode 1 on)', () => { + // Create handler with getMode callback that returns true for mode 1 + const handler = new InputHandler( + ghostty, + container as any, + (data) => dataReceived.push(data), + () => { + bellCalled = true; + }, + undefined, + undefined, + (mode: number) => mode === 1 // Application cursor mode enabled + ); + + simulateKey(container, createKeyEvent('ArrowUp', 'ArrowUp')); + simulateKey(container, createKeyEvent('ArrowDown', 'ArrowDown')); + simulateKey(container, createKeyEvent('ArrowLeft', 'ArrowLeft')); + simulateKey(container, createKeyEvent('ArrowRight', 'ArrowRight')); + + expect(dataReceived.length).toBe(4); + // Application mode: SS3 sequences (ESCOA, ESCOB, ESCOD, ESCOC) + expect(dataReceived[0]).toBe('\x1bOA'); + expect(dataReceived[1]).toBe('\x1bOB'); + expect(dataReceived[2]).toBe('\x1bOD'); + expect(dataReceived[3]).toBe('\x1bOC'); + }); }); describe('Function Keys', () => { diff --git a/lib/input-handler.ts b/lib/input-handler.ts index 46f6ef2..711bb61 100644 --- a/lib/input-handler.ts +++ b/lib/input-handler.ts @@ -16,7 +16,7 @@ import type { Ghostty } from './ghostty'; import type { KeyEncoder } from './ghostty'; import type { IKeyEvent } from './interfaces'; -import { Key, KeyAction, Mods } from './types'; +import { Key, KeyAction, KeyEncoderOption, Mods } from './types'; /** * Map KeyboardEvent.code values to USB HID Key enum values @@ -135,8 +135,8 @@ const KEY_MAP: Record = { NumpadDecimal: Key.KP_PERIOD, // International - IntlBackslash: Key.NON_US_BACKSLASH, - ContextMenu: Key.APPLICATION, + IntlBackslash: Key.INTL_BACKSLASH, + ContextMenu: Key.CONTEXT_MENU, // Additional function keys F13: Key.F13, @@ -165,6 +165,7 @@ export class InputHandler { private onBellCallback: () => void; private onKeyCallback?: (keyEvent: IKeyEvent) => void; private customKeyEventHandler?: (event: KeyboardEvent) => boolean; + private getModeCallback?: (mode: number) => boolean; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; @@ -178,6 +179,7 @@ export class InputHandler { * @param onBell - Callback for bell/beep event * @param onKey - Optional callback for raw key events * @param customKeyEventHandler - Optional custom key event handler + * @param getMode - Optional callback to query terminal mode state (for application cursor mode) */ constructor( ghostty: Ghostty, @@ -185,7 +187,8 @@ export class InputHandler { onData: (data: string) => void, onBell: () => void, onKey?: (keyEvent: IKeyEvent) => void, - customKeyEventHandler?: (event: KeyboardEvent) => boolean + customKeyEventHandler?: (event: KeyboardEvent) => boolean, + getMode?: (mode: number) => boolean ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; @@ -193,6 +196,7 @@ export class InputHandler { this.onBellCallback = onBell; this.onKeyCallback = onKey; this.customKeyEventHandler = customKeyEventHandler; + this.getModeCallback = getMode; // Attach event listeners this.attach(); @@ -347,19 +351,7 @@ export class InputHandler { case Key.ESCAPE: simpleOutput = '\x1B'; // ESC break; - // Arrow keys (CSI sequences) - case Key.UP: - simpleOutput = '\x1B[A'; - break; - case Key.DOWN: - simpleOutput = '\x1B[B'; - break; - case Key.RIGHT: - simpleOutput = '\x1B[C'; - break; - case Key.LEFT: - simpleOutput = '\x1B[D'; - break; + // Arrow keys are handled by the encoder (respects application cursor mode) // Navigation keys case Key.HOME: simpleOutput = '\x1B[H'; @@ -430,6 +422,13 @@ export class InputHandler { // For non-printable keys or keys with modifiers, encode using Ghostty try { + // Sync encoder options with terminal mode state + // Mode 1 (DECCKM) controls whether arrow keys send CSI or SS3 sequences + if (this.getModeCallback) { + const appCursorMode = this.getModeCallback(1); + this.encoder.setOption(KeyEncoderOption.CURSOR_KEY_APPLICATION, appCursorMode); + } + // For letter/number keys, even with modifiers, pass the base character // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) // For special keys (Enter, Arrow keys, etc.), don't pass utf8 diff --git a/lib/terminal.ts b/lib/terminal.ts index 0322840..00079bc 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -408,7 +408,11 @@ export class Terminal implements ITerminalCore { // Forward key events this.keyEmitter.fire(keyEvent); }, - this.customKeyEventHandler + this.customKeyEventHandler, + (mode: number) => { + // Query terminal mode state (e.g., mode 1 for application cursor mode) + return this.wasmTerm?.getMode(mode, false) ?? false; + } ); // Create selection manager (pass textarea for context menu positioning) diff --git a/lib/types.ts b/lib/types.ts index c505277..0f1b39e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -124,138 +124,203 @@ export enum KeyAction { } /** - * Physical key codes (based on USB HID Usage Tables - Keyboard/Keypad Page 0x07) - * Reference: https://www.usb.org/sites/default/files/hut1_21.pdf + * Physical key codes matching Ghostty's internal Key enum. + * These values are used by Ghostty's key encoder to produce correct escape sequences. + * Reference: ghostty/src/input/key.zig */ export enum Key { - // Letters (0x04-0x1D) - A = 4, - B = 5, - C = 6, - D = 7, - E = 8, - F = 9, - G = 10, - H = 11, - I = 12, - J = 13, - K = 14, - L = 15, - M = 16, - N = 17, - O = 18, - P = 19, - Q = 20, - R = 21, - S = 22, - T = 23, - U = 24, - V = 25, - W = 26, - X = 27, - Y = 28, - Z = 29, - - // Numbers (0x1E-0x27) - ONE = 30, - TWO = 31, - THREE = 32, - FOUR = 33, - FIVE = 34, - SIX = 35, - SEVEN = 36, - EIGHT = 37, - NINE = 38, - ZERO = 39, - - // Special keys (0x28-0x2C) - ENTER = 40, - ESCAPE = 41, - BACKSPACE = 42, - TAB = 43, - SPACE = 44, - - // Punctuation (0x2D-0x38) - MINUS = 45, // - and _ - EQUAL = 46, // = and + - BRACKET_LEFT = 47, // [ and { - BRACKET_RIGHT = 48, // ] and } - BACKSLASH = 49, // \ and | - SEMICOLON = 51, // ; and : - QUOTE = 52, // ' and " - GRAVE = 53, // ` and ~ - COMMA = 54, // , and < - PERIOD = 55, // . and > - SLASH = 56, // / and ? - - // Function keys (0x3A-0x45) - CAPS_LOCK = 57, - F1 = 58, - F2 = 59, - F3 = 60, - F4 = 61, - F5 = 62, - F6 = 63, - F7 = 64, - F8 = 65, - F9 = 66, - F10 = 67, - F11 = 68, - F12 = 69, - - // Special keys (0x46-0x4E) - PRINT_SCREEN = 70, - SCROLL_LOCK = 71, - PAUSE = 72, - INSERT = 73, - HOME = 74, - PAGE_UP = 75, - DELETE = 76, - END = 77, - PAGE_DOWN = 78, - - // Arrow keys (0x4F-0x52) - RIGHT = 79, - LEFT = 80, - DOWN = 81, - UP = 82, - - // Keypad (0x53-0x63) - NUM_LOCK = 83, - KP_DIVIDE = 84, // Keypad / - KP_MULTIPLY = 85, // Keypad * - KP_MINUS = 86, // Keypad - - KP_PLUS = 87, // Keypad + - KP_ENTER = 88, // Keypad Enter - KP_1 = 89, - KP_2 = 90, - KP_3 = 91, - KP_4 = 92, - KP_5 = 93, - KP_6 = 94, - KP_7 = 95, - KP_8 = 96, - KP_9 = 97, - KP_0 = 98, - KP_PERIOD = 99, // Keypad . - - // International keys (0x64-0x65) - NON_US_BACKSLASH = 100, // \ and | on non-US keyboards - APPLICATION = 101, // Context menu key - - // Additional function keys (0x68-0x73) - optional but included for completeness - F13 = 104, - F14 = 105, - F15 = 106, - F16 = 107, - F17 = 108, - F18 = 109, - F19 = 110, - F20 = 111, - F21 = 112, - F22 = 113, - F23 = 114, - F24 = 115, + // Unidentified key + UNIDENTIFIED = 0, + + // Writing System Keys + GRAVE = 1, // ` and ~ + BACKSLASH = 2, // \ and | + BRACKET_LEFT = 3, // [ and { + BRACKET_RIGHT = 4, // ] and } + COMMA = 5, // , and < + ZERO = 6, + ONE = 7, + TWO = 8, + THREE = 9, + FOUR = 10, + FIVE = 11, + SIX = 12, + SEVEN = 13, + EIGHT = 14, + NINE = 15, + EQUAL = 16, // = and + + INTL_BACKSLASH = 17, + INTL_RO = 18, + INTL_YEN = 19, + A = 20, + B = 21, + C = 22, + D = 23, + E = 24, + F = 25, + G = 26, + H = 27, + I = 28, + J = 29, + K = 30, + L = 31, + M = 32, + N = 33, + O = 34, + P = 35, + Q = 36, + R = 37, + S = 38, + T = 39, + U = 40, + V = 41, + W = 42, + X = 43, + Y = 44, + Z = 45, + MINUS = 46, // - and _ + PERIOD = 47, // . and > + QUOTE = 48, // ' and " + SEMICOLON = 49, // ; and : + SLASH = 50, // / and ? + + // Functional Keys + ALT_LEFT = 51, + ALT_RIGHT = 52, + BACKSPACE = 53, + CAPS_LOCK = 54, + CONTEXT_MENU = 55, + CONTROL_LEFT = 56, + CONTROL_RIGHT = 57, + ENTER = 58, + META_LEFT = 59, + META_RIGHT = 60, + SHIFT_LEFT = 61, + SHIFT_RIGHT = 62, + SPACE = 63, + TAB = 64, + CONVERT = 65, + KANA_MODE = 66, + NON_CONVERT = 67, + + // Control Pad Section + DELETE = 68, + END = 69, + HELP = 70, + HOME = 71, + INSERT = 72, + PAGE_DOWN = 73, + PAGE_UP = 74, + + // Arrow Pad Section + DOWN = 75, + LEFT = 76, + RIGHT = 77, + UP = 78, + + // Numpad Section + NUM_LOCK = 79, + KP_0 = 80, + KP_1 = 81, + KP_2 = 82, + KP_3 = 83, + KP_4 = 84, + KP_5 = 85, + KP_6 = 86, + KP_7 = 87, + KP_8 = 88, + KP_9 = 89, + KP_PLUS = 90, // Keypad + + KP_BACKSPACE = 91, + KP_CLEAR = 92, + KP_CLEAR_ENTRY = 93, + KP_COMMA = 94, + KP_PERIOD = 95, // Keypad . + KP_DIVIDE = 96, // Keypad / + KP_ENTER = 97, // Keypad Enter + KP_EQUAL = 98, + KP_MEMORY_ADD = 99, + KP_MEMORY_CLEAR = 100, + KP_MEMORY_RECALL = 101, + KP_MEMORY_STORE = 102, + KP_MEMORY_SUBTRACT = 103, + KP_MULTIPLY = 104, // Keypad * + KP_PAREN_LEFT = 105, + KP_PAREN_RIGHT = 106, + KP_MINUS = 107, // Keypad - + KP_SEPARATOR = 108, + NUMPAD_UP = 109, + NUMPAD_DOWN = 110, + NUMPAD_RIGHT = 111, + NUMPAD_LEFT = 112, + NUMPAD_BEGIN = 113, + NUMPAD_HOME = 114, + NUMPAD_END = 115, + NUMPAD_INSERT = 116, + NUMPAD_DELETE = 117, + NUMPAD_PAGE_UP = 118, + NUMPAD_PAGE_DOWN = 119, + + // Function Keys + ESCAPE = 120, + F1 = 121, + F2 = 122, + F3 = 123, + F4 = 124, + F5 = 125, + F6 = 126, + F7 = 127, + F8 = 128, + F9 = 129, + F10 = 130, + F11 = 131, + F12 = 132, + F13 = 133, + F14 = 134, + F15 = 135, + F16 = 136, + F17 = 137, + F18 = 138, + F19 = 139, + F20 = 140, + F21 = 141, + F22 = 142, + F23 = 143, + F24 = 144, + F25 = 145, + FN_LOCK = 146, + PRINT_SCREEN = 147, + SCROLL_LOCK = 148, + PAUSE = 149, + + // Media Keys + BROWSER_BACK = 150, + BROWSER_FAVORITES = 151, + BROWSER_FORWARD = 152, + BROWSER_HOME = 153, + BROWSER_REFRESH = 154, + BROWSER_SEARCH = 155, + BROWSER_STOP = 156, + EJECT = 157, + LAUNCH_APP_1 = 158, + LAUNCH_APP_2 = 159, + LAUNCH_MAIL = 160, + MEDIA_PLAY_PAUSE = 161, + MEDIA_SELECT = 162, + MEDIA_STOP = 163, + MEDIA_TRACK_NEXT = 164, + MEDIA_TRACK_PREVIOUS = 165, + POWER = 166, + SLEEP = 167, + AUDIO_VOLUME_DOWN = 168, + AUDIO_VOLUME_MUTE = 169, + AUDIO_VOLUME_UP = 170, + WAKE_UP = 171, + + // Clipboard Keys + COPY = 172, + CUT = 173, + PASTE = 174, } /**