Skip to content

Commit 91c4761

Browse files
authored
Merge pull request #5600 from Tyriar/4198_kitty
Implement kitty keyboard protocol (CSI =|?|>|< u)
2 parents 934b5dc + c9fc6fc commit 91c4761

16 files changed

Lines changed: 1603 additions & 71 deletions

bin/lint_changes.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ if (files.length === 0) {
3333

3434
console.log(`Linting ${files.length} changed file(s)...`);
3535

36-
const eslintArgs = ['--max-warnings', '0'];
36+
const eslintArgs = ['--max-warnings', '0', '--no-warn-ignored'];
3737
if (fix) {
3838
eslintArgs.push('--fix');
3939
}

demo/client/components/window/optionsWindow.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,9 +119,14 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
119119
'overviewRuler',
120120
'quirks',
121121
'theme',
122+
'vtExtensions',
122123
'windowOptions',
123124
'windowsPty',
124125
];
126+
const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [
127+
{ label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' },
128+
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' }
129+
];
125130
const stringOptions: { [key: string]: string[] | null } = {
126131
cursorStyle: ['block', 'underline', 'bar'],
127132
cursorInactiveStyle: ['outline', 'block', 'bar', 'underline', 'none'],
@@ -156,6 +161,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
156161
booleanOptions.forEach(o => {
157162
html += `<div class="option"><label><input id="opt-${o}" type="checkbox" ${this._terminal.options[o] ? 'checked' : ''}/> ${o}</label></div>`;
158163
});
164+
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
165+
const checked = this._terminal.options[parent]?.[prop] ?? false;
166+
html += `<div class="option"><label><input id="opt-${label.replace('.', '-')}" type="checkbox" ${checked ? 'checked' : ''}/> ${label}</label></div>`;
167+
});
159168
html += '</div><div class="option-group">';
160169
numberOptions.forEach(o => {
161170
html += `<div class="option"><label>${o} <input id="opt-${o}" type="number" value="${this._terminal.options[o] ?? ''}" step="${o === 'lineHeight' || o === 'scrollSensitivity' ? '0.1' : '1'}"/></label></div>`;
@@ -187,6 +196,13 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
187196
}
188197
});
189198
});
199+
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
200+
const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement;
201+
addDomListener(input, 'change', () => {
202+
console.log('change', label, input.checked);
203+
this._terminal.options[parent] = { ...this._terminal.options[parent], [prop]: input.checked };
204+
});
205+
});
190206
numberOptions.forEach(o => {
191207
const input = document.getElementById(`opt-${o}`) as HTMLInputElement;
192208
addDomListener(input, 'change', () => {

src/browser/CoreBrowserTerminal.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,16 +39,16 @@ import { LinkProviderService } from 'browser/services/LinkProviderService';
3939
import { MouseService } from 'browser/services/MouseService';
4040
import { RenderService } from 'browser/services/RenderService';
4141
import { SelectionService } from 'browser/services/SelectionService';
42-
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
42+
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
4343
import { ThemeService } from 'browser/services/ThemeService';
44+
import { KeyboardService } from 'browser/services/KeyboardService';
4445
import { channels, color } from 'common/Color';
4546
import { CoreTerminal } from 'common/CoreTerminal';
4647
import * as Browser from 'common/Platform';
4748
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
4849
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
4950
import { IBuffer } from 'common/buffer/Types';
5051
import { C0, C1_ESCAPED } from 'common/data/EscapeSequences';
51-
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
5252
import { toRgbString } from 'common/input/XParseColor';
5353
import { DecorationService } from 'common/services/DecorationService';
5454
import { IDecorationService } from 'common/services/Services';
@@ -80,8 +80,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
8080
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
8181

8282
// Browser services
83-
private _decorationService: DecorationService;
84-
private _linkProviderService: ILinkProviderService;
83+
private readonly _decorationService: DecorationService;
84+
private readonly _keyboardService: IKeyboardService;
85+
private readonly _linkProviderService: ILinkProviderService;
8586

8687
// Optional browser services
8788
private _charSizeService: ICharSizeService | undefined;
@@ -173,6 +174,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
173174

174175
this._decorationService = this._instantiationService.createInstance(DecorationService);
175176
this._instantiationService.setService(IDecorationService, this._decorationService);
177+
this._keyboardService = this._instantiationService.createInstance(KeyboardService);
178+
this._instantiationService.setService(IKeyboardService, this._keyboardService);
176179
this._linkProviderService = this._instantiationService.createInstance(LinkProviderService);
177180
this._instantiationService.setService(ILinkProviderService, this._linkProviderService);
178181
this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider));
@@ -1081,7 +1084,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
10811084
this._unprocessedDeadKey = true;
10821085
}
10831086

1084-
const result = evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta);
1087+
const result = this._keyboardService.evaluateKeyDown(event);
10851088

