From 7228e2e2dc72f01cc44f8dbd453cdd2d54414012 Mon Sep 17 00:00:00 2001 From: Chris Lloyd Date: Thu, 4 Dec 2025 11:16:25 -0800 Subject: [PATCH] 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 3b430943..f6ec55d2 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 00000000..d8b84780 --- /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 3ca0314a..016a0bfc 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 ca80a789..b3e3caa5 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 e12311ff..70ff8fe6 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 c254d330..3c147b8e 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 5d3eccb8..7b5f532d 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 6ad48b93..eb2e815d 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 a2847f1b..80367f77 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 d1c2667d..849d1f5a 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` */