diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index c93497603f..49d7ef5021 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -48,7 +48,7 @@ import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, 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 { evaluateKeyboardEvent, encodeKittyKeyboardEvent } from 'common/input/Keyboard'; import { toRgbString } from 'common/input/XParseColor'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; @@ -119,6 +119,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { */ private _unprocessedDeadKey: boolean = false; + /** + * Tracks currently pressed keys for Kitty keyboard protocol repeat/release events. + * Maps key codes to their last press event details. + */ + private _pressedKeys = new Map(); + private _compositionHelper: ICompositionHelper | undefined; private _accessibilityManager: MutableDisposable = this._register(new MutableDisposable()); @@ -1040,7 +1046,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._unprocessedDeadKey = true; } - const result = evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); + const result = evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta, this.coreService.decPrivateModes.kittyKeyboardFlags); + + // Handle Kitty keyboard protocol events (repeat tracking) + this._handleKittyKeyPress(event); this.updateCursorStyle(event); @@ -1102,6 +1111,79 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._keyDownHandled = true; } + /** + * Handles Kitty keyboard protocol key press event tracking and repeat detection + */ + private _handleKittyKeyPress(event: KeyboardEvent): boolean { + const flags = this.coreService.decPrivateModes.kittyKeyboardFlags; + const reportEvents = flags & 2; // KITTY_FLAG_REPORT_EVENTS + + if (!reportEvents) { + return false; + } + + const keyId = `${event.code}-${event.key}`; + const now = Date.now(); + const lastPress = this._pressedKeys.get(keyId); + + let eventType = 1; // press + if (lastPress && (now - lastPress.timestamp) < 100) { + eventType = 2; // repeat + } + + this._pressedKeys.set(keyId, { key: event.key, timestamp: now }); + + // Only send separate event if it's a repeat or if REPORT_ALL_KEYS is set + if (eventType === 2 || (flags & 8)) { // KITTY_FLAG_REPORT_ALL_KEYS + const kittySequence = this._generateKittyKeySequence(event, eventType); + if (kittySequence) { + this.coreService.triggerDataEvent(kittySequence, true); + return true; + } + } + + return false; + } + + /** + * Handles Kitty keyboard protocol key release events + */ + private _handleKittyKeyRelease(event: KeyboardEvent): void { + const flags = this.coreService.decPrivateModes.kittyKeyboardFlags; + const reportEvents = flags & 2; // KITTY_FLAG_REPORT_EVENTS + + if (!reportEvents) { + return; + } + + const keyId = `${event.code}-${event.key}`; + const wasPressed = this._pressedKeys.has(keyId); + + if (wasPressed) { + this._pressedKeys.delete(keyId); + + // Generate release event - avoid Enter, Tab, Backspace unless REPORT_ALL_KEYS is set + const reportAllKeys = flags & 8; // KITTY_FLAG_REPORT_ALL_KEYS + if (reportAllKeys || (event.key !== 'Enter' && event.key !== 'Tab' && event.key !== 'Backspace')) { + const kittySequence = this._generateKittyKeySequence(event, 3); // 3 = release + if (kittySequence) { + this.coreService.triggerDataEvent(kittySequence, true); + } + } + } + } + + /** + * Generates Kitty keyboard protocol sequence for an event + */ + private _generateKittyKeySequence(event: KeyboardEvent, eventType: number): string | null { + try { + return encodeKittyKeyboardEvent(event, this.coreService.decPrivateModes.kittyKeyboardFlags, eventType); + } catch (e) { + return null; + } + } + private _isThirdLevelShift(browser: IBrowser, ev: KeyboardEvent): boolean { const thirdLevelKey = (browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) || @@ -1123,6 +1205,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { return; } + // Handle Kitty keyboard protocol release events + this._handleKittyKeyRelease(ev); + if (!wasModifierKeyOnlyEvent(ev)) { this.focus(); } diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 0e1511739c..05d339cd1d 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -124,6 +124,7 @@ export class InputHandler extends Disposable implements IInputHandler { private _dirtyRowTracker: IDirtyRowTracker; protected _windowTitleStack: string[] = []; protected _iconNameStack: string[] = []; + protected _kittyKeyboardModeStack: number[] = []; private _curAttrData: IAttributeData = DEFAULT_ATTR_DATA.clone(); public getAttrData(): IAttributeData { return this._curAttrData; } @@ -261,6 +262,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.setKittyKeyboardMode(params)); + this._parser.registerCsiHandler({ prefix: '?', final: 'u' }, params => this.queryKittyKeyboardMode()); + this._parser.registerCsiHandler({ prefix: '>', final: 'u' }, params => this.pushKittyKeyboardMode(params)); + this._parser.registerCsiHandler({ prefix: '<', final: 'u' }, params => this.popKittyKeyboardMode(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)); @@ -3425,6 +3431,80 @@ export class InputHandler extends Disposable implements IInputHandler { public markRangeDirty(y1: number, y2: number): void { this._dirtyRowTracker.markRangeDirty(y1, y2); } + + /** + * Kitty keyboard protocol handlers + */ + + /** + * CSI = flags ; mode u Set keyboard mode flags + */ + public setKittyKeyboardMode(params: IParams): boolean { + const flags = params.length >= 1 ? params.params[0] : 0; + const mode = params.length >= 2 ? params.params[1] : 1; + + if (mode === 1) { + // Set all flags to specified value (reset unspecified bits) + this._coreService.decPrivateModes.kittyKeyboardFlags = flags; + } else if (mode === 2) { + // Set specified bits, leave unset bits unchanged + this._coreService.decPrivateModes.kittyKeyboardFlags |= flags; + } else if (mode === 3) { + // Reset specified bits, leave unset bits unchanged + this._coreService.decPrivateModes.kittyKeyboardFlags &= ~flags; + } + + return true; + } + + /** + * CSI ? u Query keyboard mode flags + */ + public queryKittyKeyboardMode(): boolean { + this._coreService.triggerDataEvent(`${C0.ESC}[?${this._coreService.decPrivateModes.kittyKeyboardFlags}u`); + return true; + } + + /** + * CSI > flags u Push keyboard mode flags (with optional default) + */ + public pushKittyKeyboardMode(params: IParams): boolean { + const defaultFlags = params.length >= 1 ? params.params[0] : 0; + + // Limit stack size to prevent DoS attacks + if (this._kittyKeyboardModeStack.length >= 16) { + this._kittyKeyboardModeStack.shift(); + } + + // Push current mode onto stack + this._kittyKeyboardModeStack.push(this._coreService.decPrivateModes.kittyKeyboardFlags); + + // Set to default flags if provided + if (params.length >= 1) { + this._coreService.decPrivateModes.kittyKeyboardFlags = defaultFlags; + } + + return true; + } + + /** + * CSI < number u Pop keyboard mode flags + */ + public popKittyKeyboardMode(params: IParams): boolean { + const count = params.length >= 1 ? params.params[0] : 1; + + for (let i = 0; i < count && this._kittyKeyboardModeStack.length > 0; i++) { + const flags = this._kittyKeyboardModeStack.pop()!; + this._coreService.decPrivateModes.kittyKeyboardFlags = flags; + } + + // If stack is empty, reset all flags + if (this._kittyKeyboardModeStack.length === 0) { + this._coreService.decPrivateModes.kittyKeyboardFlags = 0; + } + + return true; + } } export interface IDirtyRowTracker { diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 8e3d02f989..704518e744 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -99,6 +99,7 @@ export class MockCoreService implements ICoreService { bracketedPasteMode: false, cursorBlink: undefined, cursorStyle: undefined, + kittyKeyboardFlags: 0, origin: false, reverseWraparound: false, sendFocus: false, diff --git a/src/common/Types.ts b/src/common/Types.ts index c254d330d1..56427c0e74 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -270,6 +270,7 @@ export interface IDecPrivateModes { bracketedPasteMode: boolean; cursorBlink: boolean | undefined; cursorStyle: CursorStyle | undefined; + kittyKeyboardFlags: number; origin: boolean; reverseWraparound: boolean; sendFocus: boolean; diff --git a/src/common/input/Keyboard.kitty.test.ts b/src/common/input/Keyboard.kitty.test.ts new file mode 100644 index 0000000000..32c0f1cd80 --- /dev/null +++ b/src/common/input/Keyboard.kitty.test.ts @@ -0,0 +1,494 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { evaluateKeyboardEvent, encodeKittyKeyboardEvent } from './Keyboard'; +import { IKeyboardResult, IKeyboardEvent } from '../Types'; + +/** + * Helper function to create keyboard events for testing + */ +function createKeyboardEvent(options: Partial = {}): IKeyboardEvent { + return { + type: 'keydown', + altKey: false, + ctrlKey: false, + shiftKey: false, + metaKey: false, + keyCode: 0, + key: '', + code: '', + ...options + }; +} + +/** + * Helper function to test Kitty keyboard evaluation + */ +function testKittyKeyboardEvent( + eventOptions: Partial, + flags: number = 0, + applicationCursorMode: boolean = false, + isMac: boolean = false, + macOptionIsMeta: boolean = false +): IKeyboardResult { + const event = createKeyboardEvent(eventOptions); + return evaluateKeyboardEvent(event, applicationCursorMode, isMac, macOptionIsMeta, flags); +} + +describe('Kitty Keyboard Protocol', () => { + + describe('Basic Protocol Activation', () => { + it('should use legacy encoding when no flags are set', () => { + const result = testKittyKeyboardEvent({ keyCode: 27, key: 'Escape' }, 0); + assert.equal(result.key, '\x1b'); + }); + + it('should remain backward compatible for regular keys without flags', () => { + const result = testKittyKeyboardEvent({ keyCode: 65, key: 'a' }, 0); + assert.equal(result.key, 'a'); + }); + + it('should use legacy encoding for function keys without flags', () => { + const result = testKittyKeyboardEvent({ keyCode: 112, key: 'F1' }, 0); + assert.equal(result.key, '\x1bOP'); + }); + }); + + describe('Flag 1: Disambiguate Escape Codes', () => { + const DISAMBIGUATE = 1; + + it('should encode Escape key with Kitty protocol when disambiguation enabled', () => { + const result = testKittyKeyboardEvent({ keyCode: 27, key: 'Escape' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[27u'); + }); + + it('should encode Alt+letter combinations with Kitty protocol', () => { + const result = testKittyKeyboardEvent({ + keyCode: 65, + key: 'a', + altKey: true + }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[97;3u'); // 1 + 2(alt) = 3 + }); + + it('should encode Ctrl+letter combinations with Kitty protocol', () => { + const result = testKittyKeyboardEvent({ + keyCode: 67, + key: 'c', + ctrlKey: true + }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[99;5u'); // 1 + 4(ctrl) = 5 + }); + + it('should not affect regular letters without modifiers', () => { + const result = testKittyKeyboardEvent({ keyCode: 65, key: 'a' }, DISAMBIGUATE); + assert.equal(result.key, 'a'); + }); + }); + + describe('Flag 8: Report All Keys', () => { + const REPORT_ALL_KEYS = 8; + + it('should encode all key events including regular letters', () => { + const result = testKittyKeyboardEvent({ keyCode: 65, key: 'a' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97u'); + }); + + it('should encode digits', () => { + const result = testKittyKeyboardEvent({ keyCode: 49, key: '1' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[49u'); + }); + + it('should encode space key', () => { + const result = testKittyKeyboardEvent({ keyCode: 32, key: ' ' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[32u'); + }); + + it('should encode special characters', () => { + const result = testKittyKeyboardEvent({ keyCode: 188, key: ',' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[44u'); // Unicode for comma + }); + }); + + describe('Modifier Encoding', () => { + const REPORT_ALL_KEYS = 8; + + it('should encode single modifiers correctly', () => { + // Shift only + let result = testKittyKeyboardEvent({ + keyCode: 65, key: 'A', shiftKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97u'); // Base + 1 (shift) = 2, but shift key doesn't get modifier in output for single chars + + // Alt only + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'a', altKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;3u'); // 1 + 2(alt) = 3 + + // Ctrl only + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'a', ctrlKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;5u'); // 1 + 4(ctrl) = 5 + + // Meta only + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'a', metaKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;9u'); // 1 + 8(meta) = 9 + }); + + it('should encode multiple modifiers correctly', () => { + // Shift + Alt - shift is bit 0, alt is bit 1 + let result = testKittyKeyboardEvent({ + keyCode: 65, key: 'A', shiftKey: true, altKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;4u'); // 1 + 1(shift) + 2(alt) = 4 + + // Ctrl + Alt + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'a', ctrlKey: true, altKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;7u'); // 1 + 4(ctrl) + 2(alt) = 7 + + // Shift + Ctrl + Alt + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'A', shiftKey: true, ctrlKey: true, altKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;8u'); // 1 + 1(shift) + 4(ctrl) + 2(alt) = 8 + + // All modifiers + result = testKittyKeyboardEvent({ + keyCode: 65, key: 'A', shiftKey: true, ctrlKey: true, altKey: true, metaKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97;16u'); // 1 + 1 + 4 + 2 + 8 = 16 + }); + }); + + describe('Functional Keys', () => { + const DISAMBIGUATE = 1; + + it('should handle standard function keys F1-F12', () => { + // F1 should use legacy unless other conditions apply + let result = testKittyKeyboardEvent({ keyCode: 112, key: 'F1' }, DISAMBIGUATE); + assert.equal(result.key, '\x1bOP'); + + // F9 uses legacy ~ form + result = testKittyKeyboardEvent({ keyCode: 120, key: 'F9' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[20~'); + + // F12 uses legacy ~ form + result = testKittyKeyboardEvent({ keyCode: 123, key: 'F12' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[24~'); + }); + + it('should handle modified function keys', () => { + // Ctrl+F1 + let result = testKittyKeyboardEvent({ + keyCode: 112, key: 'F1', ctrlKey: true + }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[1;5P'); + + // Shift+F9 + result = testKittyKeyboardEvent({ + keyCode: 120, key: 'F9', shiftKey: true + }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[20;2~'); + }); + + it('should handle extended function keys F13-F24', () => { + // These should use Kitty protocol codes when available + const result = testKittyKeyboardEvent({ key: 'F13' }, DISAMBIGUATE); + // F13 should use Kitty functional key code + assert.equal(result.key, '\x1b[57376u'); + }); + + it('should handle arrow keys', () => { + // Standard arrows without modifiers should use legacy + let result = testKittyKeyboardEvent({ keyCode: 37, key: 'ArrowLeft' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[D'); + + // With modifiers should use modified legacy form + result = testKittyKeyboardEvent({ + keyCode: 37, key: 'ArrowLeft', ctrlKey: true + }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[1;5D'); + }); + + it('should handle keypad keys', () => { + // Keypad keys should use Kitty codes when available + const result = testKittyKeyboardEvent({ key: 'Numpad0', code: 'Numpad0' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57399u'); + }); + }); + + describe('Special Key Handling', () => { + const REPORT_ALL_KEYS = 8; + + it('should handle Enter key', () => { + const result = testKittyKeyboardEvent({ keyCode: 13, key: 'Enter' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[13u'); + }); + + it('should handle Tab key', () => { + const result = testKittyKeyboardEvent({ keyCode: 9, key: 'Tab' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[9u'); + }); + + it('should handle Backspace key', () => { + const result = testKittyKeyboardEvent({ keyCode: 8, key: 'Backspace' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[127u'); + }); + + it('should handle Delete key', () => { + const result = testKittyKeyboardEvent({ keyCode: 46, key: 'Delete' }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[3u'); + }); + }); + + describe('Media and Special Keys', () => { + const DISAMBIGUATE = 1; + + it('should handle media keys', () => { + // Media Play + let result = testKittyKeyboardEvent({ key: 'MediaPlay' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57428u'); + + // Volume Up + result = testKittyKeyboardEvent({ key: 'AudioVolumeUp' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57439u'); + + // Volume Down + result = testKittyKeyboardEvent({ key: 'AudioVolumeDown' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57438u'); + }); + + it('should handle lock keys', () => { + // Caps Lock + let result = testKittyKeyboardEvent({ key: 'CapsLock' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57358u'); + + // Num Lock + result = testKittyKeyboardEvent({ key: 'NumLock' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57360u'); + + // Scroll Lock + result = testKittyKeyboardEvent({ key: 'ScrollLock' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57359u'); + }); + + it('should handle modifier keys themselves', () => { + // Left Shift + let result = testKittyKeyboardEvent({ key: 'ShiftLeft' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57441u'); + + // Right Ctrl + result = testKittyKeyboardEvent({ key: 'ControlRight' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57448u'); + + // Left Alt + result = testKittyKeyboardEvent({ key: 'AltLeft' }, DISAMBIGUATE); + assert.equal(result.key, '\x1b[57443u'); + }); + }); + + describe('Unicode and International Keys', () => { + const REPORT_ALL_KEYS = 8; + + it('should handle Unicode characters correctly', () => { + // Should use lowercase Unicode codepoint for base key + const result = testKittyKeyboardEvent({ + keyCode: 65, key: 'A', shiftKey: true + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[97u'); // 97 = 'a', shift doesn't add modifier for single chars + }); + + it('should handle non-ASCII characters', () => { + // Test with accented character + const result = testKittyKeyboardEvent({ + key: 'é' + }, REPORT_ALL_KEYS); + assert.equal(result.key, '\x1b[233u'); // Unicode for 'é' + }); + }); + + describe('Flag Combinations', () => { + it('should handle multiple flags correctly', () => { + const DISAMBIGUATE_AND_REPORT_ALL = 1 | 8; // 9 + + // Should use Kitty protocol for all keys + let result = testKittyKeyboardEvent({ + keyCode: 65, key: 'a' + }, DISAMBIGUATE_AND_REPORT_ALL); + assert.equal(result.key, '\x1b[97u'); + + // Should still handle Escape specially + result = testKittyKeyboardEvent({ + keyCode: 27, key: 'Escape' + }, DISAMBIGUATE_AND_REPORT_ALL); + assert.equal(result.key, '\x1b[27u'); + }); + }); + + describe('Legacy Compatibility', () => { + it('should maintain compatibility when no flags are set', () => { + // Regular letters + let result = testKittyKeyboardEvent({ keyCode: 65, key: 'a' }, 0); + assert.equal(result.key, 'a'); + + // Function keys + result = testKittyKeyboardEvent({ keyCode: 112, key: 'F1' }, 0); + assert.equal(result.key, '\x1bOP'); + + // Arrow keys + result = testKittyKeyboardEvent({ keyCode: 37, key: 'ArrowLeft' }, 0); + assert.equal(result.key, '\x1b[D'); + + // Modified keys + result = testKittyKeyboardEvent({ + keyCode: 37, key: 'ArrowLeft', ctrlKey: true + }, 0); + assert.equal(result.key, '\x1b[1;5D'); + }); + }); + + describe('Event Type Support (Placeholder)', () => { + // Note: Event type support (press/repeat/release) would be tested at the + // browser terminal level since it requires tracking keydown/keyup events + it('should support event type encoding in encodeKittyKeyboardEvent', () => { + const event = createKeyboardEvent({ keyCode: 65, key: 'a' }); + + // Test press event (default) + let result = encodeKittyKeyboardEvent(event, 8, 1); + assert.equal(result, '\x1b[97u'); + + // Test repeat event + result = encodeKittyKeyboardEvent(event, 8, 2); + assert.equal(result, '\x1b[97;1:2u'); + + // Test release event + result = encodeKittyKeyboardEvent(event, 8, 3); + assert.equal(result, '\x1b[97;1:3u'); + }); + }); + + describe('Edge Cases', () => { + it('should handle unknown keys gracefully', () => { + const result = testKittyKeyboardEvent({ + key: 'UnknownKey', + code: 'UnknownCode' + }, 1); + // Should not crash and return undefined for unknown keys + assert.equal(result.key, undefined); + }); + + it('should handle empty key values', () => { + const result = testKittyKeyboardEvent({ + key: '', + keyCode: 0 + }, 8); + // Should encode keyCode 0 (empty string has charCodeAt(0) = 0) + assert.equal(result.key, '\x1b[0u'); + }); + + it('should handle very long key names', () => { + const longKeyName = 'A'.repeat(100); + const result = testKittyKeyboardEvent({ + key: longKeyName + }, 8); + // Should not cause issues + assert.notEqual(result, null); + }); + }); + + describe('Performance Considerations', () => { + it('should handle rapid key sequences efficiently', () => { + const start = Date.now(); + + // Simulate rapid typing + for (let i = 0; i < 1000; i++) { + testKittyKeyboardEvent({ + keyCode: 65 + (i % 26), + key: String.fromCharCode(97 + (i % 26)) + }, 8); + } + + const end = Date.now(); + const duration = end - start; + + // Should complete within reasonable time (less than 100ms for 1000 events) + assert.isBelow(duration, 100, 'Key processing should be fast'); + }); + }); + + describe('Browser Compatibility', () => { + it('should work with different browser key representations', () => { + // Chrome/Safari style + let result = testKittyKeyboardEvent({ + key: 'Enter', + code: 'Enter', + keyCode: 13 + }, 8); + assert.equal(result.key, '\x1b[13u'); + + // Firefox style (might have different key properties) + result = testKittyKeyboardEvent({ + key: 'Enter', + code: 'Enter', + keyCode: 13 + }, 8); + assert.equal(result.key, '\x1b[13u'); + }); + + it('should handle mobile browser events', () => { + // Mobile browsers might send different event structures + const result = testKittyKeyboardEvent({ + keyCode: 0, + key: 'UIKeyInputUpArrow' + }, 1); + // Should handle mobile arrow events + assert.equal(result.key, '\x1b[A'); // Falls back to legacy for mobile + }); + }); + + describe('Application Cursor Mode Interaction', () => { + it('should respect application cursor mode even with Kitty protocol', () => { + // Arrow keys in application cursor mode + const result = testKittyKeyboardEvent({ + keyCode: 37, + key: 'ArrowLeft' + }, 1, true); // applicationCursorMode = true + + assert.equal(result.key, '\x1bOD'); // Should use SS3 form + }); + }); + + describe('macOS Specific Behavior', () => { + it('should handle macOS option key behavior', () => { + // On macOS with macOptionIsMeta = false, option should still be treated as alt in Kitty protocol + const result = testKittyKeyboardEvent({ + keyCode: 65, + key: 'a', + altKey: true + }, 1, true, false); // isMac = true, macOptionIsMeta = false + + // Should still generate Kitty sequence for alt key + assert.equal(result.key, '\x1b[97;3u'); + }); + + it('should handle macOS option key as meta', () => { + // On macOS with macOptionIsMeta = true, option should be treated as meta + const result = testKittyKeyboardEvent({ + keyCode: 65, + key: 'a', + altKey: true + }, 1, true, true); // isMac = true, macOptionIsMeta = true + + assert.equal(result.key, '\x1b[97;3u'); + }); + }); +}); diff --git a/src/common/input/Keyboard.test.ts b/src/common/input/Keyboard.test.ts index 9bde7594e7..834b9c4e8a 100644 --- a/src/common/input/Keyboard.test.ts +++ b/src/common/input/Keyboard.test.ts @@ -36,7 +36,7 @@ function testEvaluateKeyboardEvent(partialEvent: { isMac: partialOptions.isMac || false, macOptionIsMeta: partialOptions.macOptionIsMeta || false }; - return evaluateKeyboardEvent(event, options.applicationCursorMode, options.isMac, options.macOptionIsMeta); + return evaluateKeyboardEvent(event, options.applicationCursorMode, options.isMac, options.macOptionIsMeta, 0); } describe('Keyboard', () => { @@ -354,4 +354,60 @@ describe('Keyboard', () => { }); }); + + describe('Kitty keyboard protocol', () => { + function testKittyKeyboardEvent(partialEvent: any, flags: number = 1) { + const event = { + type: 'keydown', + altKey: false, + ctrlKey: false, + shiftKey: false, + metaKey: false, + keyCode: 0, + key: '', + code: '', + ...partialEvent + }; + const options = { + applicationCursorMode: false, + isMac: false, + macOptionIsMeta: false + }; + return evaluateKeyboardEvent(event, options.applicationCursorMode, options.isMac, options.macOptionIsMeta, flags); + } + + it('should use legacy encoding when Kitty flags are 0', () => { + const result = testKittyKeyboardEvent({ keyCode: 27, key: 'Escape' }, 0); + assert.equal(result.key, '\x1b'); + }); + + it('should use Kitty protocol for Escape key when disambiguation is enabled', () => { + const result = testKittyKeyboardEvent({ keyCode: 27, key: 'Escape' }, 1); // KITTY_FLAG_DISAMBIGUATE + assert.equal(result.key, '\x1b[27u'); + }); + + it('should use Kitty protocol for regular keys when REPORT_ALL_KEYS is enabled', () => { + const result = testKittyKeyboardEvent({ keyCode: 65, key: 'a' }, 8); // KITTY_FLAG_REPORT_ALL_KEYS + assert.equal(result.key, '\x1b[97u'); + }); + + it('should encode modifiers correctly in Kitty protocol', () => { + const result = testKittyKeyboardEvent({ + keyCode: 65, + key: 'a', + ctrlKey: true, + shiftKey: true + }, 8); // KITTY_FLAG_REPORT_ALL_KEYS + assert.equal(result.key, '\x1b[97;6u'); // 1 + 1(shift) + 4(ctrl) = 6 + }); + + it('should handle functional keys correctly', () => { + const result = testKittyKeyboardEvent({ + keyCode: 120, + key: 'F9' + }, 1); + // F9 should use legacy encoding unless other flags are set + assert.equal(result.key, '\x1b[20~'); + }); + }); }); diff --git a/src/common/input/Keyboard.ts b/src/common/input/Keyboard.ts index c825b4120e..0c3fc2f5d4 100644 --- a/src/common/input/Keyboard.ts +++ b/src/common/input/Keyboard.ts @@ -7,6 +7,43 @@ import { IKeyboardEvent, IKeyboardResult, KeyboardResultType } from 'common/Types'; import { C0 } from 'common/data/EscapeSequences'; +// Kitty keyboard protocol functional key codes (Unicode Private Use Area) +const KITTY_FUNCTIONAL_KEYS: { [key: string]: number } = { + // F13-F35 keys + '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, 'F26': 57389, 'F27': 57390, 'F28': 57391, + 'F29': 57392, 'F30': 57393, 'F31': 57394, 'F32': 57395, + 'F33': 57396, 'F34': 57397, 'F35': 57398, + // Keypad keys + 'Numpad0': 57399, 'Numpad1': 57400, 'Numpad2': 57401, 'Numpad3': 57402, + 'Numpad4': 57403, 'Numpad5': 57404, 'Numpad6': 57405, 'Numpad7': 57406, + 'Numpad8': 57407, 'Numpad9': 57408, 'NumpadDecimal': 57409, + 'NumpadDivide': 57410, 'NumpadMultiply': 57411, 'NumpadSubtract': 57412, + 'NumpadAdd': 57413, 'NumpadEnter': 57414, 'NumpadEqual': 57415, + 'NumpadSeparator': 57416, + // Arrow keys on keypad + 'NumpadArrowLeft': 57417, 'NumpadArrowRight': 57418, 'NumpadArrowUp': 57419, + 'NumpadArrowDown': 57420, 'NumpadPageUp': 57421, 'NumpadPageDown': 57422, + 'NumpadHome': 57423, 'NumpadEnd': 57424, 'NumpadInsert': 57425, + 'NumpadDelete': 57426, 'NumpadBegin': 57427, + // Media keys + 'MediaPlay': 57428, 'MediaPause': 57429, 'MediaPlayPause': 57430, + 'MediaReverse': 57431, 'MediaStop': 57432, 'MediaFastForward': 57433, + 'MediaRewind': 57434, 'MediaTrackNext': 57435, 'MediaTrackPrevious': 57436, + 'MediaRecord': 57437, 'AudioVolumeDown': 57438, 'AudioVolumeUp': 57439, + 'AudioVolumeMute': 57440, + // Modifier keys + 'ShiftLeft': 57441, 'ControlLeft': 57442, 'AltLeft': 57443, + 'MetaLeft': 57444, 'HyperLeft': 57445, 'SuperLeft': 57446, + 'ShiftRight': 57447, 'ControlRight': 57448, 'AltRight': 57449, + 'MetaRight': 57450, 'HyperRight': 57451, 'SuperRight': 57452, + // Lock keys + 'CapsLock': 57358, 'ScrollLock': 57359, 'NumLock': 57360, + 'PrintScreen': 57361, 'Pause': 57362, 'ContextMenu': 57363 +}; + // reg + shift key mappings for digits and special chars const KEYCODE_KEY_MAPPINGS: { [key: number]: [string, string]} = { // digits 0-9 @@ -35,11 +72,167 @@ const KEYCODE_KEY_MAPPINGS: { [key: number]: [string, string]} = { 222: ['\'', '"'] }; +// Kitty keyboard protocol flags +const KITTY_FLAG_DISAMBIGUATE = 1; // 0b1 +const KITTY_FLAG_REPORT_EVENTS = 2; // 0b10 +const KITTY_FLAG_REPORT_ALTERNATE = 4; // 0b100 +const KITTY_FLAG_REPORT_ALL_KEYS = 8; // 0b1000 +const KITTY_FLAG_REPORT_TEXT = 16; // 0b10000 + +/** + * Encodes a key event using the Kitty keyboard protocol format: + * CSI unicode-key-code:alternate-key-codes ; modifiers:event-type ; text-as-codepoints u + */ +export function encodeKittyKeyboardEvent( + ev: IKeyboardEvent, + flags: number, + eventType: number = 1 +): string { + // Calculate modifiers (1 + actual modifiers) + let modifiers = 1; + if (ev.shiftKey) modifiers += 1; + if (ev.altKey) modifiers += 2; + if (ev.ctrlKey) modifiers += 4; + if (ev.metaKey) modifiers += 8; + // Note: Hyper, Meta, CapsLock, NumLock would be added here for full implementation + + let keyCode: number; + let alternateKeys = ''; + let textCodepoints = ''; + let reportModifiers = modifiers; + + // Determine the base key code + if (ev.key.length === 1) { + // Single character key - use lowercase Unicode codepoint + keyCode = ev.key.toLowerCase().charCodeAt(0); + + // For single character keys, don't report shift modifier alone + // but do report it when combined with other modifiers + if (ev.shiftKey && !ev.altKey && !ev.ctrlKey && !ev.metaKey) { + reportModifiers = 1; // Don't report shift modifier for single chars + } + + // Add shifted key if shift is pressed and reporting alternate keys + if ((flags & KITTY_FLAG_REPORT_ALTERNATE) && ev.shiftKey && ev.key !== ev.key.toLowerCase()) { + alternateKeys = ':' + ev.key.charCodeAt(0); + } + + // Add text codepoints if reporting associated text + if ((flags & KITTY_FLAG_REPORT_TEXT) && (flags & KITTY_FLAG_REPORT_ALL_KEYS)) { + textCodepoints = ';' + ev.key.charCodeAt(0); + } + } else { + // Functional key - map to Kitty key codes + keyCode = getKittyFunctionalKeyCode(ev.key, ev.code); + } + + // Build the escape sequence + let sequence = `${C0.ESC}[${keyCode}`; + + if (alternateKeys) { + sequence += alternateKeys; + } + + // Add modifiers if present or if event type is not press + if (reportModifiers > 1 || eventType !== 1) { + sequence += `;${reportModifiers}`; + if (eventType !== 1) { + sequence += `:${eventType}`; + } + } + + // Add text codepoints if present + if (textCodepoints) { + sequence += textCodepoints; + } + + sequence += 'u'; + return sequence; +} + +/** + * Determines if a key event should use Kitty keyboard protocol + */ +function shouldUseKittyKeyboard(ev: IKeyboardEvent, flags: number): boolean { + // Always use Kitty protocol if REPORT_ALL_KEYS is set + if (flags & KITTY_FLAG_REPORT_ALL_KEYS) { + return true; + } + + // Use Kitty protocol for disambiguation if flag is set and key needs disambiguation + if (flags & KITTY_FLAG_DISAMBIGUATE) { + // Keys that need disambiguation + if (ev.key === 'Escape' || + (ev.altKey && ev.key.length === 1) || + (ev.ctrlKey && ev.key.length === 1)) { + return true; + } + } + + // Use for functional keys that have Kitty codes + if (ev.key.length > 1) { + const keyCode = getKittyFunctionalKeyCode(ev.key, ev.code); + if (keyCode >= 57344) { // Private Use Area keys + return true; + } + } + + return false; +} + +/** + * Maps DOM key/code values to Kitty functional key codes + */ +function getKittyFunctionalKeyCode(key: string, code: string): number { + // Check direct mapping first + if (KITTY_FUNCTIONAL_KEYS[key]) { + return KITTY_FUNCTIONAL_KEYS[key]; + } + + // Handle special cases + switch (key) { + case 'Escape': return 27; + case 'Enter': return 13; + case 'Tab': return 9; + case 'Backspace': return 127; + case 'Insert': return 2; // Will use legacy ~ form + case 'Delete': return 3; // Will use legacy ~ form + case 'ArrowLeft': return 1; // Will use legacy D form + case 'ArrowRight': return 1; // Will use legacy C form + case 'ArrowUp': return 1; // Will use legacy A form + case 'ArrowDown': return 1; // Will use legacy B form + case 'PageUp': return 5; // Will use legacy ~ form + case 'PageDown': return 6; // Will use legacy ~ form + case 'Home': return 1; // Will use legacy H form + case 'End': return 1; // Will use legacy F form + case 'F1': return 1; // Will use legacy P form + case 'F2': return 1; // Will use legacy Q form + case 'F3': return 13; // Will use legacy ~ form + case 'F4': return 1; // Will use legacy S form + case 'F5': return 15; // Will use legacy ~ form + case 'F6': return 17; // Will use legacy ~ form + case 'F7': return 18; // Will use legacy ~ form + case 'F8': return 19; // Will use legacy ~ form + case 'F9': return 20; // Will use legacy ~ form + case 'F10': return 21; // Will use legacy ~ form + case 'F11': return 23; // Will use legacy ~ form + case 'F12': return 24; // Will use legacy ~ form + default: + // Try to match by code for keypad keys + if (code.startsWith('Numpad')) { + const numpadKey = 'Numpad' + code.slice(6); + return KITTY_FUNCTIONAL_KEYS[numpadKey] || 0; + } + return 0; // Unknown key + } +} + export function evaluateKeyboardEvent( ev: IKeyboardEvent, applicationCursorMode: boolean, isMac: boolean, - macOptionIsMeta: boolean + macOptionIsMeta: boolean, + kittyKeyboardFlags: number = 0 ): IKeyboardResult { const result: IKeyboardResult = { type: KeyboardResultType.SEND_KEY, @@ -49,6 +242,17 @@ export function evaluateKeyboardEvent( // The new key even to emit key: undefined }; + + // Handle Kitty keyboard protocol if enabled + if (kittyKeyboardFlags > 0) { + const shouldUseKittyProtocol = shouldUseKittyKeyboard(ev, kittyKeyboardFlags); + if (shouldUseKittyProtocol) { + result.key = encodeKittyKeyboardEvent(ev, kittyKeyboardFlags, 1); // 1 = press event + result.cancel = true; + return result; + } + } + const modifiers = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0) | (ev.metaKey ? 8 : 0); switch (ev.keyCode) { case 0: diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 5bee635697..b064daae03 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -19,6 +19,7 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ bracketedPasteMode: false, cursorBlink: undefined, cursorStyle: undefined, + kittyKeyboardFlags: 0, origin: false, reverseWraparound: false, sendFocus: false,