From 9c8de633e99fcb13c5ea7369f2af582f611eed3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 13:53:31 +0200 Subject: [PATCH 01/39] multiple changes in APC handling: - change interface to IFunctionIdentifier (API change) - extend parser states APC_ENTRY, APC_INTERMEDIATE, APC_PASSTHROUGH - remove APC_STRING - change ApcParser.start to retrieve ident - change actions APC_START and APC_PUT --- addons/addon-image/src/ImageAddon.ts | 2 +- package-lock.json | 11 ++ src/browser/TestUtils.test.ts | 2 +- src/common/CoreTerminal.ts | 4 +- src/common/InputHandler.ts | 4 +- src/common/Types.ts | 4 +- src/common/parser/ApcParser.test.ts | 86 ++++++------ src/common/parser/ApcParser.ts | 132 ++++++------------ src/common/parser/Constants.ts | 6 +- .../parser/EscapeSequenceParser.test.ts | 34 ++--- src/common/parser/EscapeSequenceParser.ts | 58 +++++--- src/common/parser/Types.ts | 6 +- src/common/public/ParserApi.ts | 4 +- test/playwright/Parser.test.ts | 20 +-- typings/xterm-headless.d.ts | 10 +- typings/xterm.d.ts | 10 +- 16 files changed, 188 insertions(+), 205 deletions(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 7af0e519c..2fcf273c9 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -166,7 +166,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi { this._disposeLater( kittyStorage, kittyHandler, - terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler) + terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler) ); } } diff --git a/package-lock.json b/package-lock.json index 6130135fd..65076b8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -245,6 +245,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -558,6 +559,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -599,6 +601,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -1796,6 +1799,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2361,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2751,6 +2756,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3746,6 +3752,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7010,6 +7017,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7850,6 +7858,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8016,6 +8025,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8064,6 +8074,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 485900e41..65a18da72 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal { public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { throw new Error('Method not implemented.'); } - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { throw new Error('Method not implemented.'); } public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index e2a3adf72..0eff38f25 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -243,8 +243,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { } /** Add handler for APC escape sequence. See xterm.d.ts for details. */ - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { - return this._inputHandler.registerApcHandler(ident, callback); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { + return this._inputHandler.registerApcHandler(id, callback); } protected _setup(): void { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 68265c5df..c62000f32 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -729,8 +729,8 @@ export class InputHandler extends Disposable implements IInputHandler { /** * Forward registerApcHandler from parser. */ - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { - return this._parser.registerApcHandler(ident, new ApcHandler(callback)); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { + return this._parser.registerApcHandler(id, new ApcHandler(callback)); } /** diff --git a/src/common/Types.ts b/src/common/Types.ts index 0a9c89a1a..5edf63637 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -22,7 +22,7 @@ export interface ICoreTerminal { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; - registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; } export interface IDisposable { @@ -476,7 +476,7 @@ export interface IInputHandler { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; - registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; /** C0 BEL */ bell(): boolean; /** C0 LF */ lineFeed(): boolean; diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts index 9021fb38f..4486f71c3 100644 --- a/src/common/parser/ApcParser.test.ts +++ b/src/common/parser/ApcParser.test.ts @@ -60,8 +60,8 @@ describe('ApcParser', () => { describe('identifier parsing', () => { it('single character identifier', () => { - parser.start(); - const data = toUtf32('Gf=100,a=T;payload'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100,a=T;payload'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(reports, [ @@ -71,20 +71,20 @@ describe('ApcParser', () => { ]); }); - it('identifier with no payload', () => { - parser.start(); - const data = toUtf32('G'); - parser.put(data, 0, data.length); - parser.end(true); - assert.deepEqual(reports, [ - [0x47, 'START', undefined], - [0x47, 'END', true] - ]); - }); + //it('identifier with no payload', () => { + // parser.start('G'.charCodeAt(0)); + // const data = toUtf32(''); + // parser.put(data, 0, data.length); + // parser.end(true); + // assert.deepEqual(reports, [ + // [0x47, 'START', undefined], + // [0x47, 'END', true] + // ]); + //}); it('identifier with chunked payload', () => { - parser.start(); - let data = toUtf32('Gf=100'); + parser.start('G'.charCodeAt(0)); + let data = toUtf32('f=100'); parser.put(data, 0, data.length); data = toUtf32(',a=T'); parser.put(data, 0, data.length); @@ -100,11 +100,11 @@ describe('ApcParser', () => { ]); }); - it('empty APC sequence', () => { - parser.start(); - parser.end(true); - assert.deepEqual(reports, []); - }); + //it('empty APC sequence', () => { + // parser.start('G'.charCodeAt(0)); + // parser.end(true); + // assert.deepEqual(reports, []); + //}); }); describe('handler registration', () => { @@ -117,8 +117,8 @@ describe('ApcParser', () => { it('registerHandler for specific identifier', () => { const G_CODE = 0x47; // 'G' parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start(); - const data = toUtf32('Gf=100,a=T;imagedata'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100,a=T;imagedata'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, [ @@ -133,8 +133,8 @@ describe('ApcParser', () => { const G_CODE = 0x47; // 'G' const X_CODE = 0x58; // 'X' parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start(); - const data = toUtf32('Xsome data'); + parser.start('X'.charCodeAt(0)); + const data = toUtf32('some data'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, []); @@ -149,8 +149,8 @@ describe('ApcParser', () => { const G_CODE = 0x47; parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); parser.clearHandler(G_CODE); - parser.start(); - const data = toUtf32('Gf=100'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, []); @@ -165,8 +165,8 @@ describe('ApcParser', () => { const G_CODE = 0x47; parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); - parser.start(); - const data = toUtf32('Gdata'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('data'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, [ @@ -183,8 +183,8 @@ describe('ApcParser', () => { const G_CODE = 0x47; parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true)); - parser.start(); - const data = toUtf32('Gdata'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('data'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, [ @@ -202,8 +202,8 @@ describe('ApcParser', () => { parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); disposable.dispose(); - parser.start(); - const data = toUtf32('Gdata'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('data'); parser.put(data, 0, data.length); parser.end(true); assert.deepEqual(handlerReports, [ @@ -237,8 +237,8 @@ describe('ApcParser', () => { results.push([G_CODE, data]); return true; })); - parser.start(); - let data = toUtf32('Gf=100'); + parser.start('G'.charCodeAt(0)); + let data = toUtf32('f=100'); parser.put(data, 0, data.length); data = toUtf32(',a=T;payload'); parser.put(data, 0, data.length); @@ -253,8 +253,8 @@ describe('ApcParser', () => { results.push([G_CODE, data]); return true; })); - parser.start(); - const data = toUtf32('Gf=100,a=T;payload'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100,a=T;payload'); parser.put(data, 0, data.length); parser.end(false); assert.deepEqual(results, []); @@ -268,8 +268,8 @@ describe('ApcParser', () => { results.push([G_CODE, data]); return true; })); - parser.start(); - let data = toUtf32('G'); + parser.start('G'.charCodeAt(0)); + let data = toUtf32(''); parser.put(data, 0, data.length); data = toUtf32('A'.repeat(CHUNK_SIZE)); for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) { @@ -287,8 +287,8 @@ describe('ApcParser', () => { results.push([G_CODE, data]); return true; })); - parser.start(); - let data = toUtf32('G'); + parser.start('G'.charCodeAt(0)); + let data = toUtf32(''); parser.put(data, 0, data.length); data = toUtf32('A'.repeat(CHUNK_SIZE)); for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) { @@ -311,8 +311,8 @@ describe('ApcParser', () => { it('reset during payload cleans up handlers', () => { const G_CODE = 0x47; parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start(); - const data = toUtf32('Gf=100'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100'); parser.put(data, 0, data.length); parser.reset(); assert.deepEqual(handlerReports, [ @@ -353,8 +353,8 @@ describe('ApcParser - async tests', () => { results.push([G_CODE, data]); return true; })); - parser.start(); - const data = toUtf32('Gf=100,a=T'); + parser.start('G'.charCodeAt(0)); + const data = toUtf32('f=100,a=T'); parser.put(data, 0, data.length); await endP(parser, true); assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]); diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index dbcf0cced..c6f709071 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -19,10 +19,9 @@ const EMPTY_HANDLERS: IApcHandler[] = []; * The identifier is the character code of the first byte after ESC _. */ export class ApcParser implements IApcParser { - private _state = ApcState.START; - private _active = EMPTY_HANDLERS; - private _id = -1; private _handlers: IHandlerCollection = Object.create(null); + private _active = EMPTY_HANDLERS; + private _ident: number = 0; private _handlerFb: ApcFallbackHandlerType = () => { }; private _stack: ISubParserStackState = { paused: false, @@ -64,22 +63,24 @@ export class ApcParser implements IApcParser { } public reset(): void { - // force cleanup handlers if payload was already sent - if (this._state === ApcState.PAYLOAD) { + // force cleanup handlers + if (this._active.length) { for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) { this._active[j].end(false); } } this._stack.paused = false; this._active = EMPTY_HANDLERS; - this._id = -1; - this._state = ApcState.START; + this._ident = 0; } - private _start(): void { - this._active = this._handlers[this._id] || EMPTY_HANDLERS; + public start(ident: number): void { + // always reset leftover handlers + this.reset(); + this._ident = ident; + this._active = this._handlers[ident] || EMPTY_HANDLERS; if (!this._active.length) { - this._handlerFb(this._id, 'START'); + this._handlerFb(this._ident, 'START'); } else { for (let j = this._active.length - 1; j >= 0; j--) { this._active[j].start(); @@ -87,9 +88,9 @@ export class ApcParser implements IApcParser { } } - private _put(data: Uint32Array, start: number, end: number): void { + public put(data: Uint32Array, start: number, end: number): void { if (!this._active.length) { - this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end)); + this._handlerFb(this._ident, 'PUT', utf32ToString(data, start, end)); } else { for (let j = this._active.length - 1; j >= 0; j--) { this._active[j].put(data, start, end); @@ -97,100 +98,51 @@ export class ApcParser implements IApcParser { } } - public start(): void { - // always reset leftover handlers - this.reset(); - this._state = ApcState.ID; - } - - /** - * Put data to current APC command. - * For APC, the first character is used as the identifier. - * Format: ESC _ ESC \ - * Example: ESC _ G f=100,a=T;... ESC \ (Kitty graphics, identifier='G') - */ - public put(data: Uint32Array, start: number, end: number): void { - if (this._state === ApcState.ABORT) { - return; - } - if (this._state === ApcState.ID) { - // The first character is the identifier - if (start < end) { - this._id = data[start++]; - this._state = ApcState.PAYLOAD; - this._start(); - } - } - if (this._state === ApcState.PAYLOAD && end - start > 0) { - this._put(data, start, end); - } - } - /** * Indicates end of an APC command. * Whether the APC got aborted or finished normally * is indicated by `success`. */ public end(success: boolean, promiseResult: boolean = true): void | Promise { - if (this._state === ApcState.START) { - return; - } - // do nothing if command was faulty - if (this._state !== ApcState.ABORT) { - // if we are still in ID state and get an early end - // means we got an empty APC sequence with no identifier, - // which is invalid - just reset and return - if (this._state === ApcState.ID) { - this._active = EMPTY_HANDLERS; - this._id = -1; - this._state = ApcState.START; - return; + if (!this._active.length) { + this._handlerFb(this._ident, 'END', success); + } else { + let handlerResult: boolean | Promise = false; + let j = this._active.length - 1; + let fallThrough = false; + if (this._stack.paused) { + j = this._stack.loopPosition - 1; + handlerResult = promiseResult; + fallThrough = this._stack.fallThrough; + this._stack.paused = false; } - - if (!this._active.length) { - this._handlerFb(this._id, 'END', success); - } else { - let handlerResult: boolean | Promise = false; - let j = this._active.length - 1; - let fallThrough = false; - if (this._stack.paused) { - j = this._stack.loopPosition - 1; - handlerResult = promiseResult; - fallThrough = this._stack.fallThrough; - this._stack.paused = false; - } - if (!fallThrough && handlerResult === false) { - for (; j >= 0; j--) { - handlerResult = this._active[j].end(success); - if (handlerResult === true) { - break; - } else if (handlerResult instanceof Promise) { - this._stack.paused = true; - this._stack.loopPosition = j; - this._stack.fallThrough = false; - return handlerResult; - } - } - j--; - } - // cleanup left over handlers - // we always have to call .end for proper cleanup, - // here we use `success` to indicate whether a handler should execute + if (!fallThrough && handlerResult === false) { for (; j >= 0; j--) { - handlerResult = this._active[j].end(false); - if (handlerResult instanceof Promise) { + handlerResult = this._active[j].end(success); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { this._stack.paused = true; this._stack.loopPosition = j; - this._stack.fallThrough = true; + this._stack.fallThrough = false; return handlerResult; } } + j--; + } + // cleanup left over handlers (fallThrough for async) + for (; j >= 0; j--) { + handlerResult = this._active[j].end(false); + if (handlerResult instanceof Promise) { + this._stack.paused = true; + this._stack.loopPosition = j; + this._stack.fallThrough = true; + return handlerResult; + } } - } this._active = EMPTY_HANDLERS; - this._id = -1; - this._state = ApcState.START; + this._ident = 0; } } diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 828d7ad62..6bf3260c3 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -21,9 +21,11 @@ export const enum ParserState { DCS_IGNORE = 11, DCS_INTERMEDIATE = 12, DCS_PASSTHROUGH = 13, - APC_STRING = 14, + APC_ENTRY = 14, + APC_INTERMEDIATE = 15, + APC_PASSTHROUGH = 16, // Number of states, meaning LAST_STATE + 1. - STATE_LENGTH = 15 + STATE_LENGTH = 17 } /** diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 08d47f06e..a7101a770 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -163,7 +163,9 @@ const states: number[] = [ ParserState.DCS_IGNORE, ParserState.DCS_INTERMEDIATE, ParserState.DCS_PASSTHROUGH, - ParserState.APC_STRING + ParserState.APC_ENTRY, + ParserState.APC_INTERMEDIATE, + ParserState.APC_PASSTHROUGH ]; let state: any; @@ -278,7 +280,7 @@ describe('EscapeSequenceParser', () => { const exceptions: { [key: number]: { [key: string]: any[] } } = { 8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING 13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH - 14: { '\x18': [], '\x1a': [] } // abort APC_STRING + 16: { '\x18': [], '\x1a': [] } // abort APC_PASSTHROUGH }; parser.reset(); testTerminal.clear(); @@ -725,20 +727,20 @@ describe('EscapeSequenceParser', () => { } } }); - it('trans ANYWHERE/ESCAPE --> APC_STRING', () => { - parser.reset(); - // C0 (ESC _) - parse(parser, '\x1b_'); - assert.equal(parser.currentState, ParserState.APC_STRING); - parser.reset(); - // C1 - for (state in states) { - parser.currentState = state; - parse(parser, '\x9f'); - assert.equal(parser.currentState, ParserState.APC_STRING); - parser.reset(); - } - }); + //it('trans ANYWHERE/ESCAPE --> APC_STRING', () => { + // parser.reset(); + // // C0 (ESC _) + // parse(parser, '\x1b_'); + // assert.equal(parser.currentState, ParserState.APC_STRING); + // parser.reset(); + // // C1 + // for (state in states) { + // parser.currentState = state; + // parse(parser, '\x9f'); + // assert.equal(parser.currentState, ParserState.APC_STRING); + // parser.reset(); + // } + //}); it('state SOS_PM_STRING ignore rules', () => { parser.reset(); let ignored = r(0x00, 0x18); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index a5eea2e8e..11020e8bb 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -77,7 +77,8 @@ const NON_ASCII_PRINTABLE = 0xA0; * Taken from https://vt100.net/emu/dec_ansi_parser. */ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { - const table: TransitionTable = new TransitionTable(4095); + // table size: (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1 + const table: TransitionTable = new TransitionTable(4257); // range macro for byte const BYTE_VALUES = 256; @@ -105,7 +106,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // SOS, PM - table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC + table.add(0x9f, state, ParserAction.CLEAR, ParserState.APC_ENTRY); // APC table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS } @@ -136,11 +137,19 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(0x9c, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.GROUND); table.add(0x7f, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // apc - table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING); - table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); - table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); - table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); - table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND); + table.add(0x5f, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.APC_ENTRY); + table.addMany(EXECUTABLES, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY); + table.add(0x7f, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY); + table.addMany(r(0x20, 0x30), ParserState.APC_ENTRY, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE); + table.addMany(r(0x30, 0x7f), ParserState.APC_ENTRY, ParserAction.APC_START, ParserState.APC_PASSTHROUGH); + table.addMany(r(0x30, 0x7f), ParserState.APC_INTERMEDIATE, ParserAction.APC_START, ParserState.APC_PASSTHROUGH); + table.addMany(EXECUTABLES, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE); + table.add(0x7f, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE); + table.addMany(PRINTABLES, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH); + table.addMany(EXECUTABLES, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH); + table.addMany(r(0x08, 0x0e), ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH); + table.add(0x7f, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH); + table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_PASSTHROUGH, ParserAction.APC_END, ParserState.GROUND); // csi entries table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY); table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND); @@ -200,7 +209,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE); table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH); - table.add(NON_ASCII_PRINTABLE, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); + table.add(NON_ASCII_PRINTABLE, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH); return table; })(); @@ -439,11 +448,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._oscParser.setHandlerFallback(handler); } - public registerApcHandler(ident: number, handler: IApcHandler): IDisposable { - return this._apcParser.registerHandler(ident, handler); + public registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable { + id.prefix = undefined; // APC does not support prefix byte + return this._apcParser.registerHandler(this._identifier(id, [0x30, 0x7e]), handler); } - public clearApcHandler(ident: number): void { - this._apcParser.clearHandler(ident); + public clearApcHandler(id: IFunctionIdentifier): void { + id.prefix = undefined; // APC does not support prefix byte + this._apcParser.clearHandler(this._identifier(id, [0x30, 0x7e])); } public setApcHandlerFallback(handler: ApcFallbackHandlerType): void { this._apcParser.setHandlerFallback(handler); @@ -719,7 +730,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP } // normal transition & action lookup - transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)]; + transition = this._transitions.table[ + this.currentState << TableAccess.INDEX_STATE_SHIFT | + (code < NON_ASCII_PRINTABLE ? code : NON_ASCII_PRINTABLE) + ]; switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) { case ParserAction.PRINT: // Note: 0x20 (SP) is included, 0x7F (DEL) is excluded @@ -872,17 +886,19 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.precedingJoinState = 0; break; case ParserAction.APC_START: - this._apcParser.start(); + this._apcParser.start(this._collect << 8 | code); break; case ParserAction.APC_PUT: + // FIXME: this is wrong, must be 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE // inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c - for (let j = i + 1; ; ++j) { - if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { - this._apcParser.put(data, i, j); - i = j - 1; - break; - } - } + //for (let j = i + 1; ; ++j) { + // if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { + // this._apcParser.put(data, i, j); + // i = j - 1; + // break; + // } + //} + this._apcParser.put(data, i, i+1); break; case ParserAction.APC_END: handlerResult = this._apcParser.end(code !== 0x18 && code !== 0x1a); diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index cfa00c1a8..c04788f7e 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -220,8 +220,8 @@ export interface IEscapeSequenceParser extends IDisposable { clearOscHandler(ident: number): void; setOscHandlerFallback(handler: OscFallbackHandlerType): void; - registerApcHandler(ident: number, handler: IApcHandler): IDisposable; - clearApcHandler(ident: number): void; + registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable; + clearApcHandler(id: IFunctionIdentifier): void; setApcHandlerFallback(handler: ApcFallbackHandlerType): void; setErrorHandler(handler: (state: IParsingState) => IParsingState): void; @@ -252,7 +252,7 @@ export interface IDcsParser extends ISubParser { - start(): void; + start(ident: number): void; end(success: boolean, promiseResult?: boolean): void | Promise; } diff --git a/src/common/public/ParserApi.ts b/src/common/public/ParserApi.ts index a9b971886..4c482d7ca 100644 --- a/src/common/public/ParserApi.ts +++ b/src/common/public/ParserApi.ts @@ -34,7 +34,7 @@ export class ParserApi implements IParser { public addOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { return this.registerOscHandler(ident, callback); } - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { - return this._core.registerApcHandler(ident, callback); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { + return this._core.registerApcHandler(id, callback); } } diff --git a/test/playwright/Parser.test.ts b/test/playwright/Parser.test.ts index 989d7fd38..eceb75282 100644 --- a/test/playwright/Parser.test.ts +++ b/test/playwright/Parser.test.ts @@ -248,7 +248,7 @@ test.describe('Parser Integration Tests', () => { await ctx.proxy.evaluate(([term]) => { window.customApcHandlerCallStack = []; // APC uses first character as identifier (e.g., 0x41 = 'A') - window.disposable = term.parser.registerApcHandler(0x41, data => { + window.disposable = term.parser.registerApcHandler({ final: 'A' }, data => { window.customApcHandlerCallStack!.push(['handler', data]); return true; }); @@ -263,7 +263,7 @@ test.describe('Parser Integration Tests', () => { test('should handle short data', async () => { await ctx.proxy.evaluate(([term]) => { window.customApcHandlerCallStack = []; - window.disposable = term.parser.registerApcHandler(0x42, data => { + window.disposable = term.parser.registerApcHandler({ final: 'B' }, data => { window.customApcHandlerCallStack!.push(['handler', data]); return true; }); @@ -278,15 +278,15 @@ test.describe('Parser Integration Tests', () => { await ctx.proxy.evaluate(([term]) => { window.customApcHandlerCallStack = []; window.disposables = [ - term.parser.registerApcHandler(0x43, data => { + term.parser.registerApcHandler({ final: 'C' }, data => { window.customApcHandlerCallStack!.push(['A', data]); return false; }), - term.parser.registerApcHandler(0x43, data => { + term.parser.registerApcHandler({ final: 'C' }, data => { window.customApcHandlerCallStack!.push(['B', data]); return true; }), - term.parser.registerApcHandler(0x43, data => { + term.parser.registerApcHandler({ final: 'C' }, data => { window.customApcHandlerCallStack!.push(['C', data]); return false; }) @@ -302,17 +302,17 @@ test.describe('Parser Integration Tests', () => { await ctx.proxy.evaluate(([term]) => { window.customApcHandlerCallStack = []; window.disposables = [ - term.parser.registerApcHandler(0x44, data => { + term.parser.registerApcHandler({ final: 'D' }, data => { window.customApcHandlerCallStack!.push(['A', data]); return false; }), - term.parser.registerApcHandler(0x44, data => { + term.parser.registerApcHandler({ final: 'D' }, data => { return new Promise(res => setTimeout(res, 50)).then(() => { window.customApcHandlerCallStack!.push(['B', data]); return false; }); }), - term.parser.registerApcHandler(0x44, data => { + term.parser.registerApcHandler({ final: 'D' }, data => { window.customApcHandlerCallStack!.push(['C', data]); return false; }) @@ -330,11 +330,11 @@ test.describe('Parser Integration Tests', () => { await ctx.proxy.evaluate(([term]) => { window.customApcHandlerCallStack = []; window.disposables = [ - term.parser.registerApcHandler(0x46, data => { // 'F' + term.parser.registerApcHandler({ final: 'F' }, data => { window.customApcHandlerCallStack!.push(['F', data]); return true; }), - term.parser.registerApcHandler(0x58, data => { // 'X' + term.parser.registerApcHandler({ final: 'X' }, data => { window.customApcHandlerCallStack!.push(['X', data]); return true; }) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index de8d06c42..d3b62346d 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1274,12 +1274,12 @@ declare module '@xterm/headless' { prefix?: string; /** * Optional intermediate bytes, must be in range \x20 .. \x2f. - * Usable in CSI, DCS and ESC. + * Usable in CSI, DCS, ESC and APC. */ intermediates?: string; /** * Final byte, must be in range \x40 .. \x7e for CSI and DCS, - * \x30 .. \x7e for ESC. + * \x30 .. \x7e for ESC and APC. */ final: string; } @@ -1354,8 +1354,8 @@ declare module '@xterm/headless' { /** * Adds a handler for APC escape sequences. - * @param ident The identifier (first character) of the sequence as a - * character code, e.g. 71 for 'G' (Kitty graphics protocol). + * @param id Specifies the function identifier under which the callback + * gets registered, e.g. {final: 'G'} for Kitty graphics protocol. * @param callback The function to handle the sequence. Note that the * function will only be called once if the sequence finished successfully. * There is currently no way to intercept smaller data chunks, data chunks @@ -1368,7 +1368,7 @@ declare module '@xterm/headless' { * added handler is tried first. * @returns An IDisposable you can call to remove this handler. */ - registerApcHandler(ident: number, callback: (data: string) => boolean): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean): IDisposable; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index c8e08f509..d1ce877b9 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1909,12 +1909,12 @@ declare module '@xterm/xterm' { prefix?: string; /** * Optional intermediate bytes, must be in range \x20 .. \x2f. - * Usable in CSI, DCS and ESC. + * Usable in CSI, DCS, ESC and APC. */ intermediates?: string; /** * Final byte, must be in range \x40 .. \x7e for CSI and DCS, - * \x30 .. \x7e for ESC. + * \x30 .. \x7e for ESC and APC. */ final: string; } @@ -2002,8 +2002,8 @@ declare module '@xterm/xterm' { /** * Adds a handler for APC escape sequences. - * @param ident The identifier (first character) of the sequence as a - * character code, e.g. 71 for 'G' (Kitty graphics protocol). + * @param id Specifies the function identifier under which the callback + * gets registered, e.g. {final: 'G'} for Kitty graphics protocol. * @param callback The function to handle the sequence. Note that the * function will only be called once if the sequence finished successfully. * There is currently no way to intercept smaller data chunks, data chunks @@ -2016,7 +2016,7 @@ declare module '@xterm/xterm' { * handler. The most recently added handler is tried first. * @returns An IDisposable you can call to remove this handler. */ - registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; } /** From ee7d84bdd321f41cbd65684dc8cf7bc0227750d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 14:19:55 +0200 Subject: [PATCH 02/39] make linter happy --- src/common/parser/ApcParser.test.ts | 30 +++++++++---------- src/common/parser/ApcParser.ts | 2 +- src/common/parser/Constants.ts | 10 ------- .../parser/EscapeSequenceParser.test.ts | 14 --------- src/common/parser/EscapeSequenceParser.ts | 17 ++++++----- test/playwright/Parser.test.ts | 2 +- 6 files changed, 26 insertions(+), 49 deletions(-) diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts index 4486f71c3..39dc9fb45 100644 --- a/src/common/parser/ApcParser.test.ts +++ b/src/common/parser/ApcParser.test.ts @@ -71,16 +71,16 @@ describe('ApcParser', () => { ]); }); - //it('identifier with no payload', () => { - // parser.start('G'.charCodeAt(0)); - // const data = toUtf32(''); - // parser.put(data, 0, data.length); - // parser.end(true); - // assert.deepEqual(reports, [ - // [0x47, 'START', undefined], - // [0x47, 'END', true] - // ]); - //}); + // it('identifier with no payload', () => { + // parser.start('G'.charCodeAt(0)); + // const data = toUtf32(''); + // parser.put(data, 0, data.length); + // parser.end(true); + // assert.deepEqual(reports, [ + // [0x47, 'START', undefined], + // [0x47, 'END', true] + // ]); + // }); it('identifier with chunked payload', () => { parser.start('G'.charCodeAt(0)); @@ -100,11 +100,11 @@ describe('ApcParser', () => { ]); }); - //it('empty APC sequence', () => { - // parser.start('G'.charCodeAt(0)); - // parser.end(true); - // assert.deepEqual(reports, []); - //}); + // it('empty APC sequence', () => { + // parser.start('G'.charCodeAt(0)); + // parser.end(true); + // assert.deepEqual(reports, []); + // }); }); describe('handler registration', () => { diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index c6f709071..8f921f8cb 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -4,7 +4,7 @@ */ import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, ISubParserStackState } from 'common/parser/Types'; -import { ApcState, ParserConstants } from 'common/parser/Constants'; +import { ParserConstants } from 'common/parser/Constants'; import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 6bf3260c3..cf596c65e 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -62,16 +62,6 @@ export const enum OscState { ABORT = 3 } -/** - * Internal states of ApcParser. - */ -export const enum ApcState { - START = 0, - ID = 1, - PAYLOAD = 2, - ABORT = 3 -} - // payload limit for OSC and DCS export const enum ParserConstants { PAYLOAD_LIMIT = 10000000 diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index a7101a770..c7f5b8cfe 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -727,20 +727,6 @@ describe('EscapeSequenceParser', () => { } } }); - //it('trans ANYWHERE/ESCAPE --> APC_STRING', () => { - // parser.reset(); - // // C0 (ESC _) - // parse(parser, '\x1b_'); - // assert.equal(parser.currentState, ParserState.APC_STRING); - // parser.reset(); - // // C1 - // for (state in states) { - // parser.currentState = state; - // parse(parser, '\x9f'); - // assert.equal(parser.currentState, ParserState.APC_STRING); - // parser.reset(); - // } - //}); it('state SOS_PM_STRING ignore rules', () => { parser.reset(); let ignored = r(0x00, 0x18); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 11020e8bb..f2ea00318 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -77,7 +77,8 @@ const NON_ASCII_PRINTABLE = 0xA0; * Taken from https://vt100.net/emu/dec_ansi_parser. */ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { - // table size: (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1 + // table size: + // (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1 const table: TransitionTable = new TransitionTable(4257); // range macro for byte @@ -891,13 +892,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP case ParserAction.APC_PUT: // FIXME: this is wrong, must be 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE // inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c - //for (let j = i + 1; ; ++j) { - // if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { - // this._apcParser.put(data, i, j); - // i = j - 1; - // break; - // } - //} + // for (let j = i + 1; ; ++j) { + // if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { + // this._apcParser.put(data, i, j); + // i = j - 1; + // break; + // } + // } this._apcParser.put(data, i, i+1); break; case ParserAction.APC_END: diff --git a/test/playwright/Parser.test.ts b/test/playwright/Parser.test.ts index eceb75282..6f61fb8c9 100644 --- a/test/playwright/Parser.test.ts +++ b/test/playwright/Parser.test.ts @@ -334,7 +334,7 @@ test.describe('Parser Integration Tests', () => { window.customApcHandlerCallStack!.push(['F', data]); return true; }), - term.parser.registerApcHandler({ final: 'X' }, data => { + term.parser.registerApcHandler({ final: 'X' }, data => { window.customApcHandlerCallStack!.push(['X', data]); return true; }) From 849a9a53ca8bbfc2f0a0bd73dc7c9fd922411d3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 16:19:02 +0200 Subject: [PATCH 03/39] add state and transition tests --- .../parser/EscapeSequenceParser.test.ts | 245 +++++++++++++++++- src/common/parser/EscapeSequenceParser.ts | 4 - 2 files changed, 243 insertions(+), 6 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index c7f5b8cfe..ef91d190e 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types'; +import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType } from 'common/parser/Types'; import { EscapeSequenceParser, TransitionTable, VT500_TRANSITION_TABLE } from 'common/parser/EscapeSequenceParser'; import { assert } from 'chai'; import { StringToUtf32, stringFromCodePoint, utf32ToString } from 'common/input/TextDecoder'; @@ -145,6 +145,15 @@ const testTerminal: any = { }, actionDCSUnhook(success: boolean): void { this.calls.push(['dcs unhook', success]); + }, + actionAPCStart(): void { + this.calls.push(['apc start']); + }, + actionAPCPut(s: string): void { + this.calls.push(['apc put', s]); + }, + actionAPCEnd(success: boolean): void { + this.calls.push(['apc end', success]); } }; @@ -200,6 +209,18 @@ testParser.setDcsHandlerFallback((collectAndFlag, action, payload) => { testTerminal.actionDCSUnhook(payload); } }); +testParser.setApcHandlerFallback((collectAndFlag, action, payload) => { + switch (action) { + case 'START': + testTerminal.actionAPCStart(payload); + break; + case 'PUT': + testTerminal.actionAPCPut(payload); + break; + case 'END': + testTerminal.actionAPCEnd(payload); + } +}); // translate string based parse calls into typed array based @@ -280,7 +301,7 @@ describe('EscapeSequenceParser', () => { const exceptions: { [key: number]: { [key: string]: any[] } } = { 8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING 13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH - 16: { '\x18': [], '\x1a': [] } // abort APC_PASSTHROUGH + 16: { '\x18': [['apc end', false]], '\x1a': [['apc end', false]] } // abort APC_PASSTHROUGH }; parser.reset(); testTerminal.clear(); @@ -1023,6 +1044,134 @@ describe('EscapeSequenceParser', () => { parser.reset(); testTerminal.clear(); }); + it('state APC_ENTRY', () => { + parser.reset(); + // C0 + parse(parser, '\x1b_'); + assert.equal(parser.currentState, ParserState.APC_ENTRY); + parser.reset(); + // C1 + for (state in states) { + parser.currentState = state; + parse(parser, '\x9f'); + assert.equal(parser.currentState, ParserState.APC_ENTRY); + parser.reset(); + } + }); + it('state APC_ENTRY ignore rules', () => { + parser.reset(); + const ignored = [ + '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', + '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11', + '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f']; + for (let i = 0; i < ignored.length; ++i) { + parser.currentState = ParserState.APC_ENTRY; + parse(parser, ignored[i]); + assert.equal(parser.currentState, ParserState.APC_ENTRY); + parser.reset(); + } + }); + it('trans APC_ENTRY --> APC_INTERMEDIATE with collect action', () => { + parser.reset(); + const collect = r(0x20, 0x30); + for (let i = 0; i < collect.length; ++i) { + parser.currentState = ParserState.APC_ENTRY; + parse(parser, collect[i]); + assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE); + assert.equal(parser.collect, collect[i]); + parser.reset(); + } + }); + it('trans APC_ENTRY --> APC_PASSTHROUGH with start', () => { + parser.reset(); + testTerminal.clear(); + const collect = r(0x30, 0x7f); + for (let i = 0; i < collect.length; ++i) { + parser.currentState = ParserState.APC_ENTRY; + parse(parser, collect[i]); + assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH); + testTerminal.compare([['apc start']]); + parser.reset(); + testTerminal.clear(); + } + }); + it('trans APC_INTERMEDIATE --> APC_PASSTHROUGH with start', () => { + parser.reset(); + testTerminal.clear(); + const collect = r(0x30, 0x7f); + for (let i = 0; i < collect.length; ++i) { + parser.currentState = ParserState.APC_INTERMEDIATE; + parse(parser, collect[i]); + assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH); + testTerminal.compare([['apc start']]); + parser.reset(); + testTerminal.clear(); + } + }); + it('state APC_INTERMEDIATE ignore rules', () => { + parser.reset(); + const ignored = [ + '\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08', + '\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11', + '\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f']; + for (let i = 0; i < ignored.length; ++i) { + parser.currentState = ParserState.APC_INTERMEDIATE; + parse(parser, ignored[i]); + assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE); + parser.reset(); + } + }); + it('state APC_PASSTHROUGH put action', () => { + parser.reset(); + testTerminal.clear(); + let puts = r(0x08, 0x0e); + puts = puts.concat(r(0x20, 0x7f)); + for (let i = 0; i < puts.length; ++i) { + parser.currentState = ParserState.APC_PASSTHROUGH; + parse(parser, puts[i]); + assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH); + testTerminal.compare([['apc put', puts[i]]]); + parser.reset(); + testTerminal.clear(); + } + }); + it('state APC_PASSTHROUGH ignore rules', () => { + parser.reset(); + testTerminal.clear(); + let puts = r(0x00, 0x08); + puts = puts.concat(r(0x0e, 0x18)); + puts.push('\x19'); + puts = puts.concat(r(0x1c, 0x20)); + puts.push('\x7f'); + for (let i = 0; i < puts.length; ++i) { + parser.currentState = ParserState.APC_PASSTHROUGH; + parse(parser, puts[i]); + assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH); + testTerminal.compare([]); + parser.reset(); + testTerminal.clear(); + } + }); + it('trans APC_PASSTHROUGH --> GROUND|ESCAPE with end action', () => { + parser.reset(); + testTerminal.clear(); + const collect = ['\x9c', '\x18', '\x1a']; // ST - true, CAN & SUB - false + for (let i = 0; i < collect.length; ++i) { + parser.currentState = ParserState.APC_PASSTHROUGH; + parse(parser, collect[i]); + assert.equal(parser.currentState, ParserState.GROUND); + testTerminal.compare([['apc end', collect[i] === '\x9c']]); + parser.reset(); + testTerminal.clear(); + } + // ESC end + parser.currentState = ParserState.APC_PASSTHROUGH; + parse(parser, '\x1b'); + assert.equal(parser.currentState, ParserState.ESCAPE); + testTerminal.compare([['apc end', true]]); + parser.reset(); + testTerminal.clear(); + }); }); function test(s: string, value: any, noReset: any): void { @@ -1088,6 +1237,72 @@ describe('EscapeSequenceParser', () => { ['print', 'defg'] ], null); }); + it('single APC', () => { + test('\x1b_X3+$aäbc;däe\x9c', [ + ['apc start'], + //['apc put', '3+$aäbc;däe'], + ['apc put', '3'], + ['apc put', '+'], + ['apc put', '$'], + ['apc put', 'a'], + ['apc put', 'ä'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'ä'], + ['apc put', 'e'], + ['apc end', true] + ], null); + }); + it('multi APC', () => { + test('\x1b_Xabc;de', [ + ['apc start'], + //['dcs put', 'abc;de'] + ['apc put', 'a'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'e'], + ], null); + testTerminal.clear(); + test('abc\x9c', [ + //['apc put', 'abc'], + ['apc put', 'a'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc end', true] + ], true); + }); + it('print + DCS(C1) + print', () => { + test('abc\x9fAbc;de\x9cxyz', [ + ['print', 'abc'], + ['apc start'], + //['apc put', 'bc;de'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'e'], + ['apc end', true], + ['print', 'xyz'] + ], null); + }); + it('print + DCS(C0) + print', () => { + test('abc\x1b_Abc;de\x1b\\xyz', [ + ['print', 'abc'], + ['apc start'], + //['apc put', 'bc;de'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'e'], + ['apc end', true], + ['print', 'xyz'] + ], null); + }); it('error recovery', () => { test('\x1b[1€abcdefg\x9b<;c', [ ['print', 'abcdefg'], @@ -1134,6 +1349,32 @@ describe('EscapeSequenceParser', () => { ['dcs unhook', false] // false for abort ], null); }); + it('CAN should abort APC', () => { + test('abc\x9fXbc;de\x18', [ + ['print', 'abc'], + ['apc start'], + //['apc put', 'bc;de'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'e'], + ['apc end', false] // false for abort + ], null); + }); + it('SUB should abort APC', () => { + test('abc\x9fXbc;de\x1a', [ + ['print', 'abc'], + ['apc start'], + //['apc put', 'bc;de'], + ['apc put', 'b'], + ['apc put', 'c'], + ['apc put', ';'], + ['apc put', 'd'], + ['apc put', 'e'], + ['apc end', false] // false for abort + ], null); + }); it('CAN should abort OSC', () => { test('\x1b]0;abc123€öäü\x18', [ ['osc', '0;abc123€öäü, success: false'] diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index f2ea00318..6046e2325 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -179,22 +179,18 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY); table.addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY); table.add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY); - table.addMany(r(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY); table.addMany(r(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE); table.addMany(r(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM); table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM); table.addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.addMany(r(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE); - table.addMany(r(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM); table.add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM); - table.addMany(r(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM); table.addMany(r(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM); table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.addMany(r(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE); table.addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE); table.add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE); - table.addMany(r(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE); table.addMany(r(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE); table.addMany(r(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.addMany(r(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH); From 8e12e69e4efc677f2f541f8963c5b9857db70d50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 16:49:06 +0200 Subject: [PATCH 04/39] basic read ahead optimization --- .../parser/EscapeSequenceParser.test.ts | 54 +++---------------- src/common/parser/EscapeSequenceParser.ts | 18 +++---- 2 files changed, 16 insertions(+), 56 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index ef91d190e..6e5efd637 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -1240,38 +1240,18 @@ describe('EscapeSequenceParser', () => { it('single APC', () => { test('\x1b_X3+$aäbc;däe\x9c', [ ['apc start'], - //['apc put', '3+$aäbc;däe'], - ['apc put', '3'], - ['apc put', '+'], - ['apc put', '$'], - ['apc put', 'a'], - ['apc put', 'ä'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'ä'], - ['apc put', 'e'], + ['apc put', '3+$aäbc;däe'], ['apc end', true] ], null); }); it('multi APC', () => { test('\x1b_Xabc;de', [ ['apc start'], - //['dcs put', 'abc;de'] - ['apc put', 'a'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'e'], + ['apc put', 'abc;de'] ], null); testTerminal.clear(); test('abc\x9c', [ - //['apc put', 'abc'], - ['apc put', 'a'], - ['apc put', 'b'], - ['apc put', 'c'], + ['apc put', 'abc'], ['apc end', true] ], true); }); @@ -1279,12 +1259,7 @@ describe('EscapeSequenceParser', () => { test('abc\x9fAbc;de\x9cxyz', [ ['print', 'abc'], ['apc start'], - //['apc put', 'bc;de'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'e'], + ['apc put', 'bc;de'], ['apc end', true], ['print', 'xyz'] ], null); @@ -1293,12 +1268,7 @@ describe('EscapeSequenceParser', () => { test('abc\x1b_Abc;de\x1b\\xyz', [ ['print', 'abc'], ['apc start'], - //['apc put', 'bc;de'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'e'], + ['apc put', 'bc;de'], ['apc end', true], ['print', 'xyz'] ], null); @@ -1353,12 +1323,7 @@ describe('EscapeSequenceParser', () => { test('abc\x9fXbc;de\x18', [ ['print', 'abc'], ['apc start'], - //['apc put', 'bc;de'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'e'], + ['apc put', 'bc;de'], ['apc end', false] // false for abort ], null); }); @@ -1366,12 +1331,7 @@ describe('EscapeSequenceParser', () => { test('abc\x9fXbc;de\x1a', [ ['print', 'abc'], ['apc start'], - //['apc put', 'bc;de'], - ['apc put', 'b'], - ['apc put', 'c'], - ['apc put', ';'], - ['apc put', 'd'], - ['apc put', 'e'], + ['apc put', 'bc;de'], ['apc end', false] // false for abort ], null); }); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 6046e2325..57f39c7eb 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -886,16 +886,16 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._apcParser.start(this._collect << 8 | code); break; case ParserAction.APC_PUT: - // FIXME: this is wrong, must be 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE // inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c - // for (let j = i + 1; ; ++j) { - // if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { - // this._apcParser.put(data, i, j); - // i = j - 1; - // break; - // } - // } - this._apcParser.put(data, i, i+1); + // allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE + // FIXME: 00/08 .. 00/13 currently handled by second APC_PUT invocation + for (let j = i + 1; ; ++j) { + if (j >= length || data[j] < 0x20 || (data[j] >= 0x7f && data[j] < NON_ASCII_PRINTABLE)) { + this._apcParser.put(data, i, j); + i = j - 1; + break; + } + } break; case ParserAction.APC_END: handlerResult = this._apcParser.end(code !== 0x18 && code !== 0x1a); From b7e535e2ec00f85e485c04260de08363e6b23ab8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 17:09:37 +0200 Subject: [PATCH 05/39] APC perf test --- .../EscapeSequenceParser.benchmark.ts | 73 ++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/test/benchmark/EscapeSequenceParser.benchmark.ts b/test/benchmark/EscapeSequenceParser.benchmark.ts index 10c447973..5c9f372fb 100644 --- a/test/benchmark/EscapeSequenceParser.benchmark.ts +++ b/test/benchmark/EscapeSequenceParser.benchmark.ts @@ -6,9 +6,10 @@ import { perfContext, before, beforeEach, ThroughputRuntimeCase } from 'xterm-be import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser'; import { C0, C1 } from 'common/data/EscapeSequences'; -import { IDcsHandler, IOscHandler, IParams } from 'common/parser/Types'; +import { IDcsHandler, IOscHandler, IApcHandler, IParams } from 'common/parser/Types'; import { OscHandler } from 'common/parser/OscParser'; -import { DcsHandler } from '../../out/common/parser/DcsParser'; +import { DcsHandler } from 'common/parser/DcsParser'; +import { ApcHandler } from 'common/parser/ApcParser'; const SIZE = 5000000; @@ -32,6 +33,12 @@ class FastOscHandler implements IOscHandler { public end(success: boolean): boolean { return true; } } +class FastApcHandler implements IApcHandler { + public start(): void {} + public put(data: Uint32Array, start: number, end: number): void {} + public end(success: boolean): boolean { return true; } +} + perfContext('Parser throughput - 50MB data', () => { let parsed: Uint32Array; @@ -108,6 +115,8 @@ perfContext('Parser throughput - 50MB data', () => { parser.registerEscHandler({ intermediates: '%', final: 'G' }, () => true); parser.registerDcsHandler({ final: 'p' }, new DcsHandler(data => true)); parser.registerDcsHandler({ final: 'q' }, new FastDcsHandler()); + parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => true)); + parser.registerApcHandler({ final: 'Y' }, new FastApcHandler()); }); perfContext('PRINT - a', () => { @@ -349,4 +358,64 @@ perfContext('Parser throughput - 50MB data', () => { return { payloadSize: parsed.length }; }, { fork: true }).showAverageThroughput(); }); + + perfContext('APC string interface (short seq)', () => { + before(() => { + const data = '\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\'; + let content = ''; + while (content.length < SIZE) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return { payloadSize: parsed.length }; + }, { fork: true }).showAverageThroughput(); + }); + + perfContext('APC string interface (long seq)', () => { + before(() => { + const data = '\x1b_XLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\'; + let content = ''; + while (content.length < SIZE) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return { payloadSize: parsed.length }; + }, { fork: true }).showAverageThroughput(); + }); + + perfContext('APC class interface (short seq)', () => { + before(() => { + const data = '\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\'; + let content = ''; + while (content.length < SIZE) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return { payloadSize: parsed.length }; + }, { fork: true }).showAverageThroughput(); + }); + + perfContext('APC class interface (long seq)', () => { + before(() => { + const data = '\x1b_YLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\'; + let content = ''; + while (content.length < SIZE) { + content += data; + } + parsed = toUtf32(content); + }); + new ThroughputRuntimeCase('', async () => { + parser.parse(parsed, parsed.length); + return { payloadSize: parsed.length }; + }, { fork: true }).showAverageThroughput(); + }); }); From bf83ea06e43c1cc9e02e103e855dcffa733840a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 17:29:49 +0200 Subject: [PATCH 06/39] better read ahead condition --- src/common/parser/EscapeSequenceParser.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 57f39c7eb..14b1941b8 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -888,13 +888,14 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP case ParserAction.APC_PUT: // inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c // allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE - // FIXME: 00/08 .. 00/13 currently handled by second APC_PUT invocation for (let j = i + 1; ; ++j) { - if (j >= length || data[j] < 0x20 || (data[j] >= 0x7f && data[j] < NON_ASCII_PRINTABLE)) { - this._apcParser.put(data, i, j); - i = j - 1; - break; - } + if (j < length && ( + (data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE + ) + ) continue; + this._apcParser.put(data, i, j); + i = j - 1; + break; } break; case ParserAction.APC_END: From 4946436997a270184941955fe4b8391faca9f18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 17:35:56 +0200 Subject: [PATCH 07/39] make linter happy --- src/common/parser/EscapeSequenceParser.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 14b1941b8..4b3e778d5 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -890,9 +890,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE for (let j = i + 1; ; ++j) { if (j < length && ( - (data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE - ) - ) continue; + (data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE + )) continue; this._apcParser.put(data, i, j); i = j - 1; break; From 56986cb7eb83c843cd2bde0e64f2b71be2f7f2e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sat, 25 Apr 2026 11:10:24 +0200 Subject: [PATCH 08/39] register APC fallback handler --- src/common/InputHandler.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index c62000f32..f0fd1d550 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -211,6 +211,9 @@ export class InputHandler extends Disposable implements IInputHandler { } this._logService.debug('Unknown DCS code: ', { identifier: this._parser.identToString(ident), action, payload }); }); + this._parser.setApcHandlerFallback((ident, action, payload) => { + this._logService.debug('Unknown APC code: ', { identifier: this._parser.identToString(ident), action, payload }); + }); /** * print handler From 4d2f2e0d8e550f353f82f69a760889d349c10169 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sat, 25 Apr 2026 14:39:22 +0200 Subject: [PATCH 09/39] handler registration tests, fix mulitple immediates --- .../parser/EscapeSequenceParser.test.ts | 120 +++++++++++++++++- src/common/parser/EscapeSequenceParser.ts | 1 + 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 6e5efd637..46c6e05f0 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -12,6 +12,7 @@ import { Params } from 'common/parser/Params'; import { OscHandler } from 'common/parser/OscParser'; import { IDisposable } from 'common/Types'; import { DcsHandler } from 'common/parser/DcsParser'; +import { ApcHandler } from 'common/parser/ApcParser'; function r(a: number, b: number): string[] { @@ -1399,6 +1400,7 @@ describe('EscapeSequenceParser', () => { const exe: string[] = []; const osc: [number, string][] = []; const dcs: ([string] | [string, string] | [string, string, ParamsArray, number])[] = []; + const apc: [string, string][] = []; function clearAccu(): void { print = ''; esc.length = 0; @@ -1406,6 +1408,7 @@ describe('EscapeSequenceParser', () => { exe.length = 0; osc.length = 0; dcs.length = 0; + apc.length = 0; } beforeEach(() => { parser2 = new TestEscapeSequenceParser(); @@ -1763,6 +1766,95 @@ describe('EscapeSequenceParser', () => { assert.deepEqual(dcsCustom, [['A', [1, 2, 3], 'abc']]); }); }); + it('APC handler', () => { + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, { + start: function (): void { + apc.push(['start', '']); + }, + put: function (data: Uint32Array, start: number, end: number): void { + let s = ''; + for (let i = start; i < end; ++i) { + s += stringFromCodePoint(data[i]); + } + apc.push(['put', s]); + }, + end: function (success): boolean { + apc.push(['end', success ? '1' : '0']); + return true; + } + }); + parse(parser2, '\x1b_+pabc'); + parse(parser2, ';de\x9c'); + assert.deepEqual(apc, [ + ['start', ''], + ['put', 'abc'], ['put', ';de'], + ['end', '1'] + ]); + parser2.clearApcHandler({ intermediates: '+', final: 'p' }); + parser2.clearApcHandler({ intermediates: '+', final: 'p' }); // should not throw + clearAccu(); + parse(parser2, '\x1b_+pabc'); + parse(parser2, ';de\x9c'); + assert.deepEqual(apc, []); + }); + describe('APC custom handlers', () => { + const APC_INPUT = '\x1b_+pabc\x1b\\'; + it('Prevent fallback', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; })); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['B', 'abc']]); + }); + it('Allow fallback', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return false; })); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['B', 'abc'], ['A', 'abc']]); + }); + it('Multiple custom handlers fallback once', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return false; })); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['C', 'abc'], ['B', 'abc']]); + }); + it('Multiple custom handlers no fallback', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return true; })); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['C', 'abc']]); + }); + it('Execution order should go from latest handler down to the original', () => { + const order: number[] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(1); return true; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(2); return false; })); + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(3); return false; })); + parse(parser2, APC_INPUT); + assert.deepEqual(order, [3, 2, 1]); + }); + it('Dispose should work', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; })); + dispo.dispose(); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['A', 'abc']]); + }); + it('Should not corrupt the parser when dispose is called twice', () => { + const apcCustom: [string, string][] = []; + parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; })); + const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; })); + dispo.dispose(); + dispo.dispose(); + parse(parser2, APC_INPUT); + assert.deepEqual(apcCustom, [['A', 'abc']]); + }); + }); it('ERROR handler', () => { let errorState: IParsingState | null = null; parser2.setErrorHandler(function (state: IParsingState): IParsingState { @@ -1814,15 +1906,22 @@ describe('EscapeSequenceParser', () => { assert.throws(() => { parser.identifier({ final: '\x7f' }); }, 'final must be in range 64 .. 126'); assert.throws(() => { parser.identifier({ final: 'zz' }); }, 'final must be a single byte'); }); - it('final ESC range 0x30 .. 0x7e, one byte', () => { + it('final ESC + APC range 0x30 .. 0x7e, one byte', () => { for (let i = 0x30; i <= 0x7e; ++i) { const final = String.fromCharCode(i); let handler: IDisposable | undefined; assert.doesNotThrow(() => { handler = parser.registerEscHandler({ final }, () => true); }, 'final must be in range 48 .. 126'); if (handler) handler.dispose(); + assert.doesNotThrow( + () => { handler = parser.registerApcHandler({ final }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, + 'final must be in range 48 .. 126' + ); + if (handler) handler.dispose(); } assert.throws(() => { parser.registerEscHandler({ final: '\x2f' }, () => true); }, 'final must be in range 48 .. 126'); assert.throws(() => { parser.registerEscHandler({ final: '\x7f' }, () => true); }, 'final must be in range 48 .. 126'); + assert.throws(() => { parser.registerApcHandler({ final: '\x2f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126'); + assert.throws(() => { parser.registerApcHandler({ final: '\x7f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126'); }); it('id calculation - should stacking prefix -> intermediate -> final', () => { assert.equal(parser.identToString(parser.identifier({ final: 'z' })), 'z'); @@ -1894,6 +1993,25 @@ describe('EscapeSequenceParser', () => { ] ); }); + it('APC', () => { + const callstack: any[] = []; + const h1 = parser.registerApcHandler({ final: 'z' }, new ApcHandler(data => { callstack.push(['z', data]); return true; })); + const h2 = parser.registerApcHandler({ intermediates: '!', final: 'z' }, new ApcHandler(data => { callstack.push(['!z', data]); return true; })); + const h3 = parser.registerApcHandler({ intermediates: '!!', final: 'z' }, new ApcHandler(data => { callstack.push(['!!z', data]); return true; })); + parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\'); + h1.dispose(); + h2.dispose(); + h3.dispose(); + parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\'); + assert.deepEqual( + callstack, + [ + ['z', 'AB'], + ['!z', 'AB'], + ['!!z', 'AB'], + ] + ); + }); }); }); // TODO: error conditions and error recovery (not implemented yet in parser) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 4b3e778d5..f022a9f34 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -145,6 +145,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.addMany(r(0x30, 0x7f), ParserState.APC_ENTRY, ParserAction.APC_START, ParserState.APC_PASSTHROUGH); table.addMany(r(0x30, 0x7f), ParserState.APC_INTERMEDIATE, ParserAction.APC_START, ParserState.APC_PASSTHROUGH); table.addMany(EXECUTABLES, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE); + table.addMany(r(0x20, 0x30), ParserState.APC_INTERMEDIATE, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE); table.add(0x7f, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE); table.addMany(PRINTABLES, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH); table.addMany(EXECUTABLES, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH); From d82d42ef9a83e4c1d5008cd164fd063c1583a296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sat, 25 Apr 2026 15:12:45 +0200 Subject: [PATCH 10/39] APC async tests --- .../parser/EscapeSequenceParser.test.ts | 139 +++++++++++++++--- 1 file changed, 115 insertions(+), 24 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 46c6e05f0..2d5a04df7 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -2065,9 +2065,9 @@ async function throwsAsync(fn: () => Promise, message?: string | undefined) } describe('EscapeSequenceParser - async', () => { - // sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | FIN - // needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1 - const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\FIN'; + // sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | APC X abc ST | FIN + // needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1, APC X + const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\\x1b_Xabc\x1b\\FIN'; let RESULT: any[]; let parser: TestEscapeSequenceParser; const callstack: any[] = []; @@ -2089,6 +2089,7 @@ describe('EscapeSequenceParser - async', () => { ['PRINT', '$>'], ['DCS a', ['xyz', [1, 2]]], ['OSC 1', 'foo=bar'], + ['APC X', 'abc'], ['PRINT', 'FIN'] ]; parser = new TestEscapeSequenceParser(); @@ -2112,6 +2113,7 @@ describe('EscapeSequenceParser - async', () => { parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; }); parser.registerOscHandler(1, new OscHandler(data => { callstack.push(['OSC 1', data]); return true; })); parser.registerDcsHandler({ final: 'a' }, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; })); + parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => { callstack.push(['APC X', data]); return true; })); }); it('sync handlers keep being parsed in sync mode', () => { @@ -2147,6 +2149,7 @@ describe('EscapeSequenceParser - async', () => { parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; }); parser.registerOscHandler(1, new OscHandler(async data => { callstack.push(['OSC 1', data]); return true; })); parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; })); + parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['APC X', data]); return true; })); }); it('sync parse call does not work anymore', () => { @@ -2183,7 +2186,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); }); it('correct result on chunked awaited parse calls', async () => { @@ -2210,6 +2214,7 @@ describe('EscapeSequenceParser - async', () => { ['PRINT', '>'], ['DCS a', ['xyz', [1, 2]]], ['OSC 1', 'foo=bar'], + ['APC X', 'abc'], ['PRINT', 'F'], ['PRINT', 'I'], ['PRINT', 'N'] @@ -2228,7 +2233,8 @@ describe('EscapeSequenceParser - async', () => { [0, ParserStackType.ESC, 0], [0, ParserStackType.CSI, 0], [0, ParserStackType.DCS, 0], - [0, ParserStackType.OSC, 0] + [0, ParserStackType.OSC, 0], + [0, ParserStackType.APC, 0], ]); }); it('multiple async SGR handlers', async () => { @@ -2248,7 +2254,8 @@ describe('EscapeSequenceParser - async', () => { [27, ParserStackType.CSI, 1], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2261,7 +2268,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); @@ -2279,7 +2287,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 1], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2292,7 +2301,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); }); it('multiple async ESC handlers', async () => { @@ -2310,7 +2320,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2323,7 +2334,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); @@ -2340,7 +2352,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 1], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2353,7 +2366,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); }); it('sync/async SGR mixed', async () => { @@ -2378,7 +2392,8 @@ describe('EscapeSequenceParser - async', () => { [27, ParserStackType.CSI, 2], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // dispose SGR2 (sync one) @@ -2399,7 +2414,8 @@ describe('EscapeSequenceParser - async', () => { [27, ParserStackType.CSI, 1], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // dispose SGR3 (async one) @@ -2412,7 +2428,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); }); it('multiple async OSC handlers', async () => { @@ -2430,7 +2447,8 @@ describe('EscapeSequenceParser - async', () => { [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], [54, ParserStackType.OSC, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2443,7 +2461,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); @@ -2460,7 +2479,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2473,7 +2493,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); }); @@ -2492,7 +2513,8 @@ describe('EscapeSequenceParser - async', () => { [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2505,7 +2527,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); @@ -2522,7 +2545,8 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); // after dispose we should be back to RESULT @@ -2535,7 +2559,74 @@ describe('EscapeSequenceParser - async', () => { [20, ParserStackType.ESC, 0], [27, ParserStackType.CSI, 0], [41, ParserStackType.DCS, 0], - [54, ParserStackType.OSC, 0] + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], + ]); + clearAccu(); + }); + it('multiple async APC handlers', async () => { + // register with fallback + const APC2 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return false; })); + await parseP(parser, INPUT); + for (let i = 0; i < callstack.length; ++i) { + const entry = callstack[i]; + if (entry[0] === '2# APC X') assert.equal(callstack[i + 1][0], 'APC a', 'Should fallback to original handler'); + } + evalStackSaves(parser.trackedStack, [ + [6, ParserStackType.CSI, 0], + [15, ParserStackType.ESC, 0], + [20, ParserStackType.ESC, 0], + [27, ParserStackType.CSI, 0], + [41, ParserStackType.DCS, 0], + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], + [62, ParserStackType.APC, 0], + ]); + clearAccu(); + // after dispose we should be back to RESULT + APC2.dispose(); + await parseP(parser, INPUT); + assert.deepEqual(callstack, RESULT, 'Should not call custom handler'); + evalStackSaves(parser.trackedStack, [ + [6, ParserStackType.CSI, 0], + [15, ParserStackType.ESC, 0], + [20, ParserStackType.ESC, 0], + [27, ParserStackType.CSI, 0], + [41, ParserStackType.DCS, 0], + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], + ]); + clearAccu(); + + // register without fallback + const APC22 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return true; })); + await parseP(parser, INPUT); + for (let i = 0; i < callstack.length; ++i) { + const entry = callstack[i]; + if (entry[0] === '2# APC X') assert.notEqual(callstack[i + 1][0], 'APC X', 'Should fallback to original handler'); + } + evalStackSaves(parser.trackedStack, [ + [6, ParserStackType.CSI, 0], + [15, ParserStackType.ESC, 0], + [20, ParserStackType.ESC, 0], + [27, ParserStackType.CSI, 0], + [41, ParserStackType.DCS, 0], + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], + ]); + clearAccu(); + // after dispose we should be back to RESULT + APC22.dispose(); + await parseP(parser, INPUT); + assert.deepEqual(callstack, RESULT, 'Should not call custom handler'); + evalStackSaves(parser.trackedStack, [ + [6, ParserStackType.CSI, 0], + [15, ParserStackType.ESC, 0], + [20, ParserStackType.ESC, 0], + [27, ParserStackType.CSI, 0], + [41, ParserStackType.DCS, 0], + [54, ParserStackType.OSC, 0], + [62, ParserStackType.APC, 0], ]); clearAccu(); }); From 84aca0b912eb31d27c3b33aab868a21019954f64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sat, 25 Apr 2026 15:46:33 +0200 Subject: [PATCH 11/39] fix ApcParser tests --- src/common/parser/ApcParser.test.ts | 609 ++++++++++++++++------------ 1 file changed, 354 insertions(+), 255 deletions(-) diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts index 39dc9fb45..c2200d918 100644 --- a/src/common/parser/ApcParser.test.ts +++ b/src/common/parser/ApcParser.test.ts @@ -5,7 +5,7 @@ import { assert } from 'chai'; import { ApcParser, ApcHandler } from 'common/parser/ApcParser'; import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder'; -import { IApcHandler } from 'common/parser/Types'; +import { IApcHandler, IFunctionIdentifier } from 'common/parser/Types'; function toUtf32(s: string): Uint32Array { const utf32 = new Uint32Array(s.length); @@ -14,31 +14,53 @@ function toUtf32(s: string): Uint32Array { return utf32.subarray(0, length); } -class TestHandler implements IApcHandler { - public id: number; - public output: [string, number, string, (boolean | string)?][]; - public msg: string; - public returnFalse: boolean; - - constructor( - id: number, - output: [string, number, string, (boolean | string)?][], - msg: string, - returnFalse: boolean = false - ) { - this.id = id; - this.output = output; - this.msg = msg; - this.returnFalse = returnFalse; +function identifier(id: IFunctionIdentifier): number { + let res = 0; + if (id.prefix) { + if (id.prefix.length > 1) { + throw new Error('only one byte as prefix supported'); + } + res = id.prefix.charCodeAt(0); + if (res && 0x3c > res || res > 0x3f) { + throw new Error('prefix must be in range 0x3c .. 0x3f'); + } } + if (id.intermediates) { + if (id.intermediates.length > 2) { + throw new Error('only two bytes as intermediates are supported'); + } + for (let i = 0; i < id.intermediates.length; ++i) { + const intermediate = id.intermediates.charCodeAt(i); + if (0x20 > intermediate || intermediate > 0x2f) { + throw new Error('intermediate must be in range 0x20 .. 0x2f'); + } + res <<= 8; + res |= intermediate; + } + } + if (id.final.length !== 1) { + throw new Error('final must be a single byte'); + } + const finalCode = id.final.charCodeAt(0); + if (0x40 > finalCode || finalCode > 0x7e) { + throw new Error('final must be in range 0x40 .. 0x7e'); + } + res <<= 8; + res |= finalCode; + + return res; +} + +class TestHandler implements IApcHandler { + constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {} public start(): void { - this.output.push([this.msg, this.id, 'START']); + this.output.push([this.msg, 'START']); } public put(data: Uint32Array, start: number, end: number): void { - this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]); + this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]); } public end(success: boolean): boolean { - this.output.push([this.msg, this.id, 'END', success]); + this.output.push([this.msg, 'END', success]); if (this.returnFalse) { return false; } @@ -48,173 +70,105 @@ class TestHandler implements IApcHandler { describe('ApcParser', () => { let parser: ApcParser; - let reports: [number, string, (boolean | string | undefined)?][] = []; - + let reports: any[] = []; beforeEach(() => { reports = []; parser = new ApcParser(); - parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => { - reports.push([id, action, data]); - }); + parser.setHandlerFallback((id, action, data) => reports.push([id, action, data])); }); - - describe('identifier parsing', () => { - it('single character identifier', () => { - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100,a=T;payload'); - parser.put(data, 0, data.length); - parser.end(true); - assert.deepEqual(reports, [ - [0x47, 'START', undefined], // 0x47 = 'G' - [0x47, 'PUT', 'f=100,a=T;payload'], - [0x47, 'END', true] - ]); - }); - - // it('identifier with no payload', () => { - // parser.start('G'.charCodeAt(0)); - // const data = toUtf32(''); - // parser.put(data, 0, data.length); - // parser.end(true); - // assert.deepEqual(reports, [ - // [0x47, 'START', undefined], - // [0x47, 'END', true] - // ]); - // }); - - it('identifier with chunked payload', () => { - parser.start('G'.charCodeAt(0)); - let data = toUtf32('f=100'); - parser.put(data, 0, data.length); - data = toUtf32(',a=T'); - parser.put(data, 0, data.length); - data = toUtf32(';payload'); - parser.put(data, 0, data.length); - parser.end(true); - assert.deepEqual(reports, [ - [0x47, 'START', undefined], - [0x47, 'PUT', 'f=100'], - [0x47, 'PUT', ',a=T'], - [0x47, 'PUT', ';payload'], - [0x47, 'END', true] - ]); - }); - - // it('empty APC sequence', () => { - // parser.start('G'.charCodeAt(0)); - // parser.end(true); - // assert.deepEqual(reports, []); - // }); - }); - describe('handler registration', () => { - let handlerReports: [string, number, string, (boolean | string)?][]; - - beforeEach(() => { - handlerReports = []; - }); - - it('registerHandler for specific identifier', () => { - const G_CODE = 0x47; // 'G' - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100,a=T;imagedata'); + it('setApcHandler', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th')); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(handlerReports, [ - ['kitty', G_CODE, 'START'], - ['kitty', G_CODE, 'PUT', 'f=100,a=T;imagedata'], - ['kitty', G_CODE, 'END', true] - ]); - assert.deepEqual(reports, []); - }); - - it('unregistered identifier falls back', () => { - const G_CODE = 0x47; // 'G' - const X_CODE = 0x58; // 'X' - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start('X'.charCodeAt(0)); - const data = toUtf32('some data'); - parser.put(data, 0, data.length); - parser.end(true); - assert.deepEqual(handlerReports, []); assert.deepEqual(reports, [ - [X_CODE, 'START', undefined], - [X_CODE, 'PUT', 'some data'], - [X_CODE, 'END', true] + // messages from TestHandler + ['th', 'START'], + ['th', 'PUT', 'Here comes'], + ['th', 'PUT', 'the mouse!'], + ['th', 'END', true] ]); }); - - it('clearHandler removes handler', () => { - const G_CODE = 0x47; - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.clearHandler(G_CODE); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100'); + it('clearApcHandler', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th')); + parser.clearHandler(identifier({intermediates: '+', final: 'p'})); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(handlerReports, []); assert.deepEqual(reports, [ - [G_CODE, 'START', undefined], - [G_CODE, 'PUT', 'f=100'], - [G_CODE, 'END', true] + // messages from fallback handler + [identifier({intermediates: '+', final: 'p'}), 'START', undefined], + [identifier({intermediates: '+', final: 'p'}), 'PUT', 'Here comes'], + [identifier({intermediates: '+', final: 'p'}), 'PUT', 'the mouse!'], + [identifier({intermediates: '+', final: 'p'}), 'END', true] ]); }); - - it('multiple handlers for same identifier', () => { - const G_CODE = 0x47; - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('data'); + it('addApcHandler', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1')); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2')); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(handlerReports, [ - ['handler2', G_CODE, 'START'], - ['handler1', G_CODE, 'START'], - ['handler2', G_CODE, 'PUT', 'data'], - ['handler1', G_CODE, 'PUT', 'data'], - ['handler2', G_CODE, 'END', true], - ['handler1', G_CODE, 'END', false] + assert.deepEqual(reports, [ + ['th2', 'START'], + ['th1', 'START'], + ['th2', 'PUT', 'Here comes'], + ['th1', 'PUT', 'Here comes'], + ['th2', 'PUT', 'the mouse!'], + ['th1', 'PUT', 'the mouse!'], + ['th2', 'END', true], + ['th1', 'END', false] // false due being already handled by th2! ]); }); - - it('handler returning false allows fallthrough', () => { - const G_CODE = 0x47; - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true)); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('data'); + it('addApcHandler with return false', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1')); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true)); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(handlerReports, [ - ['handler2', G_CODE, 'START'], - ['handler1', G_CODE, 'START'], - ['handler2', G_CODE, 'PUT', 'data'], - ['handler1', G_CODE, 'PUT', 'data'], - ['handler2', G_CODE, 'END', true], - ['handler1', G_CODE, 'END', true] + assert.deepEqual(reports, [ + ['th2', 'START'], + ['th1', 'START'], + ['th2', 'PUT', 'Here comes'], + ['th1', 'PUT', 'Here comes'], + ['th2', 'PUT', 'the mouse!'], + ['th1', 'PUT', 'the mouse!'], + ['th2', 'END', true], + ['th1', 'END', true] // true since th2 indicated to keep bubbling ]); }); - - it('dispose removes handler', () => { - const G_CODE = 0x47; - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); - const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); - disposable.dispose(); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('data'); + it('dispose handlers', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1')); + const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true)); + dispo.dispose(); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(handlerReports, [ - ['handler1', G_CODE, 'START'], - ['handler1', G_CODE, 'PUT', 'data'], - ['handler1', G_CODE, 'END', true] + assert.deepEqual(reports, [ + ['th1', 'START'], + ['th1', 'PUT', 'Here comes'], + ['th1', 'PUT', 'the mouse!'], + ['th1', 'END', true] ]); }); }); - - describe('ApcHandler convenience class', () => { + describe('ApcHandlerFactory', () => { const TEST_PAYLOAD_LIMIT = 100; const CHUNK_SIZE = 10; let originalPayloadLimit: number; @@ -231,133 +185,278 @@ describe('ApcParser', () => { }); it('should be called once on end(true)', () => { - const G_CODE = 0x47; - const results: [number, string][] = []; - parser.registerHandler(G_CODE, new ApcHandler((data: string) => { - results.push([G_CODE, data]); - return true; - })); - parser.start('G'.charCodeAt(0)); - let data = toUtf32('f=100'); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); parser.put(data, 0, data.length); - data = toUtf32(',a=T;payload'); + data = toUtf32(' the mouse!'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(results, [[G_CODE, 'f=100,a=T;payload']]); + assert.deepEqual(reports, ['Here comes the mouse!']); }); - it('should not be called on end(false)', () => { - const G_CODE = 0x47; - const results: [number, string][] = []; - parser.registerHandler(G_CODE, new ApcHandler((data: string) => { - results.push([G_CODE, data]); - return true; - })); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100,a=T;payload'); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); parser.put(data, 0, data.length); parser.end(false); - assert.deepEqual(results, []); + assert.deepEqual(reports, []); }); - - it('should handle payload up to limit', function(): void { - this.timeout(30000); - const G_CODE = 0x47; - const results: [number, string][] = []; - parser.registerHandler(G_CODE, new ApcHandler((data: string) => { - results.push([G_CODE, data]); - return true; - })); - parser.start('G'.charCodeAt(0)); - let data = toUtf32(''); + it('should be disposable', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; })); + const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); parser.put(data, 0, data.length); - data = toUtf32('A'.repeat(CHUNK_SIZE)); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!']]); + dispo.dispose(); + parser.start(identifier({intermediates: '+', final: 'p'})); + data = toUtf32('some other'); + parser.put(data, 0, data.length); + data = toUtf32(' data'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]); + }); + it('should respect return false', () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; })); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return false; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]); + }); + it('should work up to payload limit', function(): void { + this.timeout(30000); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + const data = toUtf32('A'.repeat(CHUNK_SIZE)); for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) { parser.put(data, 0, data.length); } parser.end(true); - assert.deepEqual(results, [[G_CODE, 'A'.repeat(TEST_PAYLOAD_LIMIT)]]); + assert.deepEqual(reports, ['A'.repeat(TEST_PAYLOAD_LIMIT)]); }); - - it('should abort for payload over limit', function(): void { + it('should abort for payload limit +1', function(): void { this.timeout(30000); - const G_CODE = 0x47; - const results: [number, string][] = []; - parser.registerHandler(G_CODE, new ApcHandler((data: string) => { - results.push([G_CODE, data]); - return true; - })); - parser.start('G'.charCodeAt(0)); - let data = toUtf32(''); - parser.put(data, 0, data.length); - data = toUtf32('A'.repeat(CHUNK_SIZE)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('A'.repeat(CHUNK_SIZE)); for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) { parser.put(data, 0, data.length); } data = toUtf32('A'); parser.put(data, 0, data.length); parser.end(true); - assert.deepEqual(results, []); - }); - }); - - describe('reset behavior', () => { - let handlerReports: [string, number, string, (boolean | string)?][]; - - beforeEach(() => { - handlerReports = []; - }); - - it('reset during payload cleans up handlers', () => { - const G_CODE = 0x47; - parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100'); - parser.put(data, 0, data.length); - parser.reset(); - assert.deepEqual(handlerReports, [ - ['kitty', G_CODE, 'START'], - ['kitty', G_CODE, 'PUT', 'f=100'], - ['kitty', G_CODE, 'END', false] - ]); + assert.deepEqual(reports, []); }); }); }); + +class TestHandlerAsync implements IApcHandler { + constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {} + public start(): void { + this.output.push([this.msg, 'START']); + } + public put(data: Uint32Array, start: number, end: number): void { + this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]); + } + public async end(success: boolean): Promise { + // simple sleep to check in tests whether ordering gets messed up + await Promise.resolve(); + this.output.push([this.msg, 'END', success]); + if (this.returnFalse) { + return false; + } + return true; + } +} +async function unhookP(parser: ApcParser, success: boolean): Promise { + let result: void | Promise; + let prev: boolean | undefined; + while (result = parser.end(success, prev)) { + prev = await result; + } +} + + describe('ApcParser - async tests', () => { let parser: ApcParser; - let reports: [number, string, (boolean | string | undefined)?][] = []; - + let reports: any[] = []; beforeEach(() => { reports = []; parser = new ApcParser(); - parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => { - reports.push([id, action, data]); - }); + parser.setHandlerFallback((id, action, data) => reports.push([id, action, data])); }); - - async function endP(parser: ApcParser, success: boolean): Promise { - let result: void | Promise; - let prev: boolean | undefined; - while (result = parser.end(success, prev)) { - prev = await result; - } - } - - describe('async ApcHandler', () => { - it('should handle async handler', async () => { - const G_CODE = 0x47; - const results: [number, string][] = []; - parser.registerHandler(G_CODE, new ApcHandler(async (data: string) => { - await new Promise(res => setTimeout(res, 10)); - results.push([G_CODE, data]); - return true; - })); - parser.start('G'.charCodeAt(0)); - const data = toUtf32('f=100,a=T'); - parser.put(data, 0, data.length); - await endP(parser, true); - assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]); + describe('sync and async mixed', () => { + describe('sync | async | sync', () => { + it('first should run, cleanup action for others', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', false)); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [ + // messages from TestHandler + ['s2', 'START'], + ['a1', 'START'], + ['s1', 'START'], + ['s2', 'PUT', 'Here comes'], + ['a1', 'PUT', 'Here comes'], + ['s1', 'PUT', 'Here comes'], + ['s2', 'PUT', 'the mouse!'], + ['a1', 'PUT', 'the mouse!'], + ['s1', 'PUT', 'the mouse!'], + ['s2', 'END', true], + ['a1', 'END', false], // important: a1 before s1 + ['s1', 'END', false] + ]); + }); + it('all should run', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', true)); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [ + // messages from TestHandler + ['s2', 'START'], + ['a1', 'START'], + ['s1', 'START'], + ['s2', 'PUT', 'Here comes'], + ['a1', 'PUT', 'Here comes'], + ['s1', 'PUT', 'Here comes'], + ['s2', 'PUT', 'the mouse!'], + ['a1', 'PUT', 'the mouse!'], + ['s1', 'PUT', 'the mouse!'], + ['s2', 'END', true], + ['a1', 'END', true], // important: a1 before s1 + ['s1', 'END', true] + ]); + }); + }); + describe('async | sync | async', () => { + it('first should run, cleanup action for others', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', false)); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [ + // messages from TestHandler + ['a2', 'START'], + ['s1', 'START'], + ['a1', 'START'], + ['a2', 'PUT', 'Here comes'], + ['s1', 'PUT', 'Here comes'], + ['a1', 'PUT', 'Here comes'], + ['a2', 'PUT', 'the mouse!'], + ['s1', 'PUT', 'the mouse!'], + ['a1', 'PUT', 'the mouse!'], + ['a2', 'END', true], + ['s1', 'END', false], // important: s1 between a2 .. a1 + ['a1', 'END', false] + ]); + }); + it('all should run', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true)); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', true)); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32('the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [ + // messages from TestHandler + ['a2', 'START'], + ['s1', 'START'], + ['a1', 'START'], + ['a2', 'PUT', 'Here comes'], + ['s1', 'PUT', 'Here comes'], + ['a1', 'PUT', 'Here comes'], + ['a2', 'PUT', 'the mouse!'], + ['s1', 'PUT', 'the mouse!'], + ['a1', 'PUT', 'the mouse!'], + ['a2', 'END', true], + ['s1', 'END', true], // important: s1 between a2 .. a1 + ['a1', 'END', true] + ]); + }); + }); + describe('ApcHandlerFactory', () => { + it('should be called once on end(true)', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, ['Here comes the mouse!']); + }); + it('should not be called on end(false)', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, false); + assert.deepEqual(reports, []); + }); + it('should be disposable', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; })); + const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return true; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!']]); + dispo.dispose(); + parser.start(identifier({intermediates: '+', final: 'p'})); + data = toUtf32('some other'); + parser.put(data, 0, data.length); + data = toUtf32(' data'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]); + }); + it('should respect return false', async () => { + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; })); + parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return false; })); + parser.start(identifier({intermediates: '+', final: 'p'})); + let data = toUtf32('Here comes'); + parser.put(data, 0, data.length); + data = toUtf32(' the mouse!'); + parser.put(data, 0, data.length); + await unhookP(parser, true); + assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]); + }); }); }); }); From 4e9abe7d96cf22a07e79ecc6b394173c3858ad90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 14:34:42 +0200 Subject: [PATCH 12/39] fix vt feature listing --- addons/addon-image/src/ImageAddon.ts | 38 +++++++++++++++++++++++ bin/extract_vtfeatures.js | 33 +++++++++++++++++++- package.json | 2 +- src/common/InputHandler.ts | 19 ------------ src/common/parser/EscapeSequenceParser.ts | 18 +++++++++++ 5 files changed, 89 insertions(+), 21 deletions(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 2fcf273c9..25ca41584 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -16,6 +16,44 @@ import { SixelImageStorage } from './SixelImageStorage'; import { IIPImageStorage } from './IIPImageStorage'; import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; + +/** + * Document VT features provided by this addon. + * + * @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image." + * + * Sixel support is provided by the addon @xterm/addon-image with these limitations: + * - immediate coloring (no shared palette, allows high color settings of `img2sixel`) + * - max. palette size of 4096 colors + * - max. pixel width of 16K + * - max. 25 MB per sixel sequence + * - VT340 cursor positioning (begin of last sixel data row) + * + * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details. + * + * + * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands." + * + * Only the inline image protocol (IIP) is supported with the following limitations: + * - sequence: + * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL` + * - size param must be set and payload may not exceed CEIL(size * 4 / 3) + * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed) + * - supported params: size, name, width, height, preserveAspectRatio + * - image formats: PNG, JPEG and GIF + * - no animation support (renders first image of a GIF) + * - no multipart support + * - VT340 cursor positioning (begin of last sixel data row) + * + * See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) + * and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details. + * + * + * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol." + * + * **Note:** While basic image output already works, this is still work in progress. + */ + // default values of addon ctor options const DEFAULT_OPTIONS: IImageAddonOptions = { enableSizeReports: true, diff --git a/bin/extract_vtfeatures.js b/bin/extract_vtfeatures.js index 60979cce4..1fde261bb 100644 --- a/bin/extract_vtfeatures.js +++ b/bin/extract_vtfeatures.js @@ -57,6 +57,9 @@ xterm.js version: {{version}} {{#CSI.length}} - [CSI](#csi) {{/CSI.length}} +{{#APC.length}} +- [APC](#apc) +{{/APC.length}} {{#DCS.length}} - [DCS](#dcs) {{/DCS.length}} @@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g - CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit) - DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit) - OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit) +- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit) -Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported, +Privacy Message (PM) and Start of String (SOS) are recognized but not supported, any sequence of these types will be silently ignored. They are also not hookable by the API. Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either @@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does: {{/CSI.length}} +{{#APC.length}} +## APC + +| Mnemonic | Name | Sequence | Short Description | Support | +| -------- | ---- | -------- | ----------------- | ------- | +{{#APC}} +| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} | +{{/APC}} + +{{#APC.hasLongDescriptions}} +{{#APC}} +{{#longDescription.length}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/APC}} +{{/APC.hasLongDescriptions}} + +{{/APC.length}} + + {{#DCS.length}} ## DCS diff --git a/package.json b/package.json index 151566544..af7e329cc 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,7 @@ "benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js", "benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js", "clean": "rm -rf lib out addons/*/lib addons/*/out", - "vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts", + "vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts", "prepackage": "npm run build", "package": "webpack", "postpackage": "npm run esbuild-package", diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index f0fd1d550..e117a49ef 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -30,28 +30,9 @@ import { XTERM_VERSION } from 'common/Version'; */ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2 }; -/** - * VT commands done by the parser - FIXME: move this to the parser? - */ -// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence." -// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence." -// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence." -// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences." -// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message." -// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence." -// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence." -// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence." -// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence." -// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences." -// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message." -// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence." -// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored." -// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence." - /** * Document xterm VT features here that are currently unsupported */ -// @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image." // @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps \| Pt ST" "Definitions for user-defined keys." // @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String." // @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data." diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index f022a9f34..d944112db 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -12,6 +12,24 @@ import { OscParser } from 'common/parser/OscParser'; import { DcsParser } from 'common/parser/DcsParser'; import { ApcParser } from 'common/parser/ApcParser'; +/** + * VT commands done by the parser + */ +// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence." +// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence." +// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence." +// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences." +// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message." +// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence." +// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence." +// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence." +// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence." +// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences." +// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message." +// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence." +// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored." +// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence." + /** * Table values are generated like this: * index: currentState << TableValue.INDEX_STATE_SHIFT | charCode From 915b0b81a17f0246181cf9e053164cccc1cd5958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 14:37:30 +0200 Subject: [PATCH 13/39] fix vt feature listing --- addons/addon-image/src/ImageAddon.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 25ca41584..12b3326f8 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -34,7 +34,7 @@ import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; * * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands." * - * Only the inline image protocol (IIP) is supported with the following limitations: + * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with the following limitations: * - sequence: * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL` * - size param must be set and payload may not exceed CEIL(size * 4 / 3) @@ -51,7 +51,8 @@ import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; * * @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol." * - * **Note:** While basic image output already works, this is still work in progress. + * Kitty graphics support is provided by the addon @xterm/addon-image. + * Note that while basic image output already works, this is still work in progress. */ // default values of addon ctor options From ea7e9589591fd89f6cf4e3d9c93a57d97744e30c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 14:41:36 +0200 Subject: [PATCH 14/39] fix vt feature listing --- addons/addon-image/src/ImageAddon.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 12b3326f8..1cc3e054c 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -38,7 +38,8 @@ import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; * - sequence: * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL` * - size param must be set and payload may not exceed CEIL(size * 4 / 3) - * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed) + * - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, + * no separator bytes allowed) * - supported params: size, name, width, height, preserveAspectRatio * - image formats: PNG, JPEG and GIF * - no animation support (renders first image of a GIF) From 785c3cb336845014f4cd75683aec4029dcd2c0f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 14:43:03 +0200 Subject: [PATCH 15/39] fix vt feature listing --- addons/addon-image/src/ImageAddon.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 1cc3e054c..9038ff6c3 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -34,7 +34,8 @@ import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types'; * * @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands." * - * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with the following limitations: + * Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with + * the following limitations: * - sequence: * - format: `OSC 1337 ; File=inline=1 ; size= ; ... : BEL` * - size param must be set and payload may not exceed CEIL(size * 4 / 3) From 41938b307e74fcd7e6986a8de98c6ed20c1b0466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 16:44:22 +0200 Subject: [PATCH 16/39] adjust image addon defaults --- addons/addon-image/src/ImageAddon.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 7af0e519c..ec4f5bb90 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -22,14 +22,14 @@ const DEFAULT_OPTIONS: IImageAddonOptions = { pixelLimit: 16777216, // limit to 4096 * 4096 pixels sixelSupport: true, sixelScrolling: true, - sixelPaletteLimit: 256, - sixelSizeLimit: 25000000, + sixelPaletteLimit: 4096, + sixelSizeLimit: 33554432, storageLimit: 128, showPlaceholder: true, iipSupport: true, - iipSizeLimit: 20000000, + iipSizeLimit: 33554432, kittySupport: true, - kittySizeLimit: 20000000 + kittySizeLimit: 33554432 }; // max palette size supported by the sixel lib (compile time setting) From d9082215e76a9d5e0152d096acd9e9220c7f0345 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 26 Apr 2026 23:28:18 +0200 Subject: [PATCH 17/39] qoi support for IIP --- addons/addon-image/package.json | 2 +- addons/addon-image/src/IIPHandler.ts | 43 +++++++++++++++------------- addons/addon-image/src/IIPMetrics.ts | 10 ++++++- package-lock.json | 19 +++++++++--- 4 files changed, 48 insertions(+), 26 deletions(-) diff --git a/addons/addon-image/package.json b/addons/addon-image/package.json index 3d5fa37f6..fc6864a0e 100644 --- a/addons/addon-image/package.json +++ b/addons/addon-image/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } } diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index 76492c405..6f94ef8ed 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer'; import { IIPImageStorage } from './IIPImageStorage'; import { CELL_SIZE_DEFAULT } from './ImageStorage'; import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; +import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm'; import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; @@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; private _dec: Base64Decoder; + private _qoiDec: QoiDecoder; private _metrics = UNSUPPORTED_TYPE; constructor( @@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3); const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes); this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes); + this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA); } public reset(): void {} @@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler { return true; } - // HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it - const blob = new Blob([this._dec.data8 as Uint8Array], { type: this._metrics.mime }); - this._dec.release(); - - if (!window.createImageBitmap) { - const url = URL.createObjectURL(blob); - const img = new Image(); - return new Promise(r => { - img.addEventListener('load', () => { - URL.revokeObjectURL(url); - const canvas = ImageRenderer.createCanvas(window.document, w, h); - canvas.getContext('2d')?.drawImage(img, 0, 0, w, h); - this._storage.addImage(canvas); - r(true); - }); - img.src = url; - // sanity measure to avoid terminal blocking from dangling promise - // happens from corrupt data (onload never gets fired) - setTimeout(() => r(true), 1000); - }); + let blob: Blob | ImageData; + if (this._metrics.mime === 'image/qoi') { + const data = this._qoiDec.decode(this._dec.data8); + blob = new ImageData( + new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), + this._qoiDec.width, + this._qoiDec.height + ); + this._qoiDec.release(); + if (w === this._qoiDec.width && h === this._qoiDec.height) { + // use fast-path if we don't need to rescale + this._dec.release(); + const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height); + canvas.getContext('2d')?.putImageData(blob, 0, 0); + this._storage.addImage(canvas); + return true; + } + } else { + blob = new Blob([this._dec.data8], { type: this._metrics.mime }); } + this._dec.release(); return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h }) .then(bm => { this._storage.addImage(bm); diff --git a/addons/addon-image/src/IIPMetrics.ts b/addons/addon-image/src/IIPMetrics.ts index d5470466f..b377cf061 100644 --- a/addons/addon-image/src/IIPMetrics.ts +++ b/addons/addon-image/src/IIPMetrics.ts @@ -4,7 +4,7 @@ */ -export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | ''; +export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | ''; export interface IMetrics { mime: ImageType; @@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics { height: d[9] << 8 | d[8] }; } + // QOI: qoif + if (d32[0] === 0x66696F71) { + return { + mime: 'image/qoi', + width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7], + height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11] + }; + } return UNSUPPORTED_TYPE; } diff --git a/package-lock.json b/package-lock.json index 6130135fd..40aa0b683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "license": "MIT", "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } }, "addons/addon-ligatures": { @@ -245,6 +245,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -558,6 +559,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -599,6 +601,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -1796,6 +1799,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2361,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2751,6 +2756,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3746,6 +3752,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7010,6 +7017,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7850,6 +7858,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8016,6 +8025,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8064,6 +8074,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -8435,9 +8446,9 @@ } }, "node_modules/xterm-wasm-parts": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz", - "integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.4.1.tgz", + "integrity": "sha512-/qwoasrSyrzs90WHTN4wpISAEz1u9sOpLsDhkd9a2h27l1pz/KZd9h4p+X2pUR7OqyXvDsOdj0dV3906s3wh8g==", "dev": true, "license": "MIT", "dependencies": { From 92339ac9dd6cfee4cc57ec0c8ccd190b6b192ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 21:45:50 +0200 Subject: [PATCH 18/39] fix #5849 --- src/common/InputHandler.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 68265c5df..63a786f4b 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -2415,7 +2415,7 @@ export class InputHandler extends Disposable implements IInputHandler { color &= ~Attributes.RGB_MASK; color |= AttributeData.fromColorRGB([c1, c2, c3]); } else if (mode === 5) { - color &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK); + color &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); color |= Attributes.CM_P256 | (c1 & 0xff); } return color; @@ -2628,19 +2628,19 @@ export class InputHandler extends Disposable implements IInputHandler { p = params.params[i]; if (p >= 30 && p <= 37) { // fg color 8 - attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK); + attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); attr.fg |= Attributes.CM_P16 | (p - 30); } else if (p >= 40 && p <= 47) { // bg color 8 - attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK); + attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); attr.bg |= Attributes.CM_P16 | (p - 40); } else if (p >= 90 && p <= 97) { // fg color 16 - attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK); + attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); attr.fg |= Attributes.CM_P16 | (p - 90) | 8; } else if (p >= 100 && p <= 107) { // bg color 16 - attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK); + attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); attr.bg |= Attributes.CM_P16 | (p - 100) | 8; } else if (p === 0) { // default @@ -2700,11 +2700,11 @@ export class InputHandler extends Disposable implements IInputHandler { } else if (p === 39) { // reset fg attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); - attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK); + attr.fg |= DEFAULT_ATTR_DATA.fg & Attributes.RGB_MASK; } else if (p === 49) { // reset bg attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); - attr.bg |= DEFAULT_ATTR_DATA.bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK); + attr.bg |= DEFAULT_ATTR_DATA.bg & Attributes.RGB_MASK; } else if (p === 38 || p === 48 || p === 58) { // fg color 256 and RGB i += this._extractColor(params, i, attr); From 7abcca7e7375cdef443baa79bb4d6486faf9e6cc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Tue, 28 Apr 2026 23:57:12 -0700 Subject: [PATCH 19/39] Add Orca to real-world uses --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index a6fecc191..75ff03313 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch. - [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management. - [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators. +- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. From e8b5fd3c234b16676c22689c31dde6c50c5afaa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:28:02 +0200 Subject: [PATCH 20/39] add iip resize & qoi tests --- addons/addon-image/test/ImageAddon.test.ts | 71 ++++++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 67769204f..3bf69911f 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -8,6 +8,7 @@ import { readFileSync } from 'fs'; import { FINALIZER, introducer, sixelEncode } from 'sixel'; import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils'; import { deepStrictEqual, ok, strictEqual } from 'assert'; +import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm'; /** * Plugin ctor options. @@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [ [readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]], [readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]] ]; +const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64'); +const qoiEnc = new QoiEncoder(1024*1024); +const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80); +const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64'); let ctx: ITestContext; test.beforeAll(async ({ browser }) => { @@ -86,10 +91,10 @@ test.describe('ImageAddon', () => { test.beforeEach(async ({}, testInfo) => { // DEBT: This test never worked on webkit - if (ctx.browser.browserType().name() === 'webkit') { - testInfo.skip(); - return; - } + //if (ctx.browser.browserType().name() === 'webkit') { + // testInfo.skip(); + // return; + //} await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); @@ -312,6 +317,60 @@ test.describe('ImageAddon', () => { deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]); }); }); + + test.describe('IIP - resizing', () => { + /** + * The correct resize behavior is wonky and needs to be tested against iTerm2, + * especially for missing params to derive correct default behavior. + * We document the current behavior here w'o claiming to be fully in line with iTerm2. + * ref: https://iterm2.com/documentation-images.html + * imgcat: https://iterm2.com/utilities/imgcat + */ + test('palette.png: N --> width=20 height=5 preserveAspectRatio=0', async () => { + // cell based resize + const header = 'size=525;width=20;height=5;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); + }); + test('palette.png: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + // pixel based resize + const header = 'size=525;width=320px;height=160px;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [320, 160]); + }); + test('palette.png: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + // % of viewport resize + const header = 'size=525;width=50%;height=30%;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); + }); + test('palette.png: ommitted dimension assumes preserveAspectRatio=1', async () => { + // width provided in percent + const header = 'size=525;width=50%'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); + const dim = await getDimensions(); + const width = Math.floor(dim.width * 0.5); + deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); + // height provided in pixel + const header2 = 'size=525;height=200px'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); + }); + }); + + test.describe('QOI support', () => { + test('palette should yield same bytes from PNG and QOI', async () => { + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [640, 80]); + await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [640, 80]); + const pngScrape = await getImageAtBufferCell(0, 0); + const qoiScrape = await getImageAtBufferCell(0, 11); + deepStrictEqual(qoiScrape, pngScrape); + }); + }); }); /** @@ -345,3 +404,7 @@ async function getOrigSize(id: number): Promise<[number, number]> { window.imageAddon._storage._images.get(${id}).orig.height ]`); } + +async function getImageAtBufferCell(x: number, y: number): Promise { + return ctx.page.evaluate(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); +} From 0d2975884f54cc5eada861db7eab7b7dc033cca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:40:31 +0200 Subject: [PATCH 21/39] activate tests for webkit --- addons/addon-image/test/ImageAddon.test.ts | 5 ----- addons/addon-image/test/KittyGraphics.test.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 3bf69911f..61bdea5b3 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -90,11 +90,6 @@ test.afterAll(async () => await ctx.page.close()); test.describe('ImageAddon', () => { test.beforeEach(async ({}, testInfo) => { - // DEBT: This test never worked on webkit - //if (ctx.browser.browserType().name() === 'webkit') { - // testInfo.skip(); - // return; - //} await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 315538aa1..ac5acae77 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => { // TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data) test.beforeEach(async ({}, testInfo) => { - // DEBT: This test never worked on webkit - if (ctx.browser.browserType().name() === 'webkit') { - testInfo.skip(); - return; - } await ctx.page.evaluate(` window.term.reset() window.imageAddon?.dispose(); From 31c53bc9c2103f918a6d16543389b813fecbb1b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 14:54:57 +0200 Subject: [PATCH 22/39] IIP resizing tests for QOI --- addons/addon-image/test/ImageAddon.test.ts | 38 ++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 61bdea5b3..379e4615f 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -365,6 +365,44 @@ test.describe('ImageAddon', () => { const qoiScrape = await getImageAtBufferCell(0, 11); deepStrictEqual(qoiScrape, pngScrape); }); + test.describe.only('IIP resizing - QOI format', () => { + /** + * Same as the tests above, but with QOI format. + * This is needed, as QOI uses a slightly different parse path. + */ + test('palette.qoi: N --> width=20 height=5 preserveAspectRatio=0', async () => { + // cell based resize + const header = `size=${qoiData.length};width=20;height=5;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); + }); + test('palette.qoi: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + // pixel based resize + const header = `size=${qoiData.length};width=320px;height=160px;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(1), [320, 160]); + }); + test('palette.qoi: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + // % of viewport resize + const header = `size=${qoiData.length};width=50%;height=30%;preserveAspectRatio=0`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); + }); + test('palette.qoi: ommitted dimension assumes preserveAspectRatio=1', async () => { + // width provided in percent + const header = `size=${qoiData.length};width=50%`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const dim = await getDimensions(); + const width = Math.floor(dim.width * 0.5); + deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); + // height provided in pixel + const header2 = `size=${qoiData.length};height=200px`; + await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_QOI_BASE64}\x07`); + deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); + }); + }); }); }); From b2eff5ac6712ee41932184054e5f3648c6c029b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Wed, 29 Apr 2026 15:09:40 +0200 Subject: [PATCH 23/39] merge resize tests for PNG & QOI into one block --- addons/addon-image/test/ImageAddon.test.ts | 96 ++++++++-------------- 1 file changed, 33 insertions(+), 63 deletions(-) diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 379e4615f..ac2bed61c 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -313,49 +313,7 @@ test.describe('ImageAddon', () => { }); }); - test.describe('IIP - resizing', () => { - /** - * The correct resize behavior is wonky and needs to be tested against iTerm2, - * especially for missing params to derive correct default behavior. - * We document the current behavior here w'o claiming to be fully in line with iTerm2. - * ref: https://iterm2.com/documentation-images.html - * imgcat: https://iterm2.com/utilities/imgcat - */ - test('palette.png: N --> width=20 height=5 preserveAspectRatio=0', async () => { - // cell based resize - const header = 'size=525;width=20;height=5;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); - }); - test('palette.png: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { - // pixel based resize - const header = 'size=525;width=320px;height=160px;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - deepStrictEqual(await getOrigSize(1), [320, 160]); - }); - test('palette.png: N% --> width=50% height=30% preserveAspectRatio=0', async () => { - // % of viewport resize - const header = 'size=525;width=50%;height=30%;preserveAspectRatio=0'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); - }); - test('palette.png: ommitted dimension assumes preserveAspectRatio=1', async () => { - // width provided in percent - const header = 'size=525;width=50%'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_PNG_BASE64}\x07`); - const dim = await getDimensions(); - const width = Math.floor(dim.width * 0.5); - deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); - // height provided in pixel - const header2 = 'size=525;height=200px'; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_PNG_BASE64}\x07`); - deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); - }); - }); - - test.describe('QOI support', () => { + test.describe('IIP - QOI support', () => { test('palette should yield same bytes from PNG and QOI', async () => { await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`); deepStrictEqual(await getOrigSize(1), [640, 80]); @@ -365,44 +323,56 @@ test.describe('ImageAddon', () => { const qoiScrape = await getImageAtBufferCell(0, 11); deepStrictEqual(qoiScrape, pngScrape); }); - test.describe.only('IIP resizing - QOI format', () => { - /** - * Same as the tests above, but with QOI format. - * This is needed, as QOI uses a slightly different parse path. - */ - test('palette.qoi: N --> width=20 height=5 preserveAspectRatio=0', async () => { + }); + + test.describe('IIP - resizing', () => { + /** + * The correct resize behavior is wonky and needs to be tested against iTerm2, + * especially for missing params to derive correct default behavior. + * We document the current behavior here w'o claiming to be fully in line with iTerm2. + * ref: https://iterm2.com/documentation-images.html + * imgcat: https://iterm2.com/utilities/imgcat + * + * NOTE: QOI has a slightly different parse path, thus we test resizing explicitly + */ + const images = [ + ['palette.png', 525, PALETTE_PNG_BASE64], + ['palette.qoi', qoiData.length, PALETTE_QOI_BASE64] + ]; + for (const [name, size, payload] of images) { + test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => { // cell based resize - const header = `size=${qoiData.length};width=20;height=5;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=20;height=5;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]); }); - test('palette.qoi: Npx --> width=320px height=160px preserveAspectRatio=0', async () => { + test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => { // pixel based resize - const header = `size=${qoiData.length};width=320px;height=160px;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=320px;height=160px;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); deepStrictEqual(await getOrigSize(1), [320, 160]); }); - test('palette.qoi: N% --> width=50% height=30% preserveAspectRatio=0', async () => { + test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => { // % of viewport resize - const header = `size=${qoiData.length};width=50%;height=30%;preserveAspectRatio=0`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=50%;height=30%;preserveAspectRatio=0'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]); }); - test('palette.qoi: ommitted dimension assumes preserveAspectRatio=1', async () => { + test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => { // width provided in percent - const header = `size=${qoiData.length};width=50%`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header}:${PALETTE_QOI_BASE64}\x07`); + const header = 'width=50%'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`); const dim = await getDimensions(); const width = Math.floor(dim.width * 0.5); deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]); // height provided in pixel - const header2 = `size=${qoiData.length};height=200px`; - await ctx.proxy.write(`\x1b]1337;File=inline=1;${header2}:${PALETTE_QOI_BASE64}\x07`); + const header2 = 'height=200px'; + await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`); deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]); }); - }); + } }); }); From f9cea732f2f11d9718600dcdcde77c646299c0ef Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 1 May 2026 10:05:16 -0700 Subject: [PATCH 24/39] Make agent instructions harness agnostic --- .../skills/benchmark/SKILL.md | 18 ++++++++++++++---- .../skills/unit-test/SKILL.md | 8 ++++++-- .github/hooks/setupRepo.json | 10 ---------- .github/copilot-instructions.md => AGENTS.md | 0 bin/{agent/setup-repo.mjs => setup-full.mjs} | 0 package.json | 4 ++-- 6 files changed, 22 insertions(+), 18 deletions(-) rename .github/instructions/benchmark.instructions.md => .agents/skills/benchmark/SKILL.md (51%) rename .github/instructions/unit-test.instructions.md => .agents/skills/unit-test/SKILL.md (51%) delete mode 100644 .github/hooks/setupRepo.json rename .github/copilot-instructions.md => AGENTS.md (100%) rename bin/{agent/setup-repo.mjs => setup-full.mjs} (100%) diff --git a/.github/instructions/benchmark.instructions.md b/.agents/skills/benchmark/SKILL.md similarity index 51% rename from .github/instructions/benchmark.instructions.md rename to .agents/skills/benchmark/SKILL.md index 2e586708d..e87c285d9 100644 --- a/.github/instructions/benchmark.instructions.md +++ b/.agents/skills/benchmark/SKILL.md @@ -1,7 +1,11 @@ --- -applyTo: '**/*.benchmark.ts' +name: benchmark +description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case. --- -# Benchmark run instructions + +# Benchmark Run Instructions + +## Running Benchmarks - Full suite: `npm run benchmark` - Single benchmark file: @@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts' - Use `-t` to get the path, then: - `npm run benchmark -- -s "" out-test/benchmark/Event.benchmark.js` -When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s. +## Benchmark Case Selection + +When writing benchmark instructions: + +- Use `RuntimeCase` to measure pure runtime in ms. +- Use `ThroughputRuntimeCase` when measuring throughput in MB/s. + +## Notes -Notes: - Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`. - Keep `NODE_PATH=./out` (handled by the npm script). diff --git a/.github/instructions/unit-test.instructions.md b/.agents/skills/unit-test/SKILL.md similarity index 51% rename from .github/instructions/unit-test.instructions.md rename to .agents/skills/unit-test/SKILL.md index f139e8223..a285983b5 100644 --- a/.github/instructions/unit-test.instructions.md +++ b/.agents/skills/unit-test/SKILL.md @@ -1,7 +1,11 @@ --- -applyTo: '**/*.test.ts' +name: unit-test +description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines. --- -When writing unit tests follow these rules: + +# Unit Test Instructions + +When writing unit tests, follow these rules: - When writing unit tests for addons, always create a real xterm.js instance instead of mocking it. - Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not. diff --git a/.github/hooks/setupRepo.json b/.github/hooks/setupRepo.json deleted file mode 100644 index 5dd0c6560..000000000 --- a/.github/hooks/setupRepo.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "type": "command", - "command": "npm run agent:setup-repo" - } - ] - } -} \ No newline at end of file diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 100% rename from .github/copilot-instructions.md rename to AGENTS.md diff --git a/bin/agent/setup-repo.mjs b/bin/setup-full.mjs similarity index 100% rename from bin/agent/setup-repo.mjs rename to bin/setup-full.mjs diff --git a/package.json b/package.json index 151566544..7cd19edaf 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "scripts": { "presetup": "npm run build", "setup": "npm run esbuild", + "setup-full": "node bin/setup-full.mjs", "postsetup": "npm run esbuild-demo-server", "start": "node demo/start", "dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"", @@ -72,8 +73,7 @@ "prepackage-headless": "npm run esbuild-package-headless-only", "package-headless": "webpack --config ./webpack.config.headless.js", "postpackage-headless": "node ./bin/package_headless.js", - "prepublishOnly": "npm run package", - "agent:setup-repo": "node bin/agent/setup-repo.mjs" + "prepublishOnly": "npm run package" }, "devDependencies": { "@lunapaint/png-codec": "^0.2.0", From 2a18600d18bc010104859bcfdf1955dde0a69f68 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 1 May 2026 14:06:39 -0700 Subject: [PATCH 25/39] Cache translateToString calls inside BufferLine Fixes #5863 --- src/common/buffer/BufferLine.test.ts | 57 ++++++++++++++++++++++++++++ src/common/buffer/BufferLine.ts | 26 ++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index dce68ebbf..10781745d 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -807,5 +807,62 @@ describe('BufferLine', function(): void { assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3)); assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4)); }); + + it('should cache canonical trimmed string translations', () => { + const line = new TestBufferLine(3); + line.setCell(0, createCellData(1, 'a', 1)); + line.setCell(1, createCellData(1, 'b', 1)); + line.setCell(2, createCellData(1, 'c', 1)); + + const originalGetTrimmedLength = line.getTrimmedLength.bind(line); + let trimmedLengthCalls = 0; + line.getTrimmedLength = (): number => { + trimmedLengthCalls++; + return originalGetTrimmedLength(); + }; + + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(trimmedLengthCalls, 1); + }); + + it('should invalidate cached trimmed strings on line mutations', () => { + const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => { + const line = new TestBufferLine(5); + line.fill(createCellData(1, 'a', 1)); + const originalGetTrimmedLength = line.getTrimmedLength.bind(line); + let trimmedLengthCalls = 0; + line.getTrimmedLength = (): number => { + trimmedLengthCalls++; + return originalGetTrimmedLength(); + }; + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); + assert.equal(trimmedLengthCalls, 1); + mutate(line); + line.translateToString(true, undefined, undefined, undefined); + assert.equal(trimmedLengthCalls, 2); + }; + + assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)])); + assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0)); + assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1))); + assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1))); + assertCacheInvalidated(line => { + const src = new TestBufferLine(5); + src.fill(createCellData(1, 'x', 1)); + line.copyFrom(src); + }); + assertCacheInvalidated(line => { + const src = new TestBufferLine(5); + src.fill(createCellData(1, 'x', 1)); + line.copyCellsFrom(src, 0, 0, 2, false); + }); + }); }); }); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e415851a0..d8e911906 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -63,6 +63,7 @@ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; + private _cachedTrimmedString: string | undefined; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { @@ -98,6 +99,7 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { + this._invalidateStringCache(); this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; @@ -197,6 +199,7 @@ export class BufferLine implements IBufferLine { * Set data at `index` to `cell`. */ public setCell(index: number, cell: ICellData): void { + this._invalidateStringCache(); if (cell.content & Content.IS_COMBINED_MASK) { this._combined[index] = cell.combinedData; } @@ -214,6 +217,7 @@ export class BufferLine implements IBufferLine { * it gets an optimized access method. */ public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void { + this._invalidateStringCache(); if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } @@ -229,6 +233,7 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { + this._invalidateStringCache(); let content = this._data[index * CELL_SIZE + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add @@ -255,6 +260,7 @@ export class BufferLine implements IBufferLine { } public insertCells(pos: number, n: number, fillCellData: ICellData): void { + this._invalidateStringCache(); pos %= this.length; // handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char @@ -282,6 +288,7 @@ export class BufferLine implements IBufferLine { } public deleteCells(pos: number, n: number, fillCellData: ICellData): void { + this._invalidateStringCache(); pos %= this.length; if (n < this.length - pos) { for (let i = 0; i < this.length - pos - n; ++i) { @@ -308,6 +315,7 @@ export class BufferLine implements IBufferLine { } public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void { + this._invalidateStringCache(); // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) { @@ -347,6 +355,7 @@ export class BufferLine implements IBufferLine { * excess memory (true after shrinking > CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { + this._invalidateStringCache(); if (cols === this.length) { return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; } @@ -406,6 +415,7 @@ export class BufferLine implements IBufferLine { /** fill a line with fillCharData */ public fill(fillCellData: ICellData, respectProtect: boolean = false): void { + this._invalidateStringCache(); // full branching on respectProtect==true, hopefully getting fast JIT for standard case if (respectProtect) { for (let i = 0; i < this.length; ++i) { @@ -424,6 +434,7 @@ export class BufferLine implements IBufferLine { /** alter to a full copy of line */ public copyFrom(line: BufferLine): void { + this._invalidateStringCache(); if (this.length !== line.length) { this._data = new Uint32Array(line._data); } else { @@ -476,6 +487,7 @@ export class BufferLine implements IBufferLine { } public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void { + this._invalidateStringCache(); const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { @@ -508,7 +520,8 @@ export class BufferLine implements IBufferLine { } /** - * Translates the buffer line to a string. + * Translates the buffer line to a string. Using any of the optional arguments will disable the + * cache. * * @param trimRight Whether to trim any empty cells on the right. * @param startCol The column to start the string (0-based inclusive). @@ -521,6 +534,10 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { + const isCanonicalTrimmedRequest = !trimRight && !startCol && endCol === undefined && outColumns === undefined; + if (isCanonicalTrimmedRequest && this._cachedTrimmedString !== undefined) { + return this._cachedTrimmedString; + } startCol = startCol ?? 0; endCol = endCol ?? this.length; if (trimRight) { @@ -545,6 +562,13 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } + if (isCanonicalTrimmedRequest) { + this._cachedTrimmedString = result; + } return result; } + + private _invalidateStringCache(): void { + this._cachedTrimmedString = undefined; + } } From 66fcdb2583edf3e23ee7f5a9d3069b72c3917c5f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 1 May 2026 14:11:14 -0700 Subject: [PATCH 26/39] Fix test/startCol handling --- src/common/buffer/BufferLine.test.ts | 32 ++++++++++------------------ src/common/buffer/BufferLine.ts | 6 +++--- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 10781745d..f7d434532 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -808,40 +808,30 @@ describe('BufferLine', function(): void { assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4)); }); - it('should cache canonical trimmed string translations', () => { + it('should cache canonical non-trimmed string translations', () => { const line = new TestBufferLine(3); line.setCell(0, createCellData(1, 'a', 1)); line.setCell(1, createCellData(1, 'b', 1)); line.setCell(2, createCellData(1, 'c', 1)); - const originalGetTrimmedLength = line.getTrimmedLength.bind(line); - let trimmedLengthCalls = 0; - line.getTrimmedLength = (): number => { - trimmedLengthCalls++; - return originalGetTrimmedLength(); - }; - + const translated = line.translateToString(false, undefined, undefined, undefined); + assert.equal(translated, 'abc'); + assert.equal((line as any)._cachedTrimmedString, 'abc'); + (line as any)._cachedTrimmedString = 'cached'; + assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached'); + // Any optional translation argument should bypass cache. + assert.equal(line.translateToString(false, 0, 2, undefined), 'ab'); assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); - assert.equal(trimmedLengthCalls, 1); }); it('should invalidate cached trimmed strings on line mutations', () => { const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => { const line = new TestBufferLine(5); line.fill(createCellData(1, 'a', 1)); - const originalGetTrimmedLength = line.getTrimmedLength.bind(line); - let trimmedLengthCalls = 0; - line.getTrimmedLength = (): number => { - trimmedLengthCalls++; - return originalGetTrimmedLength(); - }; - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'aaaaa'); - assert.equal(trimmedLengthCalls, 1); + line.translateToString(false, undefined, undefined, undefined); + assert.equal((line as any)._cachedTrimmedString, 'aaaaa'); mutate(line); - line.translateToString(true, undefined, undefined, undefined); - assert.equal(trimmedLengthCalls, 2); + assert.equal((line as any)._cachedTrimmedString, undefined); }; assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)])); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index d8e911906..40c963190 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -520,8 +520,8 @@ export class BufferLine implements IBufferLine { } /** - * Translates the buffer line to a string. Using any of the optional arguments will disable the - * cache. + * Translates the buffer line to a string. Caching only applies to canonical non-trimmed + * translation requests. Using `trimRight` or any optional range/mapping arguments disables cache. * * @param trimRight Whether to trim any empty cells on the right. * @param startCol The column to start the string (0-based inclusive). @@ -534,7 +534,7 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { - const isCanonicalTrimmedRequest = !trimRight && !startCol && endCol === undefined && outColumns === undefined; + const isCanonicalTrimmedRequest = !trimRight && (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; if (isCanonicalTrimmedRequest && this._cachedTrimmedString !== undefined) { return this._cachedTrimmedString; } From f89253d24b5aed25600aac3ec96e31e422dafc2a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 1 May 2026 16:17:16 -0700 Subject: [PATCH 27/39] Fix integration test defaults Broke due to 2 recent conflicting PRs --- addons/addon-image/README.md | 8 ++++---- addons/addon-image/test/ImageAddon.test.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/addons/addon-image/README.md b/addons/addon-image/README.md index dc225d13b..a18ce18bb 100644 --- a/addons/addon-image/README.md +++ b/addons/addon-image/README.md @@ -25,14 +25,14 @@ const customSettings: IImageAddonOptions = { pixelLimit: 16777216, // max. pixel size of a single image sixelSupport: true, // enable sixel support sixelScrolling: true, // whether to scroll on image output - sixelPaletteLimit: 256, // initial sixel palette size - sixelSizeLimit: 25000000, // size limit of a single sixel sequence + sixelPaletteLimit: 4096, // initial sixel palette size + sixelSizeLimit: 33554432, // size limit of a single sixel sequence storageLimit: 128, // FIFO storage limit in MB showPlaceholder: true, // whether to show a placeholder for evicted images iipSupport: true, // enable iTerm IIP support - iipSizeLimit: 20000000, // size limit of a single IIP sequence + iipSizeLimit: 33554432, // size limit of a single IIP sequence kittySupport: true, // enable Kitty graphics support - kittySizeLimit: 20000000 // size limit of a single Kitty sequence + kittySizeLimit: 33554432 // size limit of a single Kitty sequence } // initialization diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index ac2bed61c..dc729ec63 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -132,13 +132,13 @@ test.describe('ImageAddon', () => { sixelSupport: true, sixelScrolling: true, sixelPaletteLimit: 512, // set to 512 to get example image working - sixelSizeLimit: 25000000, + sixelSizeLimit: 33554432, storageLimit: 128, showPlaceholder: true, iipSupport: true, - iipSizeLimit: 20000000, + iipSizeLimit: 33554432, kittySupport: true, - kittySizeLimit: 20000000 + kittySizeLimit: 33554432 }; deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS); }); From 4d4b994c3593041379524997742727684dababc4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 04:31:47 -0700 Subject: [PATCH 28/39] Support caching of trimRight --- src/common/buffer/BufferLine.test.ts | 58 +++++++++++++++++++++++----- src/common/buffer/BufferLine.ts | 28 +++++++++----- src/common/tsconfig.json | 3 +- 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index f7d434532..1e7451589 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -16,6 +16,22 @@ class TestBufferLine extends BufferLine { return this._combined; } + public get cachedCanonicalString(): string | undefined { + return this._cachedCanonicalString; + } + + public set cachedCanonicalString(value: string | undefined) { + this._cachedCanonicalString = value; + } + + public get cachedCanonicalTrimmedString(): string | undefined { + return this._cachedCanonicalTrimmedString; + } + + public set cachedCanonicalTrimmedString(value: string | undefined) { + this._cachedCanonicalTrimmedString = value; + } + public toArray(): CharData[] { const result = []; for (let i = 0; i < this.length; ++i) { @@ -808,30 +824,52 @@ describe('BufferLine', function(): void { assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4)); }); - it('should cache canonical non-trimmed string translations', () => { - const line = new TestBufferLine(3); + it('should cache canonical string translations', () => { + const line = new TestBufferLine(5); line.setCell(0, createCellData(1, 'a', 1)); line.setCell(1, createCellData(1, 'b', 1)); line.setCell(2, createCellData(1, 'c', 1)); + // Trimmed-only canonical request should cache the trimmed value. + const trimmed = line.translateToString(true, undefined, undefined, undefined); + assert.equal(trimmed, 'abc'); + assert.equal(line.cachedCanonicalTrimmedString, 'abc'); + + // Non-trimmed canonical request should cache the full value and free trimmed cache. const translated = line.translateToString(false, undefined, undefined, undefined); - assert.equal(translated, 'abc'); - assert.equal((line as any)._cachedTrimmedString, 'abc'); - (line as any)._cachedTrimmedString = 'cached'; - assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached'); + assert.equal(translated, 'abc '); + assert.equal(line.cachedCanonicalString, 'abc '); + assert.equal(line.cachedCanonicalTrimmedString, undefined); + + // Once non-trimmed is cached, trimmed should be derived from trimEnd() (not re-cached). + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(line.cachedCanonicalTrimmedString, undefined); + + line.cachedCanonicalString = 'cached-non-trimmed '; + line.cachedCanonicalTrimmedString = 'cached-trimmed'; + assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed '); + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed'); + + line.cachedCanonicalTrimmedString = undefined; + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed'); + // Any optional translation argument should bypass cache. assert.equal(line.translateToString(false, 0, 2, undefined), 'ab'); - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); + assert.equal(line.translateToString(true, 0, 2, undefined), 'ab'); }); - it('should invalidate cached trimmed strings on line mutations', () => { + it('should invalidate cached canonical strings on line mutations', () => { const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => { const line = new TestBufferLine(5); line.fill(createCellData(1, 'a', 1)); + line.translateToString(true, undefined, undefined, undefined); + assert.equal(line.cachedCanonicalTrimmedString, 'aaaaa'); line.translateToString(false, undefined, undefined, undefined); - assert.equal((line as any)._cachedTrimmedString, 'aaaaa'); + assert.equal(line.cachedCanonicalString, 'aaaaa'); + assert.equal(line.cachedCanonicalTrimmedString, undefined); mutate(line); - assert.equal((line as any)._cachedTrimmedString, undefined); + assert.equal(line.cachedCanonicalString, undefined); + assert.equal(line.cachedCanonicalTrimmedString, undefined); }; assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)])); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 40c963190..66bf124e3 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -63,7 +63,9 @@ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - private _cachedTrimmedString: string | undefined; + // Cache for canonical full-line translations. + protected _cachedCanonicalString: string | undefined; + protected _cachedCanonicalTrimmedString: string | undefined; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { @@ -520,8 +522,8 @@ export class BufferLine implements IBufferLine { } /** - * Translates the buffer line to a string. Caching only applies to canonical non-trimmed - * translation requests. Using `trimRight` or any optional range/mapping arguments disables cache. + * Translates the buffer line to a string. Caching only applies to canonical full-line translation + * requests (regardless of `trimRight` value). * * @param trimRight Whether to trim any empty cells on the right. * @param startCol The column to start the string (0-based inclusive). @@ -534,9 +536,10 @@ export class BufferLine implements IBufferLine { * returned string, the corresponding entries in `outColumns` will have the same column number. */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { - const isCanonicalTrimmedRequest = !trimRight && (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - if (isCanonicalTrimmedRequest && this._cachedTrimmedString !== undefined) { - return this._cachedTrimmedString; + const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; + const cachedResult = trimRight ? this._cachedCanonicalTrimmedString ?? this._cachedCanonicalString?.trimEnd() : this._cachedCanonicalString; + if (isCanonicalRequest && cachedResult !== undefined) { + return cachedResult; } startCol = startCol ?? 0; endCol = endCol ?? this.length; @@ -562,13 +565,20 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } - if (isCanonicalTrimmedRequest) { - this._cachedTrimmedString = result; + if (isCanonicalRequest) { + if (trimRight) { + this._cachedCanonicalTrimmedString = result; + } else { + this._cachedCanonicalString = result; + // Free the trimmed string as trimEnd() will be used to save memory + this._cachedCanonicalTrimmedString = undefined; + } } return result; } private _invalidateStringCache(): void { - this._cachedTrimmedString = undefined; + this._cachedCanonicalString = undefined; + this._cachedCanonicalTrimmedString = undefined; } } diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index 22501b9cf..279e9391e 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": [ "es2015", - "es2016.Array.Include" + "es2016.Array.Include", + "es2019.string" ], "outDir": "../../out", "types": [ From 7615cd210a3c4dc2c4976b4f082f7029c062aa0a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 04:34:05 -0700 Subject: [PATCH 29/39] Store a boolean on whether the cache is trimmed --- src/common/buffer/BufferLine.test.ts | 55 ++++++++++++++++------------ src/common/buffer/BufferLine.ts | 28 +++++++------- 2 files changed, 44 insertions(+), 39 deletions(-) diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 1e7451589..7ac943d25 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -16,20 +16,20 @@ class TestBufferLine extends BufferLine { return this._combined; } - public get cachedCanonicalString(): string | undefined { - return this._cachedCanonicalString; + public get cachedString(): string | undefined { + return this._cachedString; } - public set cachedCanonicalString(value: string | undefined) { - this._cachedCanonicalString = value; + public set cachedString(value: string | undefined) { + this._cachedString = value; } - public get cachedCanonicalTrimmedString(): string | undefined { - return this._cachedCanonicalTrimmedString; + public get isCachedStringTrimmed(): boolean { + return this._isCachedStringTrimmed; } - public set cachedCanonicalTrimmedString(value: string | undefined) { - this._cachedCanonicalTrimmedString = value; + public set isCachedStringTrimmed(value: boolean) { + this._isCachedStringTrimmed = value; } public toArray(): CharData[] { @@ -833,26 +833,32 @@ describe('BufferLine', function(): void { // Trimmed-only canonical request should cache the trimmed value. const trimmed = line.translateToString(true, undefined, undefined, undefined); assert.equal(trimmed, 'abc'); - assert.equal(line.cachedCanonicalTrimmedString, 'abc'); + assert.equal(line.cachedString, 'abc'); + assert.equal(line.isCachedStringTrimmed, true); - // Non-trimmed canonical request should cache the full value and free trimmed cache. + // Non-trimmed canonical request should refresh cache with the full value. const translated = line.translateToString(false, undefined, undefined, undefined); assert.equal(translated, 'abc '); - assert.equal(line.cachedCanonicalString, 'abc '); - assert.equal(line.cachedCanonicalTrimmedString, undefined); + assert.equal(line.cachedString, 'abc '); + assert.equal(line.isCachedStringTrimmed, false); - // Once non-trimmed is cached, trimmed should be derived from trimEnd() (not re-cached). + // Once non-trimmed is cached, trimmed should be derived via trimEnd(). assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc'); - assert.equal(line.cachedCanonicalTrimmedString, undefined); + assert.equal(line.cachedString, 'abc '); + assert.equal(line.isCachedStringTrimmed, false); - line.cachedCanonicalString = 'cached-non-trimmed '; - line.cachedCanonicalTrimmedString = 'cached-trimmed'; + line.cachedString = 'cached-non-trimmed '; + line.isCachedStringTrimmed = false; assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed '); - assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed'); - - line.cachedCanonicalTrimmedString = undefined; assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed'); + line.cachedString = 'cached-trimmed'; + line.isCachedStringTrimmed = true; + assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed'); + assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc '); + assert.equal(line.cachedString, 'abc '); + assert.equal(line.isCachedStringTrimmed, false); + // Any optional translation argument should bypass cache. assert.equal(line.translateToString(false, 0, 2, undefined), 'ab'); assert.equal(line.translateToString(true, 0, 2, undefined), 'ab'); @@ -863,13 +869,14 @@ describe('BufferLine', function(): void { const line = new TestBufferLine(5); line.fill(createCellData(1, 'a', 1)); line.translateToString(true, undefined, undefined, undefined); - assert.equal(line.cachedCanonicalTrimmedString, 'aaaaa'); + assert.equal(line.cachedString, 'aaaaa'); + assert.equal(line.isCachedStringTrimmed, true); line.translateToString(false, undefined, undefined, undefined); - assert.equal(line.cachedCanonicalString, 'aaaaa'); - assert.equal(line.cachedCanonicalTrimmedString, undefined); + assert.equal(line.cachedString, 'aaaaa'); + assert.equal(line.isCachedStringTrimmed, false); mutate(line); - assert.equal(line.cachedCanonicalString, undefined); - assert.equal(line.cachedCanonicalTrimmedString, undefined); + assert.equal(line.cachedString, undefined); + assert.equal(line.isCachedStringTrimmed, false); }; assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)])); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 66bf124e3..64639c114 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -63,9 +63,8 @@ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - // Cache for canonical full-line translations. - protected _cachedCanonicalString: string | undefined; - protected _cachedCanonicalTrimmedString: string | undefined; + protected _cachedString: string | undefined; + protected _isCachedStringTrimmed: boolean = false; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { @@ -537,9 +536,13 @@ export class BufferLine implements IBufferLine { */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - const cachedResult = trimRight ? this._cachedCanonicalTrimmedString ?? this._cachedCanonicalString?.trimEnd() : this._cachedCanonicalString; - if (isCanonicalRequest && cachedResult !== undefined) { - return cachedResult; + if (isCanonicalRequest && this._cachedString !== undefined) { + if (trimRight) { + return this._isCachedStringTrimmed ? this._cachedString : this._cachedString.trimEnd(); + } + if (!this._isCachedStringTrimmed) { + return this._cachedString; + } } startCol = startCol ?? 0; endCol = endCol ?? this.length; @@ -566,19 +569,14 @@ export class BufferLine implements IBufferLine { outColumns.push(startCol); } if (isCanonicalRequest) { - if (trimRight) { - this._cachedCanonicalTrimmedString = result; - } else { - this._cachedCanonicalString = result; - // Free the trimmed string as trimEnd() will be used to save memory - this._cachedCanonicalTrimmedString = undefined; - } + this._cachedString = result; + this._isCachedStringTrimmed = !!trimRight; } return result; } private _invalidateStringCache(): void { - this._cachedCanonicalString = undefined; - this._cachedCanonicalTrimmedString = undefined; + this._cachedString = undefined; + this._isCachedStringTrimmed = false; } } From 52d4b2719cfc0d9cbce391c5ad03cfe322a9e384 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 05:32:43 -0700 Subject: [PATCH 30/39] Move cache into Buffer owned object, use WeakRefs This allows O(1) clearing of the cache, a single TTL timer and we can avoid making BufferLine's Disposable --- .../dom/DomRendererRowFactory.test.ts | 4 +- .../services/CharacterJoinerService.test.ts | 7 +- src/browser/services/SelectionService.test.ts | 9 ++- src/common/buffer/Buffer.test.ts | 67 +++++++++++++++++-- src/common/buffer/Buffer.ts | 37 ++++++++-- src/common/buffer/BufferLine.test.ts | 17 +++-- src/common/buffer/BufferLine.ts | 63 +++++++++++++---- src/common/buffer/BufferLineStringCache.ts | 37 ++++++++++ src/common/buffer/BufferReflow.test.ts | 15 +++-- src/common/tsconfig.json | 3 +- 10 files changed, 219 insertions(+), 40 deletions(-) create mode 100644 src/common/buffer/BufferLineStringCache.ts diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 05fc8000c..b7c60e8f3 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -8,6 +8,7 @@ import { assert } from 'chai'; import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory'; import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test'; @@ -15,6 +16,7 @@ import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test'; const dom = new jsdom.JSDOM(''); +const TEST_STRING_CACHE = new BufferLineStringCache(); describe('DomRendererRowFactory', () => { @@ -517,7 +519,7 @@ describe('DomRendererRowFactory', () => { } function createEmptyLineData(cols: number): IBufferLine { - const lineData = new BufferLine(cols); + const lineData = new BufferLine(TEST_STRING_CACHE, cols); for (let i = 0; i < cols; i++) { lineData.setCell(i, NULL_CELL_DATA); } diff --git a/src/browser/services/CharacterJoinerService.test.ts b/src/browser/services/CharacterJoinerService.test.ts index a0ccc67d6..06730c7ac 100644 --- a/src/browser/services/CharacterJoinerService.test.ts +++ b/src/browser/services/CharacterJoinerService.test.ts @@ -7,10 +7,13 @@ import { assert } from 'chai'; import { ICharacterJoinerService } from 'browser/services/Services'; import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'; import { BufferLine } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; import { MockBufferService, createCellData } from 'common/TestUtils.test'; +const TEST_STRING_CACHE = new BufferLineStringCache(); + describe('CharacterJoinerService', () => { let service: ICharacterJoinerService; @@ -22,7 +25,7 @@ describe('CharacterJoinerService', () => { lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]])); lines.set(3, lineData([['no joined ranges']])); - lines.set(4, new BufferLine(0)); + lines.set(4, new BufferLine(TEST_STRING_CACHE, 0)); lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]])); const line6 = lineData([['wi']]); line6.resize(line6.length + 1, createCellData(0, '¥', 2)); @@ -267,7 +270,7 @@ describe('CharacterJoinerService', () => { type IPartialLineData = ([string] | [string, number]); function lineData(data: IPartialLineData[]): IBufferLine { - const tline = new BufferLine(0); + const tline = new BufferLine(TEST_STRING_CACHE, 0); for (let i = 0; i < data.length; ++i) { const line = data[i][0]; const attr = (data[i][1] || 0) as number; diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 8deedb751..be62ccc6b 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -9,12 +9,15 @@ import { SelectionModel } from 'browser/selection/SelectionModel'; import { IBufferLine } from 'common/Types'; import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test'; import { BufferLine } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { IBufferService, IOptionsService } from 'common/services/Services'; import { MockCoreBrowserService, MockMouseService, MockRenderService } from 'browser/TestUtils.test'; import { CellData } from 'common/buffer/CellData'; import { IBuffer } from 'common/buffer/Types'; import { IRenderService } from 'browser/services/Services'; +const TEST_STRING_CACHE = new BufferLineStringCache(); + class TestSelectionService extends SelectionService { constructor( bufferService: IBufferService, @@ -55,7 +58,7 @@ describe('SelectionService', () => { }); function stringToRow(text: string): IBufferLine { - const result = new BufferLine(text.length); + const result = new BufferLine(TEST_STRING_CACHE, text.length); for (let i = 0; i < text.length; i++) { result.setCell(i, createCellData(0, text.charAt(i), 1)); } @@ -63,7 +66,7 @@ describe('SelectionService', () => { } function stringArrayToRow(chars: string[]): IBufferLine { - const line = new BufferLine(chars.length); + const line = new BufferLine(TEST_STRING_CACHE, chars.length); chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1))); return line; } @@ -118,7 +121,7 @@ describe('SelectionService', () => { [0, 'o', 1, 'o'.charCodeAt(0)], [0, 'o', 1, 'o'.charCodeAt(0)] ]; - const line = new BufferLine(data.length); + const line = new BufferLine(TEST_STRING_CACHE, data.length); for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i])); buffer.lines.set(0, line); // Ensure wide characters take up 2 columns diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 9c013c5de..93cc6882b 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -8,12 +8,14 @@ import { Buffer } from 'common/buffer/Buffer'; import { CircularList } from 'common/CircularList'; import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { CellData } from 'common/buffer/CellData'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; const INIT_COLS = 80; const INIT_ROWS = 24; const INIT_SCROLLBACK = 1000; +const TEST_STRING_CACHE = new BufferLineStringCache(); describe('Buffer', () => { let optionsService: MockOptionsService; @@ -1104,7 +1106,7 @@ describe('Buffer', () => { describe ('translateBufferLineToString', () => { it('should handle selecting a section of ascii text', () => { - const line = new BufferLine(4); + const line = new BufferLine(TEST_STRING_CACHE, 4); line.setCell(0, createCellData(0, 'a', 1)); line.setCell(1, createCellData(0, 'b', 1)); line.setCell(2, createCellData(0, 'c', 1)); @@ -1116,7 +1118,7 @@ describe('Buffer', () => { }); it('should handle a cut-off double width character by including it', () => { - const line = new BufferLine(3); + const line = new BufferLine(TEST_STRING_CACHE, 3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1127,7 +1129,7 @@ describe('Buffer', () => { }); it('should handle a zero width character in the middle of the string by not including it', () => { - const line = new BufferLine(3); + const line = new BufferLine(TEST_STRING_CACHE, 3); line.setCell(0, createCellData(0, '語', 2)); line.setCell(1, createCellData(0, '', 0)); line.setCell(2, createCellData(0, 'a', 1)); @@ -1144,7 +1146,7 @@ describe('Buffer', () => { }); it('should handle single width emojis', () => { - const line = new BufferLine(2); + const line = new BufferLine(TEST_STRING_CACHE, 2); line.setCell(0, createCellData(0, '😁', 1)); line.setCell(1, createCellData(0, 'a', 1)); buffer.lines.set(0, line); @@ -1157,7 +1159,7 @@ describe('Buffer', () => { }); it('should handle double width emojis', () => { - const line = new BufferLine(2); + const line = new BufferLine(TEST_STRING_CACHE, 2); line.setCell(0, createCellData(0, '😁', 2)); line.setCell(1, createCellData(0, '', 0)); buffer.lines.set(0, line); @@ -1168,7 +1170,7 @@ describe('Buffer', () => { const str2 = buffer.translateBufferLineToString(0, true, 0, 2); assert.equal(str2, '😁'); - const line2 = new BufferLine(3); + const line2 = new BufferLine(TEST_STRING_CACHE, 3); line2.setCell(0, createCellData(0, '😁', 2)); line2.setCell(1, createCellData(0, '', 0)); line2.setCell(2, createCellData(0, 'a', 1)); @@ -1179,6 +1181,59 @@ describe('Buffer', () => { }); }); + describe('line string cache cleanup', () => { + it('should clear shared cache entries with a single timer', async () => { + buffer.fillViewportRows(); + buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); + + const cache = (buffer as any)._stringCache; + assert.equal(cache.entries.size, 2); + const previousTimer = (buffer as any)._stringCacheClearTimeout; + assert.notEqual(previousTimer, undefined); + + // Another translation should refresh the same shared timer, not create a second queue. + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.notEqual((buffer as any)._stringCacheClearTimeout, undefined); + assert.notEqual((buffer as any)._stringCacheClearTimeout, previousTimer); + + await new Promise(r => setTimeout(r, 70)); + + assert.equal(cache.entries.size, 0); + assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + + // Cache entries should be recreated lazily after a timer-based reset. + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.equal(cache.entries.size, 1); + }); + + it('should reset line string cache state on clear and resize', () => { + buffer.fillViewportRows(); + buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + buffer.translateBufferLineToString(0, false); + + const cache = (buffer as any)._stringCache; + assert.equal(cache.entries.size, 1); + assert.notEqual((buffer as any)._stringCacheClearTimeout, undefined); + + buffer.clear(); + assert.equal(cache.entries.size, 0); + assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + + buffer.fillViewportRows(); + buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); + buffer.translateBufferLineToString(0, false); + assert.equal(cache.entries.size, 1); + + buffer.resize(INIT_COLS - 1, INIT_ROWS); + assert.equal(cache.entries.size, 0); + assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + }); + }); + describe('memory cleanup after shrinking', () => { it('should realign memory from idle task execution', async () => { buffer.fillViewportRows(); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 8efefd60a..bf78e473c 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -8,6 +8,7 @@ import { IdleTaskQueue } from 'common/TaskQueue'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow'; import { CellData } from 'common/buffer/CellData'; import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants'; @@ -17,6 +18,7 @@ import { DEFAULT_CHARSET } from 'common/data/Charsets'; import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 +const STRING_CACHE_CLEAR_DELAY_MS = 50; /** * This class represents a terminal buffer (an internal state of the terminal), where the @@ -50,6 +52,8 @@ export class Buffer implements IBuffer { private _isClearing: boolean = false; private _memoryCleanupQueue: InstanceType; private _memoryCleanupPosition = 0; + private readonly _stringCache: BufferLineStringCache; + private _stringCacheClearTimeout: ReturnType | undefined; constructor( private _hasScrollback: boolean, @@ -64,6 +68,7 @@ export class Buffer implements IBuffer { this.scrollBottom = this._rows - 1; this.setupTabStops(); this._memoryCleanupQueue = new IdleTaskQueue(this._logService); + this._stringCache = new BufferLineStringCache(() => this._scheduleStringCacheCleanup()); } public getNullCell(attr?: IAttributeData): ICellData { @@ -93,7 +98,7 @@ export class Buffer implements IBuffer { } public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine { - return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped); + return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped); } public get hasScrollback(): boolean { @@ -138,6 +143,7 @@ export class Buffer implements IBuffer { * Clears the buffer to it's initial state, discarding all previous data. */ public clear(): void { + this._resetStringCache(); this.ydisp = 0; this.ybase = 0; this.y = 0; @@ -156,6 +162,7 @@ export class Buffer implements IBuffer { public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); + this._resetStringCache(); // count bufferlines with overly big memory to be cleaned afterwards let dirtyMemoryLines = 0; @@ -190,7 +197,7 @@ export class Buffer implements IBuffer { if (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)); + this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); } else { if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) { // There is room above the buffer and there are no empty elements below the line, @@ -204,7 +211,7 @@ export class Buffer implements IBuffer { } else { // Add a blank line if there is no buffer left at the top to scroll to, or if there // are blank lines after the cursor - this.lines.push(new BufferLine(newCols, nullCell)); + this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); } } } @@ -303,6 +310,28 @@ export class Buffer implements IBuffer { return normalRun; } + private _scheduleStringCacheCleanup(): void { + if (this._stringCacheClearTimeout !== undefined) { + clearTimeout(this._stringCacheClearTimeout); + } + this._stringCacheClearTimeout = setTimeout(() => { + this._stringCacheClearTimeout = undefined; + this._clearStringCache(); + }, STRING_CACHE_CLEAR_DELAY_MS); + } + + private _resetStringCache(): void { + if (this._stringCacheClearTimeout !== undefined) { + clearTimeout(this._stringCacheClearTimeout); + this._stringCacheClearTimeout = undefined; + } + this._clearStringCache(); + } + + private _clearStringCache(): void { + this._stringCache.clear(); + } + private get _isReflowEnabled(): boolean { const windowsPty = this._optionsService.rawOptions.windowsPty; if (windowsPty && windowsPty.buildNumber) { @@ -345,7 +374,7 @@ export class Buffer implements IBuffer { } if (this.lines.length < newRows) { // Add an extra row at the bottom of the viewport - this.lines.push(new BufferLine(newCols, nullCell)); + this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false)); } } else { if (this.ydisp === this.ybase) { diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index 7ac943d25..3aec28872 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -4,32 +4,39 @@ */ import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants'; import { BufferLine } from 'common/buffer//BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { CellData } from 'common/buffer/CellData'; -import { CharData, IBufferLine } from '../Types'; +import { CharData, IBufferLine, ICellData } from '../Types'; import { assert } from 'chai'; import { AttributeData } from 'common/buffer/AttributeData'; import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestUtils.test'; +const TEST_STRING_CACHE = new BufferLineStringCache(); + class TestBufferLine extends BufferLine { + constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) { + super(TEST_STRING_CACHE, cols, fillCellData, isWrapped); + } + public get combined(): {[index: number]: string} { return this._combined; } public get cachedString(): string | undefined { - return this._cachedString; + return this._getStringCacheEntry(false)?.value; } public set cachedString(value: string | undefined) { - this._cachedString = value; + this._getStringCacheEntry(true)!.value = value; } public get isCachedStringTrimmed(): boolean { - return this._isCachedStringTrimmed; + return this._getStringCacheEntry(false)?.isTrimmed ?? false; } public set isCachedStringTrimmed(value: boolean) { - this._isCachedStringTrimmed = value; + this._getStringCacheEntry(true)!.isTrimmed = value; } public toArray(): CharData[] { diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index 64639c114..682747b16 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -44,6 +44,18 @@ const $workCell = new CellData(); /** Factor when to cleanup underlying array buffer after shrinking. */ const CLEANUP_THRESHOLD = 2; +export interface IBufferLineStringCacheEntry { + value: string | undefined; + isTrimmed: boolean; + generation: number; +} + +export interface IBufferLineStringCache { + generation: number; + allocateEntry(): IBufferLineStringCacheEntry; + touch?(): void; +} + /** * Typed array based bufferline implementation. * @@ -63,11 +75,15 @@ export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; - protected _cachedString: string | undefined; - protected _isCachedStringTrimmed: boolean = false; + protected _stringCacheEntryRef: WeakRef | undefined; public length: number; - constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { + constructor( + protected readonly _stringCache: IBufferLineStringCache, + cols: number, + fillCellData?: ICellData, + public isWrapped: boolean = false + ) { this._data = new Uint32Array(cols * CELL_SIZE); const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); for (let i = 0; i < cols; ++i) { @@ -456,7 +472,7 @@ export class BufferLine implements IBufferLine { /** create a new clone */ public clone(): IBufferLine { - const newLine = new BufferLine(0); + const newLine = new BufferLine(this._stringCache, 0, undefined, false); newLine._data = new Uint32Array(this._data); newLine.length = this.length; for (const el in this._combined) { @@ -536,12 +552,16 @@ export class BufferLine implements IBufferLine { */ public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string { const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined; - if (isCanonicalRequest && this._cachedString !== undefined) { + if (isCanonicalRequest) { + this._stringCache.touch?.(); + } + const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined; + if (isCanonicalRequest && stringCacheEntry?.value !== undefined) { if (trimRight) { - return this._isCachedStringTrimmed ? this._cachedString : this._cachedString.trimEnd(); + return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd(); } - if (!this._isCachedStringTrimmed) { - return this._cachedString; + if (!stringCacheEntry.isTrimmed) { + return stringCacheEntry.value; } } startCol = startCol ?? 0; @@ -569,14 +589,33 @@ export class BufferLine implements IBufferLine { outColumns.push(startCol); } if (isCanonicalRequest) { - this._cachedString = result; - this._isCachedStringTrimmed = !!trimRight; + const cacheEntry = this._getStringCacheEntry(true)!; + cacheEntry.value = result; + cacheEntry.isTrimmed = !!trimRight; } return result; } + protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined { + const cachedEntry = this._stringCacheEntryRef?.deref(); + if (cachedEntry) { + if (cachedEntry.generation === this._stringCache.generation) { + return cachedEntry; + } + } + if (!createIfNeeded) { + return undefined; + } + const cacheEntry = this._stringCache.allocateEntry(); + this._stringCacheEntryRef = new WeakRef(cacheEntry); + return cacheEntry; + } + private _invalidateStringCache(): void { - this._cachedString = undefined; - this._isCachedStringTrimmed = false; + const cacheEntry = this._getStringCacheEntry(false); + if (cacheEntry) { + cacheEntry.value = undefined; + cacheEntry.isTrimmed = false; + } } } diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts new file mode 100644 index 000000000..9365c847d --- /dev/null +++ b/src/common/buffer/BufferLineStringCache.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine'; + +export class BufferLineStringCache implements IBufferLineStringCache { + public generation: number = 0; + public readonly entries: Set = new Set(); + + constructor(private readonly _onCacheUsed: () => void = () => { }) { } + + public touch(): void { + this._onCacheUsed(); + } + + public allocateEntry(): IBufferLineStringCacheEntry { + const entry: IBufferLineStringCacheEntry = { + value: undefined, + isTrimmed: false, + generation: this.generation + }; + this.entries.add(entry); + this._onCacheUsed(); + return entry; + } + + public clear(): void { + this.generation++; + for (const entry of this.entries) { + entry.value = undefined; + entry.isTrimmed = false; + } + this.entries.clear(); + } +} diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index b351b89c4..d71186ddd 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -4,13 +4,16 @@ */ import { assert } from 'chai'; import { BufferLine } from 'common/buffer/BufferLine'; +import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache'; import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants'; import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow'; +const TEST_STRING_CACHE = new BufferLineStringCache(); + describe('BufferReflow', () => { describe('reflowSmallerGetNewLineLengths', () => { it('should return correct line lengths for a small line with wide characters', () => { - const line = new BufferLine(4); + const line = new BufferLine(TEST_STRING_CACHE, 4); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -20,7 +23,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语'); }); it('should return correct line lengths for a large line with wide characters', () => { - const line = new BufferLine(12); + const line = new BufferLine(TEST_STRING_CACHE, 12); for (let i = 0; i < 12; i += 4) { line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]); @@ -42,7 +45,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语'); }); it('should return correct line lengths for a string with wide and single characters', () => { - const line = new BufferLine(6); + const line = new BufferLine(TEST_STRING_CACHE, 6); line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(2, [0, '', 0, 0]); @@ -56,14 +59,14 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b'); }); it('should return correct line lengths for a wrapped line with wide and single characters', () => { - const line1 = new BufferLine(6); + const line1 = new BufferLine(TEST_STRING_CACHE, 6); line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line1.set(2, [0, '', 0, 0]); line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]); line1.set(4, [0, '', 0, 0]); line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]); - const line2 = new BufferLine(6, undefined, true); + const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true); line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]); line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]); line2.set(2, [0, '', 0, 0]); @@ -78,7 +81,7 @@ describe('BufferReflow', () => { assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b'); }); it('should work on lines ending in null space', () => { - const line = new BufferLine(5); + const line = new BufferLine(TEST_STRING_CACHE, 5); line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]); line.set(1, [0, '', 0, 0]); line.set(2, [0, '语', 2, '语'.charCodeAt(0)]); diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index 279e9391e..31a893756 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -4,7 +4,8 @@ "lib": [ "es2015", "es2016.Array.Include", - "es2019.string" + "es2019.string", + "es2021.weakref" ], "outDir": "../../out", "types": [ From 6287c6ddfef9aa13cee36288f3409bcc8ed1058f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 05:44:13 -0700 Subject: [PATCH 31/39] Cache/buffer lifecycle --- src/common/buffer/Buffer.test.ts | 14 +++---- src/common/buffer/Buffer.ts | 36 ++++-------------- src/common/buffer/BufferLineStringCache.ts | 27 +++++++++++--- src/common/buffer/BufferSet.test.ts | 43 +++++++++++++++++++++- src/common/buffer/BufferSet.ts | 6 ++- 5 files changed, 84 insertions(+), 42 deletions(-) diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 93cc6882b..76e13cc72 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1192,18 +1192,18 @@ describe('Buffer', () => { const cache = (buffer as any)._stringCache; assert.equal(cache.entries.size, 2); - const previousTimer = (buffer as any)._stringCacheClearTimeout; + const previousTimer = (cache as any)._clearTimeout; assert.notEqual(previousTimer, undefined); // Another translation should refresh the same shared timer, not create a second queue. assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.notEqual((buffer as any)._stringCacheClearTimeout, undefined); - assert.notEqual((buffer as any)._stringCacheClearTimeout, previousTimer); + assert.notEqual((cache as any)._clearTimeout, undefined); + assert.notEqual((cache as any)._clearTimeout, previousTimer); await new Promise(r => setTimeout(r, 70)); assert.equal(cache.entries.size, 0); - assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + assert.equal((cache as any)._clearTimeout, undefined); // Cache entries should be recreated lazily after a timer-based reset. assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); @@ -1217,11 +1217,11 @@ describe('Buffer', () => { const cache = (buffer as any)._stringCache; assert.equal(cache.entries.size, 1); - assert.notEqual((buffer as any)._stringCacheClearTimeout, undefined); + assert.notEqual((cache as any)._clearTimeout, undefined); buffer.clear(); assert.equal(cache.entries.size, 0); - assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + assert.equal((cache as any)._clearTimeout, undefined); buffer.fillViewportRows(); buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); @@ -1230,7 +1230,7 @@ describe('Buffer', () => { buffer.resize(INIT_COLS - 1, INIT_ROWS); assert.equal(cache.entries.size, 0); - assert.equal((buffer as any)._stringCacheClearTimeout, undefined); + assert.equal((cache as any)._clearTimeout, undefined); }); }); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index bf78e473c..99ae5d00b 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -4,6 +4,7 @@ */ import { CircularList, IInsertEvent } from 'common/CircularList'; +import { Disposable, toDisposable } from 'common/Lifecycle'; import { IdleTaskQueue } from 'common/TaskQueue'; import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; @@ -18,7 +19,6 @@ import { DEFAULT_CHARSET } from 'common/data/Charsets'; import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 -const STRING_CACHE_CLEAR_DELAY_MS = 50; /** * This class represents a terminal buffer (an internal state of the terminal), where the @@ -27,7 +27,7 @@ const STRING_CACHE_CLEAR_DELAY_MS = 50; * - cursor position * - scroll position */ -export class Buffer implements IBuffer { +export class Buffer extends Disposable implements IBuffer { public lines: CircularList; public ydisp: number = 0; public ybase: number = 0; @@ -53,7 +53,6 @@ export class Buffer implements IBuffer { private _memoryCleanupQueue: InstanceType; private _memoryCleanupPosition = 0; private readonly _stringCache: BufferLineStringCache; - private _stringCacheClearTimeout: ReturnType | undefined; constructor( private _hasScrollback: boolean, @@ -61,6 +60,8 @@ export class Buffer implements IBuffer { private _bufferService: IBufferService, private readonly _logService: ILogService ) { + super(); + this._register(toDisposable(() => this.clearAllMarkers())); this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); @@ -68,7 +69,8 @@ export class Buffer implements IBuffer { this.scrollBottom = this._rows - 1; this.setupTabStops(); this._memoryCleanupQueue = new IdleTaskQueue(this._logService); - this._stringCache = new BufferLineStringCache(() => this._scheduleStringCacheCleanup()); + this._register(toDisposable(() => this._memoryCleanupQueue.clear())); + this._stringCache = this._register(new BufferLineStringCache()); } public getNullCell(attr?: IAttributeData): ICellData { @@ -143,7 +145,7 @@ export class Buffer implements IBuffer { * Clears the buffer to it's initial state, discarding all previous data. */ public clear(): void { - this._resetStringCache(); + this._stringCache.clear(); this.ydisp = 0; this.ybase = 0; this.y = 0; @@ -162,7 +164,7 @@ export class Buffer implements IBuffer { public resize(newCols: number, newRows: number): void { // store reference to null cell with default attrs const nullCell = this.getNullCell(DEFAULT_ATTR_DATA); - this._resetStringCache(); + this._stringCache.clear(); // count bufferlines with overly big memory to be cleaned afterwards let dirtyMemoryLines = 0; @@ -310,28 +312,6 @@ export class Buffer implements IBuffer { return normalRun; } - private _scheduleStringCacheCleanup(): void { - if (this._stringCacheClearTimeout !== undefined) { - clearTimeout(this._stringCacheClearTimeout); - } - this._stringCacheClearTimeout = setTimeout(() => { - this._stringCacheClearTimeout = undefined; - this._clearStringCache(); - }, STRING_CACHE_CLEAR_DELAY_MS); - } - - private _resetStringCache(): void { - if (this._stringCacheClearTimeout !== undefined) { - clearTimeout(this._stringCacheClearTimeout); - this._stringCacheClearTimeout = undefined; - } - this._clearStringCache(); - } - - private _clearStringCache(): void { - this._stringCache.clear(); - } - private get _isReflowEnabled(): boolean { const windowsPty = this._optionsService.rawOptions.windowsPty; if (windowsPty && windowsPty.buildNumber) { diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts index 9365c847d..9382eaf83 100644 --- a/src/common/buffer/BufferLineStringCache.ts +++ b/src/common/buffer/BufferLineStringCache.ts @@ -4,15 +4,18 @@ */ import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine'; +import { disposableTimeout } from 'common/Async'; +import type { IDisposable } from 'common/Lifecycle'; -export class BufferLineStringCache implements IBufferLineStringCache { +const STRING_CACHE_CLEAR_DELAY_MS = 50; + +export class BufferLineStringCache implements IBufferLineStringCache, IDisposable { public generation: number = 0; public readonly entries: Set = new Set(); - - constructor(private readonly _onCacheUsed: () => void = () => { }) { } + private _clearTimeout: IDisposable | undefined; public touch(): void { - this._onCacheUsed(); + this._scheduleClear(); } public allocateEntry(): IBufferLineStringCacheEntry { @@ -22,11 +25,13 @@ export class BufferLineStringCache implements IBufferLineStringCache { generation: this.generation }; this.entries.add(entry); - this._onCacheUsed(); + this._scheduleClear(); return entry; } public clear(): void { + this._clearTimeout?.dispose(); + this._clearTimeout = undefined; this.generation++; for (const entry of this.entries) { entry.value = undefined; @@ -34,4 +39,16 @@ export class BufferLineStringCache implements IBufferLineStringCache { } this.entries.clear(); } + + public dispose(): void { + this.clear(); + } + + private _scheduleClear(): void { + this._clearTimeout?.dispose(); + this._clearTimeout = disposableTimeout(() => { + this._clearTimeout = undefined; + this.clear(); + }, STRING_CACHE_CLEAR_DELAY_MS); + } } diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 96737fee9..072095346 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { BufferSet } from 'common/buffer/BufferSet'; import { Buffer } from 'common/buffer/Buffer'; -import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; +import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test'; describe('BufferSet', () => { let bufferSet: BufferSet; @@ -82,4 +82,45 @@ describe('BufferSet', () => { assert.equal(bufferSet.alt.markers.length, 0); }); }); + + describe('lifecycle', () => { + it('should dispose previous buffers on reset', () => { + const oldNormal = bufferSet.normal as any; + oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + oldNormal.translateBufferLineToString(0, false); + + const oldCache = oldNormal._stringCache; + assert.equal(oldCache.entries.size, 1); + assert.notEqual(oldCache._clearTimeout, undefined); + + bufferSet.reset(); + + assert.notEqual(bufferSet.normal, oldNormal); + assert.equal(oldCache.entries.size, 0); + assert.equal(oldCache._clearTimeout, undefined); + }); + + it('should dispose both buffers when disposed', () => { + const normal = bufferSet.normal as any; + normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + normal.translateBufferLineToString(0, false); + + bufferSet.activateAltBuffer(); + const alt = bufferSet.alt as any; + alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); + alt.translateBufferLineToString(0, false); + + const normalCache = normal._stringCache; + const altCache = alt._stringCache; + assert.notEqual(normalCache._clearTimeout, undefined); + assert.notEqual(altCache._clearTimeout, undefined); + + bufferSet.dispose(); + + assert.equal(normalCache.entries.size, 0); + assert.equal(altCache.entries.size, 0); + assert.equal(normalCache._clearTimeout, undefined); + assert.equal(altCache._clearTimeout, undefined); + }); + }); }); diff --git a/src/common/buffer/BufferSet.ts b/src/common/buffer/BufferSet.ts index 772a76442..32a4bf3b4 100644 --- a/src/common/buffer/BufferSet.ts +++ b/src/common/buffer/BufferSet.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { Disposable } from 'common/Lifecycle'; +import { Disposable, MutableDisposable } from 'common/Lifecycle'; import { IAttributeData } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; @@ -18,6 +18,8 @@ export class BufferSet extends Disposable implements IBufferSet { private _normal!: Buffer; private _alt!: Buffer; private _activeBuffer!: Buffer; + private readonly _normalBuffer = this._register(new MutableDisposable()); + private readonly _altBuffer = this._register(new MutableDisposable()); private readonly _onBufferActivate = this._register(new Emitter<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>()); public readonly onBufferActivate = this._onBufferActivate.event; @@ -38,11 +40,13 @@ export class BufferSet extends Disposable implements IBufferSet { public reset(): void { this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService); + this._normalBuffer.value = this._normal; this._normal.fillViewportRows(); // The alt buffer should never have scrollback. // See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService); + this._altBuffer.value = this._alt; this._activeBuffer = this._normal; this._onBufferActivate.fire({ activeBuffer: this._normal, From 3dbfa7ce3e84426e77e612d85ff455fb8551e6b9 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 05:48:08 -0700 Subject: [PATCH 32/39] Convert cache to Disposable --- src/common/buffer/Buffer.test.ts | 66 ++++++++++++++-------- src/common/buffer/BufferLineStringCache.ts | 28 ++++----- src/common/buffer/BufferSet.test.ts | 12 ++-- 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 76e13cc72..5a5cb3522 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1182,32 +1182,52 @@ describe('Buffer', () => { }); describe('line string cache cleanup', () => { - it('should clear shared cache entries with a single timer', async () => { - buffer.fillViewportRows(); - buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); - buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); + it('should clear shared cache entries with a single timer', () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + let timeoutId = 0; + const scheduledTimeouts = new Map void>(); + (globalThis as any).setTimeout = ((handler: (...args: any[]) => void) => { + const id = ++timeoutId; + scheduledTimeouts.set(id, () => handler()); + return id as ReturnType; + }) as typeof setTimeout; + (globalThis as any).clearTimeout = ((id: ReturnType) => { + scheduledTimeouts.delete(id as unknown as number); + }) as typeof clearTimeout; + try { + buffer.fillViewportRows(); + buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); + buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1)); - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); - const cache = (buffer as any)._stringCache; - assert.equal(cache.entries.size, 2); - const previousTimer = (cache as any)._clearTimeout; - assert.notEqual(previousTimer, undefined); + const cache = (buffer as any)._stringCache; + assert.equal(cache.entries.size, 2); + const previousTimer = (cache as any)._clearTimeout.value; + assert.notEqual(previousTimer, undefined); - // Another translation should refresh the same shared timer, not create a second queue. - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.notEqual((cache as any)._clearTimeout, undefined); - assert.notEqual((cache as any)._clearTimeout, previousTimer); + // Another translation should refresh the same shared timer, not create a second queue. + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.notEqual((cache as any)._clearTimeout.value, undefined); + assert.notEqual((cache as any)._clearTimeout.value, previousTimer); - await new Promise(r => setTimeout(r, 70)); + for (const timeout of scheduledTimeouts.values()) { + timeout(); + } + scheduledTimeouts.clear(); - assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout, undefined); + assert.equal(cache.entries.size, 0); + assert.equal((cache as any)._clearTimeout.value, undefined); - // Cache entries should be recreated lazily after a timer-based reset. - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.equal(cache.entries.size, 1); + // Cache entries should be recreated lazily after a timer-based reset. + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.equal(cache.entries.size, 1); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } }); it('should reset line string cache state on clear and resize', () => { @@ -1217,11 +1237,11 @@ describe('Buffer', () => { const cache = (buffer as any)._stringCache; assert.equal(cache.entries.size, 1); - assert.notEqual((cache as any)._clearTimeout, undefined); + assert.notEqual((cache as any)._clearTimeout.value, undefined); buffer.clear(); assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout, undefined); + assert.equal((cache as any)._clearTimeout.value, undefined); buffer.fillViewportRows(); buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); @@ -1230,7 +1250,7 @@ describe('Buffer', () => { buffer.resize(INIT_COLS - 1, INIT_ROWS); assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout, undefined); + assert.equal((cache as any)._clearTimeout.value, undefined); }); }); diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts index 9382eaf83..547afc85f 100644 --- a/src/common/buffer/BufferLineStringCache.ts +++ b/src/common/buffer/BufferLineStringCache.ts @@ -5,14 +5,22 @@ import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine'; import { disposableTimeout } from 'common/Async'; +import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import type { IDisposable } from 'common/Lifecycle'; -const STRING_CACHE_CLEAR_DELAY_MS = 50; +const enum Constants { + CacheTtlMs = 15000 +} -export class BufferLineStringCache implements IBufferLineStringCache, IDisposable { +export class BufferLineStringCache extends Disposable implements IBufferLineStringCache { public generation: number = 0; public readonly entries: Set = new Set(); - private _clearTimeout: IDisposable | undefined; + private readonly _clearTimeout = this._register(new MutableDisposable()); + + constructor() { + super(); + this._register(toDisposable(() => this.entries.clear())); + } public touch(): void { this._scheduleClear(); @@ -30,8 +38,7 @@ export class BufferLineStringCache implements IBufferLineStringCache, IDisposabl } public clear(): void { - this._clearTimeout?.dispose(); - this._clearTimeout = undefined; + this._clearTimeout.clear(); this.generation++; for (const entry of this.entries) { entry.value = undefined; @@ -40,15 +47,10 @@ export class BufferLineStringCache implements IBufferLineStringCache, IDisposabl this.entries.clear(); } - public dispose(): void { - this.clear(); - } - private _scheduleClear(): void { - this._clearTimeout?.dispose(); - this._clearTimeout = disposableTimeout(() => { - this._clearTimeout = undefined; + this._clearTimeout.value = disposableTimeout(() => { + this._clearTimeout.clear(); this.clear(); - }, STRING_CACHE_CLEAR_DELAY_MS); + }, Constants.CacheTtlMs); } } diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 072095346..8809a3cb6 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -91,13 +91,13 @@ describe('BufferSet', () => { const oldCache = oldNormal._stringCache; assert.equal(oldCache.entries.size, 1); - assert.notEqual(oldCache._clearTimeout, undefined); + assert.notEqual(oldCache._clearTimeout.value, undefined); bufferSet.reset(); assert.notEqual(bufferSet.normal, oldNormal); assert.equal(oldCache.entries.size, 0); - assert.equal(oldCache._clearTimeout, undefined); + assert.equal(oldCache._clearTimeout.value, undefined); }); it('should dispose both buffers when disposed', () => { @@ -112,15 +112,15 @@ describe('BufferSet', () => { const normalCache = normal._stringCache; const altCache = alt._stringCache; - assert.notEqual(normalCache._clearTimeout, undefined); - assert.notEqual(altCache._clearTimeout, undefined); + assert.notEqual(normalCache._clearTimeout.value, undefined); + assert.notEqual(altCache._clearTimeout.value, undefined); bufferSet.dispose(); assert.equal(normalCache.entries.size, 0); assert.equal(altCache.entries.size, 0); - assert.equal(normalCache._clearTimeout, undefined); - assert.equal(altCache._clearTimeout, undefined); + assert.equal(normalCache._clearTimeout.value, undefined); + assert.equal(altCache._clearTimeout.value, undefined); }); }); }); From d911b40a47e5bad40c65d1a21c7b2658f8bd229c Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 05:52:33 -0700 Subject: [PATCH 33/39] Avoid any in test --- .../dom/DomRendererRowFactory.test.ts | 2 - src/common/buffer/Buffer.test.ts | 44 ++++++++++++------- 2 files changed, 27 insertions(+), 19 deletions(-) diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index b7c60e8f3..6ca84569a 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -15,10 +15,8 @@ import { MockCoreService, MockDecorationService, MockOptionsService, createCellD import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test'; import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test'; -const dom = new jsdom.JSDOM(''); const TEST_STRING_CACHE = new BufferLineStringCache(); - describe('DomRendererRowFactory', () => { let dom: jsdom.JSDOM; let rowFactory: DomRendererRowFactory; diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 5a5cb3522..ae086554e 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -17,15 +17,25 @@ const INIT_ROWS = 24; const INIT_SCROLLBACK = 1000; const TEST_STRING_CACHE = new BufferLineStringCache(); +class TestBuffer extends Buffer { + public getStringCache(): BufferLineStringCache { + return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache; + } + + public getStringCacheClearTimeout(): unknown { + return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value; + } +} + describe('Buffer', () => { let optionsService: MockOptionsService; let bufferService: MockBufferService; - let buffer: Buffer; + let buffer: TestBuffer; beforeEach(() => { optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK }); bufferService = new MockBufferService(INIT_COLS, INIT_ROWS); - buffer = new Buffer(true, optionsService, bufferService, new MockLogService()); + buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService()); }); describe('constructor', () => { @@ -153,7 +163,7 @@ describe('Buffer', () => { describe('no scrollback', () => { it('should trim from the top of the buffer when the cursor reaches the bottom', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); + buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); assert.equal(buffer.lines.maxLength, INIT_ROWS); buffer.y = INIT_ROWS - 1; buffer.fillViewportRows(); @@ -1056,7 +1066,7 @@ describe('Buffer', () => { describe('buffer marked to have no scrollback', () => { it('should always have a scrollback of 0', () => { // Test size on initialization - buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService()); + buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.lines.maxLength, INIT_ROWS); // Test size on buffer increase @@ -1070,7 +1080,7 @@ describe('Buffer', () => { describe('addMarker', () => { it('should adjust a marker line when the buffer is trimmed', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); + buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); assert.equal(marker.line, buffer.lines.length - 1); @@ -1078,7 +1088,7 @@ describe('Buffer', () => { assert.equal(marker.line, buffer.lines.length - 2); }); it('should dispose of a marker if it is trimmed off the buffer', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); + buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); @@ -1090,7 +1100,7 @@ describe('Buffer', () => { }); it('should call onDispose', () => { const eventStack: string[] = []; - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); + buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); @@ -1203,15 +1213,15 @@ describe('Buffer', () => { assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`); - const cache = (buffer as any)._stringCache; + const cache = buffer.getStringCache(); assert.equal(cache.entries.size, 2); - const previousTimer = (cache as any)._clearTimeout.value; - assert.notEqual(previousTimer, undefined); + const previousTimer = buffer.getStringCacheClearTimeout(); + assert.ok(previousTimer !== undefined); // Another translation should refresh the same shared timer, not create a second queue. assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); - assert.notEqual((cache as any)._clearTimeout.value, undefined); - assert.notEqual((cache as any)._clearTimeout.value, previousTimer); + assert.ok(buffer.getStringCacheClearTimeout() !== undefined); + assert.notEqual(buffer.getStringCacheClearTimeout(), previousTimer); for (const timeout of scheduledTimeouts.values()) { timeout(); @@ -1219,7 +1229,7 @@ describe('Buffer', () => { scheduledTimeouts.clear(); assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout.value, undefined); + assert.equal(buffer.getStringCacheClearTimeout(), undefined); // Cache entries should be recreated lazily after a timer-based reset. assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); @@ -1235,13 +1245,13 @@ describe('Buffer', () => { buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); buffer.translateBufferLineToString(0, false); - const cache = (buffer as any)._stringCache; + const cache = buffer.getStringCache(); assert.equal(cache.entries.size, 1); - assert.notEqual((cache as any)._clearTimeout.value, undefined); + assert.ok(buffer.getStringCacheClearTimeout() !== undefined); buffer.clear(); assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout.value, undefined); + assert.equal(buffer.getStringCacheClearTimeout(), undefined); buffer.fillViewportRows(); buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1)); @@ -1250,7 +1260,7 @@ describe('Buffer', () => { buffer.resize(INIT_COLS - 1, INIT_ROWS); assert.equal(cache.entries.size, 0); - assert.equal((cache as any)._clearTimeout.value, undefined); + assert.equal(buffer.getStringCacheClearTimeout(), undefined); }); }); From e04dd2501f9779e739782e1b8152a03ab3dae164 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 05:58:56 -0700 Subject: [PATCH 34/39] Avoid thrashing timeouts --- src/common/buffer/Buffer.test.ts | 50 +++++++++++++++------- src/common/buffer/Buffer.ts | 2 +- src/common/buffer/BufferLineStringCache.ts | 20 +++++++-- 3 files changed, 53 insertions(+), 19 deletions(-) diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index ae086554e..38fd513d3 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1195,16 +1195,28 @@ describe('Buffer', () => { it('should clear shared cache entries with a single timer', () => { const originalSetTimeout = globalThis.setTimeout; const originalClearTimeout = globalThis.clearTimeout; + const originalDateNow = Date.now; let timeoutId = 0; - const scheduledTimeouts = new Map void>(); - (globalThis as any).setTimeout = ((handler: (...args: any[]) => void) => { + let now = 0; + const clearedTimeouts: number[] = []; + const scheduledTimeouts = new Map void }>(); + (globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => { const id = ++timeoutId; - scheduledTimeouts.set(id, () => handler()); + scheduledTimeouts.set(id, { + delay: timeout ?? 0, + fire: () => { + scheduledTimeouts.delete(id); + handler(); + } + }); return id as ReturnType; }) as typeof setTimeout; (globalThis as any).clearTimeout = ((id: ReturnType) => { - scheduledTimeouts.delete(id as unknown as number); + const numericId = id as unknown as number; + clearedTimeouts.push(numericId); + scheduledTimeouts.delete(numericId); }) as typeof clearTimeout; + Date.now = () => now; try { buffer.fillViewportRows(); buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1)); @@ -1215,26 +1227,34 @@ describe('Buffer', () => { const cache = buffer.getStringCache(); assert.equal(cache.entries.size, 2); - const previousTimer = buffer.getStringCacheClearTimeout(); - assert.ok(previousTimer !== undefined); - - // Another translation should refresh the same shared timer, not create a second queue. - assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); assert.ok(buffer.getStringCacheClearTimeout() !== undefined); - assert.notEqual(buffer.getStringCacheClearTimeout(), previousTimer); + assert.equal(scheduledTimeouts.size, 1); + assert.equal([...scheduledTimeouts.values()][0].delay, 15000); + const initialTimerCreationCount = timeoutId; - for (const timeout of scheduledTimeouts.values()) { - timeout(); - } - scheduledTimeouts.clear(); + now = 5000; + assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); + assert.equal(timeoutId, initialTimerCreationCount); + assert.equal(scheduledTimeouts.size, 1); + assert.deepEqual(clearedTimeouts, []); + + now = 15000; + [...scheduledTimeouts.values()][0].fire(); + assert.equal(timeoutId, initialTimerCreationCount + 1); + assert.ok(buffer.getStringCacheClearTimeout() !== undefined); + assert.equal(scheduledTimeouts.size, 1); + assert.equal([...scheduledTimeouts.values()][0].delay, 5000); + + now = 20000; + [...scheduledTimeouts.values()][0].fire(); assert.equal(cache.entries.size, 0); assert.equal(buffer.getStringCacheClearTimeout(), undefined); - // Cache entries should be recreated lazily after a timer-based reset. assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`); assert.equal(cache.entries.size, 1); } finally { + Date.now = originalDateNow; globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; } diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 99ae5d00b..34ef02f6a 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -61,7 +61,6 @@ export class Buffer extends Disposable implements IBuffer { private readonly _logService: ILogService ) { super(); - this._register(toDisposable(() => this.clearAllMarkers())); this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; this.lines = new CircularList(this._getCorrectBufferLength(this._rows)); @@ -70,6 +69,7 @@ export class Buffer extends Disposable implements IBuffer { this.setupTabStops(); this._memoryCleanupQueue = new IdleTaskQueue(this._logService); this._register(toDisposable(() => this._memoryCleanupQueue.clear())); + this._register(toDisposable(() => this.clearAllMarkers())); this._stringCache = this._register(new BufferLineStringCache()); } diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts index 547afc85f..0918a66ba 100644 --- a/src/common/buffer/BufferLineStringCache.ts +++ b/src/common/buffer/BufferLineStringCache.ts @@ -16,6 +16,7 @@ export class BufferLineStringCache extends Disposable implements IBufferLineStri public generation: number = 0; public readonly entries: Set = new Set(); private readonly _clearTimeout = this._register(new MutableDisposable()); + private _lastAccessTimestamp: number = 0; constructor() { super(); @@ -39,6 +40,7 @@ export class BufferLineStringCache extends Disposable implements IBufferLineStri public clear(): void { this._clearTimeout.clear(); + this._lastAccessTimestamp = 0; this.generation++; for (const entry of this.entries) { entry.value = undefined; @@ -48,9 +50,21 @@ export class BufferLineStringCache extends Disposable implements IBufferLineStri } private _scheduleClear(): void { + this._lastAccessTimestamp = Date.now(); + if (this._clearTimeout.value) { + return; + } + this._scheduleClearTimeout(Constants.CacheTtlMs); + } + + private _scheduleClearTimeout(timeoutMs: number): void { this._clearTimeout.value = disposableTimeout(() => { - this._clearTimeout.clear(); - this.clear(); - }, Constants.CacheTtlMs); + const elapsed = Date.now() - this._lastAccessTimestamp; + if (elapsed >= Constants.CacheTtlMs) { + this.clear(); + return; + } + this._scheduleClearTimeout(Constants.CacheTtlMs - elapsed); + }, timeoutMs); } } From 093e81b9a7243ceb23292920345a15a691f7a51d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 06:07:41 -0700 Subject: [PATCH 35/39] Fix build, lint --- src/common/buffer/Buffer.test.ts | 2 +- src/common/buffer/BufferLineStringCache.ts | 11 +++++------ src/headless/tsconfig.json | 3 ++- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 38fd513d3..abfea7b06 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -1199,7 +1199,7 @@ describe('Buffer', () => { let timeoutId = 0; let now = 0; const clearedTimeouts: number[] = []; - const scheduledTimeouts = new Map void }>(); + const scheduledTimeouts = new Map void }>(); (globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => { const id = ++timeoutId; scheduledTimeouts.set(id, { diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts index 0918a66ba..b365eef70 100644 --- a/src/common/buffer/BufferLineStringCache.ts +++ b/src/common/buffer/BufferLineStringCache.ts @@ -5,11 +5,10 @@ import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine'; import { disposableTimeout } from 'common/Async'; -import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; -import type { IDisposable } from 'common/Lifecycle'; +import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle'; const enum Constants { - CacheTtlMs = 15000 + CACHE_TTL_MS = 15000 } export class BufferLineStringCache extends Disposable implements IBufferLineStringCache { @@ -54,17 +53,17 @@ export class BufferLineStringCache extends Disposable implements IBufferLineStri if (this._clearTimeout.value) { return; } - this._scheduleClearTimeout(Constants.CacheTtlMs); + this._scheduleClearTimeout(Constants.CACHE_TTL_MS); } private _scheduleClearTimeout(timeoutMs: number): void { this._clearTimeout.value = disposableTimeout(() => { const elapsed = Date.now() - this._lastAccessTimestamp; - if (elapsed >= Constants.CacheTtlMs) { + if (elapsed >= Constants.CACHE_TTL_MS) { this.clear(); return; } - this._scheduleClearTimeout(Constants.CacheTtlMs - elapsed); + this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed); }, timeoutMs); } } diff --git a/src/headless/tsconfig.json b/src/headless/tsconfig.json index 6ae7b1dc9..0c445bcfc 100644 --- a/src/headless/tsconfig.json +++ b/src/headless/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": [ "es2015", - "es2016.Array.Include" + "es2016.Array.Include", + "es2021.weakref" ], "outDir": "../../out", "types": [ From 4ebb3fffe9856d672a6254378f83da37e28ab13a Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 06:21:42 -0700 Subject: [PATCH 36/39] Ignore cache keys in serialize addon Bit hacky, but this is due to the way we're serializing the line in the test --- addons/addon-serialize/test/SerializeAddon.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index ee5355fa6..fb42d807d 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => { for (let i = 0; i < buffer.length; i++) { // Do this intentionally to get content of underlining source const bufferLine = buffer.getLine(i)._line; - lines.push(JSON.stringify(bufferLine)); + lines.push(JSON.stringify(bufferLine, (key, value) => { + // BufferLine caches are internal/transient and can legitimately differ + // across equivalent terminal states. + if (key === '_stringCache' || key === '_stringCacheEntryRef') { + return undefined; + } + return value; + })); } return { x: buffer.cursorX, From 5c47e6dd9a3ebed10e1c405df8c2e34936f2a027 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 06:30:18 -0700 Subject: [PATCH 37/39] Add instructions around MutableDisposables and const enums --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c9954ed7e..0d5d03588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,13 @@ export class MyAddon implements ITerminalAddon { - Proposed APIs require `allowProposedApi: true` option - Constructor-only options (cols, rows) cannot be changed after instantiation +**Disposable Management**: +- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic. +- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources. + +**TypeScript Constants**: +- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups. + **Testing Utilities**: Use `TestUtils.ts` helpers: - `openTerminal(ctx, options)` for setup - `pollFor(page, fn, expectedValue)` for async assertions From 80debafa11da2f3f26e1db947b0cb154eb125941 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 06:44:25 -0700 Subject: [PATCH 38/39] Convert a lot of top level consts to const enums --- addons/addon-fit/src/FitAddon.ts | 10 +- addons/addon-image/src/ImageRenderer.ts | 14 ++- .../src/kitty/KittyGraphicsHandler.ts | 55 ++++----- .../src/kitty/KittyGraphicsTypes.ts | 8 +- addons/addon-webgl/src/GlyphRenderer.ts | 34 ++--- addons/addon-webgl/src/RectangleRenderer.ts | 8 +- addons/addon-webgl/src/RenderModel.ts | 12 +- addons/addon-webgl/src/WebglRenderer.ts | 24 ++-- src/browser/AccessibilityManager.ts | 8 +- src/browser/renderer/dom/DomRenderer.ts | 80 ++++++------ src/browser/scrollable/scrollableElement.ts | 12 +- src/browser/services/SelectionService.ts | 53 ++++---- src/common/CoreTerminal.ts | 6 +- src/common/InputHandler.ts | 38 +++--- src/common/buffer/BufferLine.ts | 116 +++++++++--------- src/common/input/WriteBuffer.ts | 60 ++++----- src/common/parser/Params.ts | 22 ++-- src/common/services/BufferService.ts | 10 +- src/common/services/ServiceRegistry.ts | 16 +-- 19 files changed, 304 insertions(+), 282 deletions(-) diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 87b21e99e..3db988acd 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -19,8 +19,10 @@ interface ITerminalDimensions { cols: number; } -const MINIMUM_COLS = 2; -const MINIMUM_ROWS = 1; +const enum Constants { + MINIMUM_COLS = 2, + MINIMUM_ROWS = 1 +} function getWindow(e: Node): Window { if (e?.ownerDocument?.defaultView) { @@ -86,8 +88,8 @@ export class FitAddon implements ITerminalAddon, IFitApi { const availableHeight = parentElementHeight - elementPaddingVer; const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth; const geometry = { - cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), - rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)) + cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), + rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)) }; return geometry; } diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index 79b83a977..b36cdd004 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm'; import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; -const PLACEHOLDER_LENGTH = 4096; -const PLACEHOLDER_HEIGHT = 24; +const enum Constants { + PLACEHOLDER_LENGTH = 4096, + PLACEHOLDER_HEIGHT = 24 +} /** * ImageRenderer - terminal frontend extension: @@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable { public showPlaceholder(value: boolean): void { if (value) { if (!this._placeholder && this.cellSize.height !== -1) { - this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT)); + this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT)); } } else { this._placeholderBitmap?.close(); @@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable { } if (!this._placeholder) { - this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT)); + this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT)); } else if (height >= this._placeholder!.height) { this._createPlaceHolder(height + 1); } @@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable { return this._layers.has(layer); } - private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void { + private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void { this._placeholderBitmap?.close(); this._placeholderBitmap = undefined; @@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable { ctx.putImageData(imgData, 0, 0); // create placeholder line, width aligned to blueprint width - const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH; + const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH; this._placeholder = ImageRenderer.createCanvas(this.document, width, height); const ctx2 = this._placeholder.getContext('2d', { alpha: false }); if (!ctx2) { diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 17cd5ed9f..f449f3186 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -16,24 +16,23 @@ import { IKittyCommand, IPendingTransmission, IKittyImageData, - BYTES_PER_PIXEL_RGB, - BYTES_PER_PIXEL_RGBA, - ALPHA_OPAQUE, + KittyPixelConstants, parseKittyCommand } from './KittyGraphicsTypes'; -// Memory limit for base64 decoder (4MB, same as IIPHandler) -const DECODER_KEEP_DATA = 4194304; -const DECODER_INITIAL_DATA = 4194304; // 4MB +const enum Constants { + // Memory limit for base64 decoder (4MB, same as IIPHandler) + DECODER_KEEP_DATA = 4194304, + DECODER_INITIAL_DATA = 4194304, // 4MB + // Local mirror of const enum (esbuild can't inline const enums from external packages) + DECODER_OK = 0, + // Maximum control data size + MAX_CONTROL_DATA_SIZE = 512, + // Semicolon codepoint + SEMICOLON = 0x3B +} -// Local mirror of const enum (esbuild can't inline const enums from external packages) -const DECODER_OK: DecodeStatus.OK = 0; - -// Maximum control data size -const MAX_CONTROL_DATA_SIZE = 512; - -// Semicolon codepoint -const SEMICOLON = 0x3B; +const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK; // Kitty graphics protocol handler with streaming base64 decoding. export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable { @@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private _inControlData = true; // Buffer for control data. - private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE); + private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE); private _controlLength = 0; // Pre-calculated encoded size limit @@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos // Convert decoded size limit -> max encoded bytes. this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3); // ensure we preallocate more than configured limit while using 4mb initial size. - this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes); + this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes); } public reset(): void { @@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos // Scan for semicolon let controlEnd = end; for (let i = start; i < end; i++) { - if (data[i] === SEMICOLON) { + if (data[i] === Constants.SEMICOLON) { this._inControlData = false; controlEnd = i; break; @@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos // Copy control data const copyLength = controlEnd - start; - if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) { + if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) { this._aborted = true; return; } @@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos this._activeDecoder = pending.decoder; } if (!this._activeDecoder) { - this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); + this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes); this._activeDecoder.init(); } @@ -484,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos return true; } - const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB; + const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB; const expectedBytes = width * height * bytesPerPixel; if (bytes.length < expectedBytes) { @@ -723,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos throw new Error('Width and height required for raw pixel data'); } - const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB; + const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB; const expectedBytes = width * height * bytesPerPixel; if (bytes.length < expectedBytes) { @@ -734,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos if (image.format === KittyFormat.RGBA) { // RGBA: use bytes directly — no copy needed - return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height)); + return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height)); } // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration). // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster. // Assumes little-endian (all modern browsers/Node.js). - const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA); + const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA); const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4)); const dst32 = new Uint32Array(data.buffer); const alignedPixels = pixelCount & ~3; // round down to multiple of 4 @@ -759,15 +758,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos } // Handle remaining 1–3 pixels - let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB; - let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA; + let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB; + let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA; for (let i = alignedPixels; i < pixelCount; i++) { data[dstByte] = bytes[srcByte]; data[dstByte + 1] = bytes[srcByte + 1]; data[dstByte + 2] = bytes[srcByte + 2]; - data[dstByte + 3] = ALPHA_OPAQUE; - srcByte += BYTES_PER_PIXEL_RGB; - dstByte += BYTES_PER_PIXEL_RGBA; + data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE; + srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB; + dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA; } return createImageBitmap(new ImageData(data, width, height)); diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts index 82b2b0a9a..448ebcc05 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -82,9 +82,11 @@ export const enum KittyKey { } // Pixel format constants -export const BYTES_PER_PIXEL_RGB = 3; -export const BYTES_PER_PIXEL_RGBA = 4; -export const ALPHA_OPAQUE = 255; +export const enum KittyPixelConstants { + BYTES_PER_PIXEL_RGB = 3, + BYTES_PER_PIXEL_RGBA = 4, + ALPHA_OPAQUE = 255 +} // Parsed Kitty graphics command. export interface IKittyCommand { diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index f5be446f6..be4ebb76b 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -78,9 +78,11 @@ void main() { }`); } -const INDICES_PER_CELL = 11; -const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT; -const CELL_POSITION_INDICES = 2; +const enum Constants { + INDICES_PER_CELL = 11, + BYTES_PER_CELL = INDICES_PER_CELL * 4/*Float32Array.BYTES_PER_ELEMENT*/, + CELL_POSITION_INDICES = 2 +} // Work variables to avoid garbage collection let $i = 0; @@ -160,22 +162,22 @@ export class GlyphRenderer extends Disposable { this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer))); gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); gl.enableVertexAttribArray(VertexAttribLocations.OFFSET); - gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0); + gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0); gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1); gl.enableVertexAttribArray(VertexAttribLocations.SIZE); - gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT); gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1); gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE); - gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT); gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1); gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD); - gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT); gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1); gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE); - gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT); gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1); gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION); - gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT); + gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT); gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1); // Setup static uniforms @@ -222,12 +224,12 @@ export class GlyphRenderer extends Disposable { } private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void { - $i = (y * this._terminal.cols + x) * INDICES_PER_CELL; + $i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL; // Exit early if this is a null character, allow space character to continue as it may have // underline/strikethrough styles if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) { - array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES); + array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES); return; } @@ -288,7 +290,7 @@ export class GlyphRenderer extends Disposable { public clear(): void { const terminal = this._terminal; - const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL; + const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL; // Clear vertices if (this._vertices.count !== newCount) { @@ -310,7 +312,7 @@ export class GlyphRenderer extends Disposable { for (let x = 0; x < terminal.cols; x++) { this._vertices.attributes[i + 9] = x / terminal.cols; this._vertices.attributes[i + 10] = y / terminal.rows; - i += INDICES_PER_CELL; + i += Constants.INDICES_PER_CELL; } } } @@ -346,8 +348,8 @@ export class GlyphRenderer extends Disposable { // - So we don't send vertices for all the line-ending whitespace to the GPU let bufferLength = 0; for (let y = 0; y < renderModel.lineLengths.length; y++) { - const si = y * this._terminal.cols * INDICES_PER_CELL; - const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL); + const si = y * this._terminal.cols * Constants.INDICES_PER_CELL; + const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL); activeBuffer.set(sub, bufferLength); bufferLength += sub.length; } @@ -364,7 +366,7 @@ export class GlyphRenderer extends Disposable { } // Draw the viewport - gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL); + gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL); } public setAtlas(atlas: ITextureAtlas): void { diff --git a/addons/addon-webgl/src/RectangleRenderer.ts b/addons/addon-webgl/src/RectangleRenderer.ts index d22068bab..4ad8d66ff 100644 --- a/addons/addon-webgl/src/RectangleRenderer.ts +++ b/addons/addon-webgl/src/RectangleRenderer.ts @@ -10,7 +10,7 @@ import { Attributes, FgFlags } from 'common/buffer/Constants'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { IColor } from 'common/Types'; import { Terminal } from '@xterm/xterm'; -import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; +import { RenderModelConstants } from './RenderModel'; import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types'; import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils'; import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; @@ -217,9 +217,9 @@ export class RectangleRenderer extends Disposable { currentFg = 0; currentInverse = false; for (x = 0; x < terminal.cols; x++) { - modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; - bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET]; - fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET]; + modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; + bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET]; + fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET]; inverse = !!(fg & FgFlags.INVERSE); if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) { // A rectangle needs to be drawn if going from non-default to another color diff --git a/addons/addon-webgl/src/RenderModel.ts b/addons/addon-webgl/src/RenderModel.ts index a94f317ee..6113a9bdc 100644 --- a/addons/addon-webgl/src/RenderModel.ts +++ b/addons/addon-webgl/src/RenderModel.ts @@ -7,10 +7,12 @@ import { ICursorRenderModel, IRenderModel } from './Types'; import { ISelectionRenderModel } from 'browser/renderer/shared/Types'; import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel'; -export const RENDER_MODEL_INDICIES_PER_CELL = 4; -export const RENDER_MODEL_BG_OFFSET = 1; -export const RENDER_MODEL_FG_OFFSET = 2; -export const RENDER_MODEL_EXT_OFFSET = 3; +export const enum RenderModelConstants { + INDICIES_PER_CELL = 4, + BG_OFFSET = 1, + FG_OFFSET = 2, + EXT_OFFSET = 3 +} export const COMBINED_CHAR_BIT_MASK = 0x80000000; @@ -27,7 +29,7 @@ export class RenderModel implements IRenderModel { } public resize(cols: number, rows: number): void { - const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL; + const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL; if (indexCount !== this.cells.length) { this.cells = new Uint32Array(indexCount); this.lineLengths = new Uint32Array(rows); diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 83f20c9e7..91867ea26 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -19,7 +19,7 @@ import { ICoreService, IDecorationService, IOptionsService } from 'common/servic import { Terminal } from '@xterm/xterm'; import { GlyphRenderer } from './GlyphRenderer'; import { RectangleRenderer } from './RectangleRenderer'; -import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel'; +import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel'; import { IWebGL2RenderingContext, type ITextureAtlas } from './Types'; import { LinkRenderLayer } from './renderLayer/LinkRenderLayer'; import { IRenderLayer } from './renderLayer/Types'; @@ -493,7 +493,7 @@ export class WebglRenderer extends Disposable implements IRenderer { chars = cell.getChars(); code = cell.getCode(); - i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; if (!rowHasBlinkingCells && cell.isBlink()) { rowHasBlinkingCells = true; @@ -538,9 +538,9 @@ export class WebglRenderer extends Disposable implements IRenderer { // Nothing has changed, no updates needed if (this._model.cells[i] === code && - this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg && - this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg && - this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) { + this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg && + this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg && + this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) { continue; } @@ -553,9 +553,9 @@ export class WebglRenderer extends Disposable implements IRenderer { // Cache the results in the model this._model.cells[i] = code; - this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg; - this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; - this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext; + this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg; + this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg; + this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext; width = cell.getWidth(); this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg); @@ -566,14 +566,14 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x <= lastCharX; x++) { - j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; + j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL; this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0); this._model.cells[j] = NULL_CELL_CODE; // Don't re-resolve the cell color since multi-colored ligature backgrounds are not // supported - this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg; - this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; - this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext; + this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg; + this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg; + this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext; } x--; // Go back to the previous update cell for next iteration } diff --git a/src/browser/AccessibilityManager.ts b/src/browser/AccessibilityManager.ts index 1d3c44ae1..6fc421337 100644 --- a/src/browser/AccessibilityManager.ts +++ b/src/browser/AccessibilityManager.ts @@ -12,7 +12,9 @@ import { IBuffer } from 'common/buffer/Types'; import { IInstantiationService } from 'common/services/Services'; import { addDisposableListener } from 'browser/Dom'; -const MAX_ROWS_TO_READ = 20; +const enum Constants { + MAX_ROWS_TO_READ = 20 +} const enum BoundaryPosition { TOP, @@ -137,7 +139,7 @@ export class AccessibilityManager extends Disposable { } private _handleChar(char: string): void { - if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) { + if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) { if (this._charsToConsume.length > 0) { // Have the screen reader ignore the char if it was just input const shiftedChar = this._charsToConsume.shift(); @@ -150,7 +152,7 @@ export class AccessibilityManager extends Disposable { if (char === '\n') { this._liveRegionLineCount++; - if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) { + if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) { this._liveRegion.textContent = Strings.tooMuchOutput.get(); } } diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 08b5093df..6f873af7b 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -19,13 +19,15 @@ import { Emitter } from 'common/Event'; import { addDisposableListener } from 'browser/Dom'; -const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-'; -const ROW_CONTAINER_CLASS = 'xterm-rows'; -const FG_CLASS_PREFIX = 'xterm-fg-'; -const BG_CLASS_PREFIX = 'xterm-bg-'; -const FOCUS_CLASS = 'xterm-focus'; -const SELECTION_CLASS = 'xterm-selection'; -const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'; +const enum Constants { + TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-', + ROW_CONTAINER_CLASS = 'xterm-rows', + FG_CLASS_PREFIX = 'xterm-fg-', + BG_CLASS_PREFIX = 'xterm-bg-', + FOCUS_CLASS = 'xterm-focus', + SELECTION_CLASS = 'xterm-selection', + CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle' +} let nextTerminalId = 1; @@ -76,12 +78,12 @@ export class DomRenderer extends Disposable implements IRenderer { ) { super(); this._rowContainer = this._document.createElement('div'); - this._rowContainer.classList.add(ROW_CONTAINER_CLASS); + this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS); this._rowContainer.style.lineHeight = 'normal'; this._rowContainer.setAttribute('aria-hidden', 'true'); this._refreshRowElements(this._bufferService.cols, this._bufferService.rows); this._selectionContainer = this._document.createElement('div'); - this._selectionContainer.classList.add(SELECTION_CLASS); + this._selectionContainer.classList.add(Constants.SELECTION_CLASS); this._selectionContainer.setAttribute('aria-hidden', 'true'); this.dimensions = createRenderDimensions(); @@ -93,7 +95,7 @@ export class DomRenderer extends Disposable implements IRenderer { this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document); - this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass); + this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass); this._screenElement.appendChild(this._rowContainer); this._screenElement.appendChild(this._selectionContainer); @@ -110,7 +112,7 @@ export class DomRenderer extends Disposable implements IRenderer { )); this._register(toDisposable(() => { - this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass); + this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass); // Outside influences such as React unmounts may manipulate the DOM before our disposal. // https://github.com/xtermjs/xterm.js/issues/2960 @@ -160,7 +162,7 @@ export class DomRenderer extends Disposable implements IRenderer { } const styles = - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` + ` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty) ` height: 100%;` + ` vertical-align: top;` + @@ -181,7 +183,7 @@ export class DomRenderer extends Disposable implements IRenderer { // Base CSS let styles = - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` + // Disabling pointer events circumvents a browser behavior that prevents `click` events from // being delivered if the target element is replaced during the click. This happened due to // refresh() being called during the mousedown handler to start a selection. @@ -189,14 +191,14 @@ export class DomRenderer extends Disposable implements IRenderer { ` color: ${colors.foreground.css};` + `}`; styles += - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` + ` font-family: ${this._optionsService.rawOptions.fontFamily};` + ` font-size: ${this._optionsService.rawOptions.fontSize}px;` + ` font-kerning: none;` + ` white-space: pre` + `}`; styles += - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` + ` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` + `}`; // Text styles @@ -242,70 +244,70 @@ export class DomRenderer extends Disposable implements IRenderer { `}`; // Cursor styles += - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` + ` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` + ` animation: ${blinkAnimationBarId} 1s step-end infinite;` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` + ` animation: ${blinkAnimationBlockId} 1s step-end infinite;` + `}` + // Disable cursor blinking when idle - `${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` + ` animation: none !important;` + `}` + // !important helps fix an issue where the cursor will not render on top of the selection, // however it's very hard to fix this issue and retain the blink animation without the use of // !important. So this edge case fails when cursor blink is on. - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` + ` background-color: ${colors.cursor.css};` + ` color: ${colors.cursorAccent.css};` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` + ` background-color: ${colors.cursor.css} !important;` + ` color: ${colors.cursorAccent.css} !important;` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` + ` outline: 1px solid ${colors.cursor.css};` + ` outline-offset: -1px;` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` + ` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` + `}` + - `${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` + + `${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` + ` border-bottom: 1px ${colors.cursor.css};` + ` border-bottom-style: solid;` + ` height: calc(100% - 1px);` + `}`; // Selection styles += - `${this._terminalSelector} .${SELECTION_CLASS} {` + + `${this._terminalSelector} .${Constants.SELECTION_CLASS} {` + ` position: absolute;` + ` top: 0;` + ` left: 0;` + ` z-index: 1;` + ` pointer-events: none;` + `}` + - `${this._terminalSelector}.focus .${SELECTION_CLASS} div {` + + `${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` + ` position: absolute;` + ` background-color: ${colors.selectionBackgroundOpaque.css};` + `}` + - `${this._terminalSelector} .${SELECTION_CLASS} div {` + + `${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` + ` position: absolute;` + ` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` + `}`; // Colors for (const [i, c] of colors.ansi.entries()) { styles += - `${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + - `${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` + - `${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; + `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` + + `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` + + `${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`; } styles += - `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` + - `${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` + - `${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`; + `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` + + `${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` + + `${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`; this._themeStyleElement.textContent = styles; } @@ -361,13 +363,13 @@ export class DomRenderer extends Disposable implements IRenderer { } public handleBlur(): void { - this._rowContainer.classList.remove(FOCUS_CLASS); + this._rowContainer.classList.remove(Constants.FOCUS_CLASS); this._cursorBlinkStateManager.pause(); this.renderRows(0, this._bufferService.rows - 1); } public handleFocus(): void { - this._rowContainer.classList.add(FOCUS_CLASS); + this._rowContainer.classList.add(Constants.FOCUS_CLASS); this._cursorBlinkStateManager.resume(); this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y); } @@ -561,7 +563,7 @@ export class DomRenderer extends Disposable implements IRenderer { } private get _terminalSelector(): string { - return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`; + return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`; } private _handleLinkHover(e: ILinkifierEvent): void { @@ -667,7 +669,7 @@ class CursorBlinkStateManager { public restartBlinkAnimation(): void { if (this._isIdlePaused) { - this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS); + this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS); } this._resetIdleTimer(); } @@ -679,7 +681,7 @@ class CursorBlinkStateManager { public resume(): void { this._isIdlePaused = false; - this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS); + this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS); this._resetIdleTimer(); } @@ -699,7 +701,7 @@ class CursorBlinkStateManager { } private _stopBlinkingDueToIdle(): void { - this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS); + this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS); this._isIdlePaused = true; this._idleTimeout = undefined; } diff --git a/src/browser/scrollable/scrollableElement.ts b/src/browser/scrollable/scrollableElement.ts index e30b36fc3..4039d0c4e 100644 --- a/src/browser/scrollable/scrollableElement.ts +++ b/src/browser/scrollable/scrollableElement.ts @@ -18,8 +18,10 @@ import * as platform from 'common/Platform'; import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable'; // import 'vs/css!./media/scrollbars'; -const HIDE_TIMEOUT = 500; -const SCROLL_WHEEL_SENSITIVITY = 50; +const enum Constants { + HIDE_TIMEOUT = 500, + SCROLL_WHEEL_SENSITIVITY = 50 +} class MouseWheelClassifierItem { public timestamp: number; @@ -404,12 +406,12 @@ export class SmoothScrollableElement extends Widget { let desiredScrollPosition: INewScrollPosition = {}; if (deltaY) { - const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY; + const deltaScrollTop = Constants.SCROLL_WHEEL_SENSITIVITY * deltaY; const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop)); this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop); } if (deltaX) { - const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX; + const deltaScrollLeft = Constants.SCROLL_WHEEL_SENSITIVITY * deltaX; const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft)); this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft); } @@ -533,7 +535,7 @@ export class SmoothScrollableElement extends Widget { private _scheduleHide(): void { if (!this._mouseIsOver && !this._isDragging) { - this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT); + this._hideTimeout.cancelAndSet(() => this._hide(), Constants.HIDE_TIMEOUT); } } } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index e1b8510e9..39692a871 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -18,27 +18,26 @@ import { IBuffer } from 'common/buffer/Types'; import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; -/** - * The number of pixels the mouse needs to be above or below the viewport in - * order to scroll at the maximum speed. - */ -const DRAG_SCROLL_MAX_THRESHOLD = 50; - -/** - * The maximum scrolling speed - */ -const DRAG_SCROLL_MAX_SPEED = 15; - -/** - * The number of milliseconds between drag scroll updates. - */ -const DRAG_SCROLL_INTERVAL = 50; - -/** - * The maximum amount of time that can have elapsed for an alt click to move the - * cursor. - */ -const ALT_CLICK_MOVE_CURSOR_TIME = 500; +const enum Constants { + /** + * The number of pixels the mouse needs to be above or below the viewport in + * order to scroll at the maximum speed. + */ + DRAG_SCROLL_MAX_THRESHOLD = 50, + /** + * The maximum scrolling speed + */ + DRAG_SCROLL_MAX_SPEED = 15, + /** + * The number of milliseconds between drag scroll updates. + */ + DRAG_SCROLL_INTERVAL = 50, + /** + * The maximum amount of time that can have elapsed for an alt click to move the + * cursor. + */ + ALT_CLICK_MOVE_CURSOR_TIME = 500 +} const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160); const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g'); @@ -424,9 +423,9 @@ export class SelectionService extends Disposable implements ISelectionService { offset -= terminalHeight; } - offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD); - offset /= DRAG_SCROLL_MAX_THRESHOLD; - return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1)); + offset = Math.min(Math.max(offset, -Constants.DRAG_SCROLL_MAX_THRESHOLD), Constants.DRAG_SCROLL_MAX_THRESHOLD); + offset /= Constants.DRAG_SCROLL_MAX_THRESHOLD; + return (offset / Math.abs(offset)) + Math.round(offset * (Constants.DRAG_SCROLL_MAX_SPEED - 1)); } /** @@ -500,7 +499,7 @@ export class SelectionService extends Disposable implements ISelectionService { this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener); this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener); } - this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL); + this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), Constants.DRAG_SCROLL_INTERVAL); } /** @@ -675,7 +674,7 @@ export class SelectionService extends Disposable implements ISelectionService { } /** - * The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the + * The callback that occurs every Constants.DRAG_SCROLL_INTERVAL ms that does the * scrolling of the viewport. */ private _dragScroll(): void { @@ -713,7 +712,7 @@ export class SelectionService extends Disposable implements ISelectionService { this._removeMouseDownListeners(); - if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) { + if (this.selectionText.length <= 1 && timeElapsed < Constants.ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) { if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) { const coordinates = this._mouseCoordsService.getCoords( event, diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 0eff38f25..9bd7faaf5 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -24,7 +24,7 @@ import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services'; import { InstantiationService } from 'common/services/InstantiationService'; import { LogService } from 'common/services/LogService'; -import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService'; +import { BufferService, BufferServiceConstants } from 'common/services/BufferService'; import { OptionsService } from 'common/services/OptionsService'; import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types'; import { CoreService } from 'common/services/CoreService'; @@ -173,8 +173,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { return; } - x = Math.max(x, MINIMUM_COLS); - y = Math.max(y, MINIMUM_ROWS); + x = Math.max(x, BufferServiceConstants.MINIMUM_COLS); + y = Math.max(y, BufferServiceConstants.MINIMUM_ROWS); // Flush pending writes before resize to avoid race conditions where async // writes are processed with incorrect dimensions diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index c4c2a8e2e..2b7142dd0 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -41,12 +41,13 @@ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': /** * Max length of the UTF32 input buffer. Real memory consumption is 4 times higher. */ -const MAX_PARSEBUFFER_LENGTH = 131072; - -/** - * Limit length of title and icon name stacks. - */ -const STACK_LIMIT = 10; +const enum Constants { + MAX_PARSEBUFFER_LENGTH = 131072, + /** Limit length of title and icon name stacks. */ + STACK_LIMIT = 10, + // create a warning log if an async handler takes longer than the limit (in ms) + SLOW_ASYNC_LIMIT = 5000 +} // map params to window option function paramToWindowOption(n: number, opts: IWindowOptions): boolean { @@ -85,9 +86,6 @@ export enum WindowsOptionsReportType { GET_CELL_SIZE_PIXELS = 1 } -// create a warning log if an async handler takes longer than the limit (in ms) -const SLOW_ASYNC_LIMIT = 5000; - // Work variables to avoid garbage collection let $temp = 0; @@ -393,7 +391,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (this._logService.logLevel <= LogLevelEnum.WARN) { let slowTimeout: ReturnType | undefined; const slowPromise = new Promise((_res, rej) => { - slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), SLOW_ASYNC_LIMIT); + slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), Constants.SLOW_ASYNC_LIMIT); }); Promise.race([p, slowPromise]) .then(() => { @@ -407,7 +405,7 @@ export class InputHandler extends Disposable implements IInputHandler { if (err !== '#SLOW_TIMEOUT') { throw err; } - console.warn(`async parser handler taking longer than ${SLOW_ASYNC_LIMIT} ms`); + console.warn(`async parser handler taking longer than ${Constants.SLOW_ASYNC_LIMIT} ms`); }); } } @@ -445,8 +443,8 @@ export class InputHandler extends Disposable implements IInputHandler { cursorStartX = this._parseStack.cursorStartX; cursorStartY = this._parseStack.cursorStartY; this._parseStack.paused = false; - if (data.length > MAX_PARSEBUFFER_LENGTH) { - start = this._parseStack.position + MAX_PARSEBUFFER_LENGTH; + if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) { + start = this._parseStack.position + Constants.MAX_PARSEBUFFER_LENGTH; } } @@ -463,8 +461,8 @@ export class InputHandler extends Disposable implements IInputHandler { // resize input buffer if needed if (this._parseBuffer.length < data.length) { - if (this._parseBuffer.length < MAX_PARSEBUFFER_LENGTH) { - this._parseBuffer = new Uint32Array(Math.min(data.length, MAX_PARSEBUFFER_LENGTH)); + if (this._parseBuffer.length < Constants.MAX_PARSEBUFFER_LENGTH) { + this._parseBuffer = new Uint32Array(Math.min(data.length, Constants.MAX_PARSEBUFFER_LENGTH)); } } @@ -475,9 +473,9 @@ export class InputHandler extends Disposable implements IInputHandler { } // process big data in smaller chunks - if (data.length > MAX_PARSEBUFFER_LENGTH) { - for (let i = start; i < data.length; i += MAX_PARSEBUFFER_LENGTH) { - const end = i + MAX_PARSEBUFFER_LENGTH < data.length ? i + MAX_PARSEBUFFER_LENGTH : data.length; + if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) { + for (let i = start; i < data.length; i += Constants.MAX_PARSEBUFFER_LENGTH) { + const end = i + Constants.MAX_PARSEBUFFER_LENGTH < data.length ? i + Constants.MAX_PARSEBUFFER_LENGTH : data.length; const len = (typeof data === 'string') ? this._stringDecoder.decode(data.substring(i, end), this._parseBuffer) : this._utf8Decoder.decode(data.subarray(i, end), this._parseBuffer); @@ -2955,13 +2953,13 @@ export class InputHandler extends Disposable implements IInputHandler { case 22: // PushTitle if (second === 0 || second === 2) { this._windowTitleStack.push(this._windowTitle); - if (this._windowTitleStack.length > STACK_LIMIT) { + if (this._windowTitleStack.length > Constants.STACK_LIMIT) { this._windowTitleStack.shift(); } } if (second === 0 || second === 1) { this._iconNameStack.push(this._iconName); - if (this._iconNameStack.length > STACK_LIMIT) { + if (this._iconNameStack.length > Constants.STACK_LIMIT) { this._iconNameStack.shift(); } } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e415851a0..d3f5a523c 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -9,25 +9,20 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; -/** - * buffer memory layout: - * - * | uint32_t | uint32_t | uint32_t | - * | `content` | `FG` | `BG` | - * | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) | - */ - - -/** typed array slots taken by one cell */ -const CELL_SIZE = 3; +const enum Constants { + /** The number of 32 bit array indices taken by one cell. */ + CELL_INDICIES = 3, + /** Factor when to cleanup underlying array buffer after shrinking. */ + CLEANUP_THRESHOLD = 2 +} /** * Cell member indices. * * Direct access: - * `content = data[column * CELL_SIZE + Cell.CONTENT];` - * `fg = data[column * CELL_SIZE + Cell.FG];` - * `bg = data[column * CELL_SIZE + Cell.BG];` + * `content = data[column * Constants.CELL_INDICIES + Cell.CONTENT];` + * `fg = data[column * Constants.CELL_INDICIES + Cell.FG];` + * `bg = data[column * Constants.CELL_INDICIES + Cell.BG];` */ const enum Cell { CONTENT = 0, @@ -41,9 +36,6 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); let $startIndex = 0; const $workCell = new CellData(); -/** Factor when to cleanup underlying array buffer after shrinking. */ -const CLEANUP_THRESHOLD = 2; - /** * Typed array based bufferline implementation. * @@ -60,13 +52,21 @@ const CLEANUP_THRESHOLD = 2; * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ export class BufferLine implements IBufferLine { + /** + * Buffer memory layout: + * + * | uint32_t | uint32_t | uint32_t | + * |----------------------------------------|---------------------------------|---------------------------------| + * | `content` | `FG` | `BG` | + * | `wcwidth(2)` `comb(1)` `codepoint(21)` | `flags(8)` `R(8)` `G(8)` `B(8)` | `flags(8)` `R(8)` `G(8)` `B(8)` | + */ protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {}; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { - this._data = new Uint32Array(cols * CELL_SIZE); + this._data = new Uint32Array(cols * Constants.CELL_INDICIES); const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]); for (let i = 0; i < cols; ++i) { this.setCell(i, cell); @@ -79,10 +79,10 @@ export class BufferLine implements IBufferLine { * @deprecated */ public get(index: number): CharData { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; return [ - this._data[index * CELL_SIZE + Cell.FG], + this._data[index * Constants.CELL_INDICIES + Cell.FG], (content & Content.IS_COMBINED_MASK) ? this._combined[index] : (cp) ? stringFromCodePoint(cp) : '', @@ -98,12 +98,12 @@ export class BufferLine implements IBufferLine { * @deprecated */ public set(index: number, value: CharData): void { - this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; + this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX]; if (value[CHAR_DATA_CHAR_INDEX].length > 1) { this._combined[index] = value[1]; - this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } else { - this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT); } } @@ -112,22 +112,22 @@ export class BufferLine implements IBufferLine { * use these when only one value is needed, otherwise use `loadCell` */ public getWidth(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT; + return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT; } /** Test whether content has width. */ public hasWidth(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK; + return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.WIDTH_MASK; } /** Get FG cell component. */ public getFg(index: number): number { - return this._data[index * CELL_SIZE + Cell.FG]; + return this._data[index * Constants.CELL_INDICIES + Cell.FG]; } /** Get BG cell component. */ public getBg(index: number): number { - return this._data[index * CELL_SIZE + Cell.BG]; + return this._data[index * Constants.CELL_INDICIES + Cell.BG]; } /** @@ -136,7 +136,7 @@ export class BufferLine implements IBufferLine { * from real empty cells. */ public hasContent(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK; + return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK; } /** @@ -145,7 +145,7 @@ export class BufferLine implements IBufferLine { * a single UTF32 codepoint or the last codepoint of a combined string. */ public getCodePoint(index: number): number { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { return this._combined[index].charCodeAt(this._combined[index].length - 1); } @@ -154,12 +154,12 @@ export class BufferLine implements IBufferLine { /** Test whether the cell contains a combined string. */ public isCombined(index: number): number { - return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK; + return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK; } /** Returns the string content of the cell. */ public getString(index: number): string { - const content = this._data[index * CELL_SIZE + Cell.CONTENT]; + const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { return this._combined[index]; } @@ -172,7 +172,7 @@ export class BufferLine implements IBufferLine { /** Get state of protected flag. */ public isProtected(index: number): number { - return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED; + return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED; } /** @@ -180,7 +180,7 @@ export class BufferLine implements IBufferLine { * to GC as it significantly reduced the amount of new objects/references needed. */ public loadCell(index: number, cell: ICellData): ICellData { - $startIndex = index * CELL_SIZE; + $startIndex = index * Constants.CELL_INDICIES; cell.content = this._data[$startIndex + Cell.CONTENT]; cell.fg = this._data[$startIndex + Cell.FG]; cell.bg = this._data[$startIndex + Cell.BG]; @@ -203,9 +203,9 @@ export class BufferLine implements IBufferLine { if (cell.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = cell.extended; } - this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content; - this._data[index * CELL_SIZE + Cell.FG] = cell.fg; - this._data[index * CELL_SIZE + Cell.BG] = cell.bg; + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content; + this._data[index * Constants.CELL_INDICIES + Cell.FG] = cell.fg; + this._data[index * Constants.CELL_INDICIES + Cell.BG] = cell.bg; } /** @@ -217,9 +217,9 @@ export class BufferLine implements IBufferLine { if (attrs.bg & BgFlags.HAS_EXTENDED) { this._extendedAttrs[index] = attrs.extended; } - this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); - this._data[index * CELL_SIZE + Cell.FG] = attrs.fg; - this._data[index * CELL_SIZE + Cell.BG] = attrs.bg; + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT); + this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg; + this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg; } /** @@ -229,7 +229,7 @@ export class BufferLine implements IBufferLine { * by the previous `setDataFromCodePoint` call, we can omit it here. */ public addCodepointToCell(index: number, codePoint: number, width: number): void { - let content = this._data[index * CELL_SIZE + Cell.CONTENT]; + let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT]; if (content & Content.IS_COMBINED_MASK) { // we already have a combined string, simply add this._combined[index] += stringFromCodePoint(codePoint); @@ -251,7 +251,7 @@ export class BufferLine implements IBufferLine { content &= ~Content.WIDTH_MASK; content |= width << Content.WIDTH_SHIFT; } - this._data[index * CELL_SIZE + Cell.CONTENT] = content; + this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content; } public insertCells(pos: number, n: number, fillCellData: ICellData): void { @@ -344,13 +344,13 @@ export class BufferLine implements IBufferLine { * The underlying array buffer will not change if there is still enough space * to hold the new buffer line data. * Returns a boolean indicating, whether a `cleanupMemory` call would free - * excess memory (true after shrinking > CLEANUP_THRESHOLD). + * excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD). */ public resize(cols: number, fillCellData: ICellData): boolean { if (cols === this.length) { - return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; } - const uint32Cells = cols * CELL_SIZE; + const uint32Cells = cols * Constants.CELL_INDICIES; if (cols > this.length) { if (this._data.buffer.byteLength >= uint32Cells * 4) { // optimization: avoid alloc and data copy if buffer has enough room @@ -385,17 +385,17 @@ export class BufferLine implements IBufferLine { } } this.length = cols; - return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength; + return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength; } /** * Cleanup underlying array buffer. * A cleanup will be triggered if the array buffer exceeds the actual used - * memory by a factor of CLEANUP_THRESHOLD. + * memory by a factor of Constants.CLEANUP_THRESHOLD. * Returns 0 or 1 indicating whether a cleanup happened. */ public cleanupMemory(): number { - if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) { + if (this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength) { const data = new Uint32Array(this._data.length); data.set(this._data); this._data = data; @@ -459,8 +459,8 @@ export class BufferLine implements IBufferLine { public getTrimmedLength(): number { for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) { - return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); + if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) { + return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT); } } return 0; @@ -468,8 +468,8 @@ export class BufferLine implements IBufferLine { public getNoBgTrimmedLength(): number { for (let i = this.length - 1; i >= 0; --i) { - if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) { - return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT); + if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK)) { + return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT); } } return 0; @@ -479,19 +479,19 @@ export class BufferLine implements IBufferLine { const srcData = src._data; if (applyInReverse) { for (let cell = length - 1; cell >= 0; cell--) { - for (let i = 0; i < CELL_SIZE; i++) { - this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; + for (let i = 0; i < Constants.CELL_INDICIES; i++) { + this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i]; } - if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { + if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) { this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; } } } else { for (let cell = 0; cell < length; cell++) { - for (let i = 0; i < CELL_SIZE; i++) { - this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i]; + for (let i = 0; i < Constants.CELL_INDICIES; i++) { + this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i]; } - if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) { + if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) { this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell]; } } @@ -531,7 +531,7 @@ export class BufferLine implements IBufferLine { } let result = ''; while (startCol < endCol) { - const content = this._data[startCol * CELL_SIZE + Cell.CONTENT]; + const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; result += chars; diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index c9db076d6..b936c9dbf 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -9,30 +9,30 @@ import { Emitter } from 'common/Event'; declare const setTimeout: (handler: () => void, timeout?: number) => void; -/** - * Safety watermark to avoid memory exhaustion and browser engine crash on fast data input. - * Enable flow control to avoid this limit and make sure that your backend correctly - * propagates this to the underlying pty. (see docs for further instructions) - * Since this limit is meant as a safety parachute to prevent browser crashs, - * it is set to a very high number. Typically xterm.js gets unresponsive with - * a 100 times lower number (>500 kB). - */ -const DISCARD_WATERMARK = 50000000; // ~50 MB - -/** - * The max number of ms to spend on writes before allowing the renderer to - * catch up with a 0ms setTimeout. A value of < 33 to keep us close to - * 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS - * depends on the time it takes for the renderer to draw the frame. - */ -const WRITE_TIMEOUT_MS = 12; - -/** - * Threshold of max held chunks in the write buffer, that were already processed. - * This is a tradeoff between extensive write buffer shifts (bad runtime) and high - * memory consumption by data thats not used anymore. - */ -const WRITE_BUFFER_LENGTH_THRESHOLD = 50; +const enum Constants { + /** + * Safety watermark to avoid memory exhaustion and browser engine crash on fast data input. + * Enable flow control to avoid this limit and make sure that your backend correctly + * propagates this to the underlying pty. (see docs for further instructions) + * Since this limit is meant as a safety parachute to prevent browser crashs, + * it is set to a very high number. Typically xterm.js gets unresponsive with + * a 100 times lower number (>500 kB). + */ + DISCARD_WATERMARK = 50000000, // ~50 MB + /** + * The max number of ms to spend on writes before allowing the renderer to + * catch up with a 0ms setTimeout. A value of < 33 to keep us close to + * 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS + * depends on the time it takes for the renderer to draw the frame. + */ + WRITE_TIMEOUT_MS = 12, + /** + * Threshold of max held chunks in the write buffer, that were already processed. + * This is a tradeoff between extensive write buffer shifts (bad runtime) and high + * memory consumption by data thats not used anymore. + */ + WRITE_BUFFER_LENGTH_THRESHOLD = 50 +} export class WriteBuffer extends Disposable { private _writeBuffer: (string | Uint8Array)[] = []; @@ -133,7 +133,7 @@ export class WriteBuffer extends Disposable { } public write(data: string | Uint8Array, callback?: () => void): void { - if (this._pendingData > DISCARD_WATERMARK) { + if (this._pendingData > Constants.DISCARD_WATERMARK) { throw new Error('write data discarded, use flow control to avoid losing data'); } @@ -169,7 +169,7 @@ export class WriteBuffer extends Disposable { * effectively lowering the redrawing needs, schematically: * * macroTask _innerWrite: - * if (performance.now() - (lastTime | 0) < WRITE_TIMEOUT_MS): + * if (performance.now() - (lastTime | 0) < Constants.WRITE_TIMEOUT_MS): * schedule microTask _innerWrite(lastTime) * else: * schedule macroTask _innerWrite(0) @@ -218,7 +218,7 @@ export class WriteBuffer extends Disposable { * responsibility to slice hard work), but we can at least schedule a screen update as we * gain control. */ - const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= WRITE_TIMEOUT_MS + const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS ? setTimeout(() => this._innerWrite(0, r)) : this._innerWrite(startTime, r); @@ -235,7 +235,7 @@ export class WriteBuffer extends Disposable { * current microtask queue (executed before setTimeout). */ // const continuation: (r: boolean) => void = performance.now() - startTime >= - // WRITE_TIMEOUT_MS + // Constants.WRITE_TIMEOUT_MS // ? r => setTimeout(() => this._innerWrite(0, r)) // : r => this._innerWrite(startTime, r); @@ -255,14 +255,14 @@ export class WriteBuffer extends Disposable { this._bufferOffset++; this._pendingData -= data.length; - if (performance.now() - startTime >= WRITE_TIMEOUT_MS) { + if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) { break; } } if (this._writeBuffer.length > this._bufferOffset) { // Allow renderer to catch up before processing the next batch // trim already processed chunks if we are above threshold - if (this._bufferOffset > WRITE_BUFFER_LENGTH_THRESHOLD) { + if (this._bufferOffset > Constants.WRITE_BUFFER_LENGTH_THRESHOLD) { this._writeBuffer = this._writeBuffer.slice(this._bufferOffset); this._callbacks = this._callbacks.slice(this._bufferOffset); this._bufferOffset = 0; diff --git a/src/common/parser/Params.ts b/src/common/parser/Params.ts index 31872a0b2..8a77c78a4 100644 --- a/src/common/parser/Params.ts +++ b/src/common/parser/Params.ts @@ -4,10 +4,16 @@ */ import { IParams, ParamsArray } from 'common/parser/Types'; -// max value supported for a single param/subparam (clamped to positive int32 range) -const MAX_VALUE = 0x7FFFFFFF; -// max allowed subparams for a single sequence (hardcoded limitation) -const MAX_SUBPARAMS = 256; +const enum Constants { + /** + * Max value supported for a single param/subparam (clamped to positive int32 range) + */ + MAX_VALUE = 0x7FFFFFFF, + /** + * Max allowed subparams for a single sequence (hardcoded limitation) + */ + MAX_SUBPARAMS = 256 +} /** * Params storage class. @@ -70,7 +76,7 @@ export class Params implements IParams { * @param maxSubParamsLength max length of storable sub parameters */ constructor(public maxLength: number = 32, public maxSubParamsLength: number = 32) { - if (maxSubParamsLength > MAX_SUBPARAMS) { + if (maxSubParamsLength > Constants.MAX_SUBPARAMS) { throw new Error('maxSubParamsLength must not be greater than 256'); } this.params = new Int32Array(maxLength); @@ -159,7 +165,7 @@ export class Params implements IParams { throw new Error('values lesser than -1 are not allowed'); } this._subParamsIdx[this.length] = this._subParamsLength << 8 | this._subParamsLength; - this.params[this.length++] = value > MAX_VALUE ? MAX_VALUE : value; + this.params[this.length++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value; } /** @@ -181,7 +187,7 @@ export class Params implements IParams { if (value < -1) { throw new Error('values lesser than -1 are not allowed'); } - this._subParams[this._subParamsLength++] = value > MAX_VALUE ? MAX_VALUE : value; + this._subParams[this._subParamsLength++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value; this._subParamsIdx[this.length - 1]++; } @@ -237,6 +243,6 @@ export class Params implements IParams { const store = this._digitIsSub ? this._subParams : this.params; const cur = store[length - 1]; - store[length - 1] = ~cur ? Math.min(cur * 10 + value, MAX_VALUE) : value; + store[length - 1] = ~cur ? Math.min(cur * 10 + value, Constants.MAX_VALUE) : value; } } diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 6a7ce2b34..06ac6b10a 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -10,8 +10,10 @@ import { IBuffer, IBufferSet } from 'common/buffer/Types'; import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; import { Emitter } from 'common/Event'; -export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars -export const MINIMUM_ROWS = 1; +export const enum BufferServiceConstants { + MINIMUM_COLS = 2, // Less than 2 can mess with wide chars + MINIMUM_ROWS = 1 +} export class BufferService extends Disposable implements IBufferService { public serviceBrand: any; @@ -37,8 +39,8 @@ export class BufferService extends Disposable implements IBufferService { @ILogService logService: ILogService ) { super(); - this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS); - this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS); + this.cols = Math.max(optionsService.rawOptions.cols || 0, BufferServiceConstants.MINIMUM_COLS); + this.rows = Math.max(optionsService.rawOptions.rows || 0, BufferServiceConstants.MINIMUM_ROWS); this.buffers = this._register(new BufferSet(optionsService, this, logService)); this._register(this.buffers.onBufferActivate(e => { this._onScroll.fire(e.activeBuffer.ydisp); diff --git a/src/common/services/ServiceRegistry.ts b/src/common/services/ServiceRegistry.ts index 7d887bc61..af51d1641 100644 --- a/src/common/services/ServiceRegistry.ts +++ b/src/common/services/ServiceRegistry.ts @@ -11,13 +11,15 @@ import { IServiceIdentifier } from 'common/services/Services'; -const DI_TARGET = 'di$target'; -const DI_DEPENDENCIES = 'di$dependencies'; +const enum Constants { + DI_TARGET = 'di$target', + DI_DEPENDENCIES = 'di$dependencies' +} export const serviceRegistry: Map> = new Map(); export function getServiceDependencies(ctor: any): { id: IServiceIdentifier, index: number, optional: boolean }[] { - return ctor[DI_DEPENDENCIES] || []; + return ctor[Constants.DI_DEPENDENCIES] || []; } export function createDecorator(id: string): IServiceIdentifier { @@ -40,10 +42,10 @@ export function createDecorator(id: string): IServiceIdentifier { } function storeServiceDependency(id: Function, target: Function, index: number): void { - if ((target as any)[DI_TARGET] === target) { - (target as any)[DI_DEPENDENCIES].push({ id, index }); + if ((target as any)[Constants.DI_TARGET] === target) { + (target as any)[Constants.DI_DEPENDENCIES].push({ id, index }); } else { - (target as any)[DI_DEPENDENCIES] = [{ id, index }]; - (target as any)[DI_TARGET] = target; + (target as any)[Constants.DI_DEPENDENCIES] = [{ id, index }]; + (target as any)[Constants.DI_TARGET] = target; } } From 2d3909be2c99cc07b01351a0912d0f4c63476cd4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 2 May 2026 06:54:41 -0700 Subject: [PATCH 39/39] Fix lint --- addons/addon-webgl/src/GlyphRenderer.ts | 2 +- src/common/buffer/BufferLine.ts | 14 ++++++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index be4ebb76b..568d3a4f9 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -80,7 +80,7 @@ void main() { const enum Constants { INDICES_PER_CELL = 11, - BYTES_PER_CELL = INDICES_PER_CELL * 4/*Float32Array.BYTES_PER_ELEMENT*/, + BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */, CELL_POSITION_INDICES = 2 } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index b554c29e8..be200f112 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -9,6 +9,12 @@ import { CellData } from 'common/buffer/CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; +// Buffer memory layout: +// +// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21) +// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8) +// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8) + const enum Constants { /** The number of 32 bit array indices taken by one cell. */ CELL_INDICIES = 3, @@ -64,14 +70,6 @@ export interface IBufferLineStringCache { * memory allocs / GC pressure can be greatly reduced by reusing the CellData object. */ export class BufferLine implements IBufferLine { - /** - * Buffer memory layout: - * - * | uint32_t | uint32_t | uint32_t | - * |----------------------------------------|---------------------------------|---------------------------------| - * | `content` | `FG` | `BG` | - * | `wcwidth(2)` `comb(1)` `codepoint(21)` | `flags(8)` `R(8)` `G(8)` `B(8)` | `flags(8)` `R(8)` `G(8)` `B(8)` | - */ protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};