From fbb2a9710ffaf8dab4a6a59341c0deb70801c766 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 20:13:31 +0000 Subject: [PATCH 1/8] 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`. From 5ad56446b8206787c991e2e9d866b383264873e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 21:20:17 +0000 Subject: [PATCH 2/8] Explicitly enable selection when altClickForMouseEvents is on at attach Co-authored-by: Daniel Imms --- src/browser/CoreBrowserTerminal.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 248b99a21..e66f4c4bf 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -618,11 +618,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e))); // apply mouse event classes set by escape codes before terminal was attached - if (this.mouseStateService.areMouseEventsActive) { - if (!this.options.altClickForMouseEvents) { - this._selectionService.disable(); - this.element.classList.add('enable-mouse-events'); - } + if (this.mouseStateService.areMouseEventsActive && !this.options.altClickForMouseEvents) { + this._selectionService.disable(); + this.element.classList.add('enable-mouse-events'); } else { this._selectionService.enable(); } From f8cef535c7904b97280eff5163c1c6d79d784c4b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 21:41:44 +0000 Subject: [PATCH 3/8] Rename altClickForMouseEvents to mouseEventsRequireAlt Co-authored-by: Daniel Imms --- src/browser/CoreBrowserTerminal.ts | 2 +- src/browser/services/MouseService.ts | 4 ++-- src/browser/services/SelectionService.test.ts | 6 +++--- src/browser/services/SelectionService.ts | 6 +++--- src/common/services/OptionsService.ts | 2 +- src/common/services/Services.ts | 2 +- typings/xterm-headless.d.ts | 20 +++++++++---------- typings/xterm.d.ts | 20 +++++++++---------- 8 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index e66f4c4bf..2942b3952 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -618,7 +618,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e))); // apply mouse event classes set by escape codes before terminal was attached - if (this.mouseStateService.areMouseEventsActive && !this.options.altClickForMouseEvents) { + if (this.mouseStateService.areMouseEventsActive && !this.options.mouseEventsRequireAlt) { this._selectionService.disable(); this.element.classList.add('enable-mouse-events'); } else { diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index c6e581458..20f5602e6 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -154,7 +154,7 @@ export class MouseService implements IMouseService { } if (but !== CoreMouseButton.WHEEL - && this._optionsService.rawOptions.altClickForMouseEvents + && this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive && !ev.altKey) { return false; @@ -368,7 +368,7 @@ export class MouseService implements IMouseService { if (this._optionsService.rawOptions.logLevel === 'debug') { this._logService.debug('Binding to mouse events:', this._explainEvents(events)); } - if (this._optionsService.rawOptions.altClickForMouseEvents) { + if (this._optionsService.rawOptions.mouseEventsRequireAlt) { this._selectionService.enable(); } else { element.classList.add('enable-mouse-events'); diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 4677626ab..04d691d40 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -504,15 +504,15 @@ describe('SelectionService', () => { }); describe('shouldForceSelection', () => { - it('should force selection without alt when altClickForMouseEvents is enabled', () => { - optionsService.options.altClickForMouseEvents = true; + it('should force selection without alt when mouseEventsRequireAlt is enabled', () => { + optionsService.options.mouseEventsRequireAlt = 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.mouseEventsRequireAlt = 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 e10d45c54..605fa7a0e 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -435,7 +435,7 @@ 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) { + if (this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive) { return !event.altKey; } @@ -463,7 +463,7 @@ export class SelectionService extends Disposable implements ISelectionService { return; } - if (this._optionsService.rawOptions.altClickForMouseEvents && this._mouseStateService.areMouseEventsActive && event.altKey) { + if (this._optionsService.rawOptions.mouseEventsRequireAlt && this._mouseStateService.areMouseEventsActive && event.altKey) { return; } @@ -605,7 +605,7 @@ 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) { + if (this._optionsService.rawOptions.mouseEventsRequireAlt && 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 99ed8435a..8527d2ea0 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -41,6 +41,7 @@ export const DEFAULT_OPTIONS: Readonly> = { macOptionIsMeta: false, macOptionClickForcesSelection: false, minimumContrastRatio: 1, + mouseEventsRequireAlt: false, disableStdin: false, allowProposedApi: false, allowTransparency: false, @@ -52,7 +53,6 @@ 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 d63197585..fa1c4412f 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -209,7 +209,6 @@ export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off'; export interface ITerminalOptions { allowProposedApi?: boolean; allowTransparency?: boolean; - altClickForMouseEvents?: boolean; altClickMovesCursor?: boolean; cols?: number; convertEol?: boolean; @@ -235,6 +234,7 @@ export interface ITerminalOptions { macOptionIsMeta?: boolean; macOptionClickForcesSelection?: boolean; minimumContrastRatio?: number; + mouseEventsRequireAlt?: boolean; reflowCursorLine?: boolean; rescaleOverlappingGlyphs?: boolean; rightClickSelectsWord?: boolean; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index f831fa24f..830087ef6 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -38,16 +38,6 @@ 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'. @@ -146,6 +136,16 @@ declare module '@xterm/headless' { */ minimumContrastRatio?: number; + /** + * 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`. + */ + mouseEventsRequireAlt?: boolean; + /** * Whether to reflow the line containing the cursor when the terminal is * resized. Defaults to false, because shells usually handle this diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 0b0450671..f96c316ad 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -45,16 +45,6 @@ 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`. @@ -216,6 +206,16 @@ declare module '@xterm/xterm' { */ minimumContrastRatio?: number; + /** + * 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`. + */ + mouseEventsRequireAlt?: boolean; + /** * Control various quirks features that are either non-standard or standard * in but generally rejected in modern terminals. From 7711d780c28923dd69578deb793fbb174d90c5d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 27 May 2026 21:59:23 +0000 Subject: [PATCH 4/8] Handle mouseEventsRequireAlt option changes in mouse mode Co-authored-by: Daniel Imms --- src/browser/CoreBrowserTerminal.ts | 1 + src/browser/services/MouseService.test.ts | 80 +++++++++++++++++++++++ src/browser/services/MouseService.ts | 27 +++++--- 3 files changed, 100 insertions(+), 8 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 2942b3952..a75bd5165 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -623,6 +623,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this.element.classList.add('enable-mouse-events'); } else { this._selectionService.enable(); + this.element.classList.remove('enable-mouse-events'); } if (this.options.screenReaderMode) { diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index f395083e3..e9be7477c 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -7,6 +7,7 @@ import { MouseService } from 'browser/services/MouseService'; import { MouseStateService } from 'common/services/MouseStateService'; import { CoreMouseAction, CoreMouseButton } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; +import { OptionsService } from 'common/services/OptionsService'; import { MockCoreBrowserService, MockRenderService, MockSelectionService } from 'browser/TestUtils.test'; function toBytes(s: string | undefined): number[] { @@ -42,6 +43,36 @@ const logService: ILogService = { error: () => {} } as any; +class TestSelectionService extends MockSelectionService { + public enableCount = 0; + public disableCount = 0; + + public override enable(): void { + this.enableCount++; + } + + public override disable(): void { + this.disableCount++; + } + + public override shouldForceSelection(): boolean { + return false; + } +} + +function createTestMouseTargetElement(): HTMLElement { + const classes = new Set(); + return { + classList: { + add: (className: string) => classes.add(className), + remove: (className: string) => classes.delete(className), + contains: (className: string) => classes.has(className) + }, + addEventListener: () => {}, + removeEventListener: () => {} + } as any; +} + describe('MouseService _triggerMouseEvent', () => { let mouseService: MouseService; let mouseStateService: MouseStateService; @@ -211,3 +242,52 @@ describe('MouseService _triggerMouseEvent', () => { reports = []; }); }); + +describe('MouseService mouseEventsRequireAlt', () => { + it('should update selection state and cursor class when toggled while mouse events are active', () => { + const mouseStateService = new MouseStateService(); + const optionsService = new OptionsService({}); + const selectionService = new TestSelectionService(); + const mouseService = new MouseService( + new MockRenderService(), + { + getMouseReportCoords: (_ev: MouseEvent, _el: HTMLElement) => ({ col: 0, row: 0, x: 0, y: 0 }) + } as any, + mouseStateService, + { + triggerDataEvent: () => {}, + triggerBinaryEvent: () => {}, + decPrivateModes: { applicationCursorKeys: false } + } as any, + bufferService, + optionsService, + selectionService, + logService, + new MockCoreBrowserService() + ); + const element = createTestMouseTargetElement(); + const screenElement = createTestMouseTargetElement(); + const document = { + addEventListener: () => {}, + removeEventListener: () => {} + } as any; + + mouseService.bindMouse({ + element, + screenElement, + document + }, disposable => disposable, () => {}); + + mouseStateService.activeProtocol = 'ANY'; + assert.isTrue(element.classList.contains('enable-mouse-events')); + assert.equal(selectionService.disableCount, 1); + + optionsService.options.mouseEventsRequireAlt = true; + assert.isFalse(element.classList.contains('enable-mouse-events')); + assert.equal(selectionService.enableCount, 2); + + optionsService.options.mouseEventsRequireAlt = false; + assert.isTrue(element.classList.contains('enable-mouse-events')); + assert.equal(selectionService.disableCount, 2); + }); +}); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 20f5602e6..3611890d3 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -66,6 +66,9 @@ export class MouseService implements IMouseService { register(this._mouseStateService.onProtocolChange(events => { this._handleProtocolChange(ctx, eventListeners, events); })); + register(this._optionsService.onSpecificOptionChange('mouseEventsRequireAlt', () => { + this._syncMouseModeState(element); + })); // force initial onProtocolChange so we dont miss early mouse requests this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; @@ -360,6 +363,21 @@ export class MouseService implements IMouseService { this._touchScrollAccumulator = 0; } + private _syncMouseModeState(element: HTMLElement): void { + if (this._mouseStateService.areMouseEventsActive) { + if (this._optionsService.rawOptions.mouseEventsRequireAlt) { + element.classList.remove('enable-mouse-events'); + this._selectionService.enable(); + } else { + element.classList.add('enable-mouse-events'); + this._selectionService.disable(); + } + } else { + element.classList.remove('enable-mouse-events'); + this._selectionService.enable(); + } + } + private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { const { element, document } = ctx.target; const { requestedEvents } = ctx; @@ -368,17 +386,10 @@ export class MouseService implements IMouseService { if (this._optionsService.rawOptions.logLevel === 'debug') { this._logService.debug('Binding to mouse events:', this._explainEvents(events)); } - if (this._optionsService.rawOptions.mouseEventsRequireAlt) { - 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'); - this._selectionService.enable(); } + this._syncMouseModeState(element); // add/remove handlers from requestedEvents if (!(events & CoreMouseEventType.MOVE)) { From f8e9a81d0e79d95d52dc7d6722e5292c47223ccd Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 27 May 2026 16:11:35 -0700 Subject: [PATCH 5/8] Fix mouseEventsRequireAlt by stripping alt from PTY mouse reports. Alt is only used locally to gate passthrough; applications like tmux ignore alt-modified SGR sequences. Co-authored-by: Cursor --- src/browser/services/MouseService.test.ts | 42 +++++++++++++++++++++++ src/browser/services/MouseService.ts | 8 ++++- typings/xterm-headless.d.ts | 3 +- typings/xterm.d.ts | 3 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index e9be7477c..4afa0336f 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -290,4 +290,46 @@ describe('MouseService mouseEventsRequireAlt', () => { assert.isTrue(element.classList.contains('enable-mouse-events')); assert.equal(selectionService.disableCount, 2); }); + + it('should strip alt modifier from forwarded mouse reports', () => { + const mouseStateService = new MouseStateService(); + mouseStateService.activeProtocol = 'ANY'; + mouseStateService.activeEncoding = 'SGR'; + const optionsService = new OptionsService({ mouseEventsRequireAlt: true }); + const reports: string[] = []; + const mouseService = new MouseService( + new MockRenderService(), + { + getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 }) + } as any, + mouseStateService, + { + triggerDataEvent: (data: string) => reports.push(data), + triggerBinaryEvent: () => {}, + decPrivateModes: { applicationCursorKeys: false } + } as any, + bufferService, + optionsService, + new TestSelectionService(), + logService, + new MockCoreBrowserService() + ); + const ctx = { + target: { + screenElement: createTestMouseTargetElement() + }, + requestedEvents: {} + } as any; + + const sent = (mouseService as any)._sendEvent(ctx, { + type: 'mousedown', + button: 0, + altKey: true, + ctrlKey: false, + shiftKey: false + } as MouseEvent); + + assert.isTrue(sent); + assert.deepEqual(reports, ['\x1b[<0;1;1M']); + }); }); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 3611890d3..b90015139 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -163,6 +163,12 @@ export class MouseService implements IMouseService { return false; } + // Alt is only used locally to gate mouse passthrough; do not forward it to the + // application (e.g. tmux ignores alt-modified mouse reports). + const stripAltFromReport = but !== CoreMouseButton.WHEEL + && this._optionsService.rawOptions.mouseEventsRequireAlt + && this._mouseStateService.areMouseEventsActive; + return this._triggerMouseEvent({ col: pos.col, row: pos.row, @@ -171,7 +177,7 @@ export class MouseService implements IMouseService { button: but, action, ctrl: ev.ctrlKey, - alt: ev.altKey, + alt: stripAltFromReport ? false : ev.altKey, shift: ev.shiftKey }); } diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 830087ef6..c977eb31d 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -139,7 +139,8 @@ declare module '@xterm/headless' { /** * 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 + * key is held. The alt key is not included in the mouse reports sent to the + * application. 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`. diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index f96c316ad..c66a52e85 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -209,7 +209,8 @@ declare module '@xterm/xterm' { /** * 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 + * key is held. The alt key is not included in the mouse reports sent to the + * application. 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`. From bf8af4a87190b8d897c0e4a32e5cd2250771f272 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 27 May 2026 16:20:45 -0700 Subject: [PATCH 6/8] Toggle enable-mouse-events cursor while alt is held for mouseEventsRequireAlt. Extract AltMouseCursorController to bind DOM listeners only when the option and mouse protocol are active. Co-authored-by: Cursor --- src/browser/services/MouseService.test.ts | 20 +++++- src/browser/services/MouseService.ts | 75 ++++++++++++++++++++++- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index 4afa0336f..df3983572 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -3,7 +3,7 @@ * @license MIT */ import { assert } from 'chai'; -import { MouseService } from 'browser/services/MouseService'; +import { AltMouseCursorController, MouseService } from 'browser/services/MouseService'; import { MouseStateService } from 'common/services/MouseStateService'; import { CoreMouseAction, CoreMouseButton } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; @@ -332,4 +332,22 @@ describe('MouseService mouseEventsRequireAlt', () => { assert.isTrue(sent); assert.deepEqual(reports, ['\x1b[<0;1;1M']); }); + + it('should toggle enable-mouse-events class when alt modifier changes', () => { + const element = createTestMouseTargetElement(); + const altMouseCursor = new AltMouseCursorController(element, { + addEventListener: () => {}, + removeEventListener: () => {} + } as any, () => true); + + const sync = (altHeld: boolean) => altMouseCursor.syncFromModifier({ + getModifierState: (key: string) => key === 'Alt' && altHeld + } as KeyboardEvent); + + assert.isFalse(element.classList.contains('enable-mouse-events')); + sync(true); + assert.isTrue(element.classList.contains('enable-mouse-events')); + sync(false); + assert.isFalse(element.classList.contains('enable-mouse-events')); + }); }); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index b90015139..64fe75ffe 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -7,7 +7,7 @@ import { addDisposableListener } from 'browser/Dom'; import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types'; import { C0 } from 'common/data/EscapeSequences'; -import { toDisposable } from 'common/Lifecycle'; +import { DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/scrollable/touch'; @@ -25,6 +25,7 @@ export class MouseService implements IMouseService { private _lastEvent: ICoreMouseEvent | null = null; private _wheelPartialScroll: number = 0; private _touchScrollAccumulator: number = 0; + private _altMouseCursor: AltMouseCursorController | undefined; constructor( @IRenderService private readonly _renderService: IRenderService, @@ -63,11 +64,19 @@ export class MouseService implements IMouseService { mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent), mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent) }; + this._altMouseCursor = new AltMouseCursorController( + element, + document, + () => this._mouseStateService.areMouseEventsActive + && !!this._optionsService.rawOptions.mouseEventsRequireAlt + ); + register(this._altMouseCursor); register(this._mouseStateService.onProtocolChange(events => { this._handleProtocolChange(ctx, eventListeners, events); })); register(this._optionsService.onSpecificOptionChange('mouseEventsRequireAlt', () => { this._syncMouseModeState(element); + this._altMouseCursor?.sync(); })); // force initial onProtocolChange so we dont miss early mouse requests this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; @@ -372,7 +381,7 @@ export class MouseService implements IMouseService { private _syncMouseModeState(element: HTMLElement): void { if (this._mouseStateService.areMouseEventsActive) { if (this._optionsService.rawOptions.mouseEventsRequireAlt) { - element.classList.remove('enable-mouse-events'); + this._altMouseCursor?.resetClass(); this._selectionService.enable(); } else { element.classList.add('enable-mouse-events'); @@ -396,6 +405,7 @@ export class MouseService implements IMouseService { this._logService.debug('Unbinding from mouse events.'); } this._syncMouseModeState(element); + this._altMouseCursor?.sync(); // add/remove handlers from requestedEvents if (!(events & CoreMouseEventType.MOVE)) { @@ -565,3 +575,64 @@ export class MouseService implements IMouseService { } } + +/** + * Toggles `enable-mouse-events` on the terminal element while alt is held when + * `mouseEventsRequireAlt` is active. DOM listeners are only registered while active. + */ +export class AltMouseCursorController implements IDisposable { + private readonly _listeners = new MutableDisposable(); + + constructor( + private readonly _element: HTMLElement, + private readonly _document: Document, + private readonly _isActive: () => boolean + ) { + } + + public dispose(): void { + this._listeners.dispose(); + } + + public sync(): void { + this._listeners.clear(); + + if (!this._isActive()) { + return; + } + + const store = new DisposableStore(); + const syncFromModifier = (ev: KeyboardEvent | MouseEvent) => this.syncFromModifier(ev); + store.add(addDisposableListener(this._document, 'keydown', syncFromModifier)); + store.add(addDisposableListener(this._document, 'keyup', syncFromModifier)); + store.add(addDisposableListener(this._element, 'mousemove', syncFromModifier)); + const targetWindow = this._element.ownerDocument?.defaultView; + if (targetWindow) { + store.add(addDisposableListener(targetWindow, 'blur', () => { + if (this._isActive()) { + this.resetClass(); + } + })); + } + this._listeners.value = store; + } + + public resetClass(): void { + this._updateClass(false); + } + + public syncFromModifier(ev: KeyboardEvent | MouseEvent): void { + if (!this._isActive()) { + return; + } + this._updateClass(ev.getModifierState('Alt')); + } + + private _updateClass(altHeld: boolean): void { + if (altHeld) { + this._element.classList.add('enable-mouse-events'); + } else { + this._element.classList.remove('enable-mouse-events'); + } + } +} From ce1f5f96081f803dc49b9146fac7ba49c01abcdc Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 27 May 2026 16:23:17 -0700 Subject: [PATCH 7/8] Add MouseEventCssClasses const enum for enable-mouse-events class. Centralize the CSS class name in MouseService.ts and use it across mouse mode handling and tests. Co-authored-by: Cursor --- src/browser/CoreBrowserTerminal.ts | 6 +++--- src/browser/services/MouseService.test.ts | 14 +++++++------- src/browser/services/MouseService.ts | 14 +++++++++----- 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index a75bd5165..72f38c9d0 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -37,7 +37,7 @@ import { CharacterJoinerService } from 'browser/services/CharacterJoinerService' import { CoreBrowserService } from 'browser/services/CoreBrowserService'; import { LinkProviderService } from 'browser/services/LinkProviderService'; import { MouseCoordsService } from 'browser/services/MouseCoordsService'; -import { MouseService } from 'browser/services/MouseService'; +import { MouseEventCssClasses, MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; import { SelectionService } from 'browser/services/SelectionService'; import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; @@ -620,10 +620,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.options.mouseEventsRequireAlt) { this._selectionService.disable(); - this.element.classList.add('enable-mouse-events'); + this.element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } else { this._selectionService.enable(); - this.element.classList.remove('enable-mouse-events'); + this.element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } if (this.options.screenReaderMode) { diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index df3983572..d7aa7308f 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -3,7 +3,7 @@ * @license MIT */ import { assert } from 'chai'; -import { AltMouseCursorController, MouseService } from 'browser/services/MouseService'; +import { AltMouseCursorController, MouseEventCssClasses, MouseService } from 'browser/services/MouseService'; import { MouseStateService } from 'common/services/MouseStateService'; import { CoreMouseAction, CoreMouseButton } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; @@ -279,15 +279,15 @@ describe('MouseService mouseEventsRequireAlt', () => { }, disposable => disposable, () => {}); mouseStateService.activeProtocol = 'ANY'; - assert.isTrue(element.classList.contains('enable-mouse-events')); + assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); assert.equal(selectionService.disableCount, 1); optionsService.options.mouseEventsRequireAlt = true; - assert.isFalse(element.classList.contains('enable-mouse-events')); + assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); assert.equal(selectionService.enableCount, 2); optionsService.options.mouseEventsRequireAlt = false; - assert.isTrue(element.classList.contains('enable-mouse-events')); + assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); assert.equal(selectionService.disableCount, 2); }); @@ -344,10 +344,10 @@ describe('MouseService mouseEventsRequireAlt', () => { getModifierState: (key: string) => key === 'Alt' && altHeld } as KeyboardEvent); - assert.isFalse(element.classList.contains('enable-mouse-events')); + assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); sync(true); - assert.isTrue(element.classList.contains('enable-mouse-events')); + assert.isTrue(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); sync(false); - assert.isFalse(element.classList.contains('enable-mouse-events')); + assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); }); }); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 64fe75ffe..164b6de9d 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -13,6 +13,10 @@ import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/s type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>; +export const enum MouseEventCssClasses { + ENABLE_MOUSE_EVENTS = 'enable-mouse-events' +} + interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; @@ -384,11 +388,11 @@ export class MouseService implements IMouseService { this._altMouseCursor?.resetClass(); this._selectionService.enable(); } else { - element.classList.add('enable-mouse-events'); + element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); this._selectionService.disable(); } } else { - element.classList.remove('enable-mouse-events'); + element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); this._selectionService.enable(); } } @@ -577,7 +581,7 @@ export class MouseService implements IMouseService { } /** - * Toggles `enable-mouse-events` on the terminal element while alt is held when + * Toggles MouseEventCssClasses.ENABLE_MOUSE_EVENTS on the terminal element while alt is held when * `mouseEventsRequireAlt` is active. DOM listeners are only registered while active. */ export class AltMouseCursorController implements IDisposable { @@ -630,9 +634,9 @@ export class AltMouseCursorController implements IDisposable { private _updateClass(altHeld: boolean): void { if (altHeld) { - this._element.classList.add('enable-mouse-events'); + this._element.classList.add(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } else { - this._element.classList.remove('enable-mouse-events'); + this._element.classList.remove(MouseEventCssClasses.ENABLE_MOUSE_EVENTS); } } } From 1b95efa8c8207d532b237ab48410c8261fdfd15a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 27 May 2026 16:54:23 -0700 Subject: [PATCH 8/8] Add explicit void return type to alt cursor event listener. Fixes explicit-function-return-type lint in AltMouseCursorController. Co-authored-by: Cursor --- src/browser/services/MouseService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 164b6de9d..f9d68f42e 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -606,7 +606,7 @@ export class AltMouseCursorController implements IDisposable { } const store = new DisposableStore(); - const syncFromModifier = (ev: KeyboardEvent | MouseEvent) => this.syncFromModifier(ev); + const syncFromModifier = (ev: KeyboardEvent | MouseEvent): void => this.syncFromModifier(ev); store.add(addDisposableListener(this._document, 'keydown', syncFromModifier)); store.add(addDisposableListener(this._document, 'keyup', syncFromModifier)); store.add(addDisposableListener(this._element, 'mousemove', syncFromModifier));