diff --git a/.gitignore b/.gitignore index 632c311d6d..a835d8d841 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ npm-debug.log build/ .DS_Store yarn.lock +test-results/ # Keep bundled code out of Git dist/ diff --git a/addons/addon-serialize/src/SerializeAddon.ts b/addons/addon-serialize/src/SerializeAddon.ts index c6a5483ca8..36a5986495 100644 --- a/addons/addon-serialize/src/SerializeAddon.ts +++ b/addons/addon-serialize/src/SerializeAddon.ts @@ -490,6 +490,7 @@ export class SerializeAddon implements ITerminalAddon , ISerializeApi { if (modes.originMode) content += '\x1b[?6h'; if (modes.reverseWraparoundMode) content += '\x1b[?45h'; if (modes.sendFocusMode) content += '\x1b[?1004h'; + // synchronizedOutputMode doesn't need to be serialized as it's a temporary mode // Default: true if (modes.wraparoundMode === false) content += '\x1b[?7l'; diff --git a/addons/addon-unicode-graphemes/package.json b/addons/addon-unicode-graphemes/package.json index cfc8adffc8..2fa5e42926 100644 --- a/addons/addon-unicode-graphemes/package.json +++ b/addons/addon-unicode-graphemes/package.json @@ -6,7 +6,7 @@ "url": "https://xtermjs.org/" }, "main": "lib/addon-unicode-graphemes.js", - "module": "lib/.addon-unicode-graphemes.mjs", + "module": "lib/addon-unicode-graphemes.mjs", "types": "typings/addon-unicode-graphemes.d.ts", "repository": "https://github.com/xtermjs/xterm.js/tree/master/addons/addon-unicode-graphemes", "license": "MIT", diff --git a/demo/client.ts b/demo/client.ts index f5903aa4d7..00d0bf678f 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -1310,23 +1310,25 @@ function addVtButtons(): void { } const vtFragment = document.createDocumentFragment(); const buttonSpecs: { [key: string]: { label: string, description: string, paramCount?: number }} = { - A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, - B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, - C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, - D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, - E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, - F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, - G: { label: 'CHA', description: 'Cursor Character Absolute' }, - H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, - I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, - J: { label: 'ED', description: 'Erase in Display' }, - '?|J': { label: 'DECSED', description: 'Erase in Display' }, - K: { label: 'EL', description: 'Erase in Line' }, - '?|K': { label: 'DECSEL', description: 'Erase in Line' }, - L: { label: 'IL', description: 'Insert Ps Line(s)' }, - M: { label: 'DL', description: 'Delete Ps Line(s)' }, - P: { label: 'DCH', description: 'Delete Ps Character(s)' }, - ' q': { label: 'DECSCUSR', description: 'Set Cursor Style', paramCount: 1 } + A: { label: 'CUU ↑', description: 'Cursor Up Ps Times' }, + B: { label: 'CUD ↓', description: 'Cursor Down Ps Times' }, + C: { label: 'CUF →', description: 'Cursor Forward Ps Times' }, + D: { label: 'CUB ←', description: 'Cursor Backward Ps Times' }, + E: { label: 'CNL', description: 'Cursor Next Line Ps Times' }, + F: { label: 'CPL', description: 'Cursor Preceding Line Ps Times' }, + G: { label: 'CHA', description: 'Cursor Character Absolute' }, + H: { label: 'CUP', description: 'Cursor Position [row;column]', paramCount: 2 }, + I: { label: 'CHT', description: 'Cursor Forward Tabulation Ps tab stops' }, + J: { label: 'ED', description: 'Erase in Display' }, + '?|J': { label: 'DECSED', description: 'Erase in Display' }, + K: { label: 'EL', description: 'Erase in Line' }, + '?|K': { label: 'DECSEL', description: 'Erase in Line' }, + L: { label: 'IL', description: 'Insert Ps Line(s)' }, + M: { label: 'DL', description: 'Delete Ps Line(s)' }, + P: { label: 'DCH', description: 'Delete Ps Character(s)' }, + ' q': { label: 'DECSCUSR', description: 'Set Cursor Style' }, + '?2026h': { label: 'BSU', description: 'Begin synchronized update', paramCount: 0 }, + '?2026l': { label: 'ESU', description: 'End synchronized update', paramCount: 0 } }; for (const s of Object.keys(buttonSpecs)) { const spec = buttonSpecs[s]; diff --git a/headless/package.json b/headless/package.json index 5d15848b33..b8e7197496 100644 --- a/headless/package.json +++ b/headless/package.json @@ -5,6 +5,11 @@ "main": "lib-headless/xterm-headless.js", "module": "lib-headless/xterm-headless.mjs", "types": "typings/xterm-headless.d.ts", + "exports": { + "types": "./typings/xterm-headless.d.ts", + "import": "./lib-headless/xterm-headless.mjs", + "require": "./lib-headless/xterm-headless.js" + }, "repository": "https://github.com/xtermjs/xterm.js", "license": "MIT", "keywords": [ diff --git a/package-lock.json b/package-lock.json index e12072f737..fe7ef02337 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,6 @@ "@types/deep-equal": "^1.0.1", "@types/express": "4", "@types/express-ws": "^3.0.1", - "@types/glob": "^7.2.0", "@types/jsdom": "^16.2.13", "@types/mocha": "^9.0.0", "@types/node": "^18.16.0", @@ -38,7 +37,7 @@ "eslint-plugin-jsdoc": "^46.9.1", "express": "^4.19.2", "express-ws": "^5.0.2", - "glob": "^7.2.0", + "glob": "^13.0.0", "jsdom": "^18.0.1", "mocha": "^10.1.0", "mustache": "^4.2.0", @@ -747,6 +746,29 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/balanced-match": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", + "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@isaacs/brace-expansion": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@isaacs/balanced-match": "^4.0.1" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1581,20 +1603,6 @@ "@types/ws": "*" } }, - "node_modules/@types/glob": { - "version": "7.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/minimatch": "*", - "@types/node": "*" - } - }, - "node_modules/@types/glob/node_modules/@types/node": { - "version": "20.4.5", - "dev": true, - "license": "MIT" - }, "node_modules/@types/http-errors": { "version": "2.0.1", "dev": true, @@ -1633,11 +1641,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/minimatch": { - "version": "5.1.2", - "dev": true, - "license": "MIT" - }, "node_modules/@types/mocha": { "version": "9.1.1", "dev": true, @@ -4331,19 +4334,18 @@ } }, "node_modules/glob": { - "version": "7.2.3", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", + "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", "dev": true, - "license": "ISC", + "license": "BlueOak-1.0.0", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "path-scurry": "^2.0.0" }, "engines": { - "node": "*" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4365,6 +4367,49 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/lru-cache": { + "version": "11.2.4", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.4.tgz", + "integrity": "sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/brace-expansion": "^5.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/path-scurry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", + "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "13.20.0", "dev": true, @@ -6078,6 +6123,28 @@ "node": ">=8.9" } }, + "node_modules/nyc/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/nyc/node_modules/yargs": { "version": "15.4.1", "dev": true, @@ -6677,6 +6744,28 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -7337,6 +7426,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-table": { "version": "0.2.0", "dev": true, diff --git a/package.json b/package.json index 97a61bbde7..71b8059987 100644 --- a/package.json +++ b/package.json @@ -75,7 +75,6 @@ "@types/deep-equal": "^1.0.1", "@types/express": "4", "@types/express-ws": "^3.0.1", - "@types/glob": "^7.2.0", "@types/jsdom": "^16.2.13", "@types/mocha": "^9.0.0", "@types/node": "^18.16.0", @@ -93,7 +92,6 @@ "eslint-plugin-jsdoc": "^46.9.1", "express": "^4.19.2", "express-ws": "^5.0.2", - "glob": "^7.2.0", "jsdom": "^18.0.1", "mocha": "^10.1.0", "mustache": "^4.2.0", diff --git a/src/browser/Terminal2.test.ts b/src/browser/Terminal2.test.ts index 8401ba7894..6c2b3db656 100644 --- a/src/browser/Terminal2.test.ts +++ b/src/browser/Terminal2.test.ts @@ -3,7 +3,6 @@ * @license MIT */ -import * as glob from 'glob'; import * as path from 'path'; import * as os from 'os'; import * as fs from 'fs'; @@ -15,7 +14,10 @@ import { IDisposable } from '@xterm/xterm'; const COLS = 80; const ROWS = 25; -const TESTFILES = glob.sync('**/escape_sequence_files/*.in', { cwd: path.join(__dirname, '../..')}); +const escapeSequenceFilesDir = path.join(__dirname, '../../fixtures/escape_sequence_files'); +const TESTFILES = fs.readdirSync(escapeSequenceFilesDir) + .filter(f => f.endsWith('.in')) + .map(f => path.join(escapeSequenceFilesDir, f)); const SKIP_FILES = [ 't0055-EL.in', // EL/ED handle cursor at cols differently (see #3362) 't0084-CBT.in', @@ -33,7 +35,7 @@ if (os.platform() === 'darwin') { ); } // filter skipFilenames -const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(value.split('/').slice(-1)[0])); +const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(path.basename(value))); describe('Escape Sequence Files', function(): void { this.timeout(1000); @@ -62,7 +64,7 @@ describe('Escape Sequence Files', function(): void { }); for (const filename of FILES) { - (process.platform === 'win32' ? it.skip : it)(filename.split('/').slice(-1)[0], async () => { + (process.platform === 'win32' ? it.skip : it)(path.basename(filename), async () => { // reset terminal and handler if (customHandler) { customHandler.dispose(); @@ -88,7 +90,7 @@ describe('Escape Sequence Files', function(): void { }); // compare with expected output (right trimmed) - const expected = fs.readFileSync(filename.split('.')[0] + '.text', 'utf8'); + const expected = fs.readFileSync(filename.replace(/\.in$/, '.text'), 'utf8'); const expectedRightTrimmed = expected.split('\n').map(l => l.replace(/\s+$/, '')).join('\n'); if (content !== expectedRightTrimmed) { throw new Error(formatError(fs.readFileSync(filename, 'utf8'), content, expected)); diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 3b4309437f..f6ec55d24f 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -123,6 +123,7 @@ export class Terminal extends Disposable implements ITerminalApi { originMode: m.origin, reverseWraparoundMode: m.reverseWraparound, sendFocusMode: m.sendFocus, + synchronizedOutputMode: m.synchronizedOutput, wraparoundMode: m.wraparound }; } diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 3ca0314afa..e9acbeb36a 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { DebouncedIdleTask } from 'common/TaskQueue'; -import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'vs/base/common/event'; interface ISelectionState { @@ -18,6 +18,10 @@ interface ISelectionState { columnSelectMode: boolean; } +const enum Constants { + SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000 +} + export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; @@ -32,6 +36,7 @@ export class RenderService extends Disposable implements IRenderService { private _needsSelectionRefresh: boolean = false; private _canvasWidth: number = 0; private _canvasHeight: number = 0; + private _syncOutputHandler: SynchronizedOutputHandler; private _selectionState: ISelectionState = { start: undefined, end: undefined, @@ -52,23 +57,31 @@ export class RenderService extends Disposable implements IRenderService { constructor( private _rowCount: number, screenElement: HTMLElement, - @IOptionsService optionsService: IOptionsService, + @IOptionsService private readonly _optionsService: IOptionsService, @ICharSizeService private readonly _charSizeService: ICharSizeService, + @ICoreService private readonly _coreService: ICoreService, @IDecorationService decorationService: IDecorationService, @IBufferService bufferService: IBufferService, - @ICoreBrowserService coreBrowserService: ICoreBrowserService, + @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService, @IThemeService themeService: IThemeService ) { super(); - this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), coreBrowserService); + this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService); this._register(this._renderDebouncer); - this._register(coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange())); + this._syncOutputHandler = new SynchronizedOutputHandler( + this._coreBrowserService, + this._coreService, + () => this._fullRefresh() + ); + this._register(toDisposable(() => this._syncOutputHandler.dispose())); + + this._register(this._coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange())); this._register(bufferService.onResize(() => this._fullRefresh())); this._register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear())); - this._register(optionsService.onOptionChange(() => this._handleOptionsChanged())); + this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged())); this._register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged())); // Do a full refresh whenever any decoration is added or removed. This may not actually result @@ -78,7 +91,7 @@ export class RenderService extends Disposable implements IRenderService { this._register(decorationService.onDecorationRemoved(() => this._fullRefresh())); // Clear the renderer when the a change that could affect glyphs occurs - this._register(optionsService.onMultipleOptionChange([ + this._register(this._optionsService.onMultipleOptionChange([ 'customGlyphs', 'drawBoldTextInBrightColors', 'letterSpacing', @@ -96,15 +109,15 @@ export class RenderService extends Disposable implements IRenderService { })); // Refresh the cursor line when the cursor changes - this._register(optionsService.onMultipleOptionChange([ + this._register(this._optionsService.onMultipleOptionChange([ 'cursorBlink', 'cursorStyle' ], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true))); this._register(themeService.onChangeColors(() => this._fullRefresh())); - this._registerIntersectionObserver(coreBrowserService.window, screenElement); - this._register(coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement))); + this._registerIntersectionObserver(this._coreBrowserService.window, screenElement); + this._register(this._coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement))); } private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void { @@ -137,6 +150,18 @@ export class RenderService extends Disposable implements IRenderService { this._needsFullRefresh = true; return; } + + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end); + return; + } + + const buffered = this._syncOutputHandler.flush(); + if (buffered) { + start = Math.min(start, buffered.start); + end = Math.max(end, buffered.end); + } + if (!isRedrawOnly) { this._isNextRenderRedrawOnly = false; } @@ -148,6 +173,13 @@ export class RenderService extends Disposable implements IRenderService { return; } + // Skip rendering if synchronized output mode is enabled. This check must happen here + // (in addition to refreshRows) to handle renders that were queued before the mode was enabled. + if (this._coreService.decPrivateModes.synchronizedOutput) { + this._syncOutputHandler.bufferRows(start, end); + return; + } + // Since this is debounced, a resize event could have happened between the time a refresh was // requested and when this triggers. Clamp the values of start and end to ensure they're valid // given the current viewport state. @@ -283,3 +315,62 @@ export class RenderService extends Disposable implements IRenderService { this._renderer.value?.clear(); } } + +/** + * Buffers row refresh requests during synchronized output mode (DEC mode 2026). + * When the mode is disabled, the accumulated row range is flushed for rendering. + * A safety timeout ensures rendering occurs even if the end sequence is not received. + */ +class SynchronizedOutputHandler { + private _start: number = 0; + private _end: number = 0; + private _timeout: number | undefined; + private _isBuffering: boolean = false; + + constructor( + private readonly _coreBrowserService: ICoreBrowserService, + private readonly _coreService: ICoreService, + private readonly _onTimeout: () => void + ) {} + + public bufferRows(start: number, end: number): void { + if (!this._isBuffering) { + this._start = start; + this._end = end; + this._isBuffering = true; + } else { + this._start = Math.min(this._start, start); + this._end = Math.max(this._end, end); + } + + if (this._timeout === undefined) { + this._timeout = this._coreBrowserService.window.setTimeout(() => { + this._timeout = undefined; + this._coreService.decPrivateModes.synchronizedOutput = false; + this._onTimeout(); + }, Constants.SYNCHRONIZED_OUTPUT_TIMEOUT_MS); + } + } + + public flush(): { start: number, end: number } | undefined { + if (this._timeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._timeout); + this._timeout = undefined; + } + + if (!this._isBuffering) { + return undefined; + } + + const result = { start: this._start, end: this._end }; + this._isBuffering = false; + return result; + } + + public dispose(): void { + if (this._timeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._timeout); + this._timeout = undefined; + } + } +} diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 78ce646ad6..1325cf0f56 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2314,7 +2314,7 @@ describe('InputHandler', () => { }); it('DEC privates with set/reset semantic', async () => { // initially reset - const reset = [1, 6, 9, 12, 45, 66, 1000, 1002, 1003, 1004, 1006, 1016, 47, 1047, 1049, 2004]; + const reset = [1, 6, 9, 12, 45, 66, 1000, 1002, 1003, 1004, 1006, 1016, 47, 1047, 1049, 2004, 2026]; for (const mode of reset) { await inputHandler.parseP(`\x1b[?${mode}$p`); assert.deepEqual(reportStack.pop(), `\x1b[?${mode};2$y`); // initial reset diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index ca80a789ca..ba53941a7a 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1969,6 +1969,9 @@ export class InputHandler extends Disposable implements IInputHandler { case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = true; break; + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md) + this._coreService.decPrivateModes.synchronizedOutput = true; + break; } } return true; @@ -2197,6 +2200,10 @@ export class InputHandler extends Disposable implements IInputHandler { case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste) this._coreService.decPrivateModes.bracketedPasteMode = false; break; + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md) + this._coreService.decPrivateModes.synchronizedOutput = false; + this._onRequestRefreshRows.fire(undefined); + break; } } return true; @@ -2291,6 +2298,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (p === 1048) return f(p, V.SET); // xterm always returns SET here if (p === 47 || p === 1047 || p === 1049) return f(p, b2v(active === alt)); if (p === 2004) return f(p, b2v(dm.bracketedPasteMode)); + if (p === 2026) return f(p, b2v(dm.synchronizedOutput)); return f(p, V.NOT_RECOGNIZED); } diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index e12311ff64..70ff8fe64d 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -102,6 +102,7 @@ export class MockCoreService implements ICoreService { origin: false, reverseWraparound: false, sendFocus: false, + synchronizedOutput: false, wraparound: true }; public onData: Event = new Emitter().event; diff --git a/src/common/Types.ts b/src/common/Types.ts index c254d330d1..3c147b8ed4 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -273,6 +273,7 @@ export interface IDecPrivateModes { origin: boolean; reverseWraparound: boolean; sendFocus: boolean; + synchronizedOutput: boolean; wraparound: boolean; // defaults: xterm - true, vt100 - false } diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index 5d3eccb8da..7b5f532d3b 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -22,6 +22,7 @@ const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({ origin: false, reverseWraparound: false, sendFocus: false, + synchronizedOutput: false, wraparound: true // defaults: xterm - true, vt100 - false }); diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index ea77bdeee3..289eb72d37 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -400,6 +400,7 @@ describe('Headless API Tests', function (): void { originMode: false, reverseWraparoundMode: false, sendFocusMode: false, + synchronizedOutputMode: false, wraparoundMode: true }); }); diff --git a/src/headless/public/Terminal.ts b/src/headless/public/Terminal.ts index 738570c9e7..c8d6fe7cd7 100644 --- a/src/headless/public/Terminal.ts +++ b/src/headless/public/Terminal.ts @@ -124,6 +124,7 @@ export class Terminal extends Disposable implements ITerminalApi { originMode: m.origin, reverseWraparoundMode: m.reverseWraparound, sendFocusMode: m.sendFocus, + synchronizedOutputMode: m.synchronizedOutput, wraparoundMode: m.wraparound }; } diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 1a35a0e590..60fe9639b7 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -5,8 +5,9 @@ import { IImage32, decodePng } from '@lunapaint/png-codec'; import { LocatorScreenshotOptions, test } from '@playwright/test'; -import { ITheme } from '@xterm/xterm'; -import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate } from './TestUtils'; +import { ITheme, type ITerminalOptions } from '@xterm/xterm'; +import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate, timeout } from './TestUtils'; +import { notDeepStrictEqual } from 'node:assert'; export interface ISharedRendererTestContext { value: ITestContext; @@ -1267,6 +1268,79 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [128, 0, 0, 255]); }); }); + + test.describe('synchronized output', () => { + test.beforeEach(async () => { + const theme: ITheme = { + background: '#000000FF', + + red: '#FF0000FF', + green: '#00FF00FF', + blue: '#0000FFFF' + }; + const options: ITerminalOptions = {} + await ctx.value.page.evaluate(` + window.term.options.theme = ${JSON.stringify(theme)}; + window.term.options.cursorStyle = 'underline'; + `); + }); + test('defers rendering until ESU', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, { + equalityFn: (a, b) => { + return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]); + } + }); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + frameDetails = undefined; + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]); + }); + + test('batches multiple writes', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■\x1b[32m■\x1b[34m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, { + equalityFn: (a, b) => { + return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]); + } + }); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + frameDetails = undefined; + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [0, 0, 255, 255]); + }); + + test('nested BSU is idempotent', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[32m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, { + equalityFn: (a, b) => { + return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]); + } + }); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + frameDetails = undefined; + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 255]); + }); + + test('timeout flushes without ESU', async () => { + await ctx.value.proxy.write('\x1b[?2026h'); // BSU + await ctx.value.proxy.write('\x1b[31m■'); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, { + equalityFn: (a, b) => { + return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]); + } + }); + await ctx.value.page.waitForTimeout(1000); // Timeout hard coded + frameDetails = undefined; + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]); + }); + }); } enum CellColorPosition { @@ -1324,9 +1398,9 @@ export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestCont * @param col The 1-based column index to get the color for. * @param row The 1-based row index to get the color for. */ -function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): MaybeAsync<[red: number, green: number, blue: number, alpha: number]> { +async function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): Promise<[red: number, green: number, blue: number, alpha: number]> { if (!frameDetails) { - return getFrameDetails(ctx).then(frameDetails => getCellColorInner(frameDetails, col, row)); + frameDetails = await getFrameDetails(ctx); } switch (position) { case CellColorPosition.CENTER: diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index ebc2136a7d..830c8989bd 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -609,6 +609,7 @@ test.describe('API Integration Tests', () => { originMode: false, reverseWraparoundMode: false, sendFocusMode: false, + synchronizedOutputMode: false, wraparoundMode: true }); }); diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 11d979474b..ba5f22dec2 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1370,6 +1370,13 @@ declare module '@xterm/headless' { * Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h` */ readonly sendFocusMode: boolean; + /** + * Synchronized Output Mode: `CSI ? 2 0 2 6 h` + * + * When enabled, output is buffered and only rendered when the mode is + * disabled, allowing for atomic screen updates without tearing. + */ + readonly synchronizedOutputMode: boolean; /** * Auto-Wrap Mode (DECAWM): `CSI ? 7 h` */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1c2667d09..95b6ffcbcb 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1968,6 +1968,13 @@ declare module '@xterm/xterm' { * Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h` */ readonly sendFocusMode: boolean; + /** + * Synchronized Output Mode: `CSI ? 2 0 2 6 h` + * + * When enabled, output is buffered and only rendered when the mode is + * disabled, allowing for atomic screen updates without tearing. + */ + readonly synchronizedOutputMode: boolean; /** * Auto-Wrap Mode (DECAWM): `CSI ? 7 h` */