From fbb2a9710ffaf8dab4a6a59341c0deb70801c766 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 20:13:31 +0000 Subject: [PATCH] Add altClickForMouseEvents option for issue #5952 When enabled during mouse events mode, click/drag/move events require the alt key while wheel events are unaffected. Selection stays enabled by default and takes precedence over macOptionClickForcesSelection. Co-authored-by: Daniel Imms --- src/browser/CoreBrowserTerminal.ts | 6 +++-- src/browser/services/MouseService.ts | 15 +++++++++-- src/browser/services/SelectionService.test.ts | 27 ++++++++++++++++--- src/browser/services/SelectionService.ts | 14 +++++++++- src/common/services/OptionsService.ts | 1 + src/common/services/Services.ts | 1 + typings/xterm-headless.d.ts | 10 +++++++ typings/xterm.d.ts | 10 +++++++ 8 files changed, 75 insertions(+), 9 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index e44b1717e..248b99a21 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -619,8 +619,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // apply mouse event classes set by escape codes before terminal was attached if (this.mouseStateService.areMouseEventsActive) { - this._selectionService.disable(); - this.element.classList.add('enable-mouse-events'); + if (!this.options.altClickForMouseEvents) { + this._selectionService.disable(); + this.element.classList.add('enable-mouse-events'); + } } else { this._selectionService.enable(); } diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index d701796c2..c6e581458 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -153,6 +153,13 @@ export class MouseService implements IMouseService { return false; } + if (but !== CoreMouseButton.WHEEL + && this._optionsService.rawOptions.altClickForMouseEvents + && this._mouseStateService.areMouseEventsActive + && !ev.altKey) { + return false; + } + return this._triggerMouseEvent({ col: pos.col, row: pos.row, @@ -361,8 +368,12 @@ export class MouseService implements IMouseService { if (this._optionsService.rawOptions.logLevel === 'debug') { this._logService.debug('Binding to mouse events:', this._explainEvents(events)); } - element.classList.add('enable-mouse-events'); - this._selectionService.disable(); + if (this._optionsService.rawOptions.altClickForMouseEvents) { + this._selectionService.enable(); + } else { + element.classList.add('enable-mouse-events'); + this._selectionService.disable(); + } } else { this._logService.debug('Unbinding from mouse events.'); element.classList.remove('enable-mouse-events'); diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index be62ccc6b..4677626ab 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -7,7 +7,7 @@ import { assert } from 'chai'; import { SelectionService, SelectionMode } from './SelectionService'; import { SelectionModel } from 'browser/selection/SelectionModel'; import { IBufferLine } from 'common/Types'; -import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test'; +import { MockBufferService, MockOptionsService, MockCoreService, MockMouseStateService, createCellData } from 'common/TestUtils.test'; import { BufferLine } from 'common/buffer/BufferLine'; import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { IBufferService, IOptionsService } from 'common/services/Services'; @@ -22,9 +22,10 @@ class TestSelectionService extends SelectionService { constructor( bufferService: IBufferService, optionsService: IOptionsService, - renderService: IRenderService + renderService: IRenderService, + public readonly mouseStateService: MockMouseStateService ) { - super(null!, null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, renderService, new MockCoreBrowserService()); + super(null!, null!, null!, bufferService, new MockCoreService(), new MockMouseService(), optionsService, mouseStateService, renderService, new MockCoreBrowserService()); } public get model(): SelectionModel { return this._model; } @@ -45,16 +46,18 @@ describe('SelectionService', () => { let buffer: IBuffer; let bufferService: IBufferService; let optionsService: IOptionsService; + let mouseStateService: MockMouseStateService; let selectionService: TestSelectionService; beforeEach(() => { optionsService = new MockOptionsService(); + mouseStateService = new MockMouseStateService(); bufferService = new MockBufferService(20, 20, optionsService); buffer = bufferService.buffer; const renderService = new MockRenderService(); renderService.dimensions.css.canvas.height = 10 * 20; renderService.dimensions.css.canvas.width = 10 * 20; - selectionService = new TestSelectionService(bufferService, optionsService, renderService); + selectionService = new TestSelectionService(bufferService, optionsService, renderService, mouseStateService); }); function stringToRow(text: string): IBufferLine { @@ -499,5 +502,21 @@ describe('SelectionService', () => { assert.isFalse(selectionService.areCoordsInSelection([2, 1], [2, 0], [2, 1])); }); }); + + describe('shouldForceSelection', () => { + it('should force selection without alt when altClickForMouseEvents is enabled', () => { + optionsService.options.altClickForMouseEvents = true; + mouseStateService.areMouseEventsActive = true; + assert.isTrue(selectionService.shouldForceSelection({ altKey: false } as MouseEvent)); + assert.isFalse(selectionService.shouldForceSelection({ altKey: true } as MouseEvent)); + }); + + it('should take precedence over macOptionClickForcesSelection', () => { + optionsService.options.altClickForMouseEvents = true; + optionsService.options.macOptionClickForcesSelection = true; + mouseStateService.areMouseEventsActive = true; + assert.isFalse(selectionService.shouldForceSelection({ altKey: true } as MouseEvent)); + }); + }); }); diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 356cd77fe..e10d45c54 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -15,7 +15,7 @@ import { IBufferLine, ICellData, IDisposable } from 'common/Types'; import { getRangeLength } from 'common/buffer/BufferRange'; import { CellData } from 'common/buffer/CellData'; import { IBuffer } from 'common/buffer/Types'; -import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; const enum Constants { @@ -127,6 +127,7 @@ export class SelectionService extends Disposable implements ISelectionService { @ICoreService private readonly _coreService: ICoreService, @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService, @IOptionsService private readonly _optionsService: IOptionsService, + @IMouseStateService private readonly _mouseStateService: IMouseStateService, @IRenderService private readonly _renderService: IRenderService, @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService ) { @@ -434,6 +435,10 @@ export class SelectionService extends Disposable implements ISelectionService { * @param event The mouse event. */ public shouldForceSelection(event: MouseEvent): boolean { + if (this._optionsService.rawOptions.altClickForMouseEvents && this._mouseStateService.areMouseEventsActive) { + return !event.altKey; + } + if (Browser.isMac) { return event.altKey && this._optionsService.rawOptions.macOptionClickForcesSelection; } @@ -458,6 +463,10 @@ export class SelectionService extends Disposable implements ISelectionService { return; } + if (this._optionsService.rawOptions.altClickForMouseEvents && this._mouseStateService.areMouseEventsActive && event.altKey) { + return; + } + // Allow selection when using a specific modifier key, even when disabled if (!this._enabled) { if (!this.shouldForceSelection(event)) { @@ -596,6 +605,9 @@ export class SelectionService extends Disposable implements ISelectionService { * @param event the mouse or keyboard event */ public shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean { + if (this._optionsService.rawOptions.altClickForMouseEvents && this._mouseStateService.areMouseEventsActive) { + return false; + } return event.altKey && !(Browser.isMac && this._optionsService.rawOptions.macOptionClickForcesSelection); } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index ec647aaa5..99ed8435a 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -52,6 +52,7 @@ export const DEFAULT_OPTIONS: Readonly> = { windowOptions: {}, windowsPty: {}, wordSeparator: ' ()[]{}\',"`', + altClickForMouseEvents: false, altClickMovesCursor: true, convertEol: false, termName: 'xterm', diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a858193c3..d63197585 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -209,6 +209,7 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off'; export interface ITerminalOptions { allowProposedApi?: boolean; allowTransparency?: boolean; + altClickForMouseEvents?: boolean; altClickMovesCursor?: boolean; cols?: number; convertEol?: boolean; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index d3b62346d..f831fa24f 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -38,6 +38,16 @@ declare module '@xterm/headless' { */ altClickMovesCursor?: boolean; + /** + * When enabled and the terminal is in mouse events mode, mouse click, drag, + * and move events are only sent to the underlying application when the alt + * key is held. Wheel events are not affected. This allows normal text + * selection by default while still supporting application mouse interaction + * and scrolling when holding alt. When enabled, this takes precedence over + * `macOptionClickForcesSelection`. + */ + altClickForMouseEvents?: boolean; + /** * When enabled the cursor will be set to the beginning of the next line * with every new line. This is equivalent to sending '\r\n' for each '\n'. diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1ce877b9..0b0450671 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -45,6 +45,16 @@ declare module '@xterm/xterm' { */ altClickMovesCursor?: boolean; + /** + * When enabled and the terminal is in mouse events mode, mouse click, drag, + * and move events are only sent to the underlying application when the alt + * key is held. Wheel events are not affected. This allows normal text + * selection by default while still supporting application mouse interaction + * and scrolling when holding alt. When enabled, this takes precedence over + * `macOptionClickForcesSelection`. + */ + altClickForMouseEvents?: boolean; + /** * When enabled the cursor will be set to the beginning of the next line * with every new line. This is equivalent to sending `\r\n` for each `\n`.