/** * @license * Copyright 2025 Vybestack LLC * SPDX-License-Identifier: Apache-2.0 */ import { writeToStdout } from '@vybestack/llxprt-code-core'; import { SGR_EVENT_PREFIX, X11_EVENT_PREFIX, couldBeMouseSequence as inputCouldBeMouseSequence, readSGRMouseSequence, readX11MouseSequence, } 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; let mouseEventsActive = false; export const ENABLE_MOUSE_EVENTS = '\u001b[?1002h\u001b[?1006h'; export const DISABLE_MOUSE_EVENTS = '\u001b[?1006l\u001b[?1002l'; export function isMouseEventsActive(): boolean { return mouseEventsActive; } export function setMouseEventsActive( active: boolean, _stdout?: NodeJS.WriteStream, ): boolean { if (active) { enableMouseEvents(); return true; } disableMouseEvents(); return false; } function getButtonName( buttonCode: number, ): 'left' | 'middle' | 'right' | 'none' { const button = buttonCode & 3; switch (button) { case 0: return 'left'; case 1: return 'middle'; case 2: return 'right'; default: return 'none'; } } 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'; } return 'scroll-down'; } else if (isMove) { return 'move'; } 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 sequence = readSGRMouseSequence(buffer); if (!sequence) return null; const { buttonCode, col, row, action } = sequence; const isRelease = action === 'm'; const shift = (buttonCode & 4) !== 0; const meta = (buttonCode & 8) !== 0; const ctrl = (buttonCode & 16) !== 0; const name = getMouseEventName(buttonCode, isRelease); const button = getButtonName(buttonCode); if (name) { return { event: { name, ctrl, meta, shift, col, row, button, }, length: sequence.length, }; } return null; } export function parseX11MouseEvent( buffer: string, ): { event: MouseEvent; length: number } | null { const sequence = readX11MouseSequence(buffer); if (!sequence) return null; const b = sequence.bytes.charCodeAt(0) - 32; const col = sequence.bytes.charCodeAt(1) - 32; const row = sequence.bytes.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; } } } const button = (b & 3) === 3 ? 'none' : (['left', 'middle', 'right'][b & 3] as | 'left' | 'middle' | 'right' | 'none'); if (name) { return { event: { name, ctrl, meta, shift, col, row, button }, length: sequence.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 !buffer.includes('m') && !buffer.includes('M') && 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) // Use writeToStdout to bypass patchStdio — terminal escape sequences // must reach the real stdout, not the event system. writeToStdout(ENABLE_MOUSE_EVENTS); mouseEventsActive = true; } export function disableMouseEvents() { // Disable mouse tracking with SGR format writeToStdout(DISABLE_MOUSE_EVENTS); mouseEventsActive = false; }