From 83384184714db8d7119b8fb18897b0eca3e8a5c6 Mon Sep 17 00:00:00 2001 From: Ivan Suslov Date: Thu, 4 Dec 2025 03:56:32 -0500 Subject: [PATCH 01/25] Added exports field to headless package.json --- headless/package.json | 5 +++++ 1 file changed, 5 insertions(+) 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": [ From b41d3ef1309a45fdd222a1899e01261a31ac72f6 Mon Sep 17 00:00:00 2001 From: Ivan Suslov Date: Thu, 4 Dec 2025 05:02:12 -0500 Subject: [PATCH 02/25] Fix ESM export path for addon-unicode-graphemes --- addons/addon-unicode-graphemes/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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", From a3e1ecf91b1874f976a5abea9cff45350c482b52 Mon Sep 17 00:00:00 2001 From: Ivan Suslov Date: Thu, 4 Dec 2025 07:28:40 -0500 Subject: [PATCH 03/25] Fix tsc build - upgrade glob version --- package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/package.json b/package.json index 97a61bbde7..f9dc7ef3a3 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,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", From 7228e2e2dc72f01cc44f8dbd453cdd2d54414012 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Thu, 4 Dec 2025 11:16:25 -0800 Subject: [PATCH 04/25] Add synchronized output support (DEC mode 2026) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement synchronized output mode (CSI ? 2026 h/l) which allows applications to batch terminal updates and render them atomically, preventing screen tearing during rapid output. Features: - BSU (CSI ? 2026 h) pauses rendering, buffering row updates - ESU (CSI ? 2026 l) flushes buffer and renders atomically - Configurable timeout via synchronizedOutputTimeout option (default 5s) - Exposed via terminal.modes.synchronizedOutputMode Closes #3375 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/browser/public/Terminal.ts | 1 + src/browser/services/RenderService.test.ts | 495 +++++++++++++++++++++ src/browser/services/RenderService.ts | 87 +++- src/common/InputHandler.ts | 8 + src/common/TestUtils.test.ts | 1 + src/common/Types.ts | 1 + src/common/services/CoreService.ts | 1 + src/common/services/OptionsService.ts | 1 + src/common/services/Services.ts | 1 + typings/xterm.d.ts | 16 + 10 files changed, 602 insertions(+), 10 deletions(-) create mode 100644 src/browser/services/RenderService.test.ts 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.test.ts b/src/browser/services/RenderService.test.ts new file mode 100644 index 0000000000..d8b84780a9 --- /dev/null +++ b/src/browser/services/RenderService.test.ts @@ -0,0 +1,495 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import jsdom = require('jsdom'); +import { RenderService } from 'browser/services/RenderService'; +import { MockBufferService, MockCoreService, MockOptionsService } from 'common/TestUtils.test'; +import { IRenderer, IRenderDimensions } from 'browser/renderer/shared/Types'; +import { ICoreBrowserService } from 'browser/services/Services'; + +// Test timing constants +const RENDER_DEBOUNCE_DELAY = 50; // Time to wait for debounced renders +const DEFAULT_SYNC_OUTPUT_TIMEOUT = 5000; // Default synchronized output timeout +const TIMEOUT_TEST_BUFFER = 500; // Extra time to wait in timeout tests + +class MockRenderer implements IRenderer { + public renderRowsCalls: Array<{ start: number; end: number }> = []; + public dimensions: IRenderDimensions = { + device: { + char: { width: 10, height: 20, left: 0, top: 0 }, + cell: { width: 10, height: 20 }, + canvas: { width: 800, height: 600 } + }, + css: { + canvas: { width: 800, height: 600 }, + cell: { width: 10, height: 20 } + } + }; + + renderRows(start: number, end: number): void { + this.renderRowsCalls.push({ start, end }); + } + + onRequestRedraw(listener: (e: { start: number; end: number }) => void): { dispose: () => void } { + return { dispose: () => { } }; + } + + clearCells(x: number, y: number, width: number, height: number): void { } + clearTextureAtlas(): void { } + clear(): void { } + handleDevicePixelRatioChange(): void { } + handleResize(cols: number, rows: number): void { } + handleCharSizeChanged(): void { } + handleBlur(): void { } + handleFocus(): void { } + handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { } + handleCursorMove(): void { } + handleOptionsChanged(): void { } + dispose(): void { } +} + +class MockCoreBrowserService implements ICoreBrowserService { + public serviceBrand: any; + public isFocused: boolean = true; + public window: any; + public mainDocument: Document; + public onDprChange = () => ({ dispose: () => { } }); + public onWindowChange = () => ({ dispose: () => { } }); + public get dpr(): number { return 1; } + + constructor(window: Window) { + this.window = window; + this.mainDocument = window.document; + // Add requestAnimationFrame and cancelAnimationFrame if not present + if (!this.window.requestAnimationFrame) { + this.window.requestAnimationFrame = (callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 0) as any; + }; + this.window.cancelAnimationFrame = (id: number) => { + clearTimeout(id); + }; + } + } +} + +class MockCharSizeService { + public serviceBrand: any; + public width: number = 10; + public height: number = 20; + public hasValidSize: boolean = true; + public onCharSizeChange = () => ({ dispose: () => { } }); + public measure(): void { } +} + +class MockDecorationService { + public serviceBrand: any; + public decorations: any[] = []; + public onDecorationRegistered = () => ({ dispose: () => { } }); + public onDecorationRemoved = () => ({ dispose: () => { } }); +} + +class MockThemeService { + public serviceBrand: any; + public colors: any = {}; + public onChangeColors = () => ({ dispose: () => { } }); +} + +describe('RenderService', () => { + let dom: jsdom.JSDOM; + let window: Window; + let renderService: RenderService; + let mockRenderer: MockRenderer; + let coreService: MockCoreService; + let bufferService: MockBufferService; + let coreBrowserService: MockCoreBrowserService; + + beforeEach(() => { + dom = new jsdom.JSDOM(''); + window = dom.window as any as Window; + const screenElement = window.document.createElement('div'); + + coreService = new MockCoreService(); + bufferService = new MockBufferService(80, 30); + coreBrowserService = new MockCoreBrowserService(window); + + renderService = new RenderService( + 30, + screenElement, + new MockOptionsService() as any, + new MockCharSizeService() as any, + coreService as any, + new MockDecorationService() as any, + bufferService as any, + coreBrowserService as any, + new MockThemeService() as any + ); + + mockRenderer = new MockRenderer(); + renderService.setRenderer(mockRenderer); + }); + + afterEach(() => { + renderService.dispose(); + }); + + describe('synchronized output mode', () => { + it('should defer rendering when synchronized output is enabled', (done) => { + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + // Enable synchronized output + coreService.decPrivateModes.synchronizedOutput = true; + + // Request a refresh + renderService.refreshRows(0, 10); + + // Give time for the debounced render to trigger + setTimeout(() => { + // Renderer should NOT have been called + assert.equal(mockRenderer.renderRowsCalls.length, 0, 'Renderer should not be called during synchronized output'); + done(); + }, RENDER_DEBOUNCE_DELAY); + }); + + it('should flush buffered rows when synchronized output is disabled', (done) => { + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + // Enable synchronized output + coreService.decPrivateModes.synchronizedOutput = true; + + // Request multiple refreshes while in synchronized mode + renderService.refreshRows(0, 5); + renderService.refreshRows(10, 15); + renderService.refreshRows(3, 20); + + setTimeout(() => { + // Verify no renders happened yet + assert.equal(mockRenderer.renderRowsCalls.length, 0); + + // Disable synchronized output + coreService.decPrivateModes.synchronizedOutput = false; + + // Request a refresh to trigger the flush + renderService.refreshRows(0, 0); + + setTimeout(() => { + // Should have rendered the accumulated range + // Note: The test triggers with refreshRows(0, 0), but the accumulated buffer may extend further + assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Should render once after disabling synchronized output'); + const call = mockRenderer.renderRowsCalls[0]; + assert.equal(call.start, 0, 'Should render from start of accumulated range'); + // The accumulated range should include all requested rows (0-5, 10-15, 3-20 = 0-20) + assert.isAtLeast(call.end, 15, 'Should render at least to row 15'); + done(); + }, 50); + }, 50); + }); + + it('should render normally when synchronized output is not enabled', (done) => { + // Wait for any pending renders from initialization + setTimeout(() => { + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + // Synchronized output is disabled by default + assert.equal(coreService.decPrivateModes.synchronizedOutput, false); + + // Request a refresh + renderService.refreshRows(5, 10); + + setTimeout(() => { + // Renderer SHOULD have been called + assert.equal(mockRenderer.renderRowsCalls.length, 1); + assert.equal(mockRenderer.renderRowsCalls[0].start, 5); + assert.equal(mockRenderer.renderRowsCalls[0].end, 10); + done(); + }, 50); + }, 50); + }); + + it('should accumulate row ranges correctly', (done) => { + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + coreService.decPrivateModes.synchronizedOutput = true; + + // Multiple non-overlapping ranges + renderService.refreshRows(5, 10); + renderService.refreshRows(20, 25); + renderService.refreshRows(0, 3); + + setTimeout(() => { + assert.equal(mockRenderer.renderRowsCalls.length, 0); + + // Disable and flush + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + assert.equal(mockRenderer.renderRowsCalls.length, 1); + // Should accumulate min to max: 0 to 25 (or full viewport if refresh triggered full update) + assert.equal(mockRenderer.renderRowsCalls[0].start, 0); + assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 25, 'Should render at least to row 25'); + done(); + }, 50); + }, 50); + }); + + it('should handle timeout and force render', function(done) { + // This test needs more time for the timeout + this.timeout(10000); + + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + coreService.decPrivateModes.synchronizedOutput = true; + + // Request a refresh + renderService.refreshRows(0, 10); + + setTimeout(() => { + // Should not have rendered yet + assert.equal(mockRenderer.renderRowsCalls.length, 0); + }, 100); + + // Wait for timeout (default timeout + buffer) + setTimeout(() => { + // Timeout should have forced a render + assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Timeout should force render'); + assert.equal(mockRenderer.renderRowsCalls[0].start, 0); + assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 10, 'Should render at least the requested rows'); + + // Mode should have been automatically disabled + assert.equal(coreService.decPrivateModes.synchronizedOutput, false, 'Timeout should disable synchronized output'); + done(); + }, DEFAULT_SYNC_OUTPUT_TIMEOUT + TIMEOUT_TEST_BUFFER); + }); + + it('should restart timeout on each buffered render request', function(done) { + this.timeout(12000); + + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + coreService.decPrivateModes.synchronizedOutput = true; + + // First request + renderService.refreshRows(0, 5); + + // Keep requesting refreshes every 2 seconds (before 5s timeout) + let requestCount = 0; + const interval = setInterval(() => { + requestCount++; + renderService.refreshRows(0, 5); + + if (requestCount >= 2) { + clearInterval(interval); + + // After stopping requests, wait for timeout + setTimeout(() => { + // Should have rendered after final timeout + assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Should render after final timeout'); + assert.equal(coreService.decPrivateModes.synchronizedOutput, false); + done(); + }, 5500); + } + }, 2000); + }); + + it('should clear buffered state after flush', (done) => { + // Clear any initial renders from setRenderer + mockRenderer.renderRowsCalls = []; + + coreService.decPrivateModes.synchronizedOutput = true; + + // First cycle + renderService.refreshRows(0, 10); + + setTimeout(() => { + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + assert.equal(mockRenderer.renderRowsCalls.length, 1); + mockRenderer.renderRowsCalls = []; + + // Second cycle - should not include rows from first cycle + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(20, 25); + + setTimeout(() => { + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + assert.equal(mockRenderer.renderRowsCalls.length, 1); + assert.equal(mockRenderer.renderRowsCalls[0].start, 20); + assert.equal(mockRenderer.renderRowsCalls[0].end, 25); + done(); + }, 50); + }, 50); + }, 50); + }, 50); + }); + + it('should handle BSU sent twice without ESU (idempotent)', (done) => { + // Clear any initial renders + mockRenderer.renderRowsCalls = []; + + // Enable synchronized output + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(0, 10); + + setTimeout(() => { + assert.equal(mockRenderer.renderRowsCalls.length, 0); + + // Enable again (should be idempotent) + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(10, 20); + + setTimeout(() => { + // Still no rendering + assert.equal(mockRenderer.renderRowsCalls.length, 0); + + // Now disable + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + // Should render accumulated range + assert.equal(mockRenderer.renderRowsCalls.length, 1); + assert.equal(mockRenderer.renderRowsCalls[0].start, 0); + assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 20); + done(); + }, 50); + }, 50); + }, 50); + }); + + it('should handle ESU without BSU (no-op)', (done) => { + // Wait for any pending renders + setTimeout(() => { + // Clear any initial renders + mockRenderer.renderRowsCalls = []; + + // Synchronized output is already disabled (default state) + assert.equal(coreService.decPrivateModes.synchronizedOutput, false); + + // Disable again (ESU without BSU) + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(5, 10); + + setTimeout(() => { + // Should render - ESU without BSU should not cause issues + // The exact rows may vary due to previous test state, but rendering should occur + assert.isAtLeast(mockRenderer.renderRowsCalls.length, 1, 'Should render even with ESU before BSU'); + done(); + }, RENDER_DEBOUNCE_DELAY); + }, RENDER_DEBOUNCE_DELAY); + }); + + it('should handle rapid enable/disable toggling', (done) => { + // Clear any initial renders + mockRenderer.renderRowsCalls = []; + + // Rapid toggling + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(0, 5); + + setTimeout(() => { + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + const firstRenderCount = mockRenderer.renderRowsCalls.length; + + // Toggle again immediately + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(10, 15); + + setTimeout(() => { + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + // Should have rendered both cycles + assert.isAtLeast(mockRenderer.renderRowsCalls.length, firstRenderCount + 1); + done(); + }, 50); + }, 50); + }, 50); + }, 50); + }); + + it('should handle terminal resize during synchronized output', (done) => { + // Clear any initial renders + mockRenderer.renderRowsCalls = []; + + coreService.decPrivateModes.synchronizedOutput = true; + renderService.refreshRows(0, 10); + + setTimeout(() => { + // Resize terminal + renderService.resize(80, 50); + + // Continue buffering with new size + renderService.refreshRows(40, 45); + + setTimeout(() => { + // Disable synchronized output + coreService.decPrivateModes.synchronizedOutput = false; + renderService.refreshRows(0, 0); + + setTimeout(() => { + // Should have rendered (clamped to new size if needed) + assert.isAtLeast(mockRenderer.renderRowsCalls.length, 1); + done(); + }, 50); + }, 50); + }, 50); + }); + + it('should not timeout when timeout is disabled', function(done) { + this.timeout(3000); + + // Create a new render service with timeout disabled + const optionsServiceWithTimeout = new MockOptionsService({ synchronizedOutputTimeout: 0 }) as any; + + const newRenderService = new RenderService( + 30, + window.document.createElement('div'), + optionsServiceWithTimeout, + new MockCharSizeService() as any, + coreService as any, + new MockDecorationService() as any, + bufferService as any, + coreBrowserService as any, + new MockThemeService() as any + ); + + const newMockRenderer = new MockRenderer(); + newRenderService.setRenderer(newMockRenderer); + + setTimeout(() => { + newMockRenderer.renderRowsCalls = []; + coreService.decPrivateModes.synchronizedOutput = true; + newRenderService.refreshRows(0, 10); + + // Wait longer than the default timeout would be + setTimeout(() => { + // Should NOT have rendered (timeout disabled) + assert.equal(newMockRenderer.renderRowsCalls.length, 0); + // Mode should still be enabled + assert.equal(coreService.decPrivateModes.synchronizedOutput, true); + + newRenderService.dispose(); + done(); + }, 1000); + }, 50); + }); + }); +}); diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 3ca0314afa..016a0bfc35 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,7 @@ interface ISelectionState { columnSelectMode: boolean; } + export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; @@ -32,6 +33,9 @@ export class RenderService extends Disposable implements IRenderService { private _needsSelectionRefresh: boolean = false; private _canvasWidth: number = 0; private _canvasHeight: number = 0; + private _synchronizedOutputTimeout: number | undefined; + private _synchronizedOutputStart: number = 0; + private _synchronizedOutputEnd: number = 0; private _selectionState: ISelectionState = { start: undefined, end: undefined, @@ -52,23 +56,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())); + // Clear synchronized output timeout on dispose + this._register(toDisposable(() => { + if (this._synchronizedOutputTimeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._synchronizedOutputTimeout); + } + })); + + 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 +90,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 +108,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 +149,46 @@ export class RenderService extends Disposable implements IRenderService { this._needsFullRefresh = true; return; } + + // Handle synchronized output mode (DEC 2026) + if (this._coreService.decPrivateModes.synchronizedOutput) { + // Track the row range that needs refreshing + if (!this._needsFullRefresh) { + // First request in this sync cycle + this._synchronizedOutputStart = start; + this._synchronizedOutputEnd = end; + this._needsFullRefresh = true; + } else { + // Expand the tracked range to include new rows + this._synchronizedOutputStart = Math.min(this._synchronizedOutputStart, start); + this._synchronizedOutputEnd = Math.max(this._synchronizedOutputEnd, end); + } + // Start a safety timeout if not already running and timeout is enabled + const timeout = this._optionsService.options.synchronizedOutputTimeout; + if (this._synchronizedOutputTimeout === undefined && timeout && timeout > 0) { + this._synchronizedOutputTimeout = this._coreBrowserService.window.setTimeout(() => { + this._synchronizedOutputTimeout = undefined; + // Force-disable the mode and trigger a refresh + this._coreService.decPrivateModes.synchronizedOutput = false; + this._fullRefresh(); + }, timeout); + } + return; + } + + // Clear the timeout if synchronized output mode was just disabled + if (this._synchronizedOutputTimeout !== undefined) { + this._coreBrowserService.window.clearTimeout(this._synchronizedOutputTimeout); + this._synchronizedOutputTimeout = undefined; + } + + // If we were in synchronized output mode, use the tracked row range + if (this._needsFullRefresh) { + start = this._synchronizedOutputStart; + end = this._synchronizedOutputEnd; + this._needsFullRefresh = false; + } + if (!isRedrawOnly) { this._isNextRenderRedrawOnly = false; } @@ -148,6 +200,21 @@ 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) { + // Track the row range that needs refreshing + if (!this._needsFullRefresh) { + this._synchronizedOutputStart = start; + this._synchronizedOutputEnd = end; + this._needsFullRefresh = true; + } else { + this._synchronizedOutputStart = Math.min(this._synchronizedOutputStart, start); + this._synchronizedOutputEnd = Math.max(this._synchronizedOutputEnd, 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. diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index ca80a789ca..b3e3caa5cc 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://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) + this._coreService.decPrivateModes.synchronizedOutput = true; + break; } } return true; @@ -2197,6 +2200,11 @@ 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://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) + this._coreService.decPrivateModes.synchronizedOutput = false; + // Trigger a full refresh now that the synchronized output block has ended + this._onRequestRefreshRows.fire(undefined); + break; } } return true; 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/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 6ad48b9319..eb2e815dcd 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -37,6 +37,7 @@ export const DEFAULT_OPTIONS: Readonly> = { scrollSensitivity: 1, screenReaderMode: false, smoothScrollDuration: 0, + synchronizedOutputTimeout: 5000, macOptionIsMeta: false, macOptionClickForcesSelection: false, minimumContrastRatio: 1, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a2847f1b8f..80367f7797 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -259,6 +259,7 @@ export interface ITerminalOptions { scrollOnUserInput?: boolean; scrollSensitivity?: number; smoothScrollDuration?: number; + synchronizedOutputTimeout?: number; tabStopWidth?: number; theme?: ITheme; windowsMode?: boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index d1c2667d09..849d1f5a18 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -281,6 +281,15 @@ declare module '@xterm/xterm' { */ smoothScrollDuration?: number; + /** + * The timeout in milliseconds for synchronized output mode (DEC mode 2026). + * When an application enables synchronized output but fails to disable it + * within this timeout, the terminal will automatically flush buffered + * output to prevent the display from freezing indefinitely. Set to 0 to + * disable the timeout (not recommended). The default is 5000 (5 seconds). + */ + synchronizedOutputTimeout?: number; + /** * The size of tab stops in the terminal. */ @@ -1968,6 +1977,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` */ From 6b63c5f260cc37c84aa5df049885677accf3d958 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Tue, 9 Dec 2025 07:58:31 -0800 Subject: [PATCH 05/25] Address PR review feedback for synchronized output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove synchronizedOutputTimeout public API, hardcode 1s timeout - Extract SynchronizedOutputHandler class for cleaner code - Update spec URL to contour-terminal/vt-extensions - Remove unnecessary comment in InputHandler - Delete unit tests, add integration tests in SharedRendererTests - Fix whitespace issue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/browser/services/RenderService.test.ts | 495 --------------------- src/browser/services/RenderService.ts | 124 +++--- src/common/InputHandler.ts | 5 +- src/common/services/OptionsService.ts | 1 - src/common/services/Services.ts | 1 - test/playwright/SharedRendererTests.ts | 39 ++ typings/xterm.d.ts | 9 - 7 files changed, 114 insertions(+), 560 deletions(-) delete mode 100644 src/browser/services/RenderService.test.ts diff --git a/src/browser/services/RenderService.test.ts b/src/browser/services/RenderService.test.ts deleted file mode 100644 index d8b84780a9..0000000000 --- a/src/browser/services/RenderService.test.ts +++ /dev/null @@ -1,495 +0,0 @@ -/** - * Copyright (c) 2025 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import jsdom = require('jsdom'); -import { RenderService } from 'browser/services/RenderService'; -import { MockBufferService, MockCoreService, MockOptionsService } from 'common/TestUtils.test'; -import { IRenderer, IRenderDimensions } from 'browser/renderer/shared/Types'; -import { ICoreBrowserService } from 'browser/services/Services'; - -// Test timing constants -const RENDER_DEBOUNCE_DELAY = 50; // Time to wait for debounced renders -const DEFAULT_SYNC_OUTPUT_TIMEOUT = 5000; // Default synchronized output timeout -const TIMEOUT_TEST_BUFFER = 500; // Extra time to wait in timeout tests - -class MockRenderer implements IRenderer { - public renderRowsCalls: Array<{ start: number; end: number }> = []; - public dimensions: IRenderDimensions = { - device: { - char: { width: 10, height: 20, left: 0, top: 0 }, - cell: { width: 10, height: 20 }, - canvas: { width: 800, height: 600 } - }, - css: { - canvas: { width: 800, height: 600 }, - cell: { width: 10, height: 20 } - } - }; - - renderRows(start: number, end: number): void { - this.renderRowsCalls.push({ start, end }); - } - - onRequestRedraw(listener: (e: { start: number; end: number }) => void): { dispose: () => void } { - return { dispose: () => { } }; - } - - clearCells(x: number, y: number, width: number, height: number): void { } - clearTextureAtlas(): void { } - clear(): void { } - handleDevicePixelRatioChange(): void { } - handleResize(cols: number, rows: number): void { } - handleCharSizeChanged(): void { } - handleBlur(): void { } - handleFocus(): void { } - handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void { } - handleCursorMove(): void { } - handleOptionsChanged(): void { } - dispose(): void { } -} - -class MockCoreBrowserService implements ICoreBrowserService { - public serviceBrand: any; - public isFocused: boolean = true; - public window: any; - public mainDocument: Document; - public onDprChange = () => ({ dispose: () => { } }); - public onWindowChange = () => ({ dispose: () => { } }); - public get dpr(): number { return 1; } - - constructor(window: Window) { - this.window = window; - this.mainDocument = window.document; - // Add requestAnimationFrame and cancelAnimationFrame if not present - if (!this.window.requestAnimationFrame) { - this.window.requestAnimationFrame = (callback: FrameRequestCallback) => { - return setTimeout(() => callback(Date.now()), 0) as any; - }; - this.window.cancelAnimationFrame = (id: number) => { - clearTimeout(id); - }; - } - } -} - -class MockCharSizeService { - public serviceBrand: any; - public width: number = 10; - public height: number = 20; - public hasValidSize: boolean = true; - public onCharSizeChange = () => ({ dispose: () => { } }); - public measure(): void { } -} - -class MockDecorationService { - public serviceBrand: any; - public decorations: any[] = []; - public onDecorationRegistered = () => ({ dispose: () => { } }); - public onDecorationRemoved = () => ({ dispose: () => { } }); -} - -class MockThemeService { - public serviceBrand: any; - public colors: any = {}; - public onChangeColors = () => ({ dispose: () => { } }); -} - -describe('RenderService', () => { - let dom: jsdom.JSDOM; - let window: Window; - let renderService: RenderService; - let mockRenderer: MockRenderer; - let coreService: MockCoreService; - let bufferService: MockBufferService; - let coreBrowserService: MockCoreBrowserService; - - beforeEach(() => { - dom = new jsdom.JSDOM(''); - window = dom.window as any as Window; - const screenElement = window.document.createElement('div'); - - coreService = new MockCoreService(); - bufferService = new MockBufferService(80, 30); - coreBrowserService = new MockCoreBrowserService(window); - - renderService = new RenderService( - 30, - screenElement, - new MockOptionsService() as any, - new MockCharSizeService() as any, - coreService as any, - new MockDecorationService() as any, - bufferService as any, - coreBrowserService as any, - new MockThemeService() as any - ); - - mockRenderer = new MockRenderer(); - renderService.setRenderer(mockRenderer); - }); - - afterEach(() => { - renderService.dispose(); - }); - - describe('synchronized output mode', () => { - it('should defer rendering when synchronized output is enabled', (done) => { - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - // Enable synchronized output - coreService.decPrivateModes.synchronizedOutput = true; - - // Request a refresh - renderService.refreshRows(0, 10); - - // Give time for the debounced render to trigger - setTimeout(() => { - // Renderer should NOT have been called - assert.equal(mockRenderer.renderRowsCalls.length, 0, 'Renderer should not be called during synchronized output'); - done(); - }, RENDER_DEBOUNCE_DELAY); - }); - - it('should flush buffered rows when synchronized output is disabled', (done) => { - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - // Enable synchronized output - coreService.decPrivateModes.synchronizedOutput = true; - - // Request multiple refreshes while in synchronized mode - renderService.refreshRows(0, 5); - renderService.refreshRows(10, 15); - renderService.refreshRows(3, 20); - - setTimeout(() => { - // Verify no renders happened yet - assert.equal(mockRenderer.renderRowsCalls.length, 0); - - // Disable synchronized output - coreService.decPrivateModes.synchronizedOutput = false; - - // Request a refresh to trigger the flush - renderService.refreshRows(0, 0); - - setTimeout(() => { - // Should have rendered the accumulated range - // Note: The test triggers with refreshRows(0, 0), but the accumulated buffer may extend further - assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Should render once after disabling synchronized output'); - const call = mockRenderer.renderRowsCalls[0]; - assert.equal(call.start, 0, 'Should render from start of accumulated range'); - // The accumulated range should include all requested rows (0-5, 10-15, 3-20 = 0-20) - assert.isAtLeast(call.end, 15, 'Should render at least to row 15'); - done(); - }, 50); - }, 50); - }); - - it('should render normally when synchronized output is not enabled', (done) => { - // Wait for any pending renders from initialization - setTimeout(() => { - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - // Synchronized output is disabled by default - assert.equal(coreService.decPrivateModes.synchronizedOutput, false); - - // Request a refresh - renderService.refreshRows(5, 10); - - setTimeout(() => { - // Renderer SHOULD have been called - assert.equal(mockRenderer.renderRowsCalls.length, 1); - assert.equal(mockRenderer.renderRowsCalls[0].start, 5); - assert.equal(mockRenderer.renderRowsCalls[0].end, 10); - done(); - }, 50); - }, 50); - }); - - it('should accumulate row ranges correctly', (done) => { - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - coreService.decPrivateModes.synchronizedOutput = true; - - // Multiple non-overlapping ranges - renderService.refreshRows(5, 10); - renderService.refreshRows(20, 25); - renderService.refreshRows(0, 3); - - setTimeout(() => { - assert.equal(mockRenderer.renderRowsCalls.length, 0); - - // Disable and flush - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - assert.equal(mockRenderer.renderRowsCalls.length, 1); - // Should accumulate min to max: 0 to 25 (or full viewport if refresh triggered full update) - assert.equal(mockRenderer.renderRowsCalls[0].start, 0); - assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 25, 'Should render at least to row 25'); - done(); - }, 50); - }, 50); - }); - - it('should handle timeout and force render', function(done) { - // This test needs more time for the timeout - this.timeout(10000); - - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - coreService.decPrivateModes.synchronizedOutput = true; - - // Request a refresh - renderService.refreshRows(0, 10); - - setTimeout(() => { - // Should not have rendered yet - assert.equal(mockRenderer.renderRowsCalls.length, 0); - }, 100); - - // Wait for timeout (default timeout + buffer) - setTimeout(() => { - // Timeout should have forced a render - assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Timeout should force render'); - assert.equal(mockRenderer.renderRowsCalls[0].start, 0); - assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 10, 'Should render at least the requested rows'); - - // Mode should have been automatically disabled - assert.equal(coreService.decPrivateModes.synchronizedOutput, false, 'Timeout should disable synchronized output'); - done(); - }, DEFAULT_SYNC_OUTPUT_TIMEOUT + TIMEOUT_TEST_BUFFER); - }); - - it('should restart timeout on each buffered render request', function(done) { - this.timeout(12000); - - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - coreService.decPrivateModes.synchronizedOutput = true; - - // First request - renderService.refreshRows(0, 5); - - // Keep requesting refreshes every 2 seconds (before 5s timeout) - let requestCount = 0; - const interval = setInterval(() => { - requestCount++; - renderService.refreshRows(0, 5); - - if (requestCount >= 2) { - clearInterval(interval); - - // After stopping requests, wait for timeout - setTimeout(() => { - // Should have rendered after final timeout - assert.equal(mockRenderer.renderRowsCalls.length, 1, 'Should render after final timeout'); - assert.equal(coreService.decPrivateModes.synchronizedOutput, false); - done(); - }, 5500); - } - }, 2000); - }); - - it('should clear buffered state after flush', (done) => { - // Clear any initial renders from setRenderer - mockRenderer.renderRowsCalls = []; - - coreService.decPrivateModes.synchronizedOutput = true; - - // First cycle - renderService.refreshRows(0, 10); - - setTimeout(() => { - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - assert.equal(mockRenderer.renderRowsCalls.length, 1); - mockRenderer.renderRowsCalls = []; - - // Second cycle - should not include rows from first cycle - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(20, 25); - - setTimeout(() => { - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - assert.equal(mockRenderer.renderRowsCalls.length, 1); - assert.equal(mockRenderer.renderRowsCalls[0].start, 20); - assert.equal(mockRenderer.renderRowsCalls[0].end, 25); - done(); - }, 50); - }, 50); - }, 50); - }, 50); - }); - - it('should handle BSU sent twice without ESU (idempotent)', (done) => { - // Clear any initial renders - mockRenderer.renderRowsCalls = []; - - // Enable synchronized output - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(0, 10); - - setTimeout(() => { - assert.equal(mockRenderer.renderRowsCalls.length, 0); - - // Enable again (should be idempotent) - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(10, 20); - - setTimeout(() => { - // Still no rendering - assert.equal(mockRenderer.renderRowsCalls.length, 0); - - // Now disable - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - // Should render accumulated range - assert.equal(mockRenderer.renderRowsCalls.length, 1); - assert.equal(mockRenderer.renderRowsCalls[0].start, 0); - assert.isAtLeast(mockRenderer.renderRowsCalls[0].end, 20); - done(); - }, 50); - }, 50); - }, 50); - }); - - it('should handle ESU without BSU (no-op)', (done) => { - // Wait for any pending renders - setTimeout(() => { - // Clear any initial renders - mockRenderer.renderRowsCalls = []; - - // Synchronized output is already disabled (default state) - assert.equal(coreService.decPrivateModes.synchronizedOutput, false); - - // Disable again (ESU without BSU) - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(5, 10); - - setTimeout(() => { - // Should render - ESU without BSU should not cause issues - // The exact rows may vary due to previous test state, but rendering should occur - assert.isAtLeast(mockRenderer.renderRowsCalls.length, 1, 'Should render even with ESU before BSU'); - done(); - }, RENDER_DEBOUNCE_DELAY); - }, RENDER_DEBOUNCE_DELAY); - }); - - it('should handle rapid enable/disable toggling', (done) => { - // Clear any initial renders - mockRenderer.renderRowsCalls = []; - - // Rapid toggling - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(0, 5); - - setTimeout(() => { - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - const firstRenderCount = mockRenderer.renderRowsCalls.length; - - // Toggle again immediately - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(10, 15); - - setTimeout(() => { - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - // Should have rendered both cycles - assert.isAtLeast(mockRenderer.renderRowsCalls.length, firstRenderCount + 1); - done(); - }, 50); - }, 50); - }, 50); - }, 50); - }); - - it('should handle terminal resize during synchronized output', (done) => { - // Clear any initial renders - mockRenderer.renderRowsCalls = []; - - coreService.decPrivateModes.synchronizedOutput = true; - renderService.refreshRows(0, 10); - - setTimeout(() => { - // Resize terminal - renderService.resize(80, 50); - - // Continue buffering with new size - renderService.refreshRows(40, 45); - - setTimeout(() => { - // Disable synchronized output - coreService.decPrivateModes.synchronizedOutput = false; - renderService.refreshRows(0, 0); - - setTimeout(() => { - // Should have rendered (clamped to new size if needed) - assert.isAtLeast(mockRenderer.renderRowsCalls.length, 1); - done(); - }, 50); - }, 50); - }, 50); - }); - - it('should not timeout when timeout is disabled', function(done) { - this.timeout(3000); - - // Create a new render service with timeout disabled - const optionsServiceWithTimeout = new MockOptionsService({ synchronizedOutputTimeout: 0 }) as any; - - const newRenderService = new RenderService( - 30, - window.document.createElement('div'), - optionsServiceWithTimeout, - new MockCharSizeService() as any, - coreService as any, - new MockDecorationService() as any, - bufferService as any, - coreBrowserService as any, - new MockThemeService() as any - ); - - const newMockRenderer = new MockRenderer(); - newRenderService.setRenderer(newMockRenderer); - - setTimeout(() => { - newMockRenderer.renderRowsCalls = []; - coreService.decPrivateModes.synchronizedOutput = true; - newRenderService.refreshRows(0, 10); - - // Wait longer than the default timeout would be - setTimeout(() => { - // Should NOT have rendered (timeout disabled) - assert.equal(newMockRenderer.renderRowsCalls.length, 0); - // Mode should still be enabled - assert.equal(coreService.decPrivateModes.synchronizedOutput, true); - - newRenderService.dispose(); - done(); - }, 1000); - }, 50); - }); - }); -}); diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 016a0bfc35..1877db7029 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -18,6 +18,66 @@ interface ISelectionState { columnSelectMode: boolean; } +const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000; + +/** + * 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(); + }, 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; + } + } +} export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; @@ -33,9 +93,7 @@ export class RenderService extends Disposable implements IRenderService { private _needsSelectionRefresh: boolean = false; private _canvasWidth: number = 0; private _canvasHeight: number = 0; - private _synchronizedOutputTimeout: number | undefined; - private _synchronizedOutputStart: number = 0; - private _synchronizedOutputEnd: number = 0; + private _syncOutputHandler: SynchronizedOutputHandler; private _selectionState: ISelectionState = { start: undefined, end: undefined, @@ -69,12 +127,12 @@ export class RenderService extends Disposable implements IRenderService { this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService); this._register(this._renderDebouncer); - // Clear synchronized output timeout on dispose - this._register(toDisposable(() => { - if (this._synchronizedOutputTimeout !== undefined) { - this._coreBrowserService.window.clearTimeout(this._synchronizedOutputTimeout); - } - })); + this._syncOutputHandler = new SynchronizedOutputHandler( + this._coreBrowserService, + this._coreService, + () => this._fullRefresh() + ); + this._register(toDisposable(() => this._syncOutputHandler.dispose())); this._register(this._coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange())); @@ -150,43 +208,15 @@ export class RenderService extends Disposable implements IRenderService { return; } - // Handle synchronized output mode (DEC 2026) if (this._coreService.decPrivateModes.synchronizedOutput) { - // Track the row range that needs refreshing - if (!this._needsFullRefresh) { - // First request in this sync cycle - this._synchronizedOutputStart = start; - this._synchronizedOutputEnd = end; - this._needsFullRefresh = true; - } else { - // Expand the tracked range to include new rows - this._synchronizedOutputStart = Math.min(this._synchronizedOutputStart, start); - this._synchronizedOutputEnd = Math.max(this._synchronizedOutputEnd, end); - } - // Start a safety timeout if not already running and timeout is enabled - const timeout = this._optionsService.options.synchronizedOutputTimeout; - if (this._synchronizedOutputTimeout === undefined && timeout && timeout > 0) { - this._synchronizedOutputTimeout = this._coreBrowserService.window.setTimeout(() => { - this._synchronizedOutputTimeout = undefined; - // Force-disable the mode and trigger a refresh - this._coreService.decPrivateModes.synchronizedOutput = false; - this._fullRefresh(); - }, timeout); - } + this._syncOutputHandler.bufferRows(start, end); return; } - // Clear the timeout if synchronized output mode was just disabled - if (this._synchronizedOutputTimeout !== undefined) { - this._coreBrowserService.window.clearTimeout(this._synchronizedOutputTimeout); - this._synchronizedOutputTimeout = undefined; - } - - // If we were in synchronized output mode, use the tracked row range - if (this._needsFullRefresh) { - start = this._synchronizedOutputStart; - end = this._synchronizedOutputEnd; - this._needsFullRefresh = false; + const buffered = this._syncOutputHandler.flush(); + if (buffered) { + start = Math.min(start, buffered.start); + end = Math.max(end, buffered.end); } if (!isRedrawOnly) { @@ -203,15 +233,7 @@ export class RenderService extends Disposable implements IRenderService { // 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) { - // Track the row range that needs refreshing - if (!this._needsFullRefresh) { - this._synchronizedOutputStart = start; - this._synchronizedOutputEnd = end; - this._needsFullRefresh = true; - } else { - this._synchronizedOutputStart = Math.min(this._synchronizedOutputStart, start); - this._synchronizedOutputEnd = Math.max(this._synchronizedOutputEnd, end); - } + this._syncOutputHandler.bufferRows(start, end); return; } diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index b3e3caa5cc..423f4976b7 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1969,7 +1969,7 @@ 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://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/main/synchronized-output.md) this._coreService.decPrivateModes.synchronizedOutput = true; break; } @@ -2200,9 +2200,8 @@ 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://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036) + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/main/synchronized-output.md) this._coreService.decPrivateModes.synchronizedOutput = false; - // Trigger a full refresh now that the synchronized output block has ended this._onRequestRefreshRows.fire(undefined); break; } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index eb2e815dcd..6ad48b9319 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -37,7 +37,6 @@ export const DEFAULT_OPTIONS: Readonly> = { scrollSensitivity: 1, screenReaderMode: false, smoothScrollDuration: 0, - synchronizedOutputTimeout: 5000, macOptionIsMeta: false, macOptionClickForcesSelection: false, minimumContrastRatio: 1, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 80367f7797..a2847f1b8f 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -259,7 +259,6 @@ export interface ITerminalOptions { scrollOnUserInput?: boolean; scrollSensitivity?: number; smoothScrollDuration?: number; - synchronizedOutputTimeout?: number; tabStopWidth?: number; theme?: ITheme; windowsMode?: boolean; diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 1a35a0e590..5e4f026084 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -1267,6 +1267,45 @@ 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('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), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 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), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [36, 114, 200, 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), [0, 0, 0, 255]); + await ctx.value.proxy.write('\x1b[?2026l'); // ESU + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 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), [0, 0, 0, 255]); + await ctx.value.page.waitForTimeout(1500); + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + }); + }); } enum CellColorPosition { diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 849d1f5a18..95b6ffcbcb 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -281,15 +281,6 @@ declare module '@xterm/xterm' { */ smoothScrollDuration?: number; - /** - * The timeout in milliseconds for synchronized output mode (DEC mode 2026). - * When an application enables synchronized output but fails to disable it - * within this timeout, the terminal will automatically flush buffered - * output to prevent the display from freezing indefinitely. Set to 0 to - * disable the timeout (not recommended). The default is 5000 (5 seconds). - */ - synchronizedOutputTimeout?: number; - /** * The size of tab stops in the terminal. */ From 5d0eae85760817f7a9c57ddf83c677ca99f40f01 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Tue, 9 Dec 2025 08:23:37 -0800 Subject: [PATCH 06/25] Fix spec URL to use master branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/common/InputHandler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 423f4976b7..1e61a7cb88 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1969,7 +1969,7 @@ 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/main/synchronized-output.md) + case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md) this._coreService.decPrivateModes.synchronizedOutput = true; break; } @@ -2200,7 +2200,7 @@ 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/main/synchronized-output.md) + 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; From af5dbfcd2c63a79c7c39ed14d93a69adadf513f8 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Tue, 9 Dec 2025 08:26:04 -0800 Subject: [PATCH 07/25] Add DECRQM support for synchronized output mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applications can now detect synchronized output support by querying CSI ? 2026 $ p and receiving CSI ? 2026 ; $ y response. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/common/InputHandler.test.ts | 2 +- src/common/InputHandler.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) 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 1e61a7cb88..ba53941a7a 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2298,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); } From 7f81df5a3a57527a95b84b3bbed69b9cb8a2af83 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:49:07 -0800 Subject: [PATCH 08/25] Add BSU and ESU to VT tab in demo --- demo/client.ts | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) 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]; From 7cdf3f9dd712342200d9a44a907e67ac3461a9d2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:50:28 -0800 Subject: [PATCH 09/25] Prefer const enum so the number is inlined --- src/browser/services/RenderService.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 1877db7029..a43a3496d9 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -18,7 +18,9 @@ interface ISelectionState { columnSelectMode: boolean; } -const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000; +const enum Constants { + SynchronizedOutputTimeoutMs = 1000 +} /** * Buffers row refresh requests during synchronized output mode (DEC mode 2026). @@ -52,7 +54,7 @@ class SynchronizedOutputHandler { this._timeout = undefined; this._coreService.decPrivateModes.synchronizedOutput = false; this._onTimeout(); - }, SYNCHRONIZED_OUTPUT_TIMEOUT_MS); + }, Constants.SynchronizedOutputTimeoutMs); } } From 9ea059de6f4759935975fd86d3581fdc8f516047 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:50:59 -0800 Subject: [PATCH 10/25] Move SynchronizedOutputHandler to bottom of file --- src/browser/services/RenderService.ts | 118 +++++++++++++------------- 1 file changed, 59 insertions(+), 59 deletions(-) diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index a43a3496d9..735fd1b37a 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -22,65 +22,6 @@ const enum Constants { SynchronizedOutputTimeoutMs = 1000 } -/** - * 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.SynchronizedOutputTimeoutMs); - } - } - - 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; - } - } -} - export class RenderService extends Disposable implements IRenderService { public serviceBrand: undefined; @@ -374,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.SynchronizedOutputTimeoutMs); + } + } + + 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; + } + } +} From bd361af105ecf25b508809e192c3d7a31baa33a2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:59:52 -0800 Subject: [PATCH 11/25] Fix lint --- src/browser/services/RenderService.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 735fd1b37a..e9acbeb36a 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -19,7 +19,7 @@ interface ISelectionState { } const enum Constants { - SynchronizedOutputTimeoutMs = 1000 + SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000 } export class RenderService extends Disposable implements IRenderService { @@ -348,7 +348,7 @@ class SynchronizedOutputHandler { this._timeout = undefined; this._coreService.decPrivateModes.synchronizedOutput = false; this._onTimeout(); - }, Constants.SynchronizedOutputTimeoutMs); + }, Constants.SYNCHRONIZED_OUTPUT_TIMEOUT_MS); } } From 7f50b56ae4c45850fdbe76246162e6963ab7bdf9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:44:13 -0800 Subject: [PATCH 12/25] Fix one playwright test --- test/playwright/Terminal.test.ts | 1 + 1 file changed, 1 insertion(+) 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 }); }); From 832aa5447867006ceb162a55d192c9c02f7ab5b3 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:04:11 -0800 Subject: [PATCH 13/25] Fix colors and clear cached frames to pass tests --- test/playwright/SharedRendererTests.ts | 28 ++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 5e4f026084..a88f50d21e 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -1269,12 +1269,21 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void }); test.describe('synchronized output', () => { + test.beforeEach(async () => { + const theme: ITheme = { + red: '#FF0000FF', + green: '#00FF00FF', + blue: '#0000FFFF' + }; + await ctx.value.page.evaluate(`window.term.options.theme = ${JSON.stringify(theme)};`); + }); 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), [0, 0, 0, 255]); await ctx.value.proxy.write('\x1b[?2026l'); // ESU - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + frameDetails = undefined; + await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]); }); test('batches multiple writes', async () => { @@ -1282,9 +1291,10 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await ctx.value.proxy.write('\x1b[31m■\x1b[32m■\x1b[34m■'); await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); await ctx.value.proxy.write('\x1b[?2026l'); // ESU - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 255]); - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [36, 114, 200, 255]); + 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 () => { @@ -1294,16 +1304,18 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await ctx.value.proxy.write('\x1b[32m■'); await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [0, 0, 0, 255]); await ctx.value.proxy.write('\x1b[?2026l'); // ESU - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [13, 188, 121, 255]); + 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), [0, 0, 0, 255]); - await ctx.value.page.waitForTimeout(1500); - await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [205, 49, 49, 255]); + 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]); }); }); } From 32553fff06e62b09c0637d90f476a2231ace460d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:05:36 -0800 Subject: [PATCH 14/25] Add synchronizedOutputMode to xterm-headless.d.ts --- typings/xterm-headless.d.ts | 7 +++++++ 1 file changed, 7 insertions(+) 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` */ From 20c1674d000cf092c12474b9d37196bf94433f70 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:12:38 -0800 Subject: [PATCH 15/25] Update package-lock.json --- package-lock.json | 171 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 141 insertions(+), 30 deletions(-) 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, From 1eee5cdf18d366a89114a62afcf5b72b5c671b26 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:14:45 -0800 Subject: [PATCH 16/25] Add synchronizedOutput mode to headless terminal object --- src/headless/public/Terminal.ts | 1 + 1 file changed, 1 insertion(+) 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 }; } From 2edd121f1bc371eeaa7de137fbdd8c87a78445e6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:17:31 -0800 Subject: [PATCH 17/25] Add node about serialize addon support --- addons/addon-serialize/src/SerializeAddon.ts | 1 + 1 file changed, 1 insertion(+) 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'; From ed0ab5f9e4275e9ec46f451f379b521fa5dc20c1 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:39:27 -0800 Subject: [PATCH 18/25] Add missing property to headless API test assertion --- src/headless/public/Terminal.test.ts | 1 + 1 file changed, 1 insertion(+) 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 }); }); From e9ba31f1b642e83126946f252560c6da22f3fdf6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 06:40:16 -0800 Subject: [PATCH 19/25] Explicitly set background color in playwright tests --- test/playwright/SharedRendererTests.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index a88f50d21e..226b8eb7fc 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -1271,6 +1271,7 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void test.describe('synchronized output', () => { test.beforeEach(async () => { const theme: ITheme = { + background: '#000000FF', red: '#FF0000FF', green: '#00FF00FF', blue: '#0000FFFF' From 12c4f9536efb5b48e4f59070c29be32da42244c2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:13:41 -0800 Subject: [PATCH 20/25] Use underline cursor style to ensure cursor color isn't reported --- test/playwright/SharedRendererTests.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 226b8eb7fc..73b3945bd2 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -5,8 +5,8 @@ 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'; export interface ISharedRendererTestContext { value: ITestContext; @@ -1276,7 +1276,10 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void green: '#00FF00FF', blue: '#0000FFFF' }; - await ctx.value.page.evaluate(`window.term.options.theme = ${JSON.stringify(theme)};`); + 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 @@ -1376,10 +1379,17 @@ 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]> { + // Clear the cached frame if the request is for the same cell + if (lastFrameRequest) { + if (lastFrameRequest.col === col && lastFrameRequest.row === row) { + frameDetails = undefined; + } + } if (!frameDetails) { - return getFrameDetails(ctx).then(frameDetails => getCellColorInner(frameDetails, col, row)); + frameDetails = await getFrameDetails(ctx); } + lastFrameRequest = { col, row }; switch (position) { case CellColorPosition.CENTER: return getCellColorInner(frameDetails, col, row); @@ -1389,6 +1399,7 @@ function getCellColor(ctx: ITestContext, col: number, row: number, position: Cel } let frameDetails: { cols: number, rows: number, decoded: IImage32 } | undefined = undefined; +let lastFrameRequest: { col: number, row: number } | undefined; async function getFrameDetails(ctx: ITestContext): Promise<{ cols: number, rows: number, decoded: IImage32 }> { const screenshotOptions: LocatorScreenshotOptions | undefined = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/screenshot.png' } : undefined; const buffer = await ctx.page.locator('#terminal-container .xterm-screen').screenshot(screenshotOptions); From ad07f2cf768e5da894bd0a82938b28fb032a39cf Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:23:20 -0800 Subject: [PATCH 21/25] Revert last frame request cache clearing --- test/playwright/SharedRendererTests.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 73b3945bd2..f6747be23d 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -1380,16 +1380,9 @@ export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestCont * @param row The 1-based row index to get the color for. */ async function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): Promise<[red: number, green: number, blue: number, alpha: number]> { - // Clear the cached frame if the request is for the same cell - if (lastFrameRequest) { - if (lastFrameRequest.col === col && lastFrameRequest.row === row) { - frameDetails = undefined; - } - } if (!frameDetails) { frameDetails = await getFrameDetails(ctx); } - lastFrameRequest = { col, row }; switch (position) { case CellColorPosition.CENTER: return getCellColorInner(frameDetails, col, row); @@ -1399,7 +1392,6 @@ async function getCellColor(ctx: ITestContext, col: number, row: number, positio } let frameDetails: { cols: number, rows: number, decoded: IImage32 } | undefined = undefined; -let lastFrameRequest: { col: number, row: number } | undefined; async function getFrameDetails(ctx: ITestContext): Promise<{ cols: number, rows: number, decoded: IImage32 }> { const screenshotOptions: LocatorScreenshotOptions | undefined = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/screenshot.png' } : undefined; const buffer = await ctx.page.locator('#terminal-container .xterm-screen').screenshot(screenshotOptions); From 6733e13a44dc9bf2e39dd41982860944120a1e74 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:33:18 -0800 Subject: [PATCH 22/25] Invert initial assertion --- test/playwright/SharedRendererTests.ts | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index f6747be23d..60fe9639b7 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -7,6 +7,7 @@ import { IImage32, decodePng } from '@lunapaint/png-codec'; import { LocatorScreenshotOptions, test } from '@playwright/test'; 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; @@ -1272,10 +1273,12 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void 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'; @@ -1284,7 +1287,11 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void 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), [0, 0, 0, 255]); + 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]); @@ -1293,7 +1300,11 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void 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), [0, 0, 0, 255]); + 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]); @@ -1306,7 +1317,11 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void 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), [0, 0, 0, 255]); + 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]); @@ -1316,7 +1331,11 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void 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), [0, 0, 0, 255]); + 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]); From 5e9cd6208f27dab70a398a56bad7bbb44c0b10ec Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:37:20 -0800 Subject: [PATCH 23/25] Git ignore test-results/ --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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/ From 34fd0dcf5386536593d248aa110a162614f1dfc7 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 07:44:24 -0800 Subject: [PATCH 24/25] Remove need for glob module --- package.json | 1 - src/browser/Terminal2.test.ts | 14 ++++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index f9dc7ef3a3..71b8059987 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,6 @@ "eslint-plugin-jsdoc": "^46.9.1", "express": "^4.19.2", "express-ws": "^5.0.2", - "glob": "^13.0.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..a12c796619 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,9 +35,9 @@ 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 { +describe.only('Escape Sequence Files', function(): void { this.timeout(1000); let ptyTerm: any; @@ -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)); From 92b43bdf9ba7c96b95dccbaade21d7c859fec1b4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 20 Dec 2025 08:33:56 -0800 Subject: [PATCH 25/25] Remove only --- src/browser/Terminal2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/Terminal2.test.ts b/src/browser/Terminal2.test.ts index a12c796619..6c2b3db656 100644 --- a/src/browser/Terminal2.test.ts +++ b/src/browser/Terminal2.test.ts @@ -37,7 +37,7 @@ if (os.platform() === 'darwin') { // filter skipFilenames const FILES = TESTFILES.filter(value => !SKIP_FILES.includes(path.basename(value))); -describe.only('Escape Sequence Files', function(): void { +describe('Escape Sequence Files', function(): void { this.timeout(1000); let ptyTerm: any;