mirror of
https://github.com/wavetermdev/xterm.js.git
synced 2026-08-05 13:43:48 -07:00
Add synchronized output support (DEC mode 2026)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -273,6 +273,7 @@ export interface IDecPrivateModes {
|
||||
origin: boolean;
|
||||
reverseWraparound: boolean;
|
||||
sendFocus: boolean;
|
||||
synchronizedOutput: boolean;
|
||||
wraparound: boolean; // defaults: xterm - true, vt100 - false
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
|
||||
scrollSensitivity: 1,
|
||||
screenReaderMode: false,
|
||||
smoothScrollDuration: 0,
|
||||
synchronizedOutputTimeout: 5000,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
minimumContrastRatio: 1,
|
||||
|
||||
@@ -259,6 +259,7 @@ export interface ITerminalOptions {
|
||||
scrollOnUserInput?: boolean;
|
||||
scrollSensitivity?: number;
|
||||
smoothScrollDuration?: number;
|
||||
synchronizedOutputTimeout?: number;
|
||||
tabStopWidth?: number;
|
||||
theme?: ITheme;
|
||||
windowsMode?: boolean;
|
||||
|
||||
Vendored
+16
@@ -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`
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user