From 7f05bf08a77da9d93ac706c9f50d65bcc10bc355 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:04:10 -0700 Subject: [PATCH 1/2] Keep mouse and paste modes synchronized after terminal resets --- docs/specs/mouse-and-clipboard.md | 2 +- lib/src/lib/mouse-mode-observer.test.ts | 142 +++++++----------------- lib/src/lib/mouse-mode-observer.ts | 38 +------ lib/src/lib/xterm-test-mock.ts | 4 + 4 files changed, 54 insertions(+), 132 deletions(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 7b83f9b8d..6a8aab7c9 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -223,7 +223,7 @@ A token is whitespace-delimited and **runs on across soft wraps**. Trailing char ### 6.1 State Matrix -**Mouse reporting** is any mouse-tracking mode the inside program sets (DECSET `?9`, `?1000`, `?1002`, `?1003`), read from xterm without consuming the sequence (`lib/src/lib/mouse-mode-observer.ts`). Where a drag goes; **Terminal** means the terminal's own selection. +**Mouse reporting** is any mouse-tracking mode the inside program sets (DECSET `?9`, `?1000`, `?1002`, `?1003`), read from xterm without consuming the sequence (`lib/src/lib/mouse-mode-observer.ts`). **Must keep mouse reporting and bracketed-paste state synchronized with xterm after parsed output, including hard and soft resets.** Where a drag goes; **Terminal** means the terminal's own selection. | Program requests mouse | Override | Live-region drag | Scrollback drag | |---|---|---|---| diff --git a/lib/src/lib/mouse-mode-observer.test.ts b/lib/src/lib/mouse-mode-observer.test.ts index b67112b6e..02d29f030 100644 --- a/lib/src/lib/mouse-mode-observer.test.ts +++ b/lib/src/lib/mouse-mode-observer.test.ts @@ -1,115 +1,59 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { Terminal } from '@xterm/xterm'; +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from 'vitest'; +import { Terminal } from '@xterm/xterm'; import { attachMouseModeObserver } from './mouse-mode-observer'; -import { __resetMouseSelectionForTests, getMouseSelectionState } from './mouse-selection'; +import { __resetMouseSelectionForTests, getMouseSelectionState, setOverride, setSelection } from './mouse-selection'; +const live: Terminal[] = []; afterEach(() => { + for (const terminal of live.splice(0)) terminal.dispose(); __resetMouseSelectionForTests(); - vi.restoreAllMocks(); }); -interface MockDisposables { - setHandlers: Array<() => boolean>; - resetHandlers: Array<() => boolean>; -} - -function buildMockTerminal(): { terminal: Terminal; modes: { mouseTrackingMode: string; bracketedPasteMode: boolean }; handlers: MockDisposables } { - const handlers: MockDisposables = { setHandlers: [], resetHandlers: [] }; - const modes = { mouseTrackingMode: 'none' as const, bracketedPasteMode: false }; - const parser = { - registerCsiHandler(id: { prefix?: string; final?: string }, cb: () => boolean) { - if (id.prefix === '?' && id.final === 'h') { - handlers.setHandlers.push(cb); - } else if (id.prefix === '?' && id.final === 'l') { - handlers.resetHandlers.push(cb); - } - return { dispose: vi.fn() }; - }, - }; - const terminal = { parser, modes } as unknown as Terminal; - return { terminal, modes, handlers }; -} - -async function flushMicrotasks(): Promise { - await Promise.resolve(); - await Promise.resolve(); +function setup() { + const terminal = new Terminal({ allowProposedApi: true }); + live.push(terminal); + const observer = attachMouseModeObserver('a', terminal); + return { terminal, observer }; } +const write = (terminal: Terminal, data: string) => new Promise(resolve => terminal.write(data, resolve)); describe('attachMouseModeObserver', () => { - it('registers one DECSET and one DECRST handler', () => { - const { terminal, handlers } = buildMockTerminal(); - attachMouseModeObserver('a', terminal); - expect(handlers.setHandlers).toHaveLength(1); - expect(handlers.resetHandlers).toHaveLength(1); - }); - - it('DECSET handler returns false so xterm still processes the sequence', () => { - const { terminal, handlers } = buildMockTerminal(); - attachMouseModeObserver('a', terminal); - expect(handlers.setHandlers[0]()).toBe(false); - expect(handlers.resetHandlers[0]()).toBe(false); - }); - - it('syncs mouseReporting after a DECSET fires (modes updated in the mock)', async () => { - const { terminal, modes, handlers } = buildMockTerminal(); - attachMouseModeObserver('a', terminal); - - // Simulate xterm processing `\e[?1000h`: our handler fires, then xterm's - // builtin updates modes. We emulate by flipping the mode before the - // microtask runs. - handlers.setHandlers[0](); - modes.mouseTrackingMode = 'vt200'; - - await flushMicrotasks(); - expect(getMouseSelectionState('a').mouseReporting).toBe('vt200'); - }); - - it('syncs bracketedPaste after a DECSET fires', async () => { - const { terminal, modes, handlers } = buildMockTerminal(); - attachMouseModeObserver('a', terminal); - - handlers.setHandlers[0](); - modes.bracketedPasteMode = true; - - await flushMicrotasks(); - expect(getMouseSelectionState('a').bracketedPaste).toBe(true); + it.each([[9, 'x10'], [1000, 'vt200'], [1002, 'drag'], [1003, 'any']] as const)( + 'tracks mouse mode %i through real xterm parsing', async (mode, expected) => { + const { terminal } = setup(); + await write(terminal, `[?${mode}h[?2004h`); + expect(getMouseSelectionState('a')).toMatchObject({ mouseReporting: expected, bracketedPaste: true }); + await write(terminal, `[?${mode}l[?2004l`); + expect(getMouseSelectionState('a')).toMatchObject({ mouseReporting: 'none', bracketedPaste: false }); + }, + ); + + it('clears reporting, override, program selection, and paste mode after a hard reset', async () => { + const { terminal } = setup(); + await write(terminal, '\x1b[?1003h\x1b[?2004h'); + setOverride('a', 'permanent'); + setSelection('a', { startRow: 0, startCol: 0, endRow: 0, endCol: 4, shape: 'linewise', dragging: false, startedInScrollback: false, owner: 'program' }); + await write(terminal, '\x1bc'); + expect(terminal.modes.mouseTrackingMode).toBe('none'); + expect(terminal.modes.bracketedPasteMode).toBe(false); + expect(getMouseSelectionState('a')).toMatchObject({ mouseReporting: 'none', bracketedPaste: false, override: 'off', selection: null }); }); - it('syncs mouseReporting to none after DECRST', async () => { - const { terminal, modes, handlers } = buildMockTerminal(); - attachMouseModeObserver('a', terminal); - - // Enable first - handlers.setHandlers[0](); - modes.mouseTrackingMode = 'any'; - await flushMicrotasks(); - expect(getMouseSelectionState('a').mouseReporting).toBe('any'); - - // Then disable - handlers.resetHandlers[0](); - modes.mouseTrackingMode = 'none'; - await flushMicrotasks(); - expect(getMouseSelectionState('a').mouseReporting).toBe('none'); + it('reads the resolved soft-reset modes instead of assuming mouse tracking ended', async () => { + const { terminal } = setup(); + await write(terminal, '\x1b[?1003h\x1b[?2004h'); + await write(terminal, '\x1b[!p'); + expect(terminal.modes.mouseTrackingMode).toBe('any'); + expect(terminal.modes.bracketedPasteMode).toBe(false); + expect(getMouseSelectionState('a')).toMatchObject({ mouseReporting: 'any', bracketedPaste: false }); }); - it('dispose tears down both handlers', () => { - const disposables: Array<{ dispose: ReturnType }> = []; - const terminal = { - parser: { - registerCsiHandler() { - const d = { dispose: vi.fn() }; - disposables.push(d); - return d; - }, - }, - modes: { mouseTrackingMode: 'none', bracketedPasteMode: false }, - } as unknown as Terminal; - - const observer = attachMouseModeObserver('a', terminal); + it('stops updating the store after disposal', async () => { + const { terminal, observer } = setup(); + await write(terminal, '\x1b[?1003h\x1b[?2004h'); observer.dispose(); - - expect(disposables).toHaveLength(2); - expect(disposables[0].dispose).toHaveBeenCalledOnce(); - expect(disposables[1].dispose).toHaveBeenCalledOnce(); + await write(terminal, '\x1b[?1003l\x1b[?2004l'); + expect(getMouseSelectionState('a')).toMatchObject({ mouseReporting: 'any', bracketedPaste: true }); }); }); diff --git a/lib/src/lib/mouse-mode-observer.ts b/lib/src/lib/mouse-mode-observer.ts index d136870d2..37e1d9438 100644 --- a/lib/src/lib/mouse-mode-observer.ts +++ b/lib/src/lib/mouse-mode-observer.ts @@ -1,38 +1,12 @@ import type { Terminal, IDisposable } from '@xterm/xterm'; import { setBracketedPaste, setMouseReporting } from './mouse-selection'; -/** - * Wire an xterm terminal's mouse-tracking and bracketed-paste modes into the - * mouse-selection store. - * - * Installs CSI handlers for DECSET (`CSI ? ... h`) and DECRST (`CSI ? ... l`) - * that return false, letting xterm's built-in handler still process the - * sequence. After xterm updates its internal state we sync our store from - * the public `terminal.modes` getters in a microtask (the parser handler - * runs synchronously before xterm's, so `modes` isn't updated yet when our - * callback first fires). - */ +/** Read resolved modes after parsing, including hard and soft resets. Watching + * only DECSET/DECRST misses resets, and guessing their effects would duplicate + * xterm's mode semantics. The store ignores unchanged values. */ export function attachMouseModeObserver(id: string, terminal: Terminal): IDisposable { - const sync = () => { - queueMicrotask(() => { - setMouseReporting(id, terminal.modes.mouseTrackingMode); - setBracketedPaste(id, terminal.modes.bracketedPasteMode); - }); - }; - - const onSet = terminal.parser.registerCsiHandler({ prefix: '?', final: 'h' }, () => { - sync(); - return false; - }); - const onReset = terminal.parser.registerCsiHandler({ prefix: '?', final: 'l' }, () => { - sync(); - return false; + return terminal.onWriteParsed(() => { + setMouseReporting(id, terminal.modes.mouseTrackingMode); + setBracketedPaste(id, terminal.modes.bracketedPasteMode); }); - - return { - dispose() { - onSet.dispose(); - onReset.dispose(); - }, - }; } diff --git a/lib/src/lib/xterm-test-mock.ts b/lib/src/lib/xterm-test-mock.ts index 431525f81..24e4e335b 100644 --- a/lib/src/lib/xterm-test-mock.ts +++ b/lib/src/lib/xterm-test-mock.ts @@ -51,6 +51,10 @@ export class Terminal { return listen(this.resizeListeners, listener); } + onWriteParsed(): { dispose: () => void } { + return { dispose: () => {} }; + } + onRender(): { dispose: () => void } { return { dispose: () => {} }; } From 437a9195f97b742479f3a02a4f1a3fcb1bda4ef0 Mon Sep 17 00:00:00 2001 From: Ned Date: Wed, 21 Oct 2026 00:20:53 -0700 Subject: [PATCH 2/2] Update transfer test terminal mock for parsed writes --- standalone/src/workspace-move.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/standalone/src/workspace-move.test.ts b/standalone/src/workspace-move.test.ts index acdbdfc0a..dca914256 100644 --- a/standalone/src/workspace-move.test.ts +++ b/standalone/src/workspace-move.test.ts @@ -59,6 +59,7 @@ vi.mock("@xterm/xterm", () => ({ blur(): void {} onData(): { dispose: () => void } { return { dispose: () => {} }; } onResize(): { dispose: () => void } { return { dispose: () => {} }; } + onWriteParsed(): { dispose: () => void } { return { dispose: () => {} }; } onRender(): { dispose: () => void } { return { dispose: () => {} }; } dispose(): void {} },