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 01/18] 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 abc363ed1..32bd1b13b 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 f816e1141..a4fa8a4db 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 ef9140e49..6ba726923 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 0ccb1483f..88a466b7e 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 000000000..ff9f346d0 --- /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 000000000..eb6490069 --- /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 7b5f532d3..3bb64c53f 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 3febd9073..fd595670a 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; From 3fbaef06efbb392701db693deeecaa699bf71679 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 06:53:12 -0800 Subject: [PATCH 02/18] Align behavior closer with kitty --- src/browser/CoreBrowserTerminal.ts | 3 ++ src/common/InputHandler.ts | 2 + src/common/input/KittyKeyboard.test.ts | 51 +++++++++++++++++++++++++- src/common/input/KittyKeyboard.ts | 45 +++++++++++------------ src/headless/Terminal.ts | 4 ++ 5 files changed, 80 insertions(+), 25 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 32bd1b13b..a2db348e8 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -610,6 +610,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Listen for mouse events and translate // them into terminal mouse protocols. this.bindMouse(); + + // Emit kitty keyboard protocol support notification (31 = all flags supported) + this.coreService.triggerDataEvent(`${C0.ESC}[>31u`); } private _createRenderer(): IRenderer { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index a4fa8a4db..ade124c2e 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2012,6 +2012,8 @@ export class InputHandler extends Disposable implements IInputHandler { this._coreService.isCursorInitialized = true; this._onRequestRefreshRows.fire(undefined); this._onRequestSyncScrollBar.fire(); + // Emit kitty keyboard protocol support notification + this._coreService.triggerDataEvent(`${C0.ESC}[>31u`); break; case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = true; diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index ff9f346d0..981c47fdb 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -98,12 +98,12 @@ describe('KittyKeyboard', () => { it('should include event type for repeat events', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT); - assert.strictEqual(result.key, '\x1b[97;:2u'); + assert.strictEqual(result.key, '\x1b[97;1: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'); + assert.strictEqual(result.key, '\x1b[97;1:3u'); }); it('should include modifiers and event type', () => { @@ -157,6 +157,12 @@ describe('KittyKeyboard', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'Control', code: 'ControlRight', ctrlKey: true }), flags); assert.strictEqual(result.key, '\x1b[57448;5u'); }); + + it('should not report modifier-only keys without REPORT_EVENT_TYPES', () => { + const flagsNoEventTypes = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flagsNoEventTypes); + assert.strictEqual(result.key, undefined); + }); }); describe('release events without REPORT_EVENT_TYPES', () => { @@ -167,5 +173,46 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, undefined); }); }); + + describe('with REPORT_ASSOCIATED_TEXT flag', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; + + it('should include text codepoint for regular keys', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags); + assert.strictEqual(result.key, '\x1b[97;;97u'); + }); + + it('should include text codepoint even when same as keycode', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'b' }), flags); + assert.strictEqual(result.key, '\x1b[98;;98u'); + }); + + it('should include modifier and text codepoint together', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;2;97u'); + }); + + it('should include text on repeat events', () => { + const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flagsWithEvents, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[97;;97u'); + }); + + it('should not include text on release events', () => { + const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flagsWithEvents, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97;1:3u'); + }); + + it('should not include text for functional keys', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('should not include text for modifier keys', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57441;2u'); + }); + }); }); }); diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index eb6490069..0cb4d459f 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -268,16 +268,12 @@ export function evaluateKeyboardEventKitty( // 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) { + // Modifier-only keys are never reported without REPORT_EVENT_TYPES + if (isMod) { + return result; + } + // Use CSI u for keys that would be ambiguous in legacy encoding + if (keyCode === 27 || keyCode === 127 || keyCode === 13) { // Escape, Backspace, Enter useCsiU = true; } else if (keyCode === 9 && ev.shiftKey) { @@ -303,27 +299,30 @@ export function evaluateKeyboardEventKitty( // Format: CSI [:][:] ; [:] u let seq = C0.ESC + '[' + keyCode; - // Add modifiers and event type - if (modifiers > 0 || (reportEventTypes && eventType !== KittyKeyboardEventType.PRESS)) { + // Check if we need associated text (press and repeat events, not release) + const reportAssociatedText = !!(flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) && + eventType !== KittyKeyboardEventType.RELEASE && ev.key.length === 1 && !isFunc && !isMod; + const textCode = reportAssociatedText ? ev.key.codePointAt(0) : undefined; + + // When text is present, don't include event type marker (even for repeat) + // Release events always need event type marker + const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS && textCode === undefined; + if (modifiers > 0 || needsEventType || textCode !== undefined) { seq += ';'; + // Use 1 as base when event type needed but no modifiers (kitty format: 1 + modifier_bits) if (modifiers > 0) { seq += modifiers; + } else if (needsEventType) { + seq += '1'; } - if (reportEventTypes && eventType !== KittyKeyboardEventType.PRESS) { + if (needsEventType) { 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; - } + // Add associated text if requested + if (textCode !== undefined) { + seq += ';' + textCode; } seq += 'u'; diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index 1425699e2..41aaf4b66 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -24,6 +24,7 @@ import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { CoreTerminal } from 'common/CoreTerminal'; +import { C0 } from 'common/data/EscapeSequences'; import { IMarker, ITerminalOptions } from 'common/Types'; import { Emitter, Event } from 'vs/base/common/event'; @@ -54,6 +55,9 @@ export class Terminal extends CoreTerminal { this._register(Event.forward(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); this._register(Event.forward(this._inputHandler.onA11yTab, this._onA11yTabEmitter)); this._register(Event.forward(Event.map(this._inputHandler.onRequestRefreshRows, e => ({ start: e?.start ?? 0, end: e?.end ?? this.rows - 1 })), this._onRender)); + + // Emit kitty keyboard protocol support notification (31 = all flags supported) + this.coreService.triggerDataEvent(`${C0.ESC}[>31u`); } /** From 992da3cc4d77c1f450c665cab4a7911a2064acbc Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 07:07:10 -0800 Subject: [PATCH 03/18] Hook up vt extensions option --- bin/lint_changes.js | 2 +- src/browser/CoreBrowserTerminal.ts | 6 ++++-- src/common/InputHandler.ts | 12 +++++++++++ src/common/services/OptionsService.ts | 3 ++- src/common/services/Services.ts | 5 +++++ typings/xterm-headless.d.ts | 17 +++++++++++++++ typings/xterm.d.ts | 31 +++++++++++++++++++++------ 7 files changed, 66 insertions(+), 10 deletions(-) diff --git a/bin/lint_changes.js b/bin/lint_changes.js index a8edb44c1..6ed7ae5fa 100644 --- a/bin/lint_changes.js +++ b/bin/lint_changes.js @@ -33,7 +33,7 @@ if (files.length === 0) { console.log(`Linting ${files.length} changed file(s)...`); -const eslintArgs = ['--max-warnings', '0']; +const eslintArgs = ['--max-warnings', '0', '--no-warn-ignored']; if (fix) { eslintArgs.push('--fix'); } diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index a2db348e8..554e8865e 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1087,7 +1087,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Use Kitty keyboard protocol if enabled, otherwise use legacy encoding const kittyFlags = this.coreService.kittyKeyboard.flags; - const result = shouldUseKittyProtocol(kittyFlags) + const useKitty = this.options.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags); + const result = useKitty ? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS) : evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); @@ -1178,7 +1179,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Handle key release for Kitty keyboard protocol const kittyFlags = this.coreService.kittyKeyboard.flags; - if (shouldUseKittyProtocol(kittyFlags) && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag + const useKitty = this.options.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags); + if (useKitty && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag const result = evaluateKeyboardEventKitty(ev, kittyFlags, KittyKeyboardEventType.RELEASE); if (result.key) { this.coreService.triggerDataEvent(result.key, true); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index ade124c2e..4d37898ff 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2992,6 +2992,9 @@ export class InputHandler extends Disposable implements IInputHandler { * @vt: #Y CSI KKBDSET "Kitty Keyboard Set" "CSI = Ps ; Pm u" "Set Kitty keyboard protocol flags." */ public kittyKeyboardSet(params: IParams): boolean { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } const flags = params.params[0] || 0; const mode = params.params[1] || 1; const state = this._coreService.kittyKeyboard; @@ -3018,6 +3021,9 @@ export class InputHandler extends Disposable implements IInputHandler { * @vt: #Y CSI KKBDQUERY "Kitty Keyboard Query" "CSI ? u" "Query Kitty keyboard protocol flags." */ public kittyKeyboardQuery(params: IParams): boolean { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } const flags = this._coreService.kittyKeyboard.flags; this._coreService.triggerDataEvent(`${C0.ESC}[?${flags}u`); return true; @@ -3030,6 +3036,9 @@ export class InputHandler extends Disposable implements IInputHandler { * @vt: #Y CSI KKBDPUSH "Kitty Keyboard Push" "CSI > Ps u" "Push keyboard flags to stack and set new flags." */ public kittyKeyboardPush(params: IParams): boolean { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } const flags = params.params[0] || 0; const state = this._coreService.kittyKeyboard; const isAlt = this._bufferService.buffer === this._bufferService.buffers.alt; @@ -3048,6 +3057,9 @@ export class InputHandler extends Disposable implements IInputHandler { * @vt: #Y CSI KKBDPOP "Kitty Keyboard Pop" "CSI < Ps u" "Pop keyboard flags from stack." */ public kittyKeyboardPop(params: IParams): boolean { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } const count = Math.max(1, params.params[0] || 1); const state = this._coreService.kittyKeyboard; const isAlt = this._bufferService.buffer === this._bufferService.buffers.alt; diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 54c3db23a..f08f61f89 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -54,7 +54,8 @@ export const DEFAULT_OPTIONS: Readonly> = { termName: 'xterm', cancelEvents: false, overviewRuler: {}, - quirks: {} + quirks: {}, + vtExtensions: {} }; const FONT_WEIGHT_OPTIONS: Extract[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index fd595670a..c3f69fb46 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -266,6 +266,7 @@ export interface ITerminalOptions { overviewRuler?: IOverviewRulerOptions; quirks?: ITerminalQuirks; scrollOnEraseInDisplay?: boolean; + vtExtensions?: IVtExtensions; [key: string]: any; cancelEvents: boolean; @@ -307,6 +308,10 @@ export interface ITerminalQuirks { allowSetCursorBlink?: boolean; } +export interface IVtExtensions { + kittyKeyboard?: boolean; +} + export const IOscLinkService = createDecorator('OscLinkService'); export interface IOscLinkService { serviceBrand: undefined; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index abc28d081..7d2277d75 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -231,6 +231,11 @@ declare module '@xterm/headless' { * All features are disabled by default for security reasons. */ windowOptions?: IWindowOptions; + + /** + * Enable various VT extensions. All extensions are disabled by default. + */ + vtExtensions?: IVtExtensions; } /** @@ -313,6 +318,18 @@ declare module '@xterm/headless' { buildNumber?: number; } + /** + * Enable VT extensions that are not part of the core VT specification. + */ + export interface IVtExtensions { + /** + * Whether the kitty keyboard protocol is enabled. When enabled, the + * terminal will respond to keyboard protocol queries and allow programs to + * enable enhanced keyboard reporting. The default is false. + */ + kittyKeyboard?: boolean; + } + /** * A replacement logger for `console`. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index fb74491cb..86aeeb258 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -199,6 +199,12 @@ declare module '@xterm/xterm' { */ minimumContrastRatio?: number; + /** + * Controls the visibility and style of the overview ruler which visualizes + * decorations underneath the scroll bar. + */ + overviewRuler?: IOverviewRulerOptions; + /** * Control various quirks features that are either non-standard or standard * in but generally rejected in modern terminals. @@ -284,6 +290,11 @@ declare module '@xterm/xterm' { */ theme?: ITheme; + /** + * Enable various VT extensions. All extensions are disabled by default. + */ + vtExtensions?: IVtExtensions; + /** * Compatibility information when the pty is known to be hosted on Windows. * Setting this will turn on certain heuristics/workarounds depending on the @@ -313,12 +324,6 @@ declare module '@xterm/xterm' { * All features are disabled by default for security reasons. */ windowOptions?: IWindowOptions; - - /** - * Controls the visibility and style of the overview ruler which visualizes - * decorations underneath the scroll bar. - */ - overviewRuler?: IOverviewRulerOptions; } /** @@ -430,6 +435,20 @@ declare module '@xterm/xterm' { allowSetCursorBlink?: boolean; } + /** + * Enable certain optional VT extensions. + */ + export interface IVtExtensions { + /** + * Whether the [kitty keyboard protocol][0] (`CSI u`) is enabled. When + * enabled, the terminal will respond to keyboard protocol queries and allow + * programs to enable enhanced keyboard reporting. The default is false. + * + * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + */ + kittyKeyboard?: boolean; + } + /** * Pty information for Windows. */ From 8dbc91dc24895393f7aa32b0de9766435d96cee2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 07:15:45 -0800 Subject: [PATCH 04/18] Add vtExtensions to demo --- demo/client/components/window/optionsWindow.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index d084784fd..a84d7f75a 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -119,9 +119,13 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'overviewRuler', 'quirks', 'theme', + 'vtExtensions', 'windowOptions', 'windowsPty', ]; + const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [ + { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' } + ]; const stringOptions: { [key: string]: string[] | null } = { cursorStyle: ['block', 'underline', 'bar'], cursorInactiveStyle: ['outline', 'block', 'bar', 'underline', 'none'], @@ -156,6 +160,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { booleanOptions.forEach(o => { html += `
`; }); + nestedBooleanOptions.forEach(({ label, parent, prop }) => { + const checked = this._terminal.options[parent]?.[prop] ?? false; + html += `
`; + }); html += '
'; numberOptions.forEach(o => { html += `
`; @@ -187,6 +195,13 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { } }); }); + nestedBooleanOptions.forEach(({ label, parent, prop }) => { + const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement; + addDomListener(input, 'change', () => { + console.log('change', label, input.checked); + this._terminal.options[parent] = { ...this._terminal.options[parent], [prop]: input.checked }; + }); + }); numberOptions.forEach(o => { const input = document.getElementById(`opt-${o}`) as HTMLInputElement; addDomListener(input, 'change', () => { From 566589f7c16dd4318930d8de415cc858fe4dc9a0 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 07:23:12 -0800 Subject: [PATCH 05/18] More comprehensive tests --- src/common/input/KittyKeyboard.test.ts | 731 +++++++++++++++++++++---- 1 file changed, 631 insertions(+), 100 deletions(-) diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index 981c47fdb..97c00c49c 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -30,138 +30,632 @@ describe('KittyKeyboard', () => { }); describe('evaluateKeyboardEventKitty', () => { - describe('with DISAMBIGUATE_ESCAPE_CODES flag', () => { + describe('modifier encoding (value = 1 + modifiers)', () => { 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('shift=2 (1+1)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;2u'); }); - it('should encode Enter as CSI 13 u', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter' }), flags); - assert.strictEqual(result.key, '\x1b[13u'); + it('alt=3 (1+2)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;3u'); }); - 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', () => { + it('ctrl=5 (1+4)', () => { 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('super/meta=9 (1+8)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', metaKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;9u'); }); - 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'); + it('ctrl+shift=6 (1+4+1)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;6u'); + }); + + it('ctrl+alt=7 (1+4+2)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;7u'); + }); + + it('ctrl+alt+shift=8 (1+4+2+1)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, altKey: true, shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;8u'); + }); + + it('ctrl+super=13 (1+4+8)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, metaKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;13u'); + }); + + it('all four modifiers=16 (1+1+2+4+8)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', shiftKey: true, altKey: true, ctrlKey: true, metaKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;16u'); + }); + + it('no modifiers omits modifier field', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); + assert.strictEqual(result.key, '\x1b[27u'); }); }); - describe('with REPORT_EVENT_TYPES flag', () => { + describe('C0 control keys with DISAMBIGUATE_ESCAPE_CODES', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('Escape → CSI 27 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('Enter → CSI 13 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter' }), flags); + assert.strictEqual(result.key, '\x1b[13u'); + }); + + it('Tab → CSI 9 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Tab' }), flags); + assert.strictEqual(result.key, '\x1b[9u'); + }); + + it('Backspace → CSI 127 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Backspace' }), flags); + assert.strictEqual(result.key, '\x1b[127u'); + }); + + it('Space → CSI 32 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: ' ' }), flags); + assert.strictEqual(result.key, '\x1b[32u'); + }); + + it('Shift+Tab → CSI 9;2 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Tab', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[9;2u'); + }); + + it('Ctrl+Enter → CSI 13;5 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[13;5u'); + }); + + it('Alt+Escape → CSI 27;3 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape', altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[27;3u'); + }); + }); + + describe('navigation keys', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('Insert → CSI 2 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Insert' }), flags); + assert.strictEqual(result.key, '\x1b[2~'); + }); + + it('Delete → CSI 3 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Delete' }), flags); + assert.strictEqual(result.key, '\x1b[3~'); + }); + + it('PageUp → CSI 5 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'PageUp' }), flags); + assert.strictEqual(result.key, '\x1b[5~'); + }); + + it('PageDown → CSI 6 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'PageDown' }), flags); + assert.strictEqual(result.key, '\x1b[6~'); + }); + + it('Home → CSI H', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Home' }), flags); + assert.strictEqual(result.key, '\x1b[H'); + }); + + it('End → CSI F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'End' }), flags); + assert.strictEqual(result.key, '\x1b[F'); + }); + + it('Shift+PageUp → CSI 5;2 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'PageUp', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[5;2~'); + }); + + it('Ctrl+Home → CSI 1;5 H', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Home', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[1;5H'); + }); + }); + + describe('arrow keys', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('ArrowUp → CSI A', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowUp' }), flags); + assert.strictEqual(result.key, '\x1b[A'); + }); + + it('ArrowDown → CSI B', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowDown' }), flags); + assert.strictEqual(result.key, '\x1b[B'); + }); + + it('ArrowRight → CSI C', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowRight' }), flags); + assert.strictEqual(result.key, '\x1b[C'); + }); + + it('ArrowLeft → CSI D', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowLeft' }), flags); + assert.strictEqual(result.key, '\x1b[D'); + }); + + it('Shift+ArrowUp → CSI 1;2 A', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowUp', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[1;2A'); + }); + + it('Ctrl+ArrowLeft → CSI 1;5 D', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowLeft', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[1;5D'); + }); + + it('Ctrl+Shift+ArrowRight → CSI 1;6 C', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ArrowRight', ctrlKey: true, shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[1;6C'); + }); + }); + + describe('function keys F1-F12', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('F1 → CSI P (SS3 form)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F1' }), flags); + assert.strictEqual(result.key, '\x1bOP'); + }); + + it('F2 → CSI Q (SS3 form)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F2' }), flags); + assert.strictEqual(result.key, '\x1bOQ'); + }); + + it('F3 → CSI R (SS3 form)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F3' }), flags); + assert.strictEqual(result.key, '\x1bOR'); + }); + + it('F4 → CSI S (SS3 form)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F4' }), flags); + assert.strictEqual(result.key, '\x1bOS'); + }); + + it('F5 → CSI 15 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F5' }), flags); + assert.strictEqual(result.key, '\x1b[15~'); + }); + + it('F6 → CSI 17 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F6' }), flags); + assert.strictEqual(result.key, '\x1b[17~'); + }); + + it('F7 → CSI 18 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F7' }), flags); + assert.strictEqual(result.key, '\x1b[18~'); + }); + + it('F8 → CSI 19 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F8' }), flags); + assert.strictEqual(result.key, '\x1b[19~'); + }); + + it('F9 → CSI 20 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F9' }), flags); + assert.strictEqual(result.key, '\x1b[20~'); + }); + + it('F10 → CSI 21 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F10' }), flags); + assert.strictEqual(result.key, '\x1b[21~'); + }); + + it('F11 → CSI 23 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F11' }), flags); + assert.strictEqual(result.key, '\x1b[23~'); + }); + + it('F12 → CSI 24 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F12' }), flags); + assert.strictEqual(result.key, '\x1b[24~'); + }); + + it('Shift+F1 → CSI 1;2 P', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F1', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[1;2P'); + }); + + it('Ctrl+F5 → CSI 15;5 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F5', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[15;5~'); + }); + }); + + describe('extended function keys F13-F35 (Private Use Area)', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('F13 → CSI 57376 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F13' }), flags); + assert.strictEqual(result.key, '\x1b[57376u'); + }); + + it('F14 → CSI 57377 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F14' }), flags); + assert.strictEqual(result.key, '\x1b[57377u'); + }); + + it('F20 → CSI 57383 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F20' }), flags); + assert.strictEqual(result.key, '\x1b[57383u'); + }); + + it('F24 → CSI 57387 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'F24' }), flags); + assert.strictEqual(result.key, '\x1b[57387u'); + }); + }); + + describe('numpad keys (Private Use Area)', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('Numpad0 → CSI 57399 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '0', code: 'Numpad0' }), flags); + assert.strictEqual(result.key, '\x1b[57399u'); + }); + + it('Numpad1 → CSI 57400 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '1', code: 'Numpad1' }), flags); + assert.strictEqual(result.key, '\x1b[57400u'); + }); + + it('Numpad9 → CSI 57408 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '9', code: 'Numpad9' }), flags); + assert.strictEqual(result.key, '\x1b[57408u'); + }); + + it('NumpadDecimal → CSI 57409 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '.', code: 'NumpadDecimal' }), flags); + assert.strictEqual(result.key, '\x1b[57409u'); + }); + + it('NumpadDivide → CSI 57410 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '/', code: 'NumpadDivide' }), flags); + assert.strictEqual(result.key, '\x1b[57410u'); + }); + + it('NumpadMultiply → CSI 57411 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '*', code: 'NumpadMultiply' }), flags); + assert.strictEqual(result.key, '\x1b[57411u'); + }); + + it('NumpadSubtract → CSI 57412 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '-', code: 'NumpadSubtract' }), flags); + assert.strictEqual(result.key, '\x1b[57412u'); + }); + + it('NumpadAdd → CSI 57413 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '+', code: 'NumpadAdd' }), flags); + assert.strictEqual(result.key, '\x1b[57413u'); + }); + + it('NumpadEnter → CSI 57414 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter', code: 'NumpadEnter' }), flags); + assert.strictEqual(result.key, '\x1b[57414u'); + }); + + it('NumpadEqual → CSI 57415 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '=', code: 'NumpadEqual' }), flags); + assert.strictEqual(result.key, '\x1b[57415u'); + }); + + it('Ctrl+Numpad5 → CSI 57404;5 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '5', code: 'Numpad5', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57404;5u'); + }); + }); + + describe('modifier keys (Private Use Area)', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES; + + it('Left Shift → CSI 57441 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57441;2u'); + }); + + it('Right Shift → CSI 57447 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftRight', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57447;2u'); + }); + + it('Left Control → CSI 57442 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Control', code: 'ControlLeft', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57442;5u'); + }); + + it('Right Control → CSI 57448 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Control', code: 'ControlRight', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57448;5u'); + }); + + it('Left Alt → CSI 57443 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Alt', code: 'AltLeft', altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57443;3u'); + }); + + it('Right Alt → CSI 57449 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Alt', code: 'AltRight', altKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57449;3u'); + }); + + it('Left Meta/Super → CSI 57444 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Meta', code: 'MetaLeft', metaKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57444;9u'); + }); + + it('Right Meta/Super → CSI 57450 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Meta', code: 'MetaRight', metaKey: true }), flags); + assert.strictEqual(result.key, '\x1b[57450;9u'); + }); + + it('CapsLock → CSI 57358 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'CapsLock', code: 'CapsLock' }), flags); + assert.strictEqual(result.key, '\x1b[57358u'); + }); + + it('NumLock → CSI 57360 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'NumLock', code: 'NumLock' }), flags); + assert.strictEqual(result.key, '\x1b[57360u'); + }); + + it('ScrollLock → CSI 57359 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ScrollLock', code: 'ScrollLock' }), flags); + assert.strictEqual(result.key, '\x1b[57359u'); + }); + }); + + describe('event types (press/repeat/release)', () => { const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; - it('should not include event type for press events', () => { + it('press event (default, no suffix)', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS); assert.strictEqual(result.key, '\x1b[97u'); }); - it('should include event type for repeat events', () => { + it('press event explicit :1 when modifiers present', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[97;5u'); + }); + + it('repeat event → :2 suffix', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT); assert.strictEqual(result.key, '\x1b[97;1:2u'); }); - it('should include event type for release events', () => { + it('release event → :3 suffix', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[97;1:3u'); }); - it('should include modifiers and event type', () => { + it('release with modifier → mod:3', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[97;5:3u'); }); + + it('repeat with modifier → mod:2', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', shiftKey: true, altKey: true }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[97;4:2u'); + }); + + it('functional key release → CSI code;1:3 ~', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Delete' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[3;1:3~'); + }); + + it('modifier key release includes its own bit cleared', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[57441;1:3u'); + }); }); - describe('with REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => { + describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => { const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES; - it('should encode regular letters as CSI u', () => { + it('lowercase letter → CSI codepoint 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('uppercase letter uses lowercase codepoint', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;2u'); }); - it('should encode space as CSI 32 u', () => { + it('digit → CSI codepoint u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '5' }), flags); + assert.strictEqual(result.key, '\x1b[53u'); + }); + + it('punctuation → CSI codepoint u', () => { + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: '.' }), flags).key, '\x1b[46u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: ',' }), flags).key, '\x1b[44u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: ';' }), flags).key, '\x1b[59u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: '/' }), flags).key, '\x1b[47u'); + }); + + it('brackets → CSI codepoint u', () => { + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: '[' }), flags).key, '\x1b[91u'); + assert.strictEqual(evaluateKeyboardEventKitty(createEvent({ key: ']' }), flags).key, '\x1b[93u'); + }); + + it('space → CSI 32 u', () => { const result = evaluateKeyboardEventKitty(createEvent({ key: ' ' }), flags); assert.strictEqual(result.key, '\x1b[32u'); }); }); - describe('numpad keys', () => { - const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + describe('REPORT_ASSOCIATED_TEXT flag', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; - it('should encode numpad digits with Kitty codepoints', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '0', code: 'Numpad0' }), flags); - assert.strictEqual(result.key, '\x1b[57399u'); + it('regular key includes text codepoint', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags); + assert.strictEqual(result.key, '\x1b[97;;97u'); }); - it('should encode numpad enter', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'Enter', code: 'NumpadEnter' }), flags); - assert.strictEqual(result.key, '\x1b[57414u'); + it('shifted key includes shifted text', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;2;65u'); + }); + + it('Ctrl+key omits text (control code)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;5u'); + }); + + it('functional key has no text', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('release event has no text', () => { + const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flagsWithEvents, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97;1:3u'); + }); + + it('digit with text', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '5' }), flags); + assert.strictEqual(result.key, '\x1b[53;;53u'); + }); + + it('Shift+digit shows shifted symbol', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '%', shiftKey: true, code: 'Digit5' }), flags); + assert.strictEqual(result.key, '\x1b[53;2;37u'); }); }); - 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'); + describe('legacy ctrl mapping', () => { + it('ctrl+a → 0x01', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x01'); }); - 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'); + it('ctrl+b → 0x02', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'b', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x02'); }); - it('should not report modifier-only keys without REPORT_EVENT_TYPES', () => { - const flagsNoEventTypes = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; - const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flagsNoEventTypes); - assert.strictEqual(result.key, undefined); + it('ctrl+c → 0x03', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'c', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x03'); + }); + + it('ctrl+z → 0x1A', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'z', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1a'); + }); + + it('ctrl+space → 0x00', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: ' ', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x00'); + }); + + it('ctrl+[ → 0x1B (ESC)', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '[', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1b'); + }); + + it('ctrl+\\ → 0x1C', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '\\', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1c'); + }); + + it('ctrl+] → 0x1D', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: ']', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1d'); + }); + + it('ctrl+^ → 0x1E', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '^', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1e'); + }); + + it('ctrl+_ → 0x1F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '_', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1f'); + }); + + it('ctrl+2 → 0x00', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '2', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x00'); + }); + + it('ctrl+3 → 0x1B', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '3', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1b'); + }); + + it('ctrl+4 → 0x1C', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '4', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1c'); + }); + + it('ctrl+5 → 0x1D', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '5', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1d'); + }); + + it('ctrl+6 → 0x1E', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '6', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1e'); + }); + + it('ctrl+7 → 0x1F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '7', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1f'); + }); + + it('ctrl+8 → 0x7F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '8', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x7f'); + }); + + it('ctrl+/ → 0x1F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '/', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x1f'); + }); + + it('ctrl+? → 0x7F', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '?', ctrlKey: true }), 0); + assert.strictEqual(result.key, '\x7f'); + }); + + it('alt+a → ESC a', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', altKey: true }), 0); + assert.strictEqual(result.key, '\x1ba'); + }); + + it('alt+A → ESC A', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', altKey: true, shiftKey: true }), 0); + assert.strictEqual(result.key, '\x1bA'); + }); + + it('ctrl+alt+a → ESC ctrl+a', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, altKey: true }), 0); + assert.strictEqual(result.key, '\x1b\x01'); }); }); @@ -174,44 +668,81 @@ describe('KittyKeyboard', () => { }); }); - describe('with REPORT_ASSOCIATED_TEXT flag', () => { - const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; + describe('edge cases', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; - it('should include text codepoint for regular keys', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flags); - assert.strictEqual(result.key, '\x1b[97;;97u'); + it('always uses lowercase codepoint for letters', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;2u'); }); - it('should include text codepoint even when same as keycode', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'b' }), flags); - assert.strictEqual(result.key, '\x1b[98;;98u'); + it('ctrl+shift+a sends lowercase codepoint 97', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', ctrlKey: true, shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[97;6u'); }); - it('should include modifier and text codepoint together', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', shiftKey: true }), flags); - assert.strictEqual(result.key, '\x1b[97;2;97u'); + it('Dead key produces no output', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Dead' }), flags); + assert.strictEqual(result.key, undefined); }); - it('should include text on repeat events', () => { - const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flagsWithEvents, KittyKeyboardEventType.REPEAT); - assert.strictEqual(result.key, '\x1b[97;;97u'); + it('Unidentified key produces no output', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Unidentified' }), flags); + assert.strictEqual(result.key, undefined); }); - it('should not include text on release events', () => { - const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a' }), flagsWithEvents, KittyKeyboardEventType.RELEASE); - assert.strictEqual(result.key, '\x1b[97;1:3u'); + it('PrintScreen → CSI 57361 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'PrintScreen' }), flags); + assert.strictEqual(result.key, '\x1b[57361u'); }); - it('should not include text for functional keys', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape' }), flags); - assert.strictEqual(result.key, '\x1b[27u'); + it('Pause → CSI 57362 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Pause' }), flags); + assert.strictEqual(result.key, '\x1b[57362u'); }); - it('should not include text for modifier keys', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); - assert.strictEqual(result.key, '\x1b[57441;2u'); + it('ContextMenu → CSI 57363 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'ContextMenu' }), flags); + assert.strictEqual(result.key, '\x1b[57363u'); + }); + }); + + describe('media keys (Private Use Area)', () => { + const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES; + + it('MediaPlayPause → CSI 57430 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'MediaPlayPause' }), flags); + assert.strictEqual(result.key, '\x1b[57430u'); + }); + + it('MediaStop → CSI 57432 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'MediaStop' }), flags); + assert.strictEqual(result.key, '\x1b[57432u'); + }); + + it('MediaTrackNext → CSI 57435 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'MediaTrackNext' }), flags); + assert.strictEqual(result.key, '\x1b[57435u'); + }); + + it('MediaTrackPrevious → CSI 57436 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'MediaTrackPrevious' }), flags); + assert.strictEqual(result.key, '\x1b[57436u'); + }); + + it('AudioVolumeDown → CSI 57438 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'AudioVolumeDown' }), flags); + assert.strictEqual(result.key, '\x1b[57438u'); + }); + + it('AudioVolumeUp → CSI 57439 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'AudioVolumeUp' }), flags); + assert.strictEqual(result.key, '\x1b[57439u'); + }); + + it('AudioVolumeMute → CSI 57440 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'AudioVolumeMute' }), flags); + assert.strictEqual(result.key, '\x1b[57440u'); }); }); }); From 3adf6be9887907e7d68914acc8c342cc53c3d371 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 10:36:13 -0800 Subject: [PATCH 06/18] Fix some edge cases --- src/browser/CoreBrowserTerminal.ts | 5 +- src/common/input/KittyKeyboard.test.ts | 129 +++------- src/common/input/KittyKeyboard.ts | 326 +++++++++++++++++++------ 3 files changed, 277 insertions(+), 183 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 554e8865e..7f9b95652 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -1118,8 +1118,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } // HACK: Process A-Z in the keypress event to fix an issue with macOS IMEs where lower case - // letters cannot be input while caps lock is on. - if (event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) { + // letters cannot be input while caps lock is on. Skip this hack when using kitty protocol + // as it needs to send proper CSI u sequences for all key events. + if (!useKitty && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) { if (event.key.charCodeAt(0) >= 65 && event.key.charCodeAt(0) <= 90) { return true; } diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index 97c00c49c..3e068f467 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -547,115 +547,42 @@ describe('KittyKeyboard', () => { }); }); - describe('legacy ctrl mapping', () => { - it('ctrl+a → 0x01', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x01'); + describe('REPORT_ALTERNATE_KEYS flag', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ALTERNATE_KEYS; + + it('Shift+a includes shifted key → CSI 97:65 ; 2 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flags); + assert.strictEqual(result.key, '\x1b[97:65;2u'); }); - it('ctrl+b → 0x02', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'b', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x02'); + it('unshifted key has no alternate', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', code: 'KeyA' }), flags); + assert.strictEqual(result.key, '\x1b[97u'); }); - it('ctrl+c → 0x03', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'c', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x03'); + it('Shift+5 includes shifted key → CSI 53:37 ; 2 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: '%', shiftKey: true, code: 'Digit5' }), flags); + assert.strictEqual(result.key, '\x1b[53:37;2u'); }); - it('ctrl+z → 0x1A', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'z', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1a'); + it('functional keys have no shifted alternate', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'Escape', shiftKey: true }), flags); + assert.strictEqual(result.key, '\x1b[27;2u'); + }); + }); + + describe('REPORT_ALTERNATE_KEYS with REPORT_ASSOCIATED_TEXT', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ALTERNATE_KEYS | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; + + it('Shift+a → CSI 97:65 ; 2 ; 65 u', () => { + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flags); + assert.strictEqual(result.key, '\x1b[97:65;2;65u'); }); - it('ctrl+space → 0x00', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: ' ', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x00'); - }); - - it('ctrl+[ → 0x1B (ESC)', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '[', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1b'); - }); - - it('ctrl+\\ → 0x1C', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '\\', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1c'); - }); - - it('ctrl+] → 0x1D', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: ']', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1d'); - }); - - it('ctrl+^ → 0x1E', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '^', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1e'); - }); - - it('ctrl+_ → 0x1F', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '_', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1f'); - }); - - it('ctrl+2 → 0x00', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '2', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x00'); - }); - - it('ctrl+3 → 0x1B', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '3', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1b'); - }); - - it('ctrl+4 → 0x1C', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '4', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1c'); - }); - - it('ctrl+5 → 0x1D', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '5', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1d'); - }); - - it('ctrl+6 → 0x1E', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '6', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1e'); - }); - - it('ctrl+7 → 0x1F', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '7', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1f'); - }); - - it('ctrl+8 → 0x7F', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '8', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x7f'); - }); - - it('ctrl+/ → 0x1F', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '/', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x1f'); - }); - - it('ctrl+? → 0x7F', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: '?', ctrlKey: true }), 0); - assert.strictEqual(result.key, '\x7f'); - }); - - it('alt+a → ESC a', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', altKey: true }), 0); - assert.strictEqual(result.key, '\x1ba'); - }); - - it('alt+A → ESC A', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', altKey: true, shiftKey: true }), 0); - assert.strictEqual(result.key, '\x1bA'); - }); - - it('ctrl+alt+a → ESC ctrl+a', () => { - const result = evaluateKeyboardEventKitty(createEvent({ key: 'a', ctrlKey: true, altKey: true }), 0); - assert.strictEqual(result.key, '\x1b\x01'); + it('Shift+a release → CSI 97:65 ; 2:3 u (no text)', () => { + const flagsWithEvents = flags | KittyKeyboardFlags.REPORT_EVENT_TYPES; + const result = evaluateKeyboardEventKitty(createEvent({ key: 'A', shiftKey: true, code: 'KeyA' }), flagsWithEvents, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97:65;2:3u'); }); }); diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index 0cb4d459f..98cf3ded2 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -59,35 +59,13 @@ const FUNCTIONAL_KEY_CODES: { [key: string]: number } = { '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-F35 (F1-F12 use legacy encoding) 'F13': 57376, 'F14': 57377, 'F15': 57378, @@ -138,6 +116,46 @@ const FUNCTIONAL_KEY_CODES: { [key: string]: number } = { 'AudioVolumeMute': 57440 }; +/** + * Keys that use CSI ~ encoding with a number parameter. + */ +const CSI_TILDE_KEYS: { [key: string]: number } = { + 'Insert': 2, + 'Delete': 3, + 'PageUp': 5, + 'PageDown': 6, + 'F5': 15, + 'F6': 17, + 'F7': 18, + 'F8': 19, + 'F9': 20, + 'F10': 21, + 'F11': 23, + 'F12': 24 +}; + +/** + * Keys that use CSI letter encoding (arrows, Home, End). + */ +const CSI_LETTER_KEYS: { [key: string]: string } = { + 'ArrowUp': 'A', + 'ArrowDown': 'B', + 'ArrowRight': 'C', + 'ArrowLeft': 'D', + 'Home': 'H', + 'End': 'F' +}; + +/** + * Function keys F1-F4 use SS3 encoding without modifiers. + */ +const SS3_FUNCTION_KEYS: { [key: string]: string } = { + 'F1': 'P', + 'F2': 'Q', + 'F3': 'R', + 'F4': 'S' +}; + /** * Map browser key codes to Kitty numpad codes. */ @@ -194,6 +212,8 @@ function encodeModifiers(ev: IKeyboardEvent): number { /** * Get the unicode key code for a keyboard event. + * Returns the lowercase codepoint for letters. + * For shifted keys, uses the code property to get the base key. */ function getKeyCode(ev: IKeyboardEvent): number | undefined { // Check for numpad first @@ -214,9 +234,31 @@ function getKeyCode(ev: IKeyboardEvent): number | undefined { return funcCode; } + // For shifted keys, use code property to get base key + if (ev.shiftKey && ev.code) { + // Handle Digit0-Digit9 + if (ev.code.startsWith('Digit') && ev.code.length === 6) { + const digit = ev.code.charAt(5); + if (digit >= '0' && digit <= '9') { + return digit.charCodeAt(0); + } + } + // Handle KeyA-KeyZ + if (ev.code.startsWith('Key') && ev.code.length === 4) { + const letter = ev.code.charAt(3).toLowerCase(); + return letter.charCodeAt(0); + } + } + // For regular keys, use the key character's codepoint + // Always use lowercase for letters (per spec) if (ev.key.length === 1) { - return ev.key.codePointAt(0); + const code = ev.key.codePointAt(0)!; + // Convert uppercase A-Z to lowercase a-z + if (code >= 65 && code <= 90) { + return code + 32; + } + return code; } return undefined; @@ -248,36 +290,64 @@ export function evaluateKeyboardEventKitty( key: undefined }; - // Get the key code + const modifiers = encodeModifiers(ev); + const isMod = isModifierKey(ev); + const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES); + + // Don't report release events unless flag is set + if (!reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) { + return result; + } + + // Modifier-only keys require REPORT_ALL_KEYS_AS_ESCAPE_CODES or REPORT_EVENT_TYPES + if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) { + return result; + } + + // Check for CSI letter keys (arrows, Home, End) + const csiLetter = CSI_LETTER_KEYS[ev.key]; + if (csiLetter) { + result.key = buildCsiLetterSequence(csiLetter, modifiers, eventType, reportEventTypes); + result.cancel = true; + return result; + } + + // Check for SS3/CSI function keys (F1-F4) + const ss3Letter = SS3_FUNCTION_KEYS[ev.key]; + if (ss3Letter) { + result.key = buildSs3Sequence(ss3Letter, modifiers, eventType, reportEventTypes); + result.cancel = true; + return result; + } + + // Check for CSI ~ keys (Insert, Delete, PageUp/Down, F5-F12) + const tildeCode = CSI_TILDE_KEYS[ev.key]; + if (tildeCode !== undefined) { + result.key = buildCsiTildeSequence(tildeCode, modifiers, eventType, reportEventTypes); + result.cancel = true; + return result; + } + + // Get the key code for CSI u encoding 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 + // Determine if we should use CSI u encoding 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 + } else if (reportEventTypes) { useCsiU = true; } else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) { - // Modifier-only keys are never reported without REPORT_EVENT_TYPES - if (isMod) { - return result; - } + // Modifier-only keys already handled above // Use CSI u for keys that would be ambiguous in legacy encoding - if (keyCode === 27 || keyCode === 127 || keyCode === 13) { - // Escape, Backspace, Enter - useCsiU = true; - } else if (keyCode === 9 && ev.shiftKey) { - // Shift+Tab + if (keyCode === 27 || keyCode === 127 || keyCode === 13 || keyCode === 9 || keyCode === 32) { + // Escape, Backspace, Enter, Tab, Space useCsiU = true; } else if (isFunc) { useCsiU = true; @@ -287,58 +357,154 @@ export function evaluateKeyboardEventKitty( } } - // 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; - - // Check if we need associated text (press and repeat events, not release) - const reportAssociatedText = !!(flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) && - eventType !== KittyKeyboardEventType.RELEASE && ev.key.length === 1 && !isFunc && !isMod; - const textCode = reportAssociatedText ? ev.key.codePointAt(0) : undefined; - - // When text is present, don't include event type marker (even for repeat) - // Release events always need event type marker - const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS && textCode === undefined; - if (modifiers > 0 || needsEventType || textCode !== undefined) { - seq += ';'; - // Use 1 as base when event type needed but no modifiers (kitty format: 1 + modifier_bits) - if (modifiers > 0) { - seq += modifiers; - } else if (needsEventType) { - seq += '1'; - } - if (needsEventType) { - seq += ':' + eventType; - } - } - - // Add associated text if requested - if (textCode !== undefined) { - seq += ';' + textCode; - } - - seq += 'u'; - result.key = seq; + result.key = buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod); 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; } +/** + * Build CSI letter sequence for arrow keys, Home, End. + * Format: CSI [1;mod] letter + */ +function buildCsiLetterSequence( + letter: string, + modifiers: number, + eventType: KittyKeyboardEventType, + reportEventTypes: boolean +): string { + const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS; + + if (modifiers > 0 || needsEventType) { + let seq = C0.ESC + '[1;' + (modifiers > 0 ? modifiers : '1'); + if (needsEventType) { + seq += ':' + eventType; + } + seq += letter; + return seq; + } + return C0.ESC + '[' + letter; +} + +/** + * Build SS3 sequence for F1-F4. + * Without modifiers: SS3 letter + * With modifiers: CSI 1;mod letter + */ +function buildSs3Sequence( + letter: string, + modifiers: number, + eventType: KittyKeyboardEventType, + reportEventTypes: boolean +): string { + const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS; + + if (modifiers > 0 || needsEventType) { + let seq = C0.ESC + '[1;' + (modifiers > 0 ? modifiers : '1'); + if (needsEventType) { + seq += ':' + eventType; + } + seq += letter; + return seq; + } + return C0.ESC + 'O' + letter; +} + +/** + * Build CSI ~ sequence for Insert, Delete, PageUp/Down, F5-F12. + * Format: CSI number [;mod[:event]] ~ + */ +function buildCsiTildeSequence( + number: number, + modifiers: number, + eventType: KittyKeyboardEventType, + reportEventTypes: boolean +): string { + const needsEventType = reportEventTypes && eventType !== KittyKeyboardEventType.PRESS; + + let seq = C0.ESC + '[' + number; + if (modifiers > 0 || needsEventType) { + seq += ';' + (modifiers > 0 ? modifiers : '1'); + if (needsEventType) { + seq += ':' + eventType; + } + } + seq += '~'; + return seq; +} + +/** + * Build CSI u sequence. + * Format: CSI keycode[:shifted[:base]] [;mod[:event][;text]] u + */ +function buildCsiUSequence( + ev: IKeyboardEvent, + keyCode: number, + modifiers: number, + eventType: KittyKeyboardEventType, + flags: number, + isFunc: boolean, + isMod: boolean +): string { + const reportEventTypes = !!(flags & KittyKeyboardFlags.REPORT_EVENT_TYPES); + const reportAlternateKeys = !!(flags & KittyKeyboardFlags.REPORT_ALTERNATE_KEYS); + + let seq = C0.ESC + '[' + keyCode; + + // Add shifted key alternate if REPORT_ALTERNATE_KEYS is set and shift is pressed + // Only for text-producing keys (not functional or modifier keys) + let shiftedKey: number | undefined; + if (reportAlternateKeys && ev.shiftKey && ev.key.length === 1 && !isFunc && !isMod) { + shiftedKey = ev.key.codePointAt(0); + seq += ':' + shiftedKey; + } + + // Check if we need associated text (press and repeat events, not release) + // Only for text-producing keys (not functional or modifier keys) + // Also don't include text when ctrl is pressed (produces control code) + const reportAssociatedText = !!(flags & KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT) && + eventType !== KittyKeyboardEventType.RELEASE && + ev.key.length === 1 && + !isFunc && + !isMod && + !ev.ctrlKey; + const textCode = reportAssociatedText ? ev.key.codePointAt(0) : undefined; + + // Determine if we need event type suffix + // For repeat: only include :2 when there's no text (text implies it's still useful input) + // For release: always include :3 + const needsEventType = reportEventTypes && + eventType !== KittyKeyboardEventType.PRESS && + (eventType === KittyKeyboardEventType.RELEASE || textCode === undefined); + + if (modifiers > 0 || needsEventType || textCode !== undefined) { + seq += ';'; + if (modifiers > 0) { + seq += modifiers; + } else if (needsEventType) { + seq += '1'; + } + if (needsEventType) { + seq += ':' + eventType; + } + } + + // Add associated text if requested + if (textCode !== undefined) { + seq += ';' + textCode; + } + + seq += 'u'; + return seq; +} + /** * 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. From 4a67836a5b88634500b6bbcce223ca648504da3f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:42:00 -0800 Subject: [PATCH 07/18] Remove support responses Wrong place for them --- src/browser/CoreBrowserTerminal.ts | 3 --- src/common/InputHandler.ts | 2 -- src/headless/Terminal.ts | 3 --- 3 files changed, 8 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 7f9b95652..0f452aff7 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -610,9 +610,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Listen for mouse events and translate // them into terminal mouse protocols. this.bindMouse(); - - // Emit kitty keyboard protocol support notification (31 = all flags supported) - this.coreService.triggerDataEvent(`${C0.ESC}[>31u`); } private _createRenderer(): IRenderer { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 4d37898ff..641e68a85 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2012,8 +2012,6 @@ export class InputHandler extends Disposable implements IInputHandler { this._coreService.isCursorInitialized = true; this._onRequestRefreshRows.fire(undefined); this._onRequestSyncScrollBar.fire(); - // Emit kitty keyboard protocol support notification - this._coreService.triggerDataEvent(`${C0.ESC}[>31u`); break; case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = true; diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index 41aaf4b66..3480856e3 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -55,9 +55,6 @@ export class Terminal extends CoreTerminal { this._register(Event.forward(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); this._register(Event.forward(this._inputHandler.onA11yTab, this._onA11yTabEmitter)); this._register(Event.forward(Event.map(this._inputHandler.onRequestRefreshRows, e => ({ start: e?.start ?? 0, end: e?.end ?? this.rows - 1 })), this._onRender)); - - // Emit kitty keyboard protocol support notification (31 = all flags supported) - this.coreService.triggerDataEvent(`${C0.ESC}[>31u`); } /** From 5a29d489c35d01e6f0feb4e7ccc7c313441a7d87 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:46:57 -0800 Subject: [PATCH 08/18] Separate out kitty keyboard handlers --- src/common/InputHandler.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 641e68a85..6ba8abb77 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -264,17 +264,18 @@ 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)); this._parser.registerCsiHandler({ intermediates: '$', final: 'p' }, params => this.requestMode(params, true)); this._parser.registerCsiHandler({ prefix: '?', intermediates: '$', final: 'p' }, params => this.requestMode(params, false)); + // 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)); + /** * execute handler */ From c624a47780550bdf2fe8e616179e62596e634bf6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 11:59:50 -0800 Subject: [PATCH 09/18] Maintain different buffer flags, stack limit, tests --- src/common/InputHandler.test.ts | 143 +++++++++++++++++++---------- src/common/InputHandler.ts | 17 ++++ src/common/TestUtils.test.ts | 2 + src/common/Types.ts | 6 +- src/common/services/CoreService.ts | 2 + 5 files changed, 122 insertions(+), 48 deletions(-) diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index ee12e9a06..59966fd7d 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2413,62 +2413,111 @@ describe('InputHandler', () => { } }); }); -}); + describe('InputHandler - kitty keyboard', () => { + let bufferService: IBufferService; + let coreService: ICoreService; + let optionsService: MockOptionsService; + let inputHandler: TestInputHandler; -describe('InputHandler - async handlers', () => { - let bufferService: IBufferService; - let coreService: ICoreService; - let optionsService: MockOptionsService; - let inputHandler: TestInputHandler; + beforeEach(() => { + optionsService = new MockOptionsService({ vtExtensions: { kittyKeyboard: true } }); + bufferService = new BufferService(optionsService); + bufferService.resize(80, 30); + coreService = new CoreService(bufferService, new MockLogService(), optionsService); + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + }); - beforeEach(() => { - optionsService = new MockOptionsService(); - bufferService = new BufferService(optionsService); - bufferService.resize(80, 30); - coreService = new CoreService(bufferService, new MockLogService(), optionsService); - coreService.onData(data => { console.log(data); }); + describe('stack limit', () => { + it('should evict oldest entry when stack exceeds 16 entries', async () => { + for (let i = 1; i <= 20; i++) { + await inputHandler.parseP(`\x1b[>${i}u`); + } + assert.strictEqual(coreService.kittyKeyboard.mainStack.length, 16); + assert.strictEqual(coreService.kittyKeyboard.mainStack[0], 4); + }); + }); - inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); + describe('buffer switch', () => { + it('should maintain separate flags for main and alt screens', async () => { + await inputHandler.parseP('\x1b[>5u'); + assert.strictEqual(coreService.kittyKeyboard.flags, 5); + await inputHandler.parseP('\x1b[?1049h'); + assert.strictEqual(coreService.kittyKeyboard.flags, 0); + assert.strictEqual(coreService.kittyKeyboard.mainFlags, 5); + await inputHandler.parseP('\x1b[>7u'); + assert.strictEqual(coreService.kittyKeyboard.flags, 7); + await inputHandler.parseP('\x1b[?1049l'); + assert.strictEqual(coreService.kittyKeyboard.flags, 5); + assert.strictEqual(coreService.kittyKeyboard.altFlags, 7); + }); + }); + + describe('pop reset', () => { + it('should reset flags to 0 when stack is emptied', async () => { + await inputHandler.parseP('\x1b[>5u'); + assert.strictEqual(coreService.kittyKeyboard.flags, 5); + await inputHandler.parseP('\x1b[<10u'); + assert.strictEqual(coreService.kittyKeyboard.flags, 0); + }); + }); }); - it('async CUP with CPR check', async () => { - const cup: number[][] = []; - const cpr: number[][] = []; - inputHandler.registerCsiHandler({ final: 'H' }, async params => { - cup.push(params.toArray() as number[]); - await new Promise(res => setTimeout(res, 50)); - // late call of real repositioning - return inputHandler.cursorPosition(params); + + describe('InputHandler - async handlers', () => { + let bufferService: IBufferService; + let coreService: ICoreService; + let optionsService: MockOptionsService; + let inputHandler: TestInputHandler; + + beforeEach(() => { + optionsService = new MockOptionsService(); + bufferService = new BufferService(optionsService); + bufferService.resize(80, 30); + coreService = new CoreService(bufferService, new MockLogService(), optionsService); + coreService.onData(data => { console.log(data); }); + + inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); }); - coreService.onData(data => { - const m = data.match(/\x1b\[(.*?);(.*?)R/); - if (m) { - cpr.push([parseInt(m[1]), parseInt(m[2])]); - } + + it('async CUP with CPR check', async () => { + const cup: number[][] = []; + const cpr: number[][] = []; + inputHandler.registerCsiHandler({ final: 'H' }, async params => { + cup.push(params.toArray() as number[]); + await new Promise(res => setTimeout(res, 50)); + // late call of real repositioning + return inputHandler.cursorPosition(params); + }); + coreService.onData(data => { + const m = data.match(/\x1b\[(.*?);(.*?)R/); + if (m) { + cpr.push([parseInt(m[1]), parseInt(m[2])]); + } + }); + await inputHandler.parseP('aaa\x1b[3;4H\x1b[6nbbb\x1b[6;8H\x1b[6n'); + assert.deepEqual(cup, cpr); }); - await inputHandler.parseP('aaa\x1b[3;4H\x1b[6nbbb\x1b[6;8H\x1b[6n'); - assert.deepEqual(cup, cpr); - }); - it('async OSC between', async () => { - inputHandler.registerOscHandler(1000, async data => { - await new Promise(res => setTimeout(res, 50)); - assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']); - assert.equal(data, 'some data'); - return true; + it('async OSC between', async () => { + inputHandler.registerOscHandler(1000, async data => { + await new Promise(res => setTimeout(res, 50)); + assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']); + assert.equal(data, 'some data'); + return true; + }); + await inputHandler.parseP('hello world!\r\n\x1b]1000;some data\x07second line'); + assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']); }); - await inputHandler.parseP('hello world!\r\n\x1b]1000;some data\x07second line'); - assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']); - }); - it('async DCS between', async () => { - inputHandler.registerDcsHandler({ final: 'a' }, async (data, params) => { - await new Promise(res => setTimeout(res, 50)); - assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']); - assert.equal(data, 'some data'); - assert.deepEqual(params.toArray(), [1, 2]); - return true; + it('async DCS between', async () => { + inputHandler.registerDcsHandler({ final: 'a' }, async (data, params) => { + await new Promise(res => setTimeout(res, 50)); + assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']); + assert.equal(data, 'some data'); + assert.deepEqual(params.toArray(), [1, 2]); + return true; + }); + await inputHandler.parseP('hello world!\r\n\x1bP1;2asome data\x1b\\second line'); + assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']); }); - await inputHandler.parseP('hello world!\r\n\x1bP1;2asome data\x1b\\second line'); - assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']); }); }); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 6ba8abb77..fa2521130 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2009,6 +2009,12 @@ export class InputHandler extends Disposable implements IInputHandler { // FALL-THROUGH case 47: // alt screen buffer case 1047: // alt screen buffer + // Swap kitty keyboard flags: save main, restore alt + if (this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + const state = this._coreService.kittyKeyboard; + state.mainFlags = state.flags; + state.flags = state.altFlags; + } this._bufferService.buffers.activateAltBuffer(this._eraseAttrData()); this._coreService.isCursorInitialized = true; this._onRequestRefreshRows.fire(undefined); @@ -2238,6 +2244,12 @@ export class InputHandler extends Disposable implements IInputHandler { // FALL-THROUGH case 47: // normal screen buffer case 1047: // normal screen buffer - clearing it first + // Swap kitty keyboard flags: save alt, restore main + if (this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + const state = this._coreService.kittyKeyboard; + state.altFlags = state.flags; + state.flags = state.mainFlags; + } // Ensure the selection manager has the correct buffer this._bufferService.buffers.activateNormalBuffer(); if (params.params[i] === 1049) { @@ -3043,6 +3055,11 @@ export class InputHandler extends Disposable implements IInputHandler { const isAlt = this._bufferService.buffer === this._bufferService.buffers.alt; const stack = isAlt ? state.altStack : state.mainStack; + // Evict oldest entry if stack is full (DoS protection, limit of 16) + if (stack.length >= 16) { + stack.shift(); + } + // Push current flags onto stack and set new flags stack.push(state.flags); state.flags = flags; diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 6ba726923..9bfb2d197 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -116,6 +116,8 @@ export class MockCoreService implements ICoreService { }; public kittyKeyboard = { flags: 0, + mainFlags: 0, + altFlags: 0, mainStack: [] as number[], altStack: [] as number[] }; diff --git a/src/common/Types.ts b/src/common/Types.ts index 88a466b7e..9ad294f06 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -282,8 +282,12 @@ export interface IDecPrivateModes { * Maintains per-screen stacks of enhancement flags. */ export interface IKittyKeyboardState { - /** Current active enhancement flags */ + /** Current active enhancement flags (for current screen) */ flags: number; + /** Saved flags for main screen when alt is active */ + mainFlags: number; + /** Saved flags for alternate screen when main is active */ + altFlags: number; /** Stack of flags for main screen */ mainStack: number[]; /** Stack of flags for alternate screen */ diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 3bb64c53f..e160e6e92 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -28,6 +28,8 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ const DEFAULT_KITTY_KEYBOARD_STATE = (): IKittyKeyboardState => ({ flags: 0, + mainFlags: 0, + altFlags: 0, mainStack: [], altStack: [] }); From 0674663a5acb483b747e37d128f52c9992bc6fa9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:56:45 -0800 Subject: [PATCH 10/18] Remove unused import --- src/headless/Terminal.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index 3480856e3..1425699e2 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -24,7 +24,6 @@ import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { CoreTerminal } from 'common/CoreTerminal'; -import { C0 } from 'common/data/EscapeSequences'; import { IMarker, ITerminalOptions } from 'common/Types'; import { Emitter, Event } from 'vs/base/common/event'; From e3fdcc78696b50c330a5df0f88a00931556e8700 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:01:22 -0800 Subject: [PATCH 11/18] Fix API lint --- typings/xterm.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 86aeeb258..e79e4713c 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -443,7 +443,7 @@ declare module '@xterm/xterm' { * Whether the [kitty keyboard protocol][0] (`CSI u`) is enabled. When * enabled, the terminal will respond to keyboard protocol queries and allow * programs to enable enhanced keyboard reporting. The default is false. - * + * * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ kittyKeyboard?: boolean; From 4c905dad33c9fb89e319ec648f16d1291634edc7 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:02:18 -0800 Subject: [PATCH 12/18] Update API jsdoc --- typings/xterm-headless.d.ts | 8 +++++--- typings/xterm.d.ts | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 7d2277d75..4f8281844 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -323,9 +323,11 @@ declare module '@xterm/headless' { */ export interface IVtExtensions { /** - * Whether the kitty keyboard protocol is enabled. When enabled, the - * terminal will respond to keyboard protocol queries and allow programs to - * enable enhanced keyboard reporting. The default is false. + * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. When + * enabled, the terminal will respond to keyboard protocol queries and allow + * programs to enable enhanced keyboard reporting. The default is false. + * + * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ kittyKeyboard?: boolean; } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index e79e4713c..6ca2fae41 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -440,7 +440,7 @@ declare module '@xterm/xterm' { */ export interface IVtExtensions { /** - * Whether the [kitty keyboard protocol][0] (`CSI u`) is enabled. When + * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. When * enabled, the terminal will respond to keyboard protocol queries and allow * programs to enable enhanced keyboard reporting. The default is false. * From 98ea14824a3f9131818a63c107ae338449aa6fbf Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 9 Jan 2026 13:14:30 -0800 Subject: [PATCH 13/18] Make lint happy --- typings/xterm-headless.d.ts | 7 ++++--- typings/xterm.d.ts | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 4f8281844..f4a4f9919 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -323,9 +323,10 @@ declare module '@xterm/headless' { */ export interface IVtExtensions { /** - * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. When - * enabled, the terminal will respond to keyboard protocol queries and allow - * programs to enable enhanced keyboard reporting. The default is false. + * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. + * When enabled, the terminal will respond to keyboard protocol queries and + * allow programs to enable enhanced keyboard reporting. The default is + * false. * * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 6ca2fae41..8001c25e1 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -440,9 +440,10 @@ declare module '@xterm/xterm' { */ export interface IVtExtensions { /** - * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. When - * enabled, the terminal will respond to keyboard protocol queries and allow - * programs to enable enhanced keyboard reporting. The default is false. + * Whether the [kitty keyboard protocol][0] (`CSI =|?|>|< u`) is enabled. + * When enabled, the terminal will respond to keyboard protocol queries and + * allow programs to enable enhanced keyboard reporting. The default is + * false. * * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ From ec78500f869dbde44ae986c70ad742203dbe3ed0 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:19:23 -0800 Subject: [PATCH 14/18] Create IKeyboardService --- src/browser/CoreBrowserTerminal.ts | 27 ++++++---------- src/browser/services/KeyboardService.ts | 41 +++++++++++++++++++++++++ src/browser/services/Services.ts | 10 +++++- 3 files changed, 60 insertions(+), 18 deletions(-) create mode 100644 src/browser/services/KeyboardService.ts diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 0f452aff7..27e309d15 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -39,8 +39,9 @@ import { LinkProviderService } from 'browser/services/LinkProviderService'; import { MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; import { SelectionService } from 'browser/services/SelectionService'; -import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; +import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; import { ThemeService } from 'browser/services/ThemeService'; +import { KeyboardService } from 'browser/services/KeyboardService'; import { channels, color } from 'common/Color'; import { CoreTerminal } from 'common/CoreTerminal'; import * as Browser from 'common/Platform'; @@ -48,8 +49,6 @@ 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 { 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'; @@ -82,6 +81,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // Browser services private _decorationService: DecorationService; + private _keyboardService: IKeyboardService; private _linkProviderService: ILinkProviderService; // Optional browser services @@ -174,6 +174,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._decorationService = this._instantiationService.createInstance(DecorationService); this._instantiationService.setService(IDecorationService, this._decorationService); + this._keyboardService = this._instantiationService.createInstance(KeyboardService); + this._instantiationService.setService(IKeyboardService, this._keyboardService); this._linkProviderService = this._instantiationService.createInstance(LinkProviderService); this._instantiationService.setService(ILinkProviderService, this._linkProviderService); this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider)); @@ -1082,12 +1084,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._unprocessedDeadKey = true; } - // Use Kitty keyboard protocol if enabled, otherwise use legacy encoding - const kittyFlags = this.coreService.kittyKeyboard.flags; - const useKitty = this.options.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags); - const result = useKitty - ? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS) - : evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); + const result = this._keyboardService.evaluateKeyDown(event); this.updateCursorStyle(event); @@ -1117,7 +1114,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // HACK: Process A-Z in the keypress event to fix an issue with macOS IMEs where lower case // letters cannot be input while caps lock is on. Skip this hack when using kitty protocol // as it needs to send proper CSI u sequences for all key events. - if (!useKitty && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) { + if (!this._keyboardService.useKitty && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) { if (event.key.charCodeAt(0) >= 65 && event.key.charCodeAt(0) <= 90) { return true; } @@ -1176,13 +1173,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } // Handle key release for Kitty keyboard protocol - const kittyFlags = this.coreService.kittyKeyboard.flags; - const useKitty = this.options.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags); - if (useKitty && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag - const result = evaluateKeyboardEventKitty(ev, kittyFlags, KittyKeyboardEventType.RELEASE); - if (result.key) { - this.coreService.triggerDataEvent(result.key, true); - } + const result = this._keyboardService.evaluateKeyUp(ev); + if (result?.key) { + this.coreService.triggerDataEvent(result.key, true); } this.updateCursorStyle(ev); diff --git a/src/browser/services/KeyboardService.ts b/src/browser/services/KeyboardService.ts new file mode 100644 index 000000000..c1603300e --- /dev/null +++ b/src/browser/services/KeyboardService.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IKeyboardService } from 'browser/services/Services'; +import { evaluateKeyboardEvent } from 'common/input/Keyboard'; +import { evaluateKeyboardEventKitty, KittyKeyboardEventType, shouldUseKittyProtocol } from 'common/input/KittyKeyboard'; +import { isMac } from 'common/Platform'; +import { ICoreService, IOptionsService } from 'common/services/Services'; +import { IKeyboardResult } from 'common/Types'; + +export class KeyboardService implements IKeyboardService { + public serviceBrand: undefined; + + constructor( + @ICoreService private readonly _coreService: ICoreService, + @IOptionsService private readonly _optionsService: IOptionsService + ) { + } + + public evaluateKeyDown(event: KeyboardEvent): IKeyboardResult { + const kittyFlags = this._coreService.kittyKeyboard.flags; + return this.useKitty + ? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS) + : evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, isMac, this._optionsService.rawOptions.macOptionIsMeta); + } + + public evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined { + const kittyFlags = this._coreService.kittyKeyboard.flags; + if (this.useKitty && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag + return evaluateKeyboardEventKitty(event, kittyFlags, KittyKeyboardEventType.RELEASE); + } + return undefined; + } + + public get useKitty(): boolean { + const kittyFlags = this._coreService.kittyKeyboard.flags; + return !!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags)); + } +} diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts index 6103ada1b..cac62915a 100644 --- a/src/browser/services/Services.ts +++ b/src/browser/services/Services.ts @@ -7,7 +7,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; import { IColorSet, ILink, ReadonlyColorSet } from 'browser/Types'; import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; -import { AllColorIndex, IDisposable } from 'common/Types'; +import { AllColorIndex, IDisposable, IKeyboardResult } from 'common/Types'; import type { Event } from 'vs/base/common/event'; export const ICharSizeService = createDecorator('CharSizeService'); @@ -156,3 +156,11 @@ export interface ILinkProviderService extends IDisposable { export interface ILinkProvider { provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void; } + +export const IKeyboardService = createDecorator('KeyboardService'); +export interface IKeyboardService { + serviceBrand: undefined; + evaluateKeyDown(event: KeyboardEvent): IKeyboardResult; + evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined; + readonly useKitty: boolean; +} From 686c67a14bd7513d43cafba57cced54fa460c4da Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:20:29 -0800 Subject: [PATCH 15/18] Make mandatory browser services readonly --- src/browser/CoreBrowserTerminal.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 27e309d15..dca090f67 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -80,9 +80,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { private _customWheelEventHandler: CustomWheelEventHandler | undefined; // Browser services - private _decorationService: DecorationService; - private _keyboardService: IKeyboardService; - private _linkProviderService: ILinkProviderService; + private readonly _decorationService: DecorationService; + private readonly _keyboardService: IKeyboardService; + private readonly _linkProviderService: ILinkProviderService; // Optional browser services private _charSizeService: ICharSizeService | undefined; From 50b0f3d7ee908dd0923dcf9d255ee7a8102c3a9a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:31:36 -0800 Subject: [PATCH 16/18] Add vtExtensions.kittySgrBoldFaintControl --- src/common/InputHandler.ts | 4 ++-- src/common/services/Services.ts | 1 + typings/xterm-headless.d.ts | 9 +++++++++ typings/xterm.d.ts | 9 +++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 2e57278ed..17baef11a 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2672,10 +2672,10 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (p === 55) { // not overline attr.bg &= ~BgFlags.OVERLINE; - } else if (p === 221) { + } else if (p === 221 && (this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl ?? true)) { // not bold (kitty extension) attr.fg &= ~FgFlags.BOLD; - } else if (p === 222) { + } else if (p === 222 && (this._optionsService.rawOptions.vtExtensions?.kittySgrBoldFaintControl ?? true)) { // not faint (kitty extension) attr.bg &= ~BgFlags.DIM; } else if (p === 59) { diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index c3f69fb46..70d145e07 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -310,6 +310,7 @@ export interface ITerminalQuirks { export interface IVtExtensions { kittyKeyboard?: boolean; + kittySgrBoldFaintControl?: boolean; } export const IOscLinkService = createDecorator('OscLinkService'); diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index f4a4f9919..9094b09d9 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -331,6 +331,15 @@ declare module '@xterm/headless' { * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ kittyKeyboard?: boolean; + + /** + * Whether [SGR 221 (not bold) and SGR 222 (not faint) are enabled][0]. + * These are kitty extensions that allow resetting bold and faint + * independently. The default is true. + * + * [0]: https://sw.kovidgoyal.net/kitty/misc-protocol/ + */ + kittySgrBoldFaintControl?: boolean; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 8001c25e1..21239981c 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -448,6 +448,15 @@ declare module '@xterm/xterm' { * [0]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ */ kittyKeyboard?: boolean; + + /** + * Whether [SGR 221 (not bold) and SGR 222 (not faint) are enabled][0]. + * These are kitty extensions that allow resetting bold and faint + * independently. The default is true. + * + * [0]: https://sw.kovidgoyal.net/kitty/misc-protocol/ + */ + kittySgrBoldFaintControl?: boolean; } /** From 73cba6dda37a15203142d01ecd1b2803c4c91ab9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:38:06 -0800 Subject: [PATCH 17/18] Clean up, add sgr extension to api --- .../client/components/window/optionsWindow.ts | 3 +- src/browser/services/KeyboardService.ts | 4 +- src/common/InputHandler.ts | 200 +++++++++--------- src/common/input/KittyKeyboard.ts | 1 - 4 files changed, 105 insertions(+), 103 deletions(-) diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index a84d7f75a..18f37b58d 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -124,7 +124,8 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'windowsPty', ]; const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [ - { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' } + { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' }, + { label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' } ]; const stringOptions: { [key: string]: string[] | null } = { cursorStyle: ['block', 'underline', 'bar'], diff --git a/src/browser/services/KeyboardService.ts b/src/browser/services/KeyboardService.ts index c1603300e..da2368a45 100644 --- a/src/browser/services/KeyboardService.ts +++ b/src/browser/services/KeyboardService.ts @@ -5,7 +5,7 @@ import { IKeyboardService } from 'browser/services/Services'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; -import { evaluateKeyboardEventKitty, KittyKeyboardEventType, shouldUseKittyProtocol } from 'common/input/KittyKeyboard'; +import { evaluateKeyboardEventKitty, KittyKeyboardEventType, KittyKeyboardFlags, shouldUseKittyProtocol } from 'common/input/KittyKeyboard'; import { isMac } from 'common/Platform'; import { ICoreService, IOptionsService } from 'common/services/Services'; import { IKeyboardResult } from 'common/Types'; @@ -28,7 +28,7 @@ export class KeyboardService implements IKeyboardService { public evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined { const kittyFlags = this._coreService.kittyKeyboard.flags; - if (this.useKitty && (kittyFlags & 0b10)) { // REPORT_EVENT_TYPES flag + if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) { return evaluateKeyboardEventKitty(event, kittyFlags, KittyKeyboardEventType.RELEASE); } return undefined; diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 17baef11a..85f6f725c 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -3002,105 +3002,6 @@ export class InputHandler extends Disposable implements IInputHandler { return true; } - - /** - * 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 { - if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { - return true; - } - 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 { - if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { - return true; - } - 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 { - if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { - return true; - } - 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; - - // Evict oldest entry if stack is full (DoS protection, limit of 16) - if (stack.length >= 16) { - stack.shift(); - } - - // 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 { - if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { - return true; - } - 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. @@ -3612,6 +3513,107 @@ export class InputHandler extends Disposable implements IInputHandler { public markRangeDirty(y1: number, y2: number): void { this._dirtyRowTracker.markRangeDirty(y1, y2); } + + // #region Kitty keyboard + + /** + * 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 { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } + 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 { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } + 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 { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } + 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; + + // Evict oldest entry if stack is full (DoS protection, limit of 16) + if (stack.length >= 16) { + stack.shift(); + } + + // 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 { + if (!this._optionsService.rawOptions.vtExtensions?.kittyKeyboard) { + return true; + } + 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; + } + + // #endregion } export interface IDirtyRowTracker { diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index 98cf3ded2..0dfd0b564 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -206,7 +206,6 @@ function encodeModifiers(ev: IKeyboardEvent): number { 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; } From c9fc6fca8eb37a5a3dfa3fd450ec56d01c2bd03f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 10 Jan 2026 04:39:26 -0800 Subject: [PATCH 18/18] Lint --- typings/xterm-headless.d.ts | 2 +- typings/xterm.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 9094b09d9..a9b1d6e62 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -336,7 +336,7 @@ declare module '@xterm/headless' { * Whether [SGR 221 (not bold) and SGR 222 (not faint) are enabled][0]. * These are kitty extensions that allow resetting bold and faint * independently. The default is true. - * + * * [0]: https://sw.kovidgoyal.net/kitty/misc-protocol/ */ kittySgrBoldFaintControl?: boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 21239981c..a6fb140ce 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -453,7 +453,7 @@ declare module '@xterm/xterm' { * Whether [SGR 221 (not bold) and SGR 222 (not faint) are enabled][0]. * These are kitty extensions that allow resetting bold and faint * independently. The default is true. - * + * * [0]: https://sw.kovidgoyal.net/kitty/misc-protocol/ */ kittySgrBoldFaintControl?: boolean;