From 2265fa2ade0ad157ab1a470de21fda3c599a09d0 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 05:59:57 -0800 Subject: [PATCH] Implement kitty keyboard protocol (CSI u) Fixes #4198 --- src/browser/CoreBrowserTerminal.ts | 16 +- src/common/InputHandler.ts | 86 ++++++ src/common/TestUtils.test.ts | 5 + src/common/Types.ts | 13 + src/common/input/KittyKeyboard.test.ts | 171 ++++++++++++ src/common/input/KittyKeyboard.ts | 349 +++++++++++++++++++++++++ src/common/services/CoreService.ts | 11 +- src/common/services/Services.ts | 3 +- 8 files changed, 651 insertions(+), 3 deletions(-) create mode 100644 src/common/input/KittyKeyboard.test.ts create mode 100644 src/common/input/KittyKeyboard.ts diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index abc363ed..32bd1b13 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -49,6 +49,7 @@ import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { C0, C1_ESCAPED } from 'common/data/EscapeSequences'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; +import { evaluateKeyboardEventKitty, KittyKeyboardEventType, shouldUseKittyProtocol } from 'common/input/KittyKeyboard'; import { toRgbString } from 'common/input/XParseColor'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; @@ -1081,7 +1082,11 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._unprocessedDeadKey = true; } - const result = evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); + // Use Kitty keyboard protocol if enabled, otherwise use legacy encoding + const kittyFlags = this.coreService.kittyKeyboard.flags; + const result = shouldUseKittyProtocol(kittyFlags) + ? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS) + : evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); this.updateCursorStyle(event); @@ -1168,6 +1173,15 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.focus(); } + // Handle key release for Kitty keyboard protocol + const kittyFlags = this.coreService.kittyKeyboard.flags; + if (shouldUseKittyProtocol(kittyFlags) && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag + const result = evaluateKeyboardEventKitty(ev, kittyFlags, KittyKeyboardEventType.RELEASE); + if (result.key) { + this.coreService.triggerDataEvent(result.key, true); + } + } + this.updateCursorStyle(ev); this._keyPressHandled = false; } diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index f816e114..a4fa8a4d 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -264,6 +264,11 @@ export class InputHandler extends Disposable implements IInputHandler { this._parser.registerCsiHandler({ final: 's' }, params => this.saveCursor(params)); this._parser.registerCsiHandler({ final: 't' }, params => this.windowOptions(params)); this._parser.registerCsiHandler({ final: 'u' }, params => this.restoreCursor(params)); + // Kitty keyboard protocol handlers + this._parser.registerCsiHandler({ prefix: '=', final: 'u' }, params => this.kittyKeyboardSet(params)); + this._parser.registerCsiHandler({ prefix: '?', final: 'u' }, params => this.kittyKeyboardQuery(params)); + this._parser.registerCsiHandler({ prefix: '>', final: 'u' }, params => this.kittyKeyboardPush(params)); + this._parser.registerCsiHandler({ prefix: '<', final: 'u' }, params => this.kittyKeyboardPop(params)); this._parser.registerCsiHandler({ intermediates: '\'', final: '}' }, params => this.insertColumns(params)); this._parser.registerCsiHandler({ intermediates: '\'', final: '~' }, params => this.deleteColumns(params)); this._parser.registerCsiHandler({ intermediates: '"', final: 'q' }, params => this.selectProtected(params)); @@ -2977,6 +2982,87 @@ export class InputHandler extends Disposable implements IInputHandler { } + /** + * CSI = flags ; mode u + * Set Kitty keyboard protocol flags. + * mode: 1=set, 2=set-only-specified, 3=reset-only-specified + * + * @vt: #Y CSI KKBDSET "Kitty Keyboard Set" "CSI = Ps ; Pm u" "Set Kitty keyboard protocol flags." + */ + public kittyKeyboardSet(params: IParams): boolean { + const flags = params.params[0] || 0; + const mode = params.params[1] || 1; + const state = this._coreService.kittyKeyboard; + + switch (mode) { + case 1: // Set all flags + state.flags = flags; + break; + case 2: // Set only specified flags (OR) + state.flags |= flags; + break; + case 3: // Reset only specified flags (AND NOT) + state.flags &= ~flags; + break; + } + return true; + } + + /** + * CSI ? u + * Query Kitty keyboard protocol flags. + * Terminal responds with CSI ? flags u + * + * @vt: #Y CSI KKBDQUERY "Kitty Keyboard Query" "CSI ? u" "Query Kitty keyboard protocol flags." + */ + public kittyKeyboardQuery(params: IParams): boolean { + const flags = this._coreService.kittyKeyboard.flags; + this._coreService.triggerDataEvent(`${C0.ESC}[?${flags}u`); + return true; + } + + /** + * CSI > flags u + * Push Kitty keyboard flags onto stack and set new flags. + * + * @vt: #Y CSI KKBDPUSH "Kitty Keyboard Push" "CSI > Ps u" "Push keyboard flags to stack and set new flags." + */ + public kittyKeyboardPush(params: IParams): boolean { + const flags = params.params[0] || 0; + const state = this._coreService.kittyKeyboard; + const isAlt = this._bufferService.buffer === this._bufferService.buffers.alt; + const stack = isAlt ? state.altStack : state.mainStack; + + // Push current flags onto stack and set new flags + stack.push(state.flags); + state.flags = flags; + return true; + } + + /** + * CSI < count u + * Pop Kitty keyboard flags from stack. + * + * @vt: #Y CSI KKBDPOP "Kitty Keyboard Pop" "CSI < Ps u" "Pop keyboard flags from stack." + */ + public kittyKeyboardPop(params: IParams): boolean { + const count = Math.max(1, params.params[0] || 1); + const state = this._coreService.kittyKeyboard; + const isAlt = this._bufferService.buffer === this._bufferService.buffers.alt; + const stack = isAlt ? state.altStack : state.mainStack; + + // Pop specified number of entries from stack + for (let i = 0; i < count && stack.length > 0; i++) { + state.flags = stack.pop()!; + } + // If stack is empty after popping, reset to 0 + if (stack.length === 0 && count > 0) { + state.flags = 0; + } + return true; + } + + /** * OSC 2; ST (set window title) * Proxy to set window title. diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index ef9140e4..6ba72692 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -114,6 +114,11 @@ export class MockCoreService implements ICoreService { synchronizedOutput: false, wraparound: true }; + public kittyKeyboard = { + flags: 0, + mainStack: [] as number[], + altStack: [] as number[] + }; public onData: Event = new Emitter().event; public onUserInput: Event = new Emitter().event; public onBinary: Event = new Emitter().event; diff --git a/src/common/Types.ts b/src/common/Types.ts index 0ccb1483..88a466b7 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -277,6 +277,19 @@ export interface IDecPrivateModes { wraparound: boolean; // defaults: xterm - true, vt100 - false } +/** + * Kitty keyboard protocol state. + * Maintains per-screen stacks of enhancement flags. + */ +export interface IKittyKeyboardState { + /** Current active enhancement flags */ + flags: number; + /** Stack of flags for main screen */ + mainStack: number[]; + /** Stack of flags for alternate screen */ + altStack: number[]; +} + export interface IRowRange { start: number; end: number; diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts new file mode 100644 index 00000000..ff9f346d --- /dev/null +++ b/src/common/input/KittyKeyboard.test.ts @@ -0,0 +1,171 @@ + +import { assert } from 'chai'; +import { evaluateKeyboardEventKitty, KittyKeyboardEventType, KittyKeyboardFlags, shouldUseKittyProtocol } from 'common/input/KittyKeyboard'; +import { IKeyboardResult, IKeyboardEvent } from 'common/Types'; + +function createEvent(partialEvent: Partial = {}): IKeyboardEvent { + return { + altKey: partialEvent.altKey || false, + ctrlKey: partialEvent.ctrlKey || false, + shiftKey: partialEvent.shiftKey || false, + metaKey: partialEvent.metaKey || false, + keyCode: partialEvent.keyCode !== undefined ? partialEvent.keyCode : 0, + code: partialEvent.code || '', + key: partialEvent.key || '', + type: partialEvent.type || 'keydown' + }; +} + +describe('KittyKeyboard', () => { + describe('shouldUseKittyProtocol', () => { + it('should return false when flags are 0', () => { + assert.strictEqual(shouldUseKittyProtocol(0), false); + }); + + it('should return true when any flag is set', () => { + assert.strictEqual(shouldUseKittyProtocol(KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES), true); + assert.strictEqual(shouldUseKittyProtocol(KittyKeyboardFlags.REPORT_EVENT_TYPES), true); + assert.strictEqual(shouldUseKittyProtocol(0b11111), true); + }); + }); + + describe('evaluateKeyboardEventKitty', () => { + describe('with DISAMBIGUATE_ESCAPE_CODES flag', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('should encode Escape as CSI 27 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('should encode Enter as CSI 13 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter' }), flags); + assert.strictEqual(result.key, '\x1b[13u'); + }); + + it('should encode Backspace as CSI 127 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Backspace' }), flags); + assert.strictEqual(result.key, '\x1b[127u'); + }); + + it('should encode Tab as CSI 9 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Tab' }), flags); + assert.strictEqual(result.key, '\x1b[9u'); + }); + + it('should encode Shift+Tab with modifiers', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Tab', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[9;2u'); + }); + + it('should encode arrow keys using Kitty codepoints', () => { + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'ArrowUp' }), flags).key, '\x1b[57417u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'ArrowDown' }), flags).key, '\x1b[57420u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'ArrowLeft' }), flags).key, '\x1b[57419u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'ArrowRight' }), flags).key, '\x1b[57421u'); + }); + + it('should encode F1-F4 using Kitty codepoints', () => { + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'F1' }), flags).key, '\x1b[57364u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'F2' }), flags).key, '\x1b[57365u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'F3' }), flags).key, '\x1b[57366u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: 'F4' }), flags).key, '\x1b[57367u'); + }); + + it('should encode Ctrl+A with modifiers', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;5u'); + }); + + it('should encode Alt+X with modifiers', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'x', altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[120;3u'); + }); + + it('should encode Ctrl+Shift+A with combined modifiers', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', ctrlKey: true, shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[65;6u'); + }); + }); + + describe('with REPORT_EVENT_TYPES flag', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('should not include event type for press events', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[97u'); + }); + + it('should include event type for repeat events', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[97;:2u'); + }); + + it('should include event type for release events', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97;:3u'); + }); + + it('should include modifiers and event type', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97;5:3u'); + }); + }); + + describe('with REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES; + + it('should encode regular letters as CSI u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags); + assert.strictEqual(result.key, '\x1b[97u'); + }); + + it('should encode numbers as CSI u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '1' }), flags); + assert.strictEqual(result.key, '\x1b[49u'); + }); + + it('should encode space as CSI 32 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: ' ' }), flags); + assert.strictEqual(result.key, '\x1b[32u'); + }); + }); + + describe('numpad keys', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('should encode numpad digits with Kitty codepoints', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '0', code: 'Numpad0' }), flags); + assert.strictEqual(result.key, '\x1b[57399u'); + }); + + it('should encode numpad enter', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter', code: 'NumpadEnter' }), flags); + assert.strictEqual(result.key, '\x1b[57414u'); + }); + }); + + describe('modifier keys', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('should encode left shift with correct codepoint', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57441;2u'); + }); + + it('should encode right control with correct codepoint', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Control', code: 'ControlRight', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57448;5u'); + }); + }); + + describe('release events without REPORT_EVENT_TYPES', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('should not generate key sequence for release events', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + }); + }); +}); diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts new file mode 100644 index 00000000..eb649006 --- /dev/null +++ b/src/common/input/KittyKeyboard.ts @@ -0,0 +1,349 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + * + * Kitty keyboard protocol implementation. + * @see https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + */ + +import { IKeyboardEvent, IKeyboardResult, KeyboardResultType } from 'common/Types'; +import { C0 } from 'common/data/EscapeSequences'; + +/** + * Kitty keyboard protocol enhancement flags (bitfield). + */ +export const enum KittyKeyboardFlags { + NONE = 0b00000, + /** Disambiguate escape codes - fixes ambiguous legacy encodings */ + DISAMBIGUATE_ESCAPE_CODES = 0b00001, + /** Report event types - press/repeat/release */ + REPORT_EVENT_TYPES = 0b00010, + /** Report alternate keys - shifted key and base layout key */ + REPORT_ALTERNATE_KEYS = 0b00100, + /** Report all keys as escape codes - text-producing keys as CSI u */ + REPORT_ALL_KEYS_AS_ESCAPE_CODES = 0b01000, + /** Report associated text - includes text codepoints in escape code */ + REPORT_ASSOCIATED_TEXT = 0b10000, +} + +/** + * Kitty keyboard event types. + */ +export const enum KittyKeyboardEventType { + PRESS = 1, + REPEAT = 2, + RELEASE = 3, +} + +/** + * Kitty modifier bits (different from xterm modifier encoding). + * Value sent = 1 + modifier_bits + */ +export const enum KittyKeyboardModifiers { + SHIFT = 0b00000001, + ALT = 0b00000010, + CTRL = 0b00000100, + SUPER = 0b00001000, + HYPER = 0b00010000, + META = 0b00100000, + CAPS_LOCK = 0b01000000, + NUM_LOCK = 0b10000000, +} + +/** + * Functional key codes for Kitty protocol. + * Keys that don't produce text have specific unicode codepoint mappings. + */ +const FUNCTIONAL_KEY_CODES: { [key: string]: number } = { + 'Escape': 27, + 'Enter': 13, + 'Tab': 9, + 'Backspace': 127, + 'Insert': 2, + 'Delete': 3, + 'ArrowLeft': 57419, + 'ArrowRight': 57421, + 'ArrowUp': 57417, + 'ArrowDown': 57420, + 'PageUp': 57423, + 'PageDown': 57424, + 'Home': 57416, + 'End': 57418, + 'CapsLock': 57358, + 'ScrollLock': 57359, + 'NumLock': 57360, + 'PrintScreen': 57361, + 'Pause': 57362, + 'ContextMenu': 57363, + // F1-F35 + 'F1': 57364, + 'F2': 57365, + 'F3': 57366, + 'F4': 57367, + 'F5': 57368, + 'F6': 57369, + 'F7': 57370, + 'F8': 57371, + 'F9': 57372, + 'F10': 57373, + 'F11': 57374, + 'F12': 57375, + 'F13': 57376, + 'F14': 57377, + 'F15': 57378, + 'F16': 57379, + 'F17': 57380, + 'F18': 57381, + 'F19': 57382, + 'F20': 57383, + 'F21': 57384, + 'F22': 57385, + 'F23': 57386, + 'F24': 57387, + 'F25': 57388, + // Keypad keys + 'KP_0': 57399, + 'KP_1': 57400, + 'KP_2': 57401, + 'KP_3': 57402, + 'KP_4': 57403, + 'KP_5': 57404, + 'KP_6': 57405, + 'KP_7': 57406, + 'KP_8': 57407, + 'KP_9': 57408, + 'KP_Decimal': 57409, + 'KP_Divide': 57410, + 'KP_Multiply': 57411, + 'KP_Subtract': 57412, + 'KP_Add': 57413, + 'KP_Enter': 57414, + 'KP_Equal': 57415, + // Modifier keys + 'ShiftLeft': 57441, + 'ShiftRight': 57447, + 'ControlLeft': 57442, + 'ControlRight': 57448, + 'AltLeft': 57443, + 'AltRight': 57449, + 'MetaLeft': 57444, + 'MetaRight': 57450, + // Media keys + 'MediaPlayPause': 57430, + 'MediaStop': 57432, + 'MediaTrackNext': 57435, + 'MediaTrackPrevious': 57436, + 'AudioVolumeDown': 57438, + 'AudioVolumeUp': 57439, + 'AudioVolumeMute': 57440 +}; + +/** + * Map browser key codes to Kitty numpad codes. + */ +function getNumpadKeyCode(ev: IKeyboardEvent): number | undefined { + // Detect numpad via code property + if (ev.code.startsWith('Numpad')) { + const suffix = ev.code.slice(6); + if (suffix >= '0' && suffix <= '9') { + return 57399 + parseInt(suffix, 10); + } + switch (suffix) { + case 'Decimal': return 57409; + case 'Divide': return 57410; + case 'Multiply': return 57411; + case 'Subtract': return 57412; + case 'Add': return 57413; + case 'Enter': return 57414; + case 'Equal': return 57415; + } + } + return undefined; +} + +/** + * Get modifier key code from code property. + */ +function getModifierKeyCode(ev: IKeyboardEvent): number | undefined { + switch (ev.code) { + case 'ShiftLeft': return 57441; + case 'ShiftRight': return 57447; + case 'ControlLeft': return 57442; + case 'ControlRight': return 57448; + case 'AltLeft': return 57443; + case 'AltRight': return 57449; + case 'MetaLeft': return 57444; + case 'MetaRight': return 57450; + } + return undefined; +} + +/** + * Encode modifiers for Kitty protocol. + * Returns 1 + modifier bits, or 0 if no modifiers. + */ +function encodeModifiers(ev: IKeyboardEvent): number { + let mods = 0; + if (ev.shiftKey) mods |= KittyKeyboardModifiers.SHIFT; + if (ev.altKey) mods |= KittyKeyboardModifiers.ALT; + if (ev.ctrlKey) mods |= KittyKeyboardModifiers.CTRL; + if (ev.metaKey) mods |= KittyKeyboardModifiers.SUPER; + // Note: getModifierState would be needed for CAPS_LOCK/NUM_LOCK but not in IKeyboardEvent + return mods > 0 ? mods + 1 : 0; +} + +/** + * Get the unicode key code for a keyboard event. + */ +function getKeyCode(ev: IKeyboardEvent): number | undefined { + // Check for numpad first + const numpadCode = getNumpadKeyCode(ev); + if (numpadCode !== undefined) { + return numpadCode; + } + + // Check for modifier keys + const modifierCode = getModifierKeyCode(ev); + if (modifierCode !== undefined) { + return modifierCode; + } + + // Check functional keys + const funcCode = FUNCTIONAL_KEY_CODES[ev.key]; + if (funcCode !== undefined) { + return funcCode; + } + + // For regular keys, use the key character's codepoint + if (ev.key.length === 1) { + return ev.key.codePointAt(0); + } + + return undefined; +} + +/** + * Check if a key is a modifier key. + */ +function isModifierKey(ev: IKeyboardEvent): boolean { + return ev.key === 'Shift' || ev.key === 'Control' || ev.key === 'Alt' || ev.key === 'Meta'; +} + +/** + * Evaluate a keyboard event using Kitty keyboard protocol. + * + * @param ev The keyboard event. + * @param flags The active Kitty keyboard enhancement flags. + * @param eventType The event type (press, repeat, release). + * @returns The keyboard result with the encoded key sequence. + */ +export function evaluateKeyboardEventKitty( + ev: IKeyboardEvent, + flags: number, + eventType: KittyKeyboardEventType = KittyKeyboardEventType.PRESS +): IKeyboardResult { + const result: IKeyboardResult = { + type: KeyboardResultType.SEND_KEY, + cancel: false, + key: undefined + }; + + // Get the key code + const keyCode = getKeyCode(ev); + if (keyCode === undefined) { + return result; + } + + const modifiers = encodeModifiers(ev); + const isFunc = FUNCTIONAL_KEY_CODES[ev.key] !== undefined || getNumpadKeyCode(ev) !== undefined; + const isMod = isModifierKey(ev); + + // Determine if we should use CSI u encoding or can use legacy + let useCsiU = false; + + if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) { + // All keys use CSI u + useCsiU = true; + } else if (flags & KittyKeyboardFlags.REPORT_EVENT_TYPES) { + // When reporting event types, use CSI u for all keys + useCsiU = true; + } else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) { + // Use CSI u for: + // - Modifier-only keys when reporting event types + // - Keys that would be ambiguous in legacy encoding + // - Escape key + // - Backspace + // - Tab (when shifted) + // - Enter + if (isMod && (flags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) { + useCsiU = true; + } else if (keyCode === 27 || keyCode === 127 || keyCode === 13) { + // Escape, Backspace, Enter + useCsiU = true; + } else if (keyCode === 9 && ev.shiftKey) { + // Shift+Tab + useCsiU = true; + } else if (isFunc) { + useCsiU = true; + } else if (modifiers > 0) { + // Any modified key + useCsiU = true; + } + } + + // Determine if we should report this event type + const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES); + if (!reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) { + // Don't report release events unless flag is set + return result; + } + + if (useCsiU) { + // Build CSI u sequence: CSI keycode ; modifiers:event-type u + // Format: CSI [:][:] ; [:] u + let seq = C0.ESC + '[' + keyCode; + + // Add modifiers and event type + if (modifiers > 0 || (reportEventTypes && eventType !== KittyKeyboardEventType.PRESS)) { + seq += ';'; + if (modifiers > 0) { + seq += modifiers; + } + if (reportEventTypes && eventType !== KittyKeyboardEventType.PRESS) { + seq += ':' + eventType; + } + } + + // Add associated text if requested and available + if ((flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) && ev.key.length === 1 && !isFunc && !isMod) { + const textCode = ev.key.codePointAt(0); + if (textCode !== undefined && textCode !== keyCode) { + // Append text as ; text u + if (!seq.includes(';')) { + seq += ';'; + } + seq += ';' + textCode; + } + } + + seq += 'u'; + result.key = seq; + result.cancel = true; + } else { + // Legacy-compatible encoding for text keys without modifiers + if (ev.key.length === 1 && !ev.ctrlKey && !ev.altKey && !ev.metaKey) { + result.key = ev.key; + } + // Otherwise no key sequence (will fall through to legacy handling) + } + + return result; +} + +/** + * Check if a keyboard event should be handled by Kitty protocol. + * Returns true if Kitty flags are active and the event should use Kitty encoding. + */ +export function shouldUseKittyProtocol(flags: number): boolean { + return flags > 0; +} diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 7b5f532d..3bb64c53 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -5,7 +5,7 @@ import { clone } from 'common/Clone'; import { Disposable } from 'vs/base/common/lifecycle'; -import { IDecPrivateModes, IModes } from 'common/Types'; +import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'vs/base/common/event'; @@ -26,6 +26,12 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ wraparound: true // defaults: xterm - true, vt100 - false }); +const DEFAULT_KITTY_KEYBOARD_STATE = (): IKittyKeyboardState => ({ + flags: 0, + mainStack: [], + altStack: [] +}); + export class CoreService extends Disposable implements ICoreService { public serviceBrand: any; @@ -33,6 +39,7 @@ export class CoreService extends Disposable implements ICoreService { public isCursorHidden: boolean = false; public modes: IModes; public decPrivateModes: IDecPrivateModes; + public kittyKeyboard: IKittyKeyboardState; private readonly _onData = this._register(new Emitter()); public readonly onData = this._onData.event; @@ -51,11 +58,13 @@ export class CoreService extends Disposable implements ICoreService { super(); this.modes = clone(DEFAULT_MODES); this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); } public reset(): void { this.modes = clone(DEFAULT_MODES); this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); } public triggerDataEvent(data: string, wasUserInput: boolean = false): void { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 3febd907..fd595670 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -4,7 +4,7 @@ */ import { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, type IOverviewRulerOptions } from '@xterm/xterm'; -import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; +import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; import type { Emitter, Event } from 'vs/base/common/event'; @@ -85,6 +85,7 @@ export interface ICoreService { readonly modes: IModes; readonly decPrivateModes: IDecPrivateModes; + readonly kittyKeyboard: IKittyKeyboardState; readonly onData: Event; readonly onUserInput: Event;