Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 19 additions & 17 deletions demo/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down
1 change: 1 addition & 0 deletions src/browser/public/Terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}
Expand Down
111 changes: 101 additions & 10 deletions src/browser/services/RenderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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',
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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.
Expand Down Expand Up @@ -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;
}
}
}
2 changes: 1 addition & 1 deletion src/common/InputHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/common/InputHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
1 change: 1 addition & 0 deletions src/common/TestUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/common/Types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export interface IDecPrivateModes {
origin: boolean;
reverseWraparound: boolean;
sendFocus: boolean;
synchronizedOutput: boolean;
wraparound: boolean; // defaults: xterm - true, vt100 - false
}

Expand Down
1 change: 1 addition & 0 deletions src/common/services/CoreService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down
39 changes: 39 additions & 0 deletions test/playwright/SharedRendererTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions test/playwright/Terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,7 @@ test.describe('API Integration Tests', () => {
originMode: false,
reverseWraparoundMode: false,
sendFocusMode: false,
synchronizedOutputMode: false,
wraparoundMode: true
});
});
Expand Down
7 changes: 7 additions & 0 deletions typings/xterm.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
*/
Expand Down
Loading