10861089
this.updateCursorStyle(event);
10871090

@@ -1109,8 +1112,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
11091112
}
11101113

11111114
// HACK: Process A-Z in the keypress event to fix an issue with macOS IMEs where lower case
1112-
// letters cannot be input while caps lock is on.
1113-
if (event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) {
1115+
// letters cannot be input while caps lock is on. Skip this hack when using kitty protocol
1116+
// as it needs to send proper CSI u sequences for all key events.
1117+
if (!this._keyboardService.useKitty && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) {
11141118
if (event.key.charCodeAt(0) >= 65 && event.key.charCodeAt(0) <= 90) {
11151119
return true;
11161120
}
@@ -1168,6 +1172,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
11681172
this.focus();
11691173
}
11701174

1175+
// Handle key release for Kitty keyboard protocol
1176+
const result = this._keyboardService.evaluateKeyUp(ev);
1177+
if (result?.key) {
1178+
this.coreService.triggerDataEvent(result.key, true);
1179+
}
1180+
11711181
this.updateCursorStyle(ev);
11721182
this._keyPressHandled = false;
11731183
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
3+
* @license MIT
4+
*/
5+
6+
import { IKeyboardService } from 'browser/services/Services';
7+
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
8+
import { evaluateKeyboardEventKitty, KittyKeyboardEventType, KittyKeyboardFlags, shouldUseKittyProtocol } from 'common/input/KittyKeyboard';
9+
import { isMac } from 'common/Platform';
10+
import { ICoreService, IOptionsService } from 'common/services/Services';
11+
import { IKeyboardResult } from 'common/Types';
12+
13+
export class KeyboardService implements IKeyboardService {
14+
public serviceBrand: undefined;
15+
16+
constructor(
17+
@ICoreService private readonly _coreService: ICoreService,
18+
@IOptionsService private readonly _optionsService: IOptionsService
19+
) {
20+
}
21+
22+
public evaluateKeyDown(event: KeyboardEvent): IKeyboardResult {
23+
const kittyFlags = this._coreService.kittyKeyboard.flags;
24+
return this.useKitty
25+
? evaluateKeyboardEventKitty(event, kittyFlags, event.repeat ? KittyKeyboardEventType.REPEAT : KittyKeyboardEventType.PRESS)
26+
: evaluateKeyboardEvent(event, this._coreService.decPrivateModes.applicationCursorKeys, isMac, this._optionsService.rawOptions.macOptionIsMeta);
27+
}
28+
29+
public evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined {
30+
const kittyFlags = this._coreService.kittyKeyboard.flags;
31+
if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) {
32+
return evaluateKeyboardEventKitty(event, kittyFlags, KittyKeyboardEventType.RELEASE);
33+
}
34+
return undefined;
35+
}
36+
37+
public get useKitty(): boolean {
38+
const kittyFlags = this._coreService.kittyKeyboard.flags;
39+
return !!(this._optionsService.rawOptions.vtExtensions?.kittyKeyboard && shouldUseKittyProtocol(kittyFlags));
40+
}
41+
}

src/browser/services/Services.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
77
import { IColorSet, ILink, ReadonlyColorSet } from 'browser/Types';
88
import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
99
import { createDecorator } from 'common/services/ServiceRegistry';
10-
import { AllColorIndex, IDisposable } from 'common/Types';
10+
import { AllColorIndex, IDisposable, IKeyboardResult } from 'common/Types';
1111
import type { Event } from 'vs/base/common/event';
1212

