Files
gemini-cli/packages/cli/src/ui/utils/mouse.ts

234 lines
5.2 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { enableMouseEvents, disableMouseEvents } from '@google/gemini-cli-core';
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;
button: 'left' | 'middle' | 'right' | 'none';
}
export type MouseHandler = (event: MouseEvent) => void | boolean;
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;
}
}
}
function getButtonFromCode(code: number): MouseEvent['button'] {
const button = code & 3;
switch (button) {
case 0:
return 'left';
case 1:
return 'middle';
case 2:
return 'right';
default:
return 'none';
}
}
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,
button: getButtonFromCode(buttonCode),
},
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) {
let button = getButtonFromCode(b);
if (name === 'left-release' && button === 'none') {
button = 'left';
}
return {
event: {
name,
ctrl,
meta,
shift,
col,
row,
button,
},
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 { enableMouseEvents, disableMouseEvents };