mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
alternate buffer support (#12471)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user