From 7228e2e2dc72f01cc44f8dbd453cdd2d54414012 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Thu, 4 Dec 2025 11:16:25 -0800 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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 }); });