alternate buffer support (#12471)

This commit is contained in:
Jacob Richman
2025-11-03 13:41:58 -08:00
committed by GitHub
parent 60973aacd9
commit 4fc9b1cde2
26 changed files with 1893 additions and 257 deletions
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { couldBeSGRMouseSequence, SGR_MOUSE_REGEX, ESC } from './input.js';
describe('input utils', () => {
describe('SGR_MOUSE_REGEX', () => {
it('should match valid SGR mouse sequences', () => {
// Press left button at 10, 20
expect('\x1b[<0;10;20M').toMatch(SGR_MOUSE_REGEX);
// Release left button at 10, 20
expect('\x1b[<0;10;20m').toMatch(SGR_MOUSE_REGEX);
// Move with left button held at 30, 40
expect('\x1b[<32;30;40M').toMatch(SGR_MOUSE_REGEX);
// Scroll up at 5, 5
expect('\x1b[<64;5;5M').toMatch(SGR_MOUSE_REGEX);
});
it('should not match invalid sequences', () => {
expect('hello').not.toMatch(SGR_MOUSE_REGEX);
expect('\x1b[A').not.toMatch(SGR_MOUSE_REGEX); // Arrow up
expect('\x1b[<0;10;20').not.toMatch(SGR_MOUSE_REGEX); // Incomplete
});
});
describe('couldBeSGRMouseSequence', () => {
it('should return true for empty string', () => {
expect(couldBeSGRMouseSequence('')).toBe(true);
});
it('should return true for partial SGR prefixes', () => {
expect(couldBeSGRMouseSequence(ESC)).toBe(true);
expect(couldBeSGRMouseSequence(`${ESC}[`)).toBe(true);
expect(couldBeSGRMouseSequence(`${ESC}[<`)).toBe(true);
});
it('should return true for full SGR sequence start', () => {
expect(couldBeSGRMouseSequence(`${ESC}[<0;10;20M`)).toBe(true);
});
it('should return false for non-SGR sequences', () => {
expect(couldBeSGRMouseSequence('a')).toBe(false);
expect(couldBeSGRMouseSequence(`${ESC}a`)).toBe(false);
expect(couldBeSGRMouseSequence(`${ESC}[A`)).toBe(false);
});
});
});
+58
View File
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
export const ESC = '\u001B';
export const SGR_EVENT_PREFIX = `${ESC}[<`;
export const X11_EVENT_PREFIX = `${ESC}[M`;
// eslint-disable-next-line no-control-regex
export const SGR_MOUSE_REGEX = /^\x1b\[<(\d+);(\d+);(\d+)([mM])/; // SGR mouse events
// X11 is ESC [ M followed by 3 bytes.
// eslint-disable-next-line no-control-regex
export const X11_MOUSE_REGEX = /^\x1b\[M([\s\S]{3})/;
export function couldBeSGRMouseSequence(buffer: string): boolean {
if (buffer.length === 0) return true;
// Check if buffer is a prefix of a mouse sequence starter
if (SGR_EVENT_PREFIX.startsWith(buffer)) return true;
// Check if buffer is a mouse sequence prefix
if (buffer.startsWith(SGR_EVENT_PREFIX)) return true;
return false;
}
export function couldBeMouseSequence(buffer: string): boolean {
if (buffer.length === 0) return true;
// Check SGR prefix
if (
SGR_EVENT_PREFIX.startsWith(buffer) ||
buffer.startsWith(SGR_EVENT_PREFIX)
)
return true;
// Check X11 prefix
if (
X11_EVENT_PREFIX.startsWith(buffer) ||
buffer.startsWith(X11_EVENT_PREFIX)
)
return true;
return false;
}
/**
* Checks if the buffer *starts* with a complete mouse sequence.
* Returns the length of the sequence if matched, or 0 if not.
*/
export function getMouseSequenceLength(buffer: string): number {
const sgrMatch = buffer.match(SGR_MOUSE_REGEX);
if (sgrMatch) return sgrMatch[0].length;
const x11Match = buffer.match(X11_MOUSE_REGEX);
if (x11Match) return x11Match[0].length;
return 0;
}
@@ -7,6 +7,7 @@
let detectionComplete = false;
let protocolSupported = false;
let protocolEnabled = false;
let sgrMouseEnabled = false;
/**
* Detects Kitty keyboard protocol support.
@@ -76,12 +77,17 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
process.stdout.write('\x1b[>1u');
protocolSupported = true;
protocolEnabled = true;
// Set up cleanup on exit
process.on('exit', disableProtocol);
process.on('SIGTERM', disableProtocol);
}
// Broaden mouse support by enabling SGR mode if we get any device
// attribute response, which is a strong signal of a modern terminal.
process.stdout.write('\x1b[?1006h');
sgrMouseEnabled = true;
// Set up cleanup on exit for all enabled protocols
process.on('exit', disableAllProtocols);
process.on('SIGTERM', disableAllProtocols);
detectionComplete = true;
resolve(protocolSupported);
}
@@ -100,11 +106,15 @@ export async function detectAndEnableKittyProtocol(): Promise<boolean> {
});
}
function disableProtocol() {
function disableAllProtocols() {
if (protocolEnabled) {
process.stdout.write('\x1b[<u');
protocolEnabled = false;
}
if (sgrMouseEnabled) {
process.stdout.write('\x1b[?1006l'); // Disable SGR Mouse
sgrMouseEnabled = false;
}
}
export function isKittyProtocolEnabled(): boolean {
+156
View File
@@ -0,0 +1,156 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
parseSGRMouseEvent,
parseX11MouseEvent,
isIncompleteMouseSequence,
parseMouseEvent,
} from './mouse.js';
import { ESC } from './input.js';
describe('mouse utils', () => {
describe('parseSGRMouseEvent', () => {
it('parses a valid SGR mouse press', () => {
// Button 0 (left), col 37, row 25, press (M)
const input = `${ESC}[<0;37;25M`;
const result = parseSGRMouseEvent(input);
expect(result).not.toBeNull();
expect(result!.event).toEqual({
name: 'left-press',
col: 37,
row: 25,
shift: false,
meta: false,
ctrl: false,
});
expect(result!.length).toBe(input.length);
});
it('parses a valid SGR mouse release', () => {
// Button 0 (left), col 37, row 25, release (m)
const input = `${ESC}[<0;37;25m`;
const result = parseSGRMouseEvent(input);
expect(result).not.toBeNull();
expect(result!.event).toEqual({
name: 'left-release',
col: 37,
row: 25,
shift: false,
meta: false,
ctrl: false,
});
});
it('parses SGR with modifiers', () => {
// Button 0 + Shift(4) + Meta(8) + Ctrl(16) = 0 + 4 + 8 + 16 = 28
const input = `${ESC}[<28;10;20M`;
const result = parseSGRMouseEvent(input);
expect(result).not.toBeNull();
expect(result!.event).toEqual({
name: 'left-press',
col: 10,
row: 20,
shift: true,
meta: true,
ctrl: true,
});
});
it('parses SGR move event', () => {
// Button 0 + Move(32) = 32
const input = `${ESC}[<32;10;20M`;
const result = parseSGRMouseEvent(input);
expect(result).not.toBeNull();
expect(result!.event.name).toBe('move');
});
it('parses SGR scroll events', () => {
expect(parseSGRMouseEvent(`${ESC}[<64;1;1M`)!.event.name).toBe(
'scroll-up',
);
expect(parseSGRMouseEvent(`${ESC}[<65;1;1M`)!.event.name).toBe(
'scroll-down',
);
});
it('returns null for invalid SGR', () => {
expect(parseSGRMouseEvent(`${ESC}[<;1;1M`)).toBeNull();
expect(parseSGRMouseEvent(`${ESC}[<0;1;M`)).toBeNull();
expect(parseSGRMouseEvent(`not sgr`)).toBeNull();
});
});
describe('parseX11MouseEvent', () => {
it('parses a valid X11 mouse press', () => {
// Button 0 (left) + 32 = ' ' (space)
// Col 1 + 32 = '!'
// Row 1 + 32 = '!'
const input = `${ESC}[M !!`;
const result = parseX11MouseEvent(input);
expect(result).not.toBeNull();
expect(result!.event).toEqual({
name: 'left-press',
col: 1,
row: 1,
shift: false,
meta: false,
ctrl: false,
});
expect(result!.length).toBe(6);
});
it('returns null for incomplete X11', () => {
expect(parseX11MouseEvent(`${ESC}[M !`)).toBeNull();
});
});
describe('isIncompleteMouseSequence', () => {
it('returns true for prefixes', () => {
expect(isIncompleteMouseSequence(ESC)).toBe(true);
expect(isIncompleteMouseSequence(`${ESC}[`)).toBe(true);
expect(isIncompleteMouseSequence(`${ESC}[<`)).toBe(true);
expect(isIncompleteMouseSequence(`${ESC}[M`)).toBe(true);
});
it('returns true for partial SGR', () => {
expect(isIncompleteMouseSequence(`${ESC}[<0;10;20`)).toBe(true);
});
it('returns true for partial X11', () => {
expect(isIncompleteMouseSequence(`${ESC}[M `)).toBe(true);
expect(isIncompleteMouseSequence(`${ESC}[M !`)).toBe(true);
});
it('returns false for complete SGR', () => {
expect(isIncompleteMouseSequence(`${ESC}[<0;10;20M`)).toBe(false);
});
it('returns false for complete X11', () => {
expect(isIncompleteMouseSequence(`${ESC}[M !!!`)).toBe(false);
});
it('returns false for non-mouse sequences', () => {
expect(isIncompleteMouseSequence('a')).toBe(false);
expect(isIncompleteMouseSequence(`${ESC}[A`)).toBe(false); // Arrow up
});
it('returns false for garbage that started like a mouse sequence but got too long (SGR)', () => {
const longGarbage = `${ESC}[<` + '0'.repeat(100);
expect(isIncompleteMouseSequence(longGarbage)).toBe(false);
});
});
describe('parseMouseEvent', () => {
it('parses SGR', () => {
expect(parseMouseEvent(`${ESC}[<0;1;1M`)).not.toBeNull();
});
it('parses X11', () => {
expect(parseMouseEvent(`${ESC}[M !!!`)).not.toBeNull();
});
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import {
SGR_MOUSE_REGEX,
X11_MOUSE_REGEX,
SGR_EVENT_PREFIX,
X11_EVENT_PREFIX,
couldBeMouseSequence as inputCouldBeMouseSequence,
} from './input.js';
export type MouseEventName =
| 'left-press'
| 'left-release'
| 'right-press'
| 'right-release'
| 'middle-press'
| 'middle-release'
| 'scroll-up'
| 'scroll-down'
| 'scroll-left'
| 'scroll-right'
| 'move';
export interface MouseEvent {
name: MouseEventName;
col: number;
row: number;
shift: boolean;
meta: boolean;
ctrl: boolean;
}
export type MouseHandler = (event: MouseEvent) => void;
export function getMouseEventName(
buttonCode: number,
isRelease: boolean,
): MouseEventName | null {
const isMove = (buttonCode & 32) !== 0;
if (buttonCode === 66) {
return 'scroll-left';
} else if (buttonCode === 67) {
return 'scroll-right';
} else if ((buttonCode & 64) === 64) {
if ((buttonCode & 1) === 0) {
return 'scroll-up';
} else {
return 'scroll-down';
}
} else if (isMove) {
return 'move';
} else {
const button = buttonCode & 3;
const type = isRelease ? 'release' : 'press';
switch (button) {
case 0:
return `left-${type}`;
case 1:
return `middle-${type}`;
case 2:
return `right-${type}`;
default:
return null;
}
}
}
export function parseSGRMouseEvent(
buffer: string,
): { event: MouseEvent; length: number } | null {
const match = buffer.match(SGR_MOUSE_REGEX);
if (match) {
const buttonCode = parseInt(match[1], 10);
const col = parseInt(match[2], 10);
const row = parseInt(match[3], 10);
const action = match[4];
const isRelease = action === 'm';
const shift = (buttonCode & 4) !== 0;
const meta = (buttonCode & 8) !== 0;
const ctrl = (buttonCode & 16) !== 0;
const name = getMouseEventName(buttonCode, isRelease);
if (name) {
return {
event: {
name,
ctrl,
meta,
shift,
col,
row,
},
length: match[0].length,
};
}
return null;
}
return null;
}
export function parseX11MouseEvent(
buffer: string,
): { event: MouseEvent; length: number } | null {
const match = buffer.match(X11_MOUSE_REGEX);
if (!match) return null;
// The 3 bytes are in match[1]
const b = match[1].charCodeAt(0) - 32;
const col = match[1].charCodeAt(1) - 32;
const row = match[1].charCodeAt(2) - 32;
const shift = (b & 4) !== 0;
const meta = (b & 8) !== 0;
const ctrl = (b & 16) !== 0;
const isMove = (b & 32) !== 0;
const isWheel = (b & 64) !== 0;
let name: MouseEventName | null = null;
if (isWheel) {
const button = b & 3;
switch (button) {
case 0:
name = 'scroll-up';
break;
case 1:
name = 'scroll-down';
break;
default:
break;
}
} else if (isMove) {
name = 'move';
} else {
const button = b & 3;
if (button === 3) {
// X11 reports 'release' (3) for all button releases without specifying which one.
// We'll default to 'left-release' as a best-effort guess if we don't track state.
name = 'left-release';
} else {
switch (button) {
case 0:
name = 'left-press';
break;
case 1:
name = 'middle-press';
break;
case 2:
name = 'right-press';
break;
default:
break;
}
}
}
if (name) {
return {
event: { name, ctrl, meta, shift, col, row },
length: match[0].length,
};
}
return null;
}
export function parseMouseEvent(
buffer: string,
): { event: MouseEvent; length: number } | null {
return parseSGRMouseEvent(buffer) || parseX11MouseEvent(buffer);
}
export function isIncompleteMouseSequence(buffer: string): boolean {
if (!inputCouldBeMouseSequence(buffer)) return false;
// If it matches a complete sequence, it's not incomplete.
if (parseMouseEvent(buffer)) return false;
if (buffer.startsWith(X11_EVENT_PREFIX)) {
// X11 needs exactly 3 bytes after prefix.
return buffer.length < X11_EVENT_PREFIX.length + 3;
}
if (buffer.startsWith(SGR_EVENT_PREFIX)) {
// SGR sequences end with 'm' or 'M'.
// If it doesn't have it yet, it's incomplete.
// Add a reasonable max length check to fail early on garbage.
return !/[mM]/.test(buffer) && buffer.length < 50;
}
// It's a prefix of the prefix (e.g. "ESC" or "ESC [")
return true;
}
export function enableMouseEvents() {
// Enable mouse tracking with SGR format
// ?1002h = button event tracking (clicks + drags + scroll wheel)
// ?1006h = SGR extended mouse mode (better coordinate handling)
process.stdout.write('\u001b[?1002h\u001b[?1006h');
}
export function disableMouseEvents() {
// Disable mouse tracking with SGR format
process.stdout.write('\u001b[?1006l\u001b[?1002l');
}