diff --git a/addons/xterm-addon-canvas/package.json b/addons/xterm-addon-canvas/package.json index e2833fde..d747795a 100644 --- a/addons/xterm-addon-canvas/package.json +++ b/addons/xterm-addon-canvas/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-canvas", - "version": "0.3.0", + "version": "0.4.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-search/package.json b/addons/xterm-addon-search/package.json index afc59d85..603a72d1 100644 --- a/addons/xterm-addon-search/package.json +++ b/addons/xterm-addon-search/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-search", - "version": "0.11.0", + "version": "0.12.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-serialize/package.json b/addons/xterm-addon-serialize/package.json index de1201a7..ef0c0a9f 100644 --- a/addons/xterm-addon-serialize/package.json +++ b/addons/xterm-addon-serialize/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-serialize", - "version": "0.9.0", + "version": "0.10.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" diff --git a/addons/xterm-addon-webgl/package.json b/addons/xterm-addon-webgl/package.json index e4e848a8..a6f00ef2 100644 --- a/addons/xterm-addon-webgl/package.json +++ b/addons/xterm-addon-webgl/package.json @@ -1,6 +1,6 @@ { "name": "xterm-addon-webgl", - "version": "0.14.0", + "version": "0.15.0", "author": { "name": "The xterm.js authors", "url": "https://xtermjs.org/" 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/package.json b/package.json index 70a85a71..0c1b6281 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "xterm", "description": "Full xterm terminal, in your browser", - "version": "5.1.0", + "version": "5.2.1", "main": "lib/xterm.js", "style": "css/xterm.css", "types": "typings/xterm.d.ts", 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 = [ diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 33637421..8ff9b988 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; @@ -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); @@ -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,9 +250,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { } protected _setup(): void { - if (this.optionsService.rawOptions.windowsMode) { - this._enableWindowsMode(); - } + this._handleWindowsPtyOptionChange(); } public reset(): void { @@ -263,30 +261,36 @@ 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 && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) { + value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); + } else if (this.optionsService.rawOptions.windowsMode) { + value = true; + } 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/InputHandler.ts b/src/common/InputHandler.ts index 670cd68c..9a32ea69 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1152,10 +1152,12 @@ export class InputHandler extends Disposable implements IInputHandler { * @param y row index */ private _resetBufferLine(y: number, respectProtect: boolean = false): void { - const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!; - line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); - this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); - line.isWrapped = false; + const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y); + if (line) { + line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect); + this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y); + line.isWrapped = false; + } } /** 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..94714f33 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, @@ -177,12 +178,16 @@ export class OptionsService extends Disposable implements IOptionsService { if (value <= 0) { throw new Error(`${key} cannot be less than or equal to 0, value: ${value}`); } + break; case 'rows': case 'cols': if (!value && value !== 0) { throw new Error(`${key} must be numeric, value: ${value}`); } break; + case 'windowsPty': + value = value ?? {}; + break; } return value; } 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. */