Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
2265fa2
Implement kitty keyboard protocol (CSI u)
Tyriar Jan 9, 2026
3fbaef0
Align behavior closer with kitty
Tyriar Jan 9, 2026
992da3c
Hook up vt extensions option
Tyriar Jan 9, 2026
8dbc91d
Add vtExtensions to demo
Tyriar Jan 9, 2026
566589f
More comprehensive tests
Tyriar Jan 9, 2026
3adf6be
Fix some edge cases
Tyriar Jan 9, 2026
4a67836
Remove support responses
Tyriar Jan 9, 2026
5a29d48
Separate out kitty keyboard handlers
Tyriar Jan 9, 2026
c624a47
Maintain different buffer flags, stack limit, tests
Tyriar Jan 9, 2026
e07832a
Merge branch 'master' into 4198_kitty
Tyriar Jan 9, 2026
0674663
Remove unused import
Tyriar Jan 9, 2026
e3fdcc7
Fix API lint
Tyriar Jan 9, 2026
4c905da
Update API jsdoc
Tyriar Jan 9, 2026
98ea148
Make lint happy
Tyriar Jan 9, 2026
ec78500
Create IKeyboardService
Tyriar Jan 10, 2026
686c67a
Make mandatory browser services readonly
Tyriar Jan 10, 2026
50b0f3d
Add vtExtensions.kittySgrBoldFaintControl
Tyriar Jan 10, 2026
73cba6d
Clean up, add sgr extension to api
Tyriar Jan 10, 2026
c9fc6fc
Lint
Tyriar Jan 10, 2026
91c4761
Merge pull request #5600 from Tyriar/4198_kitty
Tyriar Jan 10, 2026
ee47054
Implement win32-input-mode
Tyriar Jan 10, 2026
6c2792a
Fix tests
Tyriar Jan 10, 2026
ebe18b3
Add more test cases
Tyriar Jan 10, 2026
0946386
Make unit tests more compact
Tyriar Jan 10, 2026
53dc85e
Add copyright
Tyriar Jan 10, 2026
8b35c67
useWin32 -> useWin32InputMode
Tyriar Jan 10, 2026
f88650e
Use win32InputMode in demo on Windows
Tyriar Jan 10, 2026
9d0beb7
Use 0b over 0x for flags
Tyriar Jan 10, 2026
f4c77d3
Merge pull request #5603 from Tyriar/2357
Tyriar Jan 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/lint_changes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand Down
5 changes: 4 additions & 1 deletion demo/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,10 @@ function createTerminal(): Terminal {
buildNumber: 22621
} : undefined,
fontFamily: '"Fira Code", monospace, "Powerline Extra Symbols"',
theme: { ...xtermjsTheme }
theme: { ...xtermjsTheme },
vtExtensions: {
win32InputMode: isWindows
}
} as ITerminalOptions);

// Load addons
Expand Down
17 changes: 17 additions & 0 deletions demo/client/components/window/optionsWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,9 +119,15 @@ 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' },
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' },
{ label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' }
];
const stringOptions: { [key: string]: string[] | null } = {
cursorStyle: ['block', 'underline', 'bar'],
cursorInactiveStyle: ['outline', 'block', 'bar', 'underline', 'none'],
Expand Down Expand Up @@ -156,6 +162,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
booleanOptions.forEach(o => {
html += `<div class="option"><label><input id="opt-${o}" type="checkbox" ${this._terminal.options[o] ? 'checked' : ''}/> ${o}</label></div>`;
});
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
const checked = this._terminal.options[parent]?.[prop] ?? false;
html += `<div class="option"><label><input id="opt-${label.replace('.', '-')}" type="checkbox" ${checked ? 'checked' : ''}/> ${label}</label></div>`;
});
html += '</div><div class="option-group">';
numberOptions.forEach(o => {
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>`;
Expand Down Expand Up @@ -187,6 +197,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', () => {
Expand Down
24 changes: 17 additions & 7 deletions src/browser/CoreBrowserTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,16 @@ 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';
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
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 { toRgbString } from 'common/input/XParseColor';
import { DecorationService } from 'common/services/DecorationService';
import { IDecorationService } from 'common/services/Services';
Expand Down Expand Up @@ -80,8 +80,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
private _customWheelEventHandler: CustomWheelEventHandler | undefined;

// Browser services
private _decorationService: DecorationService;
private _linkProviderService: ILinkProviderService;
private readonly _decorationService: DecorationService;
private readonly _keyboardService: IKeyboardService;
private readonly _linkProviderService: ILinkProviderService;

// Optional browser services
private _charSizeService: ICharSizeService | undefined;
Expand Down Expand Up @@ -173,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));
Expand Down Expand Up @@ -1081,7 +1084,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._unprocessedDeadKey = true;
}

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

this.updateCursorStyle(event);

Expand Down Expand Up @@ -1109,8 +1112,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 (!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;
}
Expand Down Expand Up @@ -1168,6 +1172,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this.focus();
}

// Handle key release for Kitty keyboard protocol
const result = this._keyboardService.evaluateKeyUp(ev);
if (result?.key) {
this.coreService.triggerDataEvent(result.key, true);
}

this.updateCursorStyle(ev);
this._keyPressHandled = false;
}
Expand Down
1 change: 1 addition & 0 deletions src/browser/public/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class Terminal extends Disposable implements ITerminalApi {
sendFocusMode: m.sendFocus,
showCursor: !this._core.coreService.isCursorHidden,
synchronizedOutputMode: m.synchronizedOutput,
win32InputMode: m.win32InputMode,
wraparoundMode: m.wraparound
};
}
Expand Down
54 changes: 54 additions & 0 deletions src/browser/services/KeyboardService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* 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, KittyKeyboardFlags, shouldUseKittyProtocol } from 'common/input/KittyKeyboard';
import { evaluateKeyboardEventWin32 } from 'common/input/Win32InputMode';
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 {
// Win32 input mode takes priority (most raw)
if (this.useWin32InputMode) {
return evaluateKeyboardEventWin32(event, true);
}
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 {
// Win32 input mode sends key up events
if (this.useWin32InputMode) {
return evaluateKeyboardEventWin32(event, false);
}
const kittyFlags = this._coreService.kittyKeyboard.flags;
if (this.useKitty && (kittyFlags & KittyKeyboardFlags.REPORT_EVENT_TYPES)) {
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));
}

public get useWin32InputMode(): boolean {
return !!(this._optionsService.rawOptions.vtExtensions?.win32InputMode && this._coreService.decPrivateModes.win32InputMode);
}
}
10 changes: 9 additions & 1 deletion src/browser/services/Services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ICharSizeService>('CharSizeService');
Expand Down Expand Up @@ -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<IKeyboardService>('KeyboardService');
export interface IKeyboardService {
serviceBrand: undefined;
evaluateKeyDown(event: KeyboardEvent): IKeyboardResult;
evaluateKeyUp(event: KeyboardEvent): IKeyboardResult | undefined;
readonly useKitty: boolean;
}
149 changes: 99 additions & 50 deletions src/common/InputHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2429,62 +2429,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);
});
});

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])]);
}
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);
});
});
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;
});
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;
});
await inputHandler.parseP('hello world!\r\n\x1bP1;2asome data\x1b\\second line');
assert.deepEqual(getLines(bufferService, 2), ['hello world!', 'second line']);


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());
});

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);
});
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']);
});
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']);
});
});
});
Loading
Loading