From 6b63c5f260cc37c84aa5df049885677accf3d958 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Tue, 9 Dec 2025 07:58:31 -0800 Subject: [PATCH] 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 d8b84780..00000000 --- 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 016a0bfc..1877db70 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 b3e3caa5..423f4976 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 eb2e815d..6ad48b93 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 80367f77..a2847f1b 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 1a35a0e5..5e4f0260 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 849d1f5a..95b6ffcb 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. */