From 22140b5e941770fc6e8d00c5bd572fe09100575f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 29 May 2023 09:39:55 -0700 Subject: [PATCH 1/4] Add more granular windowsPty option See microsoft/vscode#134448 --- demo/client.ts | 6 ++++- src/common/CoreTerminal.ts | 35 +++++++++++++----------- src/common/buffer/Buffer.ts | 6 ++++- src/common/services/OptionsService.ts | 1 + src/common/services/Services.ts | 3 ++- typings/xterm-headless.d.ts | 39 +++++++++++++++++++++++++++ typings/xterm.d.ts | 35 ++++++++++++++++++++++++ 7 files changed, 106 insertions(+), 19 deletions(-) diff --git a/demo/client.ts b/demo/client.ts index d72ddf65..11837c5c 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -240,7 +240,11 @@ function createTerminal(): void { const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; term = new Terminal({ allowProposedApi: true, - windowsMode: isWindows, + windowsPty: isWindows ? { + // In a real scenario, these values should be verified on the backend + backend: 'conpty', + buildNumber: 22621 + } : undefined, fontFamily: '"Fira Code", courier-new, courier, monospace, "Powerline Extra Symbols"', theme: xtermjsTheme } as ITerminalOptions); diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 33637421..649cd091 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -57,7 +57,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { protected _inputHandler: InputHandler; private _writeBuffer: WriteBuffer; - private _windowsMode: IDisposable | undefined; + private _windowsWrappingHeuristics: IDisposable | undefined; private readonly _onBinary = this.register(new EventEmitter()); public readonly onBinary = this._onBinary.event; @@ -146,8 +146,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed)); this.register(toDisposable(() => { - this._windowsMode?.dispose(); - this._windowsMode = undefined; + this._windowsWrappingHeuristics?.dispose(); + this._windowsWrappingHeuristics = undefined; })); } @@ -250,8 +250,13 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { } protected _setup(): void { - if (this.optionsService.rawOptions.windowsMode) { - this._enableWindowsMode(); + const windowsPty = this.optionsService.rawOptions.windowsPty; + if (windowsPty) { + if (windowsPty.buildNumber && windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376) { + this._enableWindowsWrappingHeuristics(); + } + } else if (this.optionsService.rawOptions.windowsMode) { + this._enableWindowsWrappingHeuristics(); } } @@ -265,28 +270,26 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { private _handleWindowsModeOptionChange(value: boolean): void { if (value) { - this._enableWindowsMode(); + this._enableWindowsWrappingHeuristics(); } else { - this._windowsMode?.dispose(); - this._windowsMode = undefined; + this._windowsWrappingHeuristics?.dispose(); + this._windowsWrappingHeuristics = undefined; } } - protected _enableWindowsMode(): void { - if (!this._windowsMode) { + protected _enableWindowsWrappingHeuristics(): void { + if (!this._windowsWrappingHeuristics) { const disposables: IDisposable[] = []; disposables.push(this.onLineFeed(updateWindowsModeWrappedState.bind(null, this._bufferService))); disposables.push(this.registerCsiHandler({ final: 'H' }, () => { updateWindowsModeWrappedState(this._bufferService); return false; })); - this._windowsMode = { - dispose: () => { - for (const d of disposables) { - d.dispose(); - } + this._windowsWrappingHeuristics = toDisposable(() => { + for (const d of disposables) { + d.dispose(); } - }; + }); } } } diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index cabc88fa..f32ce385 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -177,7 +177,7 @@ export class Buffer implements IBuffer { if (this._rows < newRows) { for (let y = this._rows; y < newRows; y++) { if (this.lines.length < newRows + this.ybase) { - if (this._optionsService.rawOptions.windowsMode) { + if (this._optionsService.rawOptions.windowsMode || this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) { // Just add the new missing rows on Windows as conpty reprints the screen with it's // view of the world. Once a line enters scrollback for conpty it remains there this.lines.push(new BufferLine(newCols, nullCell)); @@ -290,6 +290,10 @@ export class Buffer implements IBuffer { } private get _isReflowEnabled(): boolean { + const windowsPty = this._optionsService.rawOptions.windowsPty; + if (windowsPty && windowsPty.buildNumber) { + return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376; + } return this._hasScrollback && !this._optionsService.rawOptions.windowsMode; } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 9709f2a7..a8650e76 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -43,6 +43,7 @@ export const DEFAULT_OPTIONS: Readonly> = { rightClickSelectsWord: isMac, windowOptions: {}, windowsMode: false, + windowsPty: {}, wordSeparator: ' ()[]{}\',"`', altClickMovesCursor: true, convertEol: false, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index b2009690..679b246e 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -7,7 +7,7 @@ import { IEvent, IEventEmitter } from 'common/EventEmitter'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColor, CursorStyle, IOscLinkData } from 'common/Types'; import { createDecorator } from 'common/services/ServiceRegistry'; -import { IDecorationOptions, IDecoration, ILinkHandler } from 'xterm'; +import { IDecorationOptions, IDecoration, ILinkHandler, IWindowsPty } from 'xterm'; export const IBufferService = createDecorator('BufferService'); export interface IBufferService { @@ -242,6 +242,7 @@ export interface ITerminalOptions { tabStopWidth?: number; theme?: ITheme; windowsMode?: boolean; + windowsPty?: IWindowsPty; windowOptions?: IWindowOptions; wordSeparator?: string; overviewRulerWidth?: number; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 741bce85..45a746e3 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -183,9 +183,34 @@ declare module 'xterm-headless' { * - Reflow is disabled. * - Lines are assumed to be wrapped if the last character of the line is * not whitespace. + * + * When using conpty on Windows 11 version >= 21376, it is recommended to + * disable this because native text wrapping sequences are output correctly + * thanks to https://github.com/microsoft/terminal/issues/405 + * + * @deprecated Use {@link windowsPty}. This value will be ignored if + * windowsPty is set. */ windowsMode?: boolean; + /** + * Compatibility information when the pty is known to be hosted on Windows. + * Setting this will turn on certain heuristics/workarounds depending on the + * values: + * + * - `if (!!windowsCompat)` + * - When increasing the rows in the terminal, the amount increased into + * the scrollback. This is done because ConPTY does not behave like + * expect scrollback to come back into the viewport, instead it makes + * empty rows at of the viewport. Not having this behavior can result in + * missing data as the rows get replaced. + * - `if !(backend === 'conpty' && buildNumber >= 21376)` + * - Reflow is disabled + * - Lines are assumed to be wrapped if the last character of the line is + * not whitespace. + */ + windowsPty?: IWindowsPty; + /** * A string containing all characters that are considered word separated by the * double click to select work logic. @@ -265,6 +290,20 @@ declare module 'xterm-headless' { extendedAnsi?: string[]; } + /** + * Pty information for Windows. + */ + export interface IWindowsPty { + /** + * What pty emulation backend is being used. + */ + backend?: 'conpty' | 'winpty'; + /** + * The Windows build version (eg. 19045) + */ + buildNumber?: number; + } + /** * An object that can be disposed via a dispose function. */ diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 6a9be2f1..173af461 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -238,9 +238,30 @@ declare module 'xterm' { * When using conpty on Windows 11 version >= 21376, it is recommended to * disable this because native text wrapping sequences are output correctly * thanks to https://github.com/microsoft/terminal/issues/405 + * + * @deprecated Use {@link windowsPty}. This value will be ignored if + * windowsPty is set. */ windowsMode?: boolean; + /** + * Compatibility information when the pty is known to be hosted on Windows. + * Setting this will turn on certain heuristics/workarounds depending on the + * values: + * + * - `if (backend !== undefined || buildNumber !== undefined)` + * - When increasing the rows in the terminal, the amount increased into + * the scrollback. This is done because ConPTY does not behave like + * expect scrollback to come back into the viewport, instead it makes + * empty rows at of the viewport. Not having this behavior can result in + * missing data as the rows get replaced. + * - `if !(backend === 'conpty' && buildNumber >= 21376)` + * - Reflow is disabled + * - Lines are assumed to be wrapped if the last character of the line is + * not whitespace. + */ + windowsPty?: IWindowsPty; + /** * A string containing all characters that are considered word separated by the * double click to select work logic. @@ -330,6 +351,20 @@ declare module 'xterm' { extendedAnsi?: string[]; } + /** + * Pty information for Windows. + */ + export interface IWindowsPty { + /** + * What pty emulation backend is being used. + */ + backend?: 'conpty' | 'winpty'; + /** + * The Windows build version (eg. 19045) + */ + buildNumber?: number; + } + /** * An object that can be disposed via a dispose function. */ From a91665cb184dd2c97a08a06a9356bcaafef7043b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 29 May 2023 09:43:49 -0700 Subject: [PATCH 2/4] Listen for windowsPty changes --- src/common/CoreTerminal.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 649cd091..13ed8c5e 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -131,7 +131,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.register(forwardEvent(this.coreService.onBinary, this._onBinary)); this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom())); this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput())); - this.register(this.optionsService.onSpecificOptionChange('windowsMode', e => this._handleWindowsModeOptionChange(e))); + this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange())); this.register(this._bufferService.onScroll(event => { this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL }); this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom); @@ -250,14 +250,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { } protected _setup(): void { - const windowsPty = this.optionsService.rawOptions.windowsPty; - if (windowsPty) { - if (windowsPty.buildNumber && windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376) { - this._enableWindowsWrappingHeuristics(); - } - } else if (this.optionsService.rawOptions.windowsMode) { - this._enableWindowsWrappingHeuristics(); - } + this._handleWindowsPtyOptionChange(); } public reset(): void { @@ -268,7 +261,15 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this.coreMouseService.reset(); } - private _handleWindowsModeOptionChange(value: boolean): void { + + private _handleWindowsPtyOptionChange(): void { + let value = false; + const windowsPty = this.optionsService.rawOptions.windowsPty; + if (windowsPty) { + value = !!(windowsPty.buildNumber && windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); + } else if (this.optionsService.rawOptions.windowsMode) { + value = true; + } if (value) { this._enableWindowsWrappingHeuristics(); } else { From 3e5c6a156b8bb0c82c27e6b43db3a03e80cae0ce Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 29 May 2023 09:49:33 -0700 Subject: [PATCH 3/4] Correct Windows wrapping heuristics condition --- src/common/CoreTerminal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 13ed8c5e..8ff9b988 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -265,8 +265,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { private _handleWindowsPtyOptionChange(): void { let value = false; const windowsPty = this.optionsService.rawOptions.windowsPty; - if (windowsPty) { - value = !!(windowsPty.buildNumber && windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); + if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) { + value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); } else if (this.optionsService.rawOptions.windowsMode) { value = true; } From f6fbf63673159d7d3bf7ba7d28c570f1e4327933 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 29 May 2023 09:50:52 -0700 Subject: [PATCH 4/4] Add windowsPty unit test --- src/browser/Terminal.test.ts | 41 ++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts index 13b6c0e6..745d759a 100644 --- a/src/browser/Terminal.test.ts +++ b/src/browser/Terminal.test.ts @@ -1041,6 +1041,47 @@ describe('Terminal', () => { }); }); + describe('Windows Pty', () => { + it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => { + const data = [ + 'aaaaaaaaaa\n\r', // cannot wrap as it's the first + 'aaaaaaaaa\n\r', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: {} }); + await normalTerminal.writeP(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); + + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: { backend: 'conpty', buildNumber: 19000 } }); + await windowsModeTerminal.writeP(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false); + }); + + it('should mark lines as wrapped when the line ends in a non-null character after a CUP', async () => { + const data = [ + 'aaaaaaaaaa\x1b[2;1H', // cannot wrap as it's the first + 'aaaaaaaaa\x1b[3;1H', // wrapped (windows mode only) + 'aaaaaaaaa' // not wrapped + ]; + + const normalTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: {} }); + await normalTerminal.writeP(data.join('')); + assert.equal(normalTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(1)!.isWrapped, false); + assert.equal(normalTerminal.buffer.lines.get(2)!.isWrapped, false); + + const windowsModeTerminal = new TestTerminal({ rows: 5, cols: 10, windowsPty: { backend: 'conpty', buildNumber: 19000 } }); + await windowsModeTerminal.writeP(data.join('')); + assert.equal(windowsModeTerminal.buffer.lines.get(0)!.isWrapped, false); + assert.equal(windowsModeTerminal.buffer.lines.get(1)!.isWrapped, true, 'This line should wrap in Windows mode as the previous line ends in a non-null character'); + assert.equal(windowsModeTerminal.buffer.lines.get(2)!.isWrapped, false); + }); + }); describe('Windows Mode', () => { it('should mark lines as wrapped when the line ends in a non-null character after a LF', async () => { const data = [