1313
export const ICharSizeService = createDecorator<ICharSizeService>('CharSizeService');
@@ -156,3 +156,11 @@ export interface ILinkProviderService extends IDisposable {
156156
export interface ILinkProvider {
157157
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
158158
}
159+
160+
export const IKeyboardService = createDecorator<IKeyboardService>('KeyboardService');
161+
export interface IKeyboardService {
162+
serviceBrand: undefined;
163+
evaluateKeyDown(event: KeyboardEvent): IKeyboardResult;
164+
evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined;
165+
readonly useKitty: boolean;
166+
}

src/common/InputHandler.test.ts

Lines changed: 99 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2429,62 +2429,111 @@ describe('InputHandler', () => {
24292429
}
24302430
});
24312431
});
2432-
});
24332432

2433+
describe('InputHandler - kitty keyboard', () => {
2434+
let bufferService: IBufferService;
2435+
let coreService: ICoreService;
2436+
let optionsService: MockOptionsService;
2437+
let inputHandler: TestInputHandler;
24342438

2435-
describe('InputHandler - async handlers', () => {
2436-
let bufferService: IBufferService;
2437-
let coreService: ICoreService;
2438-
let optionsService: MockOptionsService;
2439-
let inputHandler: TestInputHandler;
2439+
beforeEach(() => {
2440+
optionsService = new MockOptionsService({ vtExtensions: { kittyKeyboard: true } });
2441+
bufferService = new BufferService(optionsService);
2442+
bufferService.resize(80, 30);
2443+
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
2444+
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
2445+
});
24402446

2441-
beforeEach(() => {
2442-
optionsService = new MockOptionsService();
2443-
bufferService = new BufferService(optionsService);
2444-
bufferService.resize(80, 30);
2445-
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
2446-
coreService.onData(data => { console.log(data); });
2447+
describe('stack limit', () => {
2448+
it('should evict oldest entry when stack exceeds 16 entries', async () => {
2449+
for (let i = 1; i <= 20; i++) {
2450+
await inputHandler.parseP(`\x1b[>${i}u`);
2451+
}
2452+
assert.strictEqual(coreService.kittyKeyboard.mainStack.length, 16);
2453+
assert.strictEqual(coreService.kittyKeyboard.mainStack[0], 4);
2454+
});
2455+
});
24472456

2448-
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
2449-
});
2457+
describe('buffer switch', () => {
2458+
it('should maintain separate flags for main and alt screens', async () => {
2459+
await inputHandler.parseP('\x1b[>5u');
2460+
assert.strictEqual(coreService.kittyKeyboard.flags, 5);
2461+
await inputHandler.parseP('\x1b[?1049h');
2462+
assert.strictEqual(coreService.kittyKeyboard.flags, 0);
2463+
assert.strictEqual(coreService.kittyKeyboard.mainFlags, 5);
2464+
await inputHandler.parseP('\x1b[>7u');
2465+
assert.strictEqual(coreService.kittyKeyboard.flags, 7);
2466+
await inputHandler.parseP('\x1b[?1049l');
2467+
assert.strictEqual(coreService.kittyKeyboard.flags, 5);
2468+
assert.strictEqual(coreService.kittyKeyboard.altFlags, 7);
2469+
});
2470+
});
24502471

2451-
it('async CUP with CPR check', async () => {
2452-
const cup: number[][] = [];
2453-
const cpr: number[][] = [];
2454-
inputHandler.registerCsiHandler({ final: 'H' }, async params => {
2455-
cup.push(params.toArray() as number[]);
2456-
await new Promise(res => setTimeout(res, 50));
2457-
// late call of real repositioning
2458-
return inputHandler.cursorPosition(params);
2459-
});
2460-
coreService.onData(data => {
2461-
const m = data.match(/\x1b\[(.*?);(.*?)R/);
2462-
if (m) {
2463-
cpr.push([parseInt(m[1]), parseInt(m[2])]);
2464-
}
2472+
describe('pop reset', () => {
2473+
it('should reset flags to 0 when stack is emptied', async () => {
2474+
await inputHandler.parseP('\x1b[>5u');
2475+
assert.strictEqual(coreService.kittyKeyboard.flags, 5);
2476+
await inputHandler.parseP('\x1b[<10u');
2477+
assert.strictEqual(coreService.kittyKeyboard.flags, 0);
2478+
});
24652479
});
2466-
await inputHandler.parseP('aaa\x1b[3;4H\x1b[6nbbb\x1b[6;8H\x1b[6n');
2467-
assert.deepEqual(cup, cpr);
24682480
});
2469-
it('async OSC between', async () => {
2470-
inputHandler.registerOscHandler(1000, async data => {
2471-
await new Promise(res => setTimeout(res, 50));
2472-
assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']);
2473-
assert.equal(data, 'some data');
2474-
return true;
2475-
});
2476-
await inputHandler.parseP('hello world!\r\n\x1b]1000;some data\x07second line');
2477-
assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']);
2478-
});
2479-
it('async DCS between', async () => {
2480-
inputHandler.registerDcsHandler({ final: 'a' }, async (data, params) => {
2481-
await new Promise(res => setTimeout(res, 50));
2482-
assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']);
2483-
assert.equal(data, 'some data');
2484-
assert.deepEqual(params.toArray(), [1, 2]);
2485-
return true;
2486-
});
2487-
await inputHandler.parseP('hello world!\r\n\x1bP1;2asome data\x1b\\second line');
2488-
assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']);
2481+
2482+
2483+
describe('InputHandler - async handlers', () => {
2484+
let bufferService: IBufferService;
2485+
let coreService: ICoreService;
2486+
let optionsService: MockOptionsService;
2487+
let inputHandler: TestInputHandler;
2488+
2489+
beforeEach(() => {
2490+
optionsService = new MockOptionsService();
2491+
bufferService = new BufferService(optionsService);
2492+
bufferService.resize(80, 30);
2493+
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
2494+
coreService.onData(data => { console.log(data); });
2495+
2496+
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
2497+
});
2498+
2499+
it('async CUP with CPR check', async () => {
2500+
const cup: number[][] = [];
2501+
const cpr: number[][] = [];
2502+
inputHandler.registerCsiHandler({ final: 'H' }, async params => {
2503+
cup.push(params.toArray() as number[]);
2504+
await new Promise(res => setTimeout(res, 50));
2505+
// late call of real repositioning
2506+
return inputHandler.cursorPosition(params);
2507+
});
2508+
coreService.onData(data => {
2509+
const m = data.match(/\x1b\[(.*?);(.*?)R/);
2510+
if (m) {
2511+
cpr.push([parseInt(m[1]), parseInt(m[2])]);
2512+
}
2513+
});
2514+
await inputHandler.parseP('aaa\x1b[3;4H\x1b[6nbbb\x1b[6;8H\x1b[6n');
2515+
assert.deepEqual(cup, cpr);
2516+
});
2517+
it('async OSC between', async () => {
2518+
inputHandler.registerOscHandler(1000, async data => {
2519+
await new Promise(res => setTimeout(res, 50));
2520+
assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']);
2521+
assert.equal(data, 'some data');
2522+
return true;
2523+
});
2524+
await inputHandler.parseP('hello world!\r\n\x1b]1000;some data\x07second line');
2525+
assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']);
2526+
});
2527+
it('async DCS between', async () => {
2528+
inputHandler.registerDcsHandler({ final: 'a' }, async (data, params) => {
2529+
await new Promise(res => setTimeout(res, 50));
2530+
assert.deepEqual(getLines(bufferService, 2), ['hello world!', '']);
2531+
assert.equal(data, 'some data');
2532+
assert.deepEqual(params.toArray(), [1, 2]);
2533+
return true;
2534+
});
2535+
await inputHandler.parseP('hello world!\r\n\x1bP1;2asome data\x1b\\second line');
2536+
assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']);
2537+
});
24892538
});
24902539
});

0 commit comments

Comments
 (0)