Merge pull request #5453 from chrislloyd/feat/synchronized-output

Add synchronized output support (DEC mode 2026)
This commit is contained in:
Daniel Imms
2025-12-20 07:44:32 -08:00
committed by GitHub
15 changed files with 229 additions and 32 deletions
@@ -490,6 +490,7 @@ export class SerializeAddon implements ITerminalAddon , ISerializeApi {
if (modes.originMode) content += '\x1b[?6h';
if (modes.reverseWraparoundMode) content += '\x1b[?45h';
if (modes.sendFocusMode) content += '\x1b[?1004h';
// synchronizedOutputMode doesn't need to be serialized as it's a temporary mode
// Default: true
if (modes.wraparoundMode === false) content += '\x1b[?7l';
+19 -17
View File
@@ -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];
+1
View File
@@ -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
};
}
+101 -10
View File
@@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { DebouncedIdleTask } from 'common/TaskQueue';
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'vs/base/common/event';
interface ISelectionState {
@@ -18,6 +18,10 @@ interface ISelectionState {
columnSelectMode: boolean;
}
const enum Constants {
SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000
}
export class RenderService extends Disposable implements IRenderService {
public serviceBrand: undefined;
@@ -32,6 +36,7 @@ export class RenderService extends Disposable implements IRenderService {
private _needsSelectionRefresh: boolean = false;
private _canvasWidth: number = 0;
private _canvasHeight: number = 0;
private _syncOutputHandler: SynchronizedOutputHandler;
private _selectionState: ISelectionState = {
start: undefined,
end: undefined,
@@ -52,23 +57,31 @@ export class RenderService extends Disposable implements IRenderService {
constructor(
private _rowCount: number,
screenElement: HTMLElement,
@IOptionsService optionsService: IOptionsService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@ICoreService private readonly _coreService: ICoreService,
@IDecorationService decorationService: IDecorationService,
@IBufferService bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
@IThemeService themeService: IThemeService
) {
super();
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), coreBrowserService);
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
this._register(this._renderDebouncer);
this._register(coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange()));
this._syncOutputHandler = new SynchronizedOutputHandler(
this._coreBrowserService,
this._coreService,
() => this._fullRefresh()
);
this._register(toDisposable(() => this._syncOutputHandler.dispose()));
this._register(this._coreBrowserService.onDprChange(() => this.handleDevicePixelRatioChange()));
this._register(bufferService.onResize(() => this._fullRefresh()));
this._register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear()));
this._register(optionsService.onOptionChange(() => this._handleOptionsChanged()));
this._register(this._optionsService.onOptionChange(() => this._handleOptionsChanged()));
this._register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged()));
// Do a full refresh whenever any decoration is added or removed. This may not actually result
@@ -78,7 +91,7 @@ export class RenderService extends Disposable implements IRenderService {
this._register(decorationService.onDecorationRemoved(() => this._fullRefresh()));
// Clear the renderer when the a change that could affect glyphs occurs
this._register(optionsService.onMultipleOptionChange([
this._register(this._optionsService.onMultipleOptionChange([
'customGlyphs',
'drawBoldTextInBrightColors',
'letterSpacing',
@@ -96,15 +109,15 @@ export class RenderService extends Disposable implements IRenderService {
}));
// Refresh the cursor line when the cursor changes
this._register(optionsService.onMultipleOptionChange([
this._register(this._optionsService.onMultipleOptionChange([
'cursorBlink',
'cursorStyle'
], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true)));
this._register(themeService.onChangeColors(() => this._fullRefresh()));
this._registerIntersectionObserver(coreBrowserService.window, screenElement);
this._register(coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
this._registerIntersectionObserver(this._coreBrowserService.window, screenElement);
this._register(this._coreBrowserService.onWindowChange((w) => this._registerIntersectionObserver(w, screenElement)));
}
private _registerIntersectionObserver(w: Window & typeof globalThis, screenElement: HTMLElement): void {
@@ -137,6 +150,18 @@ export class RenderService extends Disposable implements IRenderService {
this._needsFullRefresh = true;
return;
}
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end);
return;
}
const buffered = this._syncOutputHandler.flush();
if (buffered) {
start = Math.min(start, buffered.start);
end = Math.max(end, buffered.end);
}
if (!isRedrawOnly) {
this._isNextRenderRedrawOnly = false;
}
@@ -148,6 +173,13 @@ export class RenderService extends Disposable implements IRenderService {
return;
}
// Skip rendering if synchronized output mode is enabled. This check must happen here
// (in addition to refreshRows) to handle renders that were queued before the mode was enabled.
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._syncOutputHandler.bufferRows(start, end);
return;
}
// Since this is debounced, a resize event could have happened between the time a refresh was
// requested and when this triggers. Clamp the values of start and end to ensure they're valid
// given the current viewport state.
@@ -283,3 +315,62 @@ export class RenderService extends Disposable implements IRenderService {
this._renderer.value?.clear();
}
}
/**
* Buffers row refresh requests during synchronized output mode (DEC mode 2026).
* When the mode is disabled, the accumulated row range is flushed for rendering.
* A safety timeout ensures rendering occurs even if the end sequence is not received.
*/
class SynchronizedOutputHandler {
private _start: number = 0;
private _end: number = 0;
private _timeout: number | undefined;
private _isBuffering: boolean = false;
constructor(
private readonly _coreBrowserService: ICoreBrowserService,
private readonly _coreService: ICoreService,
private readonly _onTimeout: () => void
) {}
public bufferRows(start: number, end: number): void {
if (!this._isBuffering) {
this._start = start;
this._end = end;
this._isBuffering = true;
} else {
this._start = Math.min(this._start, start);
this._end = Math.max(this._end, end);
}
if (this._timeout === undefined) {
this._timeout = this._coreBrowserService.window.setTimeout(() => {
this._timeout = undefined;
this._coreService.decPrivateModes.synchronizedOutput = false;
this._onTimeout();
}, Constants.SYNCHRONIZED_OUTPUT_TIMEOUT_MS);
}
}
public flush(): { start: number, end: number } | undefined {
if (this._timeout !== undefined) {
this._coreBrowserService.window.clearTimeout(this._timeout);
this._timeout = undefined;
}
if (!this._isBuffering) {
return undefined;
}
const result = { start: this._start, end: this._end };
this._isBuffering = false;
return result;
}
public dispose(): void {
if (this._timeout !== undefined) {
this._coreBrowserService.window.clearTimeout(this._timeout);
this._timeout = undefined;
}
}
}
+1 -1
View File
@@ -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
+8
View File
@@ -1969,6 +1969,9 @@ export class InputHandler extends Disposable implements IInputHandler {
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
this._coreService.decPrivateModes.bracketedPasteMode = true;
break;
case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md)
this._coreService.decPrivateModes.synchronizedOutput = true;
break;
}
}
return true;
@@ -2197,6 +2200,10 @@ export class InputHandler extends Disposable implements IInputHandler {
case 2004: // bracketed paste mode (https://cirw.in/blog/bracketed-paste)
this._coreService.decPrivateModes.bracketedPasteMode = false;
break;
case 2026: // synchronized output (https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md)
this._coreService.decPrivateModes.synchronizedOutput = false;
this._onRequestRefreshRows.fire(undefined);
break;
}
}
return true;
@@ -2291,6 +2298,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (p === 1048) return f(p, V.SET); // xterm always returns SET here
if (p === 47 || p === 1047 || p === 1049) return f(p, b2v(active === alt));
if (p === 2004) return f(p, b2v(dm.bracketedPasteMode));
if (p === 2026) return f(p, b2v(dm.synchronizedOutput));
return f(p, V.NOT_RECOGNIZED);
}
+1
View File
@@ -102,6 +102,7 @@ export class MockCoreService implements ICoreService {
origin: false,
reverseWraparound: false,
sendFocus: false,
synchronizedOutput: false,
wraparound: true
};
public onData: Event<string> = new Emitter<string>().event;
+1
View File
@@ -273,6 +273,7 @@ export interface IDecPrivateModes {
origin: boolean;
reverseWraparound: boolean;
sendFocus: boolean;
synchronizedOutput: boolean;
wraparound: boolean; // defaults: xterm - true, vt100 - false
}
+1
View File
@@ -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
});
+1
View File
@@ -400,6 +400,7 @@ describe('Headless API Tests', function (): void {
originMode: false,
reverseWraparoundMode: false,
sendFocusMode: false,
synchronizedOutputMode: false,
wraparoundMode: true
});
});
+1
View File
@@ -124,6 +124,7 @@ export class Terminal extends Disposable implements ITerminalApi {
originMode: m.origin,
reverseWraparoundMode: m.reverseWraparound,
sendFocusMode: m.sendFocus,
synchronizedOutputMode: m.synchronizedOutput,
wraparoundMode: m.wraparound
};
}
+78 -4
View File
@@ -5,8 +5,9 @@
import { IImage32, decodePng } from '@lunapaint/png-codec';
import { LocatorScreenshotOptions, test } from '@playwright/test';
import { ITheme } from '@xterm/xterm';
import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate } from './TestUtils';
import { ITheme, type ITerminalOptions } from '@xterm/xterm';
import { ITestContext, MaybeAsync, openTerminal, pollFor, pollForApproximate, timeout } from './TestUtils';
import { notDeepStrictEqual } from 'node:assert';
export interface ISharedRendererTestContext {
value: ITestContext;
@@ -1267,6 +1268,79 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [128, 0, 0, 255]);
});
});
test.describe('synchronized output', () => {
test.beforeEach(async () => {
const theme: ITheme = {
background: '#000000FF',
red: '#FF0000FF',
green: '#00FF00FF',
blue: '#0000FFFF'
};
const options: ITerminalOptions = {}
await ctx.value.page.evaluate(`
window.term.options.theme = ${JSON.stringify(theme)};
window.term.options.cursorStyle = 'underline';
`);
});
test('defers rendering until ESU', async () => {
await ctx.value.proxy.write('\x1b[?2026h'); // BSU
await ctx.value.proxy.write('\x1b[31m■');
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, {
equalityFn: (a, b) => {
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
}
});
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
frameDetails = undefined;
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
});
test('batches multiple writes', async () => {
await ctx.value.proxy.write('\x1b[?2026h'); // BSU
await ctx.value.proxy.write('\x1b[31m■\x1b[32m■\x1b[34m■');
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, {
equalityFn: (a, b) => {
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
}
});
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
frameDetails = undefined;
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 255]);
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 1), [0, 0, 255, 255]);
});
test('nested BSU is idempotent', async () => {
await ctx.value.proxy.write('\x1b[?2026h'); // BSU
await ctx.value.proxy.write('\x1b[31m■');
await ctx.value.proxy.write('\x1b[?2026h'); // BSU
await ctx.value.proxy.write('\x1b[32m■');
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, {
equalityFn: (a, b) => {
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
}
});
await ctx.value.proxy.write('\x1b[?2026l'); // ESU
frameDetails = undefined;
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 1), [0, 255, 0, 255]);
});
test('timeout flushes without ESU', async () => {
await ctx.value.proxy.write('\x1b[?2026h'); // BSU
await ctx.value.proxy.write('\x1b[31m■');
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255], undefined, {
equalityFn: (a, b) => {
return !(a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3]);
}
});
await ctx.value.page.waitForTimeout(1000); // Timeout hard coded
frameDetails = undefined;
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 1, 1), [255, 0, 0, 255]);
});
});
}
enum CellColorPosition {
@@ -1324,9 +1398,9 @@ export function injectSharedRendererTestsStandalone(ctx: ISharedRendererTestCont
* @param col The 1-based column index to get the color for.
* @param row The 1-based row index to get the color for.
*/
function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): MaybeAsync<[red: number, green: number, blue: number, alpha: number]> {
async function getCellColor(ctx: ITestContext, col: number, row: number, position: CellColorPosition = CellColorPosition.CENTER): Promise<[red: number, green: number, blue: number, alpha: number]> {
if (!frameDetails) {
return getFrameDetails(ctx).then(frameDetails => getCellColorInner(frameDetails, col, row));
frameDetails = await getFrameDetails(ctx);
}
switch (position) {
case CellColorPosition.CENTER:
+1
View File
@@ -609,6 +609,7 @@ test.describe('API Integration Tests', () => {
originMode: false,
reverseWraparoundMode: false,
sendFocusMode: false,
synchronizedOutputMode: false,
wraparoundMode: true
});
});
+7
View File
@@ -1370,6 +1370,13 @@ declare module '@xterm/headless' {
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
*/
readonly sendFocusMode: boolean;
/**
* Synchronized Output Mode: `CSI ? 2 0 2 6 h`
*
* When enabled, output is buffered and only rendered when the mode is
* disabled, allowing for atomic screen updates without tearing.
*/
readonly synchronizedOutputMode: boolean;
/**
* Auto-Wrap Mode (DECAWM): `CSI ? 7 h`
*/
+7
View File
@@ -1968,6 +1968,13 @@ declare module '@xterm/xterm' {
* Send FocusIn/FocusOut events: `CSI ? 1 0 0 4 h`
*/
readonly sendFocusMode: boolean;
/**
* Synchronized Output Mode: `CSI ? 2 0 2 6 h`
*
* When enabled, output is buffered and only rendered when the mode is
* disabled, allowing for atomic screen updates without tearing.
*/
readonly synchronizedOutputMode: boolean;
/**
* Auto-Wrap Mode (DECAWM): `CSI ? 7 h`
*/