From 3a59c8ec2b3657b67f1dc974552bb88242e1e81d Mon Sep 17 00:00:00 2001 From: sergiochan Date: Wed, 11 Mar 2026 00:36:17 -0700 Subject: [PATCH 01/90] Fix image layer resize jitter Disable desynchronized 2D canvas mode for image layers so resize paint stays synchronized and avoids blank-frame jitter on some platforms. Signed-off-by: sergiochan --- addons/addon-image/src/ImageRenderer.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index 53a0a9c9b..d1473ebe4 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -358,7 +358,8 @@ export class ImageRenderer extends Disposable implements IDisposable { canvas.style.zIndex = '0'; screenElement.appendChild(canvas); } - const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true }); + // Keep rendering synchronized with resize to avoid blank-frame jitter on some platforms. + const ctx = canvas.getContext('2d', { alpha: true, desynchronized: false }); if (!ctx) { canvas.remove(); return; From 5e9b2cbc14ff4d8fd20b68884959cebedced8fe4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 12 Mar 2026 09:47:51 +0900 Subject: [PATCH 02/90] Update ImageRenderer.ts --- addons/addon-image/src/ImageRenderer.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index d1473ebe4..79b83a977 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -358,8 +358,7 @@ export class ImageRenderer extends Disposable implements IDisposable { canvas.style.zIndex = '0'; screenElement.appendChild(canvas); } - // Keep rendering synchronized with resize to avoid blank-frame jitter on some platforms. - const ctx = canvas.getContext('2d', { alpha: true, desynchronized: false }); + const ctx = canvas.getContext('2d', { alpha: true }); if (!ctx) { canvas.remove(); return; From 4b77e601cdc2588548ee143914e59d1945992480 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 12 Apr 2026 08:49:48 +0000 Subject: [PATCH 03/90] Bump axios from 1.13.2 to 1.15.0 Bumps [axios](https://github.com/axios/axios) from 1.13.2 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.2...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- addons/addon-ligatures/package.json | 2 +- package-lock.json | 27 ++++++++++++++++----------- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index 5fb0c4f66..fc7a433de 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.6.0", + "axios": "^1.15.0", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" diff --git a/package-lock.json b/package-lock.json index 6130135fd..bef898100 100644 --- a/package-lock.json +++ b/package-lock.json @@ -94,7 +94,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.6.0", + "axios": "^1.15.0", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" @@ -2620,14 +2620,15 @@ } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "dev": true, + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -6655,10 +6656,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", From 69ee4ad23b10bdf8e3fe71a1644518f469ed0820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Mangeonjean?= Date: Mon, 13 Apr 2026 12:51:17 +0200 Subject: [PATCH 04/90] fix: use element window instead of global object --- src/browser/services/MouseCoordsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts index e2455fd2b..60f0a6ab9 100644 --- a/src/browser/services/MouseCoordsService.ts +++ b/src/browser/services/MouseCoordsService.ts @@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService { public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { return getCoords( - window, + getWindow(element), event, element, colCount, From 6e375cb588df052bc5afd32e07c4e08c7951f5f2 Mon Sep 17 00:00:00 2001 From: slim-forge Date: Tue, 21 Apr 2026 08:38:27 +0200 Subject: [PATCH 05/90] slim-forge: initial harness --- .gitignore | 4 + .slimforge.toml | 6 ++ bench/workload.js | 125 +++++++++++++++++++++++++++++ program.md | 135 +++++++++++++++++++++++++++++++ run.py | 198 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 468 insertions(+) create mode 100644 .slimforge.toml create mode 100644 bench/workload.js create mode 100644 program.md create mode 100644 run.py diff --git a/.gitignore b/.gitignore index e48470eff..46ca870a7 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ demo/dist/ # dont commit benchmark folders .benchmark/ timeline/ + +# slim-forge +results.tsv +run.log diff --git a/.slimforge.toml b/.slimforge.toml new file mode 100644 index 000000000..6de626274 --- /dev/null +++ b/.slimforge.toml @@ -0,0 +1,6 @@ +install_cmd = """npm install --no-audit --no-fund --loglevel=error""" +build_cmd = """npm run build && npm run esbuild""" +test_cmd = """npm run test-unit""" +bench_cmd = """node bench/workload.js""" +primary_bench_name = """weighted_mbps""" +target = "xterm" diff --git a/bench/workload.js b/bench/workload.js new file mode 100644 index 000000000..a4b731a4d --- /dev/null +++ b/bench/workload.js @@ -0,0 +1,125 @@ +'use strict'; + +// xterm.js EscapeSequenceParser throughput benchmark +// Runs against compiled output in out-esbuild/ +// Measures weighted MB/s across representative terminal workloads + +process.env.NODE_PATH = require('path').resolve(__dirname, '../out-esbuild'); +require('module').Module._initPaths(); + +var EscapeSequenceParser = require('common/parser/EscapeSequenceParser').EscapeSequenceParser; +var C0 = require('common/data/EscapeSequences').C0; + +function toUtf32(s) { + var result = new Uint32Array(s.length); + for (var i = 0; i < s.length; i++) { + result[i] = s.charCodeAt(i); + } + return result; +} + +function makeParser() { + var parser = new EscapeSequenceParser(); + parser.setPrintHandler(function() {}); + parser.setExecuteHandler(C0.LF, function() {}); + parser.setExecuteHandler(C0.CR, function() {}); + parser.setExecuteHandler('\t', function() {}); + parser.setExecuteHandler('\b', function() {}); + parser.registerCsiHandler({ final: 'm' }, function() { return true; }); + parser.registerCsiHandler({ final: 'H' }, function() { return true; }); + parser.registerCsiHandler({ final: 'J' }, function() { return true; }); + parser.registerCsiHandler({ final: 'K' }, function() { return true; }); + parser.registerCsiHandler({ final: 'A' }, function() { return true; }); + parser.registerCsiHandler({ final: 'B' }, function() { return true; }); + parser.registerCsiHandler({ final: 'C' }, function() { return true; }); + parser.registerCsiHandler({ final: 'D' }, function() { return true; }); + parser.registerEscHandler({ final: '7' }, function() { return true; }); + parser.registerEscHandler({ final: '8' }, function() { return true; }); + return parser; +} + +var SIZE = 2000000; + +var cases = [ + ['print_ascii', (function() { + var s = 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.\n'; + var content = ''; + while (content.length < SIZE) content += s; + return toUtf32(content); + })(), 4], + + ['sgr_heavy', (function() { + var s = '\x1b[0;32mfile.txt\x1b[0m \x1b[0;34mdir/\x1b[0m \x1b[1;31merror.log\x1b[0m\n'; + var content = ''; + while (content.length < SIZE) content += s; + return toUtf32(content); + })(), 4], + + ['cursor_move', (function() { + var s = '\x1b[1;1H\x1b[2JStatus: OK\x1b[2;1HLine 2 content here\x1b[3;1HAnother line\n'; + var content = ''; + while (content.length < SIZE) content += s; + return toUtf32(content); + })(), 2], + + ['params_heavy', (function() { + var s = '\x1b[38;5;196mred\x1b[0m \x1b[38;2;0;255;128mgreen\x1b[0m \x1b[48;5;21mblue_bg\x1b[0m\n'; + var content = ''; + while (content.length < SIZE) content += s; + return toUtf32(content); + })(), 3], + + ['mixed_realistic', (function() { + var lines = [ + '\x1b[33mabcd123\x1b[0m fix: resolve parser edge case\n', + '\x1b[33mef45678\x1b[0m feat: add new feature\n', + '\x1b[33m9abc012\x1b[0m \x1b[1;31m(HEAD -> main)\x1b[0m chore: update deps\n', + '\x1b[33m3def456\x1b[0m docs: update readme\n', + '\x1b[33m789abcd\x1b[0m refactor: clean up utils\n', + ]; + var content = ''; + while (content.length < SIZE) { + for (var j = 0; j < lines.length; j++) content += lines[j]; + } + return toUtf32(content); + })(), 4], +]; + +function benchOne(parser, data) { + for (var w = 0; w < 5; w++) parser.parse(data, data.length); + var runs = []; + for (var r = 0; r < 7; r++) { + var t0 = process.hrtime.bigint(); + parser.parse(data, data.length); + var dt = Number(process.hrtime.bigint() - t0) / 1e9; + var mbps = (data.length * 4) / (1024 * 1024) / dt; + runs.push(mbps); + } + runs.sort(function(a, b) { return a - b; }); + return runs[Math.floor(runs.length / 2)]; +} + +var parser = makeParser(); +var totalWeight = 0; +var weightedSum = 0; +var perCase = []; + +for (var c = 0; c < cases.length; c++) { + var name = cases[c][0]; + var data = cases[c][1]; + var weight = cases[c][2]; + var mbps = benchOne(parser, data); + perCase.push([name, mbps]); + weightedSum += mbps * weight; + totalWeight += weight; +} + +var weighted = weightedSum / totalWeight; + +process.stderr.write('per-case MB/s:\n'); +for (var p = 0; p < perCase.length; p++) { + process.stderr.write(' ' + perCase[p][0] + ': ' + perCase[p][1].toFixed(1) + '\n'); +} +process.stderr.write('weighted: ' + weighted.toFixed(1) + ' MB/s\n'); + +process.stdout.write(weighted.toFixed(1) + '\n'); diff --git a/program.md b/program.md new file mode 100644 index 000000000..4b8128be6 --- /dev/null +++ b/program.md @@ -0,0 +1,135 @@ +# slim-forge: xterm.js parser perf + +An experiment to autonomously speed up xterm.js's `EscapeSequenceParser`, the terminal escape sequence parser inside VS Code, GitHub Codespaces, and most web-based terminals. + +## Setup + +1. **Confirm the workspace**: fresh clone of `xtermjs/xterm.js`. Branch should be `autoresearch/xterm-`. Verify: `git status`, `git log --oneline -5`. +2. **Read the in-scope files**: + * `src/common/parser/EscapeSequenceParser.ts` — ~845 LOC. **THIS is what you optimize.** The main `parse()` method is the hot loop. Read the whole file. + * `src/common/parser/Params.ts` — parameter accumulation. Called from the PARAM action in the main loop. Secondary surface. + * `src/common/parser/Constants.ts` — `ParserState` and `ParserAction` enums. + * `src/common/parser/EscapeSequenceParser.test.ts` — ~2236 LOC of tests. Read before making changes. + * `bench/workload.js` — the benchmark. **Do NOT modify.** + * `run.py` — the harness. **Do NOT modify.** + * `program.md` — this file. +3. **Verify baseline exists**: `cat results.tsv`. One row, `baseline`, with a `mbps` value and `tests_pass: 1`. +4. **Confirm and go.** + +## Mission + +Primary metric: **`mbps`** — weighted throughput in MB/s across 5 representative terminal workloads (plain ASCII print, SGR-heavy colored output, cursor movement sequences, parameter-heavy truecolor, mixed realistic git-log-style output). Higher is better. + +Hard gates: +1. `npm run test-unit` passes (2261 mocha tests, ~6s). +2. The harness enforces `mbps > best_so_far * 1.05` for a row to be labeled `keep`. + +## Important: build pipeline + +xterm.js is TypeScript. Your edits go in `src/`. The build pipeline is: + +``` +npm run build # tsc: src/**/*.ts -> out/**/*.js +npm run esbuild # esbuild: out/ -> out-esbuild/ (what tests and bench consume) +``` + +`run.py` handles this automatically. But if you want to test manually: +``` +npm run build && npm run esbuild && npm run test-unit && node bench/workload.js +``` + +The agent edits `.ts` source. The bench runs against compiled `.js` in `out-esbuild/`. If your edit has a TS type error, `npm run build` fails and the experiment is `discard`. + +## What you CAN do + +* Edit `src/common/parser/EscapeSequenceParser.ts` — the main target. +* Edit `src/common/parser/Params.ts` — if parameter handling is the bottleneck. +* Edit `src/common/parser/OscParser.ts`, `DcsParser.ts`, `ApcParser.ts` — if those sub-parsers show up in the bench. +* Add internal helper functions or restructure the parse loop. + +## What you CANNOT do + +* Modify `src/common/parser/EscapeSequenceParser.test.ts` or any test file. +* Modify `bench/workload.js` or `run.py`. +* Modify `program.md`. +* Modify the `TransitionTable` initialization — the VT500 table is the spec-correct state machine. Changing transitions changes which sequences are recognized, which is a correctness violation even if tests pass. +* Cache parse results across calls. The parser is stateful (it maintains `currentState`), but each `parse()` call must process every byte of input. No memoization. +* Remove or weaken handler dispatch. The bench registers real handlers; skipping dispatch would be a benchmark exploit. + +## What the parser already does well + +Before you start, understand what's already optimized so you don't waste time re-discovering it: + +* **Transition table**: `Uint16Array` lookup — state × charCode → action + nextState. One array access per byte. This is already near-optimal for the state machine itself. +* **PRINT action loop unrolling**: 4× unrolled inner loop for consecutive printable characters. This is why `print_ascii` hits ~2500 MB/s. +* **PARAM inner loop**: do-while that stays in the PARAM action for consecutive digit/semicolon/colon bytes without re-entering the main switch. + +## Where to look for wins + +The bench breakdown shows where time is actually spent: + +* **`sgr_heavy`: ~84 MB/s** — 30× slower than print. Every SGR sequence goes through: state transitions for `ESC [ params m`, param parsing, CSI handler dispatch. The handler dispatch involves array lookups and function calls per sequence. +* **`params_heavy`: ~92 MB/s** — similar. Truecolor sequences have many params (e.g. `38;2;0;255;128`). +* **`cursor_move`: ~127 MB/s** — CSI sequences with fewer params but more state transitions per byte. +* **`mixed_realistic`: ~158 MB/s** — blend of print runs and SGR. + +Likely sources of real speedups: + +* **Reduce handler dispatch overhead.** The CSI dispatch does `this._csiHandlers[this._collect << 8 | code]` then iterates the handler array in reverse. For the common case of a single registered handler, the array iteration + `instanceof Promise` check is overhead. A fast-path for single-handler case could help. +* **Inline the PARAM parsing more aggressively.** The current do-while re-enters `this._params.addDigit()` / `addParam()` / `addSubParam()` per byte. If `Params` methods are not inlined by V8, each is a virtual dispatch. +* **Reduce property access in the hot loop.** `this._transitions`, `this.currentState`, `this._collect`, `this._params` — each `this.` access goes through the object's hidden class. Caching in locals at the top of `parse()` and writing back at the end could help. +* **Specialize the common case in the main switch.** PRINT and PARAM are the most common actions. If the switch has many cases, V8 may generate a jump table; if it has few hot cases, a nested if-else could be faster. +* **The `code < 0xa0 ? code : NON_ASCII_PRINTABLE` ternary** runs per byte. For ASCII-only input (the overwhelming common case), the branch is always taken. Consider a fast-path that skips the check when all input is < 0x80. + +Anti-patterns: +* Don't try to change the transition table layout (e.g. making it a 2D array). The `Uint16Array` with bit shifting is already the most cache-friendly layout. +* Don't try to replace the state machine with regex. The state machine handles streaming input correctly; regex can't. +* Don't optimize the async handler resume path (top of `parse()`). It's cold code. + +## Output format + +`run.py` prints: + +``` +--- +mbps: 693.3 +tests_passed: 1 +build_ok: 1 +install_seconds: 5.0 +build_seconds: 15.0 +test_seconds: 6.0 +best_so_far: 693.3 +threshold: 728.0 (best_so_far * 1.05) +auto-status: keep +``` + +Read the headline: `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log` + +Per-case breakdown is on stderr from the bench: `grep "per-case" -A 10 run.log` (or check the bench stderr output). + +## Logging results + +`python run.py --record --description ""` appends a row. The harness auto-labels status. + +Schema: `commit mbps tests_pass build_ok status description` + +## The experiment loop + +LOOP FOREVER: + +1. `git log --oneline -10` and `tail -10 results.tsv`. What's the current best? What has been tried? +2. Pick one optimization idea. Be specific. +3. Edit `src/common/parser/EscapeSequenceParser.ts` (or Params.ts). One change per experiment. +4. `git add -A && git commit -m "try: "` (commit BEFORE the record run). +5. `python run.py --record --description "" > run.log 2>&1` +6. `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log`. +7. Decision: + * `auto-status: keep` → the branch advances. + * `auto-status: discard` → `git reset --hard HEAD~1`. +8. Goto 1. + +**Build failures**: if `npm run build` fails, it's a TS type error from your edit. Read the tsc output in run.log, fix the type error or revert. + +**Verification for large wins**: if `auto-status: keep` on a >15% improvement, run `python run.py > /tmp/verify.log 2>&1` (no --record). If verify mbps is also clearly above threshold, it's real. If verify is close to baseline, it was noise — `git reset --hard HEAD~1`. + +**NEVER STOP**: once the loop has begun, do NOT pause. Run indefinitely until manually stopped. diff --git a/run.py b/run.py new file mode 100644 index 000000000..32dda4d0e --- /dev/null +++ b/run.py @@ -0,0 +1,198 @@ +""" +run.py — one experiment for slim-forge xterm.js mission. + +invoked by the agent each cycle: + python run.py + python run.py --record --description "tried X" + +pipeline: install -> build (tsc + esbuild) -> test-unit -> bench -> record + +bench script prints one float on stdout last line (weighted MB/s). +the harness enforces ops > best_so_far * 1.05 before labeling keep. + +NEVER edit this file. +""" + +from __future__ import annotations + +import argparse +import csv +import subprocess +import sys +import time +import tomllib +from pathlib import Path + +HERE = Path(__file__).parent.resolve() +THRESHOLD_MULT = 1.05 + + +def sh(cmd: str, timeout: int = 900) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, shell=True, cwd=HERE, text=True, capture_output=True, + timeout=timeout, check=False, + ) + + +def load_spec() -> dict: + p = HERE / ".slimforge.toml" + if not p.exists(): + print("error: .slimforge.toml missing. did you run prepare.py?", file=sys.stderr) + sys.exit(2) + with open(p, "rb") as f: + return tomllib.load(f) + + +def install(spec: dict) -> tuple[bool, float]: + t0 = time.time() + r = sh(spec["install_cmd"], timeout=600) + dt = time.time() - t0 + if r.returncode != 0: + print("[install stderr tail]", file=sys.stderr) + print(r.stderr[-2000:], file=sys.stderr) + return False, dt + return True, dt + + +def build(spec: dict) -> tuple[bool, float]: + cmd = spec.get("build_cmd", "").strip() + if not cmd: + return True, 0.0 + t0 = time.time() + r = sh(cmd, timeout=600) + dt = time.time() - t0 + if r.returncode != 0: + print("[build stderr tail]", file=sys.stderr) + print(r.stderr[-3000:], file=sys.stderr) + print("[build stdout tail]", file=sys.stderr) + print(r.stdout[-3000:], file=sys.stderr) + return False, dt + return True, dt + + +def run_tests(spec: dict) -> tuple[bool, float]: + t0 = time.time() + r = sh(spec["test_cmd"], timeout=600) + dt = time.time() - t0 + if r.returncode != 0: + print("[test stderr tail]", file=sys.stderr) + print(r.stderr[-3000:], file=sys.stderr) + print("[test stdout tail]", file=sys.stderr) + print(r.stdout[-3000:], file=sys.stderr) + return r.returncode == 0, dt + + +def run_bench(spec: dict) -> tuple[float, str]: + cmd = spec.get("bench_cmd", "").strip() + if not cmd: + return -1.0, "" + r = sh(cmd, timeout=300) + tail = (r.stdout + ("\n[stderr]\n" + r.stderr if r.stderr else ""))[-3000:] + if r.returncode != 0: + print("[bench failed, tail]", file=sys.stderr) + print(tail, file=sys.stderr) + return -1.0, tail + try: + return float(r.stdout.strip().splitlines()[-1]), tail + except (ValueError, IndexError): + print("[bench output unparseable, tail]", file=sys.stderr) + print(tail, file=sys.stderr) + return -1.0, tail + + +def compute_best_so_far() -> float: + tsv = HERE / "results.tsv" + if not tsv.exists(): + return 0.0 + best = 0.0 + with open(tsv) as f: + for row in csv.DictReader(f, delimiter="\t"): + if row.get("status") == "keep" and row.get("tests_pass") == "1": + try: + v = float(row["mbps"]) + if v > best: + best = v + except (ValueError, KeyError): + pass + return best + + +def git_sha() -> str: + r = sh("git rev-parse --short HEAD", timeout=10) + return r.stdout.strip() or "0000000" + + +def append_results_row( + sha: str, mbps: float, tests_pass: bool, build_ok: bool, + status: str, description: str, +) -> None: + desc = description.replace("\t", " ").replace("\n", " ").strip() + tsv = HERE / "results.tsv" + if not tsv.exists(): + tsv.write_text("commit\tmbps\ttests_pass\tbuild_ok\tstatus\tdescription\n") + with open(tsv, "a") as f: + f.write(f"{sha}\t{mbps:.1f}\t{int(tests_pass)}\t{int(build_ok)}\t{status}\t{desc}\n") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--record", action="store_true") + ap.add_argument("--description", default="") + ap.add_argument("--status", default=None) + args = ap.parse_args() + + spec = load_spec() + + install_ok, install_secs = install(spec) + if not install_ok: + print("---") + print("install: FAILED") + if args.record: + append_results_row(git_sha(), -1.0, False, False, + args.status or "crash", + args.description or "install failed") + return 1 + + build_ok, build_secs = build(spec) + if not build_ok: + print("---") + print("build: FAILED") + if args.record: + append_results_row(git_sha(), -1.0, False, False, + args.status or "discard", + args.description or "build failed") + return 1 + + tests_pass, test_secs = run_tests(spec) + primary_mbps, bench_tail = run_bench(spec) + + print("---") + print(f"mbps: {primary_mbps:.1f}") + print(f"tests_passed: {int(tests_pass)}") + print(f"build_ok: {int(build_ok)}") + print(f"install_seconds: {install_secs:.1f}") + print(f"build_seconds: {build_secs:.1f}") + print(f"test_seconds: {test_secs:.1f}") + + if args.record: + status = args.status + if status is None: + best = compute_best_so_far() + threshold = best * THRESHOLD_MULT + if not tests_pass: + status = "discard" + elif primary_mbps < threshold: + status = "discard" + else: + status = "keep" + print(f"best_so_far: {best:.1f}") + print(f"threshold: {threshold:.1f} (best_so_far * {THRESHOLD_MULT})") + print(f"auto-status: {status}") + append_results_row(git_sha(), primary_mbps, tests_pass, build_ok, + status, args.description) + + return 0 if tests_pass else 1 + + +if __name__ == "__main__": + sys.exit(main()) From 5d120de3d5a937b0df1e7d14bf1f1492c6799bf3 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Tue, 21 Apr 2026 08:50:49 +0200 Subject: [PATCH 07/90] try: CSI fast-path - scan entire ESC[params;final without table lookups --- src/common/parser/EscapeSequenceParser.ts | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index febb4878c..e78b6e836 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -649,6 +649,60 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP for (let i = start; i < length; ++i) { code = data[i]; + // CSI fast path: in GROUND seeing ESC [, consume entire CSI sequence without table lookups + if (code === 0x1b && this.currentState === ParserState.GROUND && i + 2 < length && data[i + 1] === 0x5b) { + this._params.reset(); + this._params.addParam(0); + this._collect = 0; + let k = i + 2; + let ch = data[k]; + if (ch >= 0x3c && ch <= 0x3f) { + this._collect = ch; + k++; + } + let csiComplete = false; + for (; k < length; k++) { + ch = data[k]; + if (ch >= 0x30 && ch <= 0x39) { + this._params.addDigit(ch - 48); + } else if (ch === 0x3b) { + this._params.addParam(0); + } else if (ch === 0x3a) { + this._params.addSubParam(-1); + } else if (ch >= 0x40 && ch <= 0x7e) { + const handlers = this._csiHandlers[this._collect << 8 | ch]; + let j = handlers ? handlers.length - 1 : -1; + for (; j >= 0; j--) { + handlerResult = handlers[j](this._params); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND; + this._preserveStack(ParserStackType.CSI, handlers, j, transition, k); + return handlerResult; + } + } + if (j < 0) { + this._csiHandlerFb(this._collect << 8 | ch, this._params); + } + this.precedingJoinState = 0; + i = k; + csiComplete = true; + break; + } else { + break; + } + } + if (csiComplete) continue; + // incomplete or unexpected byte — fall back to normal path + this._params.reset(); + this._params.addParam(0); + this._collect = 0; + this.currentState = ParserState.ESCAPE; + // let normal loop process '[' at i+1 + continue; + } + // normal transition & action lookup transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)]; switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) { From 487b27f512bb7add5a28aea75cacca4bf937ccc5 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Tue, 21 Apr 2026 09:26:07 +0200 Subject: [PATCH 08/90] try: scan print run after CSI fast-path dispatch --- src/common/parser/EscapeSequenceParser.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index e78b6e836..a0784fe8f 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -686,7 +686,16 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._csiHandlerFb(this._collect << 8 | ch, this._params); } this.precedingJoinState = 0; - i = k; + // scan ahead for print run after CSI dispatch + let pEnd = k + 1; + if (pEnd < length && data[pEnd] >= 0x20 && data[pEnd] <= 0x7e) { + for (++pEnd; pEnd < length; ++pEnd) { + const pc = data[pEnd]; + if (pc < 0x20 || (pc > 0x7e && pc < NON_ASCII_PRINTABLE)) break; + } + this._printHandler(data, k + 1, pEnd); + } + i = pEnd - 1; csiComplete = true; break; } else { From ba7e775a1731cbf30d1866c601e1dc4a88f81a6e Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Tue, 21 Apr 2026 11:19:32 +0200 Subject: [PATCH 09/90] fix: move CSI fast-path into CLEAR action to eliminate non-CSI regression The CSI fast-path check previously ran on every byte of input, causing 3-18% regressions on ESCAPE, OSC, DCS, and other non-CSI sequences. Moving the check into the CLEAR action handler means it only evaluates after the state machine already identified an ESC byte. The fast-path body is extracted into a separate _csiFast method to keep the switch case body small for V8 optimization. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/common/parser/EscapeSequenceParser.ts | 128 +++++++++++----------- 1 file changed, 65 insertions(+), 63 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index a0784fe8f..68cb369e1 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -492,6 +492,59 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._parseStack.chunkPos = chunkPos; } + /** + * CSI fast path: scan params and dispatch from position `start` (first byte after '['). + * Returns new `i` position (>= 0) on success, -1 on failure/incomplete. + * Stores a Promise in `_parseStack` and returns -2 for async handlers. + */ + private _csiFast(data: Uint32Array, start: number, length: number): number | Promise { + let k = start; + let ch = data[k]; + if (ch >= 0x3c && ch <= 0x3f) { + this._collect = ch; + k++; + } + for (; k < length; k++) { + ch = data[k]; + if (ch >= 0x30 && ch <= 0x39) { + this._params.addDigit(ch - 48); + } else if (ch === 0x3b) { + this._params.addParam(0); + } else if (ch === 0x3a) { + this._params.addSubParam(-1); + } else if (ch >= 0x40 && ch <= 0x7e) { + const handlers = this._csiHandlers[this._collect << 8 | ch]; + let j = handlers ? handlers.length - 1 : -1; + for (; j >= 0; j--) { + const handlerResult = handlers[j](this._params); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + const transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND; + this._preserveStack(ParserStackType.CSI, handlers, j, transition, k); + return handlerResult; + } + } + if (j < 0) { + this._csiHandlerFb(this._collect << 8 | ch, this._params); + } + this.precedingJoinState = 0; + let pEnd = k + 1; + if (pEnd < length && data[pEnd] >= 0x20 && data[pEnd] <= 0x7e) { + for (++pEnd; pEnd < length; ++pEnd) { + const pc = data[pEnd]; + if (pc < 0x20 || (pc > 0x7e && pc < NON_ASCII_PRINTABLE)) break; + } + this._printHandler(data, k + 1, pEnd); + } + return pEnd - 1; + } else { + break; + } + } + return -1; + } + /** * Parse UTF32 codepoints in `data` up to `length`. * @@ -649,69 +702,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP for (let i = start; i < length; ++i) { code = data[i]; - // CSI fast path: in GROUND seeing ESC [, consume entire CSI sequence without table lookups - if (code === 0x1b && this.currentState === ParserState.GROUND && i + 2 < length && data[i + 1] === 0x5b) { - this._params.reset(); - this._params.addParam(0); - this._collect = 0; - let k = i + 2; - let ch = data[k]; - if (ch >= 0x3c && ch <= 0x3f) { - this._collect = ch; - k++; - } - let csiComplete = false; - for (; k < length; k++) { - ch = data[k]; - if (ch >= 0x30 && ch <= 0x39) { - this._params.addDigit(ch - 48); - } else if (ch === 0x3b) { - this._params.addParam(0); - } else if (ch === 0x3a) { - this._params.addSubParam(-1); - } else if (ch >= 0x40 && ch <= 0x7e) { - const handlers = this._csiHandlers[this._collect << 8 | ch]; - let j = handlers ? handlers.length - 1 : -1; - for (; j >= 0; j--) { - handlerResult = handlers[j](this._params); - if (handlerResult === true) { - break; - } else if (handlerResult instanceof Promise) { - transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND; - this._preserveStack(ParserStackType.CSI, handlers, j, transition, k); - return handlerResult; - } - } - if (j < 0) { - this._csiHandlerFb(this._collect << 8 | ch, this._params); - } - this.precedingJoinState = 0; - // scan ahead for print run after CSI dispatch - let pEnd = k + 1; - if (pEnd < length && data[pEnd] >= 0x20 && data[pEnd] <= 0x7e) { - for (++pEnd; pEnd < length; ++pEnd) { - const pc = data[pEnd]; - if (pc < 0x20 || (pc > 0x7e && pc < NON_ASCII_PRINTABLE)) break; - } - this._printHandler(data, k + 1, pEnd); - } - i = pEnd - 1; - csiComplete = true; - break; - } else { - break; - } - } - if (csiComplete) continue; - // incomplete or unexpected byte — fall back to normal path - this._params.reset(); - this._params.addParam(0); - this._collect = 0; - this.currentState = ParserState.ESCAPE; - // let normal loop process '[' at i+1 - continue; - } - // normal transition & action lookup transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)]; switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) { @@ -824,6 +814,18 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._params.reset(); this._params.addParam(0); // ZDM this._collect = 0; + if ((transition & TableAccess.TRANSITION_STATE_MASK) === ParserState.ESCAPE + && i + 2 < length && data[i + 1] === 0x5b) { + const csiFastResult = this._csiFast(data, i + 2, length); + if (csiFastResult instanceof Promise) { + return csiFastResult; + } + if (csiFastResult >= 0) { + i = csiFastResult as number; + this.currentState = ParserState.GROUND; + continue; + } + } break; case ParserAction.DCS_HOOK: this._dcsParser.hook(this._collect << 8 | code, this._params); From 6fd7ecc8128f69fd6c3bc1d302980b55b8939626 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 14:01:19 +0200 Subject: [PATCH 10/90] perf: move CSI fast-path to top of main loop, add EXE fast-path, improve PRINT unrolling Address reviewer feedback: - CSI fast-path is now a condition at the top of the codepoint loop instead of inside CLEAR, avoiding table lookups for the common case - EXE bytes < 0x18 get a fast-path before the CSI check so control bytes don't pay the penalty - PRINT loop unrolling replaced with while-based version that uses trueish eval and spares individual length checks in the unrolled case - Removed misplaced PRINT scan-ahead from CSI dispatch --- .gitignore | 4 - .slimforge.toml | 6 - bench/workload.js | 125 -------------- program.md | 135 --------------- run.py | 198 ---------------------- src/common/parser/EscapeSequenceParser.ts | 173 ++++++++++--------- 6 files changed, 85 insertions(+), 556 deletions(-) delete mode 100644 .slimforge.toml delete mode 100644 bench/workload.js delete mode 100644 program.md delete mode 100644 run.py diff --git a/.gitignore b/.gitignore index 46ca870a7..e48470eff 100644 --- a/.gitignore +++ b/.gitignore @@ -29,7 +29,3 @@ demo/dist/ # dont commit benchmark folders .benchmark/ timeline/ - -# slim-forge -results.tsv -run.log diff --git a/.slimforge.toml b/.slimforge.toml deleted file mode 100644 index 6de626274..000000000 --- a/.slimforge.toml +++ /dev/null @@ -1,6 +0,0 @@ -install_cmd = """npm install --no-audit --no-fund --loglevel=error""" -build_cmd = """npm run build && npm run esbuild""" -test_cmd = """npm run test-unit""" -bench_cmd = """node bench/workload.js""" -primary_bench_name = """weighted_mbps""" -target = "xterm" diff --git a/bench/workload.js b/bench/workload.js deleted file mode 100644 index a4b731a4d..000000000 --- a/bench/workload.js +++ /dev/null @@ -1,125 +0,0 @@ -'use strict'; - -// xterm.js EscapeSequenceParser throughput benchmark -// Runs against compiled output in out-esbuild/ -// Measures weighted MB/s across representative terminal workloads - -process.env.NODE_PATH = require('path').resolve(__dirname, '../out-esbuild'); -require('module').Module._initPaths(); - -var EscapeSequenceParser = require('common/parser/EscapeSequenceParser').EscapeSequenceParser; -var C0 = require('common/data/EscapeSequences').C0; - -function toUtf32(s) { - var result = new Uint32Array(s.length); - for (var i = 0; i < s.length; i++) { - result[i] = s.charCodeAt(i); - } - return result; -} - -function makeParser() { - var parser = new EscapeSequenceParser(); - parser.setPrintHandler(function() {}); - parser.setExecuteHandler(C0.LF, function() {}); - parser.setExecuteHandler(C0.CR, function() {}); - parser.setExecuteHandler('\t', function() {}); - parser.setExecuteHandler('\b', function() {}); - parser.registerCsiHandler({ final: 'm' }, function() { return true; }); - parser.registerCsiHandler({ final: 'H' }, function() { return true; }); - parser.registerCsiHandler({ final: 'J' }, function() { return true; }); - parser.registerCsiHandler({ final: 'K' }, function() { return true; }); - parser.registerCsiHandler({ final: 'A' }, function() { return true; }); - parser.registerCsiHandler({ final: 'B' }, function() { return true; }); - parser.registerCsiHandler({ final: 'C' }, function() { return true; }); - parser.registerCsiHandler({ final: 'D' }, function() { return true; }); - parser.registerEscHandler({ final: '7' }, function() { return true; }); - parser.registerEscHandler({ final: '8' }, function() { return true; }); - return parser; -} - -var SIZE = 2000000; - -var cases = [ - ['print_ascii', (function() { - var s = 'The quick brown fox jumps over the lazy dog. Pack my box with five dozen liquor jugs.\n'; - var content = ''; - while (content.length < SIZE) content += s; - return toUtf32(content); - })(), 4], - - ['sgr_heavy', (function() { - var s = '\x1b[0;32mfile.txt\x1b[0m \x1b[0;34mdir/\x1b[0m \x1b[1;31merror.log\x1b[0m\n'; - var content = ''; - while (content.length < SIZE) content += s; - return toUtf32(content); - })(), 4], - - ['cursor_move', (function() { - var s = '\x1b[1;1H\x1b[2JStatus: OK\x1b[2;1HLine 2 content here\x1b[3;1HAnother line\n'; - var content = ''; - while (content.length < SIZE) content += s; - return toUtf32(content); - })(), 2], - - ['params_heavy', (function() { - var s = '\x1b[38;5;196mred\x1b[0m \x1b[38;2;0;255;128mgreen\x1b[0m \x1b[48;5;21mblue_bg\x1b[0m\n'; - var content = ''; - while (content.length < SIZE) content += s; - return toUtf32(content); - })(), 3], - - ['mixed_realistic', (function() { - var lines = [ - '\x1b[33mabcd123\x1b[0m fix: resolve parser edge case\n', - '\x1b[33mef45678\x1b[0m feat: add new feature\n', - '\x1b[33m9abc012\x1b[0m \x1b[1;31m(HEAD -> main)\x1b[0m chore: update deps\n', - '\x1b[33m3def456\x1b[0m docs: update readme\n', - '\x1b[33m789abcd\x1b[0m refactor: clean up utils\n', - ]; - var content = ''; - while (content.length < SIZE) { - for (var j = 0; j < lines.length; j++) content += lines[j]; - } - return toUtf32(content); - })(), 4], -]; - -function benchOne(parser, data) { - for (var w = 0; w < 5; w++) parser.parse(data, data.length); - var runs = []; - for (var r = 0; r < 7; r++) { - var t0 = process.hrtime.bigint(); - parser.parse(data, data.length); - var dt = Number(process.hrtime.bigint() - t0) / 1e9; - var mbps = (data.length * 4) / (1024 * 1024) / dt; - runs.push(mbps); - } - runs.sort(function(a, b) { return a - b; }); - return runs[Math.floor(runs.length / 2)]; -} - -var parser = makeParser(); -var totalWeight = 0; -var weightedSum = 0; -var perCase = []; - -for (var c = 0; c < cases.length; c++) { - var name = cases[c][0]; - var data = cases[c][1]; - var weight = cases[c][2]; - var mbps = benchOne(parser, data); - perCase.push([name, mbps]); - weightedSum += mbps * weight; - totalWeight += weight; -} - -var weighted = weightedSum / totalWeight; - -process.stderr.write('per-case MB/s:\n'); -for (var p = 0; p < perCase.length; p++) { - process.stderr.write(' ' + perCase[p][0] + ': ' + perCase[p][1].toFixed(1) + '\n'); -} -process.stderr.write('weighted: ' + weighted.toFixed(1) + ' MB/s\n'); - -process.stdout.write(weighted.toFixed(1) + '\n'); diff --git a/program.md b/program.md deleted file mode 100644 index 4b8128be6..000000000 --- a/program.md +++ /dev/null @@ -1,135 +0,0 @@ -# slim-forge: xterm.js parser perf - -An experiment to autonomously speed up xterm.js's `EscapeSequenceParser`, the terminal escape sequence parser inside VS Code, GitHub Codespaces, and most web-based terminals. - -## Setup - -1. **Confirm the workspace**: fresh clone of `xtermjs/xterm.js`. Branch should be `autoresearch/xterm-`. Verify: `git status`, `git log --oneline -5`. -2. **Read the in-scope files**: - * `src/common/parser/EscapeSequenceParser.ts` — ~845 LOC. **THIS is what you optimize.** The main `parse()` method is the hot loop. Read the whole file. - * `src/common/parser/Params.ts` — parameter accumulation. Called from the PARAM action in the main loop. Secondary surface. - * `src/common/parser/Constants.ts` — `ParserState` and `ParserAction` enums. - * `src/common/parser/EscapeSequenceParser.test.ts` — ~2236 LOC of tests. Read before making changes. - * `bench/workload.js` — the benchmark. **Do NOT modify.** - * `run.py` — the harness. **Do NOT modify.** - * `program.md` — this file. -3. **Verify baseline exists**: `cat results.tsv`. One row, `baseline`, with a `mbps` value and `tests_pass: 1`. -4. **Confirm and go.** - -## Mission - -Primary metric: **`mbps`** — weighted throughput in MB/s across 5 representative terminal workloads (plain ASCII print, SGR-heavy colored output, cursor movement sequences, parameter-heavy truecolor, mixed realistic git-log-style output). Higher is better. - -Hard gates: -1. `npm run test-unit` passes (2261 mocha tests, ~6s). -2. The harness enforces `mbps > best_so_far * 1.05` for a row to be labeled `keep`. - -## Important: build pipeline - -xterm.js is TypeScript. Your edits go in `src/`. The build pipeline is: - -``` -npm run build # tsc: src/**/*.ts -> out/**/*.js -npm run esbuild # esbuild: out/ -> out-esbuild/ (what tests and bench consume) -``` - -`run.py` handles this automatically. But if you want to test manually: -``` -npm run build && npm run esbuild && npm run test-unit && node bench/workload.js -``` - -The agent edits `.ts` source. The bench runs against compiled `.js` in `out-esbuild/`. If your edit has a TS type error, `npm run build` fails and the experiment is `discard`. - -## What you CAN do - -* Edit `src/common/parser/EscapeSequenceParser.ts` — the main target. -* Edit `src/common/parser/Params.ts` — if parameter handling is the bottleneck. -* Edit `src/common/parser/OscParser.ts`, `DcsParser.ts`, `ApcParser.ts` — if those sub-parsers show up in the bench. -* Add internal helper functions or restructure the parse loop. - -## What you CANNOT do - -* Modify `src/common/parser/EscapeSequenceParser.test.ts` or any test file. -* Modify `bench/workload.js` or `run.py`. -* Modify `program.md`. -* Modify the `TransitionTable` initialization — the VT500 table is the spec-correct state machine. Changing transitions changes which sequences are recognized, which is a correctness violation even if tests pass. -* Cache parse results across calls. The parser is stateful (it maintains `currentState`), but each `parse()` call must process every byte of input. No memoization. -* Remove or weaken handler dispatch. The bench registers real handlers; skipping dispatch would be a benchmark exploit. - -## What the parser already does well - -Before you start, understand what's already optimized so you don't waste time re-discovering it: - -* **Transition table**: `Uint16Array` lookup — state × charCode → action + nextState. One array access per byte. This is already near-optimal for the state machine itself. -* **PRINT action loop unrolling**: 4× unrolled inner loop for consecutive printable characters. This is why `print_ascii` hits ~2500 MB/s. -* **PARAM inner loop**: do-while that stays in the PARAM action for consecutive digit/semicolon/colon bytes without re-entering the main switch. - -## Where to look for wins - -The bench breakdown shows where time is actually spent: - -* **`sgr_heavy`: ~84 MB/s** — 30× slower than print. Every SGR sequence goes through: state transitions for `ESC [ params m`, param parsing, CSI handler dispatch. The handler dispatch involves array lookups and function calls per sequence. -* **`params_heavy`: ~92 MB/s** — similar. Truecolor sequences have many params (e.g. `38;2;0;255;128`). -* **`cursor_move`: ~127 MB/s** — CSI sequences with fewer params but more state transitions per byte. -* **`mixed_realistic`: ~158 MB/s** — blend of print runs and SGR. - -Likely sources of real speedups: - -* **Reduce handler dispatch overhead.** The CSI dispatch does `this._csiHandlers[this._collect << 8 | code]` then iterates the handler array in reverse. For the common case of a single registered handler, the array iteration + `instanceof Promise` check is overhead. A fast-path for single-handler case could help. -* **Inline the PARAM parsing more aggressively.** The current do-while re-enters `this._params.addDigit()` / `addParam()` / `addSubParam()` per byte. If `Params` methods are not inlined by V8, each is a virtual dispatch. -* **Reduce property access in the hot loop.** `this._transitions`, `this.currentState`, `this._collect`, `this._params` — each `this.` access goes through the object's hidden class. Caching in locals at the top of `parse()` and writing back at the end could help. -* **Specialize the common case in the main switch.** PRINT and PARAM are the most common actions. If the switch has many cases, V8 may generate a jump table; if it has few hot cases, a nested if-else could be faster. -* **The `code < 0xa0 ? code : NON_ASCII_PRINTABLE` ternary** runs per byte. For ASCII-only input (the overwhelming common case), the branch is always taken. Consider a fast-path that skips the check when all input is < 0x80. - -Anti-patterns: -* Don't try to change the transition table layout (e.g. making it a 2D array). The `Uint16Array` with bit shifting is already the most cache-friendly layout. -* Don't try to replace the state machine with regex. The state machine handles streaming input correctly; regex can't. -* Don't optimize the async handler resume path (top of `parse()`). It's cold code. - -## Output format - -`run.py` prints: - -``` ---- -mbps: 693.3 -tests_passed: 1 -build_ok: 1 -install_seconds: 5.0 -build_seconds: 15.0 -test_seconds: 6.0 -best_so_far: 693.3 -threshold: 728.0 (best_so_far * 1.05) -auto-status: keep -``` - -Read the headline: `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log` - -Per-case breakdown is on stderr from the bench: `grep "per-case" -A 10 run.log` (or check the bench stderr output). - -## Logging results - -`python run.py --record --description ""` appends a row. The harness auto-labels status. - -Schema: `commit mbps tests_pass build_ok status description` - -## The experiment loop - -LOOP FOREVER: - -1. `git log --oneline -10` and `tail -10 results.tsv`. What's the current best? What has been tried? -2. Pick one optimization idea. Be specific. -3. Edit `src/common/parser/EscapeSequenceParser.ts` (or Params.ts). One change per experiment. -4. `git add -A && git commit -m "try: "` (commit BEFORE the record run). -5. `python run.py --record --description "" > run.log 2>&1` -6. `grep "^mbps:\|^tests_passed:\|^auto-status:" run.log`. -7. Decision: - * `auto-status: keep` → the branch advances. - * `auto-status: discard` → `git reset --hard HEAD~1`. -8. Goto 1. - -**Build failures**: if `npm run build` fails, it's a TS type error from your edit. Read the tsc output in run.log, fix the type error or revert. - -**Verification for large wins**: if `auto-status: keep` on a >15% improvement, run `python run.py > /tmp/verify.log 2>&1` (no --record). If verify mbps is also clearly above threshold, it's real. If verify is close to baseline, it was noise — `git reset --hard HEAD~1`. - -**NEVER STOP**: once the loop has begun, do NOT pause. Run indefinitely until manually stopped. diff --git a/run.py b/run.py deleted file mode 100644 index 32dda4d0e..000000000 --- a/run.py +++ /dev/null @@ -1,198 +0,0 @@ -""" -run.py — one experiment for slim-forge xterm.js mission. - -invoked by the agent each cycle: - python run.py - python run.py --record --description "tried X" - -pipeline: install -> build (tsc + esbuild) -> test-unit -> bench -> record - -bench script prints one float on stdout last line (weighted MB/s). -the harness enforces ops > best_so_far * 1.05 before labeling keep. - -NEVER edit this file. -""" - -from __future__ import annotations - -import argparse -import csv -import subprocess -import sys -import time -import tomllib -from pathlib import Path - -HERE = Path(__file__).parent.resolve() -THRESHOLD_MULT = 1.05 - - -def sh(cmd: str, timeout: int = 900) -> subprocess.CompletedProcess: - return subprocess.run( - cmd, shell=True, cwd=HERE, text=True, capture_output=True, - timeout=timeout, check=False, - ) - - -def load_spec() -> dict: - p = HERE / ".slimforge.toml" - if not p.exists(): - print("error: .slimforge.toml missing. did you run prepare.py?", file=sys.stderr) - sys.exit(2) - with open(p, "rb") as f: - return tomllib.load(f) - - -def install(spec: dict) -> tuple[bool, float]: - t0 = time.time() - r = sh(spec["install_cmd"], timeout=600) - dt = time.time() - t0 - if r.returncode != 0: - print("[install stderr tail]", file=sys.stderr) - print(r.stderr[-2000:], file=sys.stderr) - return False, dt - return True, dt - - -def build(spec: dict) -> tuple[bool, float]: - cmd = spec.get("build_cmd", "").strip() - if not cmd: - return True, 0.0 - t0 = time.time() - r = sh(cmd, timeout=600) - dt = time.time() - t0 - if r.returncode != 0: - print("[build stderr tail]", file=sys.stderr) - print(r.stderr[-3000:], file=sys.stderr) - print("[build stdout tail]", file=sys.stderr) - print(r.stdout[-3000:], file=sys.stderr) - return False, dt - return True, dt - - -def run_tests(spec: dict) -> tuple[bool, float]: - t0 = time.time() - r = sh(spec["test_cmd"], timeout=600) - dt = time.time() - t0 - if r.returncode != 0: - print("[test stderr tail]", file=sys.stderr) - print(r.stderr[-3000:], file=sys.stderr) - print("[test stdout tail]", file=sys.stderr) - print(r.stdout[-3000:], file=sys.stderr) - return r.returncode == 0, dt - - -def run_bench(spec: dict) -> tuple[float, str]: - cmd = spec.get("bench_cmd", "").strip() - if not cmd: - return -1.0, "" - r = sh(cmd, timeout=300) - tail = (r.stdout + ("\n[stderr]\n" + r.stderr if r.stderr else ""))[-3000:] - if r.returncode != 0: - print("[bench failed, tail]", file=sys.stderr) - print(tail, file=sys.stderr) - return -1.0, tail - try: - return float(r.stdout.strip().splitlines()[-1]), tail - except (ValueError, IndexError): - print("[bench output unparseable, tail]", file=sys.stderr) - print(tail, file=sys.stderr) - return -1.0, tail - - -def compute_best_so_far() -> float: - tsv = HERE / "results.tsv" - if not tsv.exists(): - return 0.0 - best = 0.0 - with open(tsv) as f: - for row in csv.DictReader(f, delimiter="\t"): - if row.get("status") == "keep" and row.get("tests_pass") == "1": - try: - v = float(row["mbps"]) - if v > best: - best = v - except (ValueError, KeyError): - pass - return best - - -def git_sha() -> str: - r = sh("git rev-parse --short HEAD", timeout=10) - return r.stdout.strip() or "0000000" - - -def append_results_row( - sha: str, mbps: float, tests_pass: bool, build_ok: bool, - status: str, description: str, -) -> None: - desc = description.replace("\t", " ").replace("\n", " ").strip() - tsv = HERE / "results.tsv" - if not tsv.exists(): - tsv.write_text("commit\tmbps\ttests_pass\tbuild_ok\tstatus\tdescription\n") - with open(tsv, "a") as f: - f.write(f"{sha}\t{mbps:.1f}\t{int(tests_pass)}\t{int(build_ok)}\t{status}\t{desc}\n") - - -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--record", action="store_true") - ap.add_argument("--description", default="") - ap.add_argument("--status", default=None) - args = ap.parse_args() - - spec = load_spec() - - install_ok, install_secs = install(spec) - if not install_ok: - print("---") - print("install: FAILED") - if args.record: - append_results_row(git_sha(), -1.0, False, False, - args.status or "crash", - args.description or "install failed") - return 1 - - build_ok, build_secs = build(spec) - if not build_ok: - print("---") - print("build: FAILED") - if args.record: - append_results_row(git_sha(), -1.0, False, False, - args.status or "discard", - args.description or "build failed") - return 1 - - tests_pass, test_secs = run_tests(spec) - primary_mbps, bench_tail = run_bench(spec) - - print("---") - print(f"mbps: {primary_mbps:.1f}") - print(f"tests_passed: {int(tests_pass)}") - print(f"build_ok: {int(build_ok)}") - print(f"install_seconds: {install_secs:.1f}") - print(f"build_seconds: {build_secs:.1f}") - print(f"test_seconds: {test_secs:.1f}") - - if args.record: - status = args.status - if status is None: - best = compute_best_so_far() - threshold = best * THRESHOLD_MULT - if not tests_pass: - status = "discard" - elif primary_mbps < threshold: - status = "discard" - else: - status = "keep" - print(f"best_so_far: {best:.1f}") - print(f"threshold: {threshold:.1f} (best_so_far * {THRESHOLD_MULT})") - print(f"auto-status: {status}") - append_results_row(git_sha(), primary_mbps, tests_pass, build_ok, - status, args.description) - - return 0 if tests_pass else 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 68cb369e1..014b46b75 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -492,59 +492,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._parseStack.chunkPos = chunkPos; } - /** - * CSI fast path: scan params and dispatch from position `start` (first byte after '['). - * Returns new `i` position (>= 0) on success, -1 on failure/incomplete. - * Stores a Promise in `_parseStack` and returns -2 for async handlers. - */ - private _csiFast(data: Uint32Array, start: number, length: number): number | Promise { - let k = start; - let ch = data[k]; - if (ch >= 0x3c && ch <= 0x3f) { - this._collect = ch; - k++; - } - for (; k < length; k++) { - ch = data[k]; - if (ch >= 0x30 && ch <= 0x39) { - this._params.addDigit(ch - 48); - } else if (ch === 0x3b) { - this._params.addParam(0); - } else if (ch === 0x3a) { - this._params.addSubParam(-1); - } else if (ch >= 0x40 && ch <= 0x7e) { - const handlers = this._csiHandlers[this._collect << 8 | ch]; - let j = handlers ? handlers.length - 1 : -1; - for (; j >= 0; j--) { - const handlerResult = handlers[j](this._params); - if (handlerResult === true) { - break; - } else if (handlerResult instanceof Promise) { - const transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND; - this._preserveStack(ParserStackType.CSI, handlers, j, transition, k); - return handlerResult; - } - } - if (j < 0) { - this._csiHandlerFb(this._collect << 8 | ch, this._params); - } - this.precedingJoinState = 0; - let pEnd = k + 1; - if (pEnd < length && data[pEnd] >= 0x20 && data[pEnd] <= 0x7e) { - for (++pEnd; pEnd < length; ++pEnd) { - const pc = data[pEnd]; - if (pc < 0x20 || (pc > 0x7e && pc < NON_ASCII_PRINTABLE)) break; - } - this._printHandler(data, k + 1, pEnd); - } - return pEnd - 1; - } else { - break; - } - } - return -1; - } - /** * Parse UTF32 codepoints in `data` up to `length`. * @@ -702,34 +649,96 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP for (let i = start; i < length; ++i) { code = data[i]; + // EXE fast-path: common control bytes (0x00-0x17) in non-payload states + if (code < 0x18 && this.currentState <= ParserState.CSI_IGNORE) { + if (this._executeHandlers[code]) this._executeHandlers[code](); + else this._executeHandlerFb(code); + this.precedingJoinState = 0; + continue; + } + + // CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop + if (code === 0x1b + && this.currentState !== ParserState.OSC_STRING + && this.currentState !== ParserState.DCS_PASSTHROUGH + && this.currentState !== ParserState.APC_STRING + && i + 1 < length && data[i + 1] === 0x5b + ) { + this._params.reset(); + this._params.addParam(0); // ZDM + this._collect = 0; + let k = i + 2; + if (k >= length) { i = k - 1; this.currentState = ParserState.CSI_ENTRY; continue; } + let ch = data[k]; + if (ch >= 0x3c && ch <= 0x3f) { + this._collect = ch; + if (++k >= length) { i = k - 1; this.currentState = ParserState.CSI_PARAM; continue; } + } + let csiDone = false; + for (; k < length; k++) { + ch = data[k]; + if (ch >= 0x30 && ch <= 0x39) { + this._params.addDigit(ch - 48); + } else if (ch === 0x3b) { + this._params.addParam(0); + } else if (ch === 0x3a) { + this._params.addSubParam(-1); + } else if (ch >= 0x40 && ch <= 0x7e) { + const handlers = this._csiHandlers[this._collect << 8 | ch]; + let j = handlers ? handlers.length - 1 : -1; + for (; j >= 0; j--) { + handlerResult = handlers[j](this._params); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND; + this._preserveStack(ParserStackType.CSI, handlers, j, transition, k); + return handlerResult; + } + } + if (j < 0) { + this._csiHandlerFb(this._collect << 8 | ch, this._params); + } + this.precedingJoinState = 0; + i = k; + this.currentState = ParserState.GROUND; + csiDone = true; + break; + } else if (ch >= 0x20 && ch <= 0x2f) { + this._collect <<= 8; + this._collect |= ch; + i = k; + this.currentState = ParserState.CSI_INTERMEDIATE; + csiDone = true; + break; + } else { + break; + } + } + if (!csiDone) { + i = k - 1; + this.currentState = ParserState.CSI_PARAM; + } + continue; + } + // normal transition & action lookup transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)]; switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) { case ParserAction.PRINT: - // read ahead with loop unrolling // Note: 0x20 (SP) is included, 0x7F (DEL) is excluded - for (let j = i + 1; ; ++j) { - if (j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) { - this._printHandler(data, i, j); - i = j - 1; - break; - } - if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) { - this._printHandler(data, i, j); - i = j - 1; - break; - } - if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) { - this._printHandler(data, i, j); - i = j - 1; - break; - } - if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) { - this._printHandler(data, i, j); - i = j - 1; - break; - } - } + let c = i; + const l4 = length - 4; + while (c < l4 + && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) + && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) + && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) + && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) + ) {} + if (c >= l4) + while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) c++; + this._printHandler(data, i, c); + i = c - 1; break; case ParserAction.EXECUTE: if (this._executeHandlers[code]) this._executeHandlers[code](); @@ -814,18 +823,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._params.reset(); this._params.addParam(0); // ZDM this._collect = 0; - if ((transition & TableAccess.TRANSITION_STATE_MASK) === ParserState.ESCAPE - && i + 2 < length && data[i + 1] === 0x5b) { - const csiFastResult = this._csiFast(data, i + 2, length); - if (csiFastResult instanceof Promise) { - return csiFastResult; - } - if (csiFastResult >= 0) { - i = csiFastResult as number; - this.currentState = ParserState.GROUND; - continue; - } - } break; case ParserAction.DCS_HOOK: this._dcsParser.hook(this._collect << 8 | code, this._params); From 18cd9858ed90a0351e08ba2a40e53c1cd86934ee Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 14:09:47 +0200 Subject: [PATCH 11/90] perf: collapse CSI state guard into single range check Replace three !== checks (OSC_STRING, DCS_PASSTHROUGH, APC_STRING) with one < OSC_STRING comparison. States below OSC_STRING are all safe for the CSI fast-path; states above need the normal table path for proper subparser abort propagation. --- src/common/parser/EscapeSequenceParser.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 014b46b75..2447bce85 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -659,9 +659,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop if (code === 0x1b - && this.currentState !== ParserState.OSC_STRING - && this.currentState !== ParserState.DCS_PASSTHROUGH - && this.currentState !== ParserState.APC_STRING + && this.currentState < ParserState.OSC_STRING && i + 1 < length && data[i + 1] === 0x5b ) { this._params.reset(); From 9deec7aabf3a45fdcd4de8e485c279c3c7d89fcc Mon Sep 17 00:00:00 2001 From: jerch Date: Thu, 23 Apr 2026 14:36:51 +0200 Subject: [PATCH 12/90] make linter happy --- src/common/parser/EscapeSequenceParser.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 2447bce85..ea15d782d 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -734,7 +734,9 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) ) {} if (c >= l4) - while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) c++; + while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) { + c++; + } this._printHandler(data, i, c); i = c - 1; break; From f0aeb70c92f9c4dee67f5627b00bc783702e7842 Mon Sep 17 00:00:00 2001 From: jerch Date: Thu, 23 Apr 2026 14:39:26 +0200 Subject: [PATCH 13/90] make linter happy --- src/common/parser/EscapeSequenceParser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index ea15d782d..04f5e8862 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -733,10 +733,11 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) && data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE) ) {} - if (c >= l4) + if (c >= l4) { while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) { c++; } + } this._printHandler(data, i, c); i = c - 1; break; From eb65910c1ba26733cf4cef4ef9157927b08826b8 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 15:35:06 +0200 Subject: [PATCH 14/90] perf: simplify CSI fast-path - drop intermediate handling and extra length checks Remove intermediate byte handling from the fast-path, bail to table lookup instead. Remove redundant per-step length checks by requiring i + 2 < length upfront and letting the for-loop guard the rest. --- src/common/parser/EscapeSequenceParser.ts | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 04f5e8862..99770b092 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -660,17 +660,16 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop if (code === 0x1b && this.currentState < ParserState.OSC_STRING - && i + 1 < length && data[i + 1] === 0x5b + && i + 2 < length && data[i + 1] === 0x5b ) { this._params.reset(); this._params.addParam(0); // ZDM this._collect = 0; let k = i + 2; - if (k >= length) { i = k - 1; this.currentState = ParserState.CSI_ENTRY; continue; } let ch = data[k]; if (ch >= 0x3c && ch <= 0x3f) { this._collect = ch; - if (++k >= length) { i = k - 1; this.currentState = ParserState.CSI_PARAM; continue; } + k++; } let csiDone = false; for (; k < length; k++) { @@ -702,13 +701,6 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.currentState = ParserState.GROUND; csiDone = true; break; - } else if (ch >= 0x20 && ch <= 0x2f) { - this._collect <<= 8; - this._collect |= ch; - i = k; - this.currentState = ParserState.CSI_INTERMEDIATE; - csiDone = true; - break; } else { break; } From 6c2465d7453c700a01a299bac780b0181b990489 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 15:59:27 +0200 Subject: [PATCH 15/90] docs: note EXE and CSI fast paths in parse method docs --- src/common/parser/EscapeSequenceParser.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 99770b092..8cb6d39f3 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -506,6 +506,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP * - OSC_STRING:OSC_PUT * - DCS_PASSTHROUGH:DCS_PUT * + * Additionally the following fast paths exist before the table lookup: + * - EXE bytes < 0x18 in non-payload states (avoids table lookup entirely) + * - 7-bit CSI sequences without intermediates (ESC [ params final) + * * Note on asynchronous handler support: * Any handler returning a promise will be treated as asynchronous. * To keep the in-band blocking working for async handlers, `parse` pauses execution, From eaebe2ab1579915c5c8b1287ff3ad89d5260807a Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 16:55:46 +0200 Subject: [PATCH 16/90] refactor: add Params.resetZdm() to collapse reset+addParam(0) into single call Replaces 10 separate reset()+addParam(0) pairs across the parser with a single resetZdm() method that inlines both operations, avoiding two calls to a foreign object per sequence reset. --- src/common/parser/EscapeSequenceParser.ts | 27 ++++++++--------------- src/common/parser/Params.ts | 13 +++++++++++ src/common/parser/Types.ts | 1 + 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 8cb6d39f3..df3fb5065 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -462,8 +462,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._oscParser.reset(); this._dcsParser.reset(); this._apcParser.reset(); - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; this.precedingJoinState = 0; // abort pending continuation from async handler @@ -612,8 +611,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; break; case ParserStackType.OSC: @@ -623,8 +621,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; break; case ParserStackType.APC: @@ -634,8 +631,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; break; } @@ -666,8 +662,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP && this.currentState < ParserState.OSC_STRING && i + 2 < length && data[i + 1] === 0x5b ) { - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; let k = i + 2; let ch = data[k]; @@ -817,8 +812,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.precedingJoinState = 0; break; case ParserAction.CLEAR: - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; break; case ParserAction.DCS_HOOK: @@ -842,8 +836,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; this.precedingJoinState = 0; break; @@ -867,8 +860,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; this.precedingJoinState = 0; break; @@ -892,8 +884,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP return handlerResult; } if (code === 0x1b) transition |= ParserState.ESCAPE; - this._params.reset(); - this._params.addParam(0); // ZDM + this._params.resetZdm(); this._collect = 0; this.precedingJoinState = 0; break; diff --git a/src/common/parser/Params.ts b/src/common/parser/Params.ts index 7071453d0..31872a0b2 100644 --- a/src/common/parser/Params.ts +++ b/src/common/parser/Params.ts @@ -129,6 +129,19 @@ export class Params implements IParams { this._digitIsSub = false; } + /** + * Reset and add 0 as first param (ZDM). + */ + public resetZdm(): void { + this.length = 1; + this._subParamsLength = 0; + this._rejectDigits = false; + this._rejectSubDigits = false; + this._digitIsSub = false; + this._subParamsIdx[0] = 0; + this.params[0] = 0; + } + /** * Add a parameter value. * `Params` only stores up to `maxLength` parameters, any later diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 2405dcb88..1f13c4893 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -32,6 +32,7 @@ export interface IParams { clone(): IParams; toArray(): ParamsArray; reset(): void; + resetZdm(): void; addParam(value: number): void; addSubParam(value: number): void; hasSubParams(idx: number): boolean; From 685434ca847cfca6152604b8be878f3e3a749ef5 Mon Sep 17 00:00:00 2001 From: Sridhar Ratnakumar Date: Thu, 23 Apr 2026 11:35:56 -0400 Subject: [PATCH 17/90] Fix IntersectionObserver retention on dispose --- src/browser/services/RenderService.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 83e3d3379..e999b7eac 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); + private _intersectionObserver: IntersectionObserver | undefined; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService { // and resume based on terminal visibility if so if ('IntersectionObserver' in w) { const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 }); + this._observerDisposable.value = toDisposable(() => { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = undefined; + }); + this._intersectionObserver = observer; observer.observe(screenElement); - this._observerDisposable.value = toDisposable(() => observer.disconnect()); } } From b28b5d0c2b7cf877811baa0187c9902d098c8be2 Mon Sep 17 00:00:00 2001 From: Ismail Pelaseyed Date: Thu, 23 Apr 2026 17:10:59 +0200 Subject: [PATCH 18/90] perf: use array-indexed lookup for execute handlers in hot path Add a parallel array container for execute handlers (codes < 0x18) alongside the existing object. Setters populate both, the EXE fast-path reads from the array with nullish coalescing to avoid branching. ExecuteHandlerType updated to accept optional ident param to allow the ?? pattern with the fallback handler. --- src/common/parser/EscapeSequenceParser.ts | 14 ++++++++++---- src/common/parser/Types.ts | 2 +- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 8cb6d39f3..d0c174d2c 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -247,6 +247,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // handler lookup containers protected _printHandler: PrintHandlerType; protected _executeHandlers: { [flag: number]: ExecuteHandlerType }; + protected _executeHandlersArr: (ExecuteHandlerType | undefined)[]; protected _csiHandlers: IHandlerCollection; protected _escHandlers: IHandlerCollection; protected readonly _oscParser: IOscParser; @@ -290,11 +291,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._errorHandlerFb = (state: IParsingState): IParsingState => state; this._printHandler = this._printHandlerFb; this._executeHandlers = Object.create(null); + this._executeHandlersArr = new Array(0x18).fill(undefined); this._csiHandlers = Object.create(null); this._escHandlers = Object.create(null); this._register(toDisposable(() => { this._csiHandlers = Object.create(null); this._executeHandlers = Object.create(null); + this._executeHandlersArr = new Array(0x18).fill(undefined); this._escHandlers = Object.create(null); })); this._oscParser = this._register(new OscParser()); @@ -381,10 +384,14 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP } public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void { - this._executeHandlers[flag.charCodeAt(0)] = handler; + const code = flag.charCodeAt(0); + this._executeHandlers[code] = handler; + if (code < 0x18) this._executeHandlersArr[code] = handler; } public clearExecuteHandler(flag: string): void { - if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)]; + const code = flag.charCodeAt(0); + if (this._executeHandlers[code]) delete this._executeHandlers[code]; + if (code < 0x18) this._executeHandlersArr[code] = undefined; } public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void { this._executeHandlerFb = handler; @@ -655,8 +662,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // EXE fast-path: common control bytes (0x00-0x17) in non-payload states if (code < 0x18 && this.currentState <= ParserState.CSI_IGNORE) { - if (this._executeHandlers[code]) this._executeHandlers[code](); - else this._executeHandlerFb(code); + (this._executeHandlersArr[code] ?? this._executeHandlerFb)(code); this.precedingJoinState = 0; continue; } diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 2405dcb88..5d1d8b53e 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -107,7 +107,7 @@ export type EscFallbackHandlerType = (identifier: number) => void; /** * EXECUTE handler types. */ -export type ExecuteHandlerType = () => boolean; +export type ExecuteHandlerType = (ident?: number) => boolean; export type ExecuteFallbackHandlerType = (ident: number) => void; /** From a11fcd2e0be7ed2cd91b0f361dee53736f68aad2 Mon Sep 17 00:00:00 2001 From: jerch Date: Thu, 23 Apr 2026 18:50:03 +0200 Subject: [PATCH 19/90] remove type hint --- src/common/parser/EscapeSequenceParser.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index d0c174d2c..8cf54d479 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -247,6 +247,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP // handler lookup containers protected _printHandler: PrintHandlerType; protected _executeHandlers: { [flag: number]: ExecuteHandlerType }; + // fast path for EXE bytes < 0x18 protected _executeHandlersArr: (ExecuteHandlerType | undefined)[]; protected _csiHandlers: IHandlerCollection; protected _escHandlers: IHandlerCollection; @@ -291,13 +292,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._errorHandlerFb = (state: IParsingState): IParsingState => state; this._printHandler = this._printHandlerFb; this._executeHandlers = Object.create(null); - this._executeHandlersArr = new Array(0x18).fill(undefined); + this._executeHandlersArr = new Array(0x18).fill(undefined); this._csiHandlers = Object.create(null); this._escHandlers = Object.create(null); this._register(toDisposable(() => { this._csiHandlers = Object.create(null); this._executeHandlers = Object.create(null); - this._executeHandlersArr = new Array(0x18).fill(undefined); + this._executeHandlersArr = new Array(0x18).fill(undefined); this._escHandlers = Object.create(null); })); this._oscParser = this._register(new OscParser()); From 22778b5b4917f8f27f7625cf0fc069a4aa4be11b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Fri, 24 Apr 2026 09:15:26 +0200 Subject: [PATCH 20/90] fix utf8 terminal benchmark --- test/benchmark/Terminal.benchmark.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/benchmark/Terminal.benchmark.ts b/test/benchmark/Terminal.benchmark.ts index 4944495b8..fb06b429e 100644 --- a/test/benchmark/Terminal.benchmark.ts +++ b/test/benchmark/Terminal.benchmark.ts @@ -44,7 +44,7 @@ perfContext('Terminal: ls -lR /usr/lib', () => { } }); - perfContext('write/string/async', () => { + perfContext('write/string', () => { let terminal: CoreBrowserTerminal; before(() => { terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 }); @@ -55,13 +55,13 @@ perfContext('Terminal: ls -lR /usr/lib', () => { }, { fork: false }).showAverageThroughput(); }); - perfContext('write/Utf8/async', () => { + perfContext('write/utf8', () => { let terminal: CoreBrowserTerminal; before(() => { terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 }); }); new ThroughputRuntimeCase('', async () => { - await new Promise(res => terminal.write(content, res)); + await new Promise(res => terminal.write(contentUtf8, res)); return { payloadSize: contentUtf8.length }; }, { fork: false }).showAverageThroughput(); }); 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 21/90] 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 22/90] 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 23/90] 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 24/90] 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 25/90] 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 26/90] 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 27/90] 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 28/90] 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 29/90] 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 30/90] 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 31/90] 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 32/90] 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 33/90] 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 34/90] 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 35/90] 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 36/90] 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 37/90] 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 da51d9608ccfbabf46df25fd971584d5a5c30f00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:07:35 +0200 Subject: [PATCH 38/90] add missing separator for test arguments --- .github/workflows/ci.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40e86ff2..204faf12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -207,31 +207,31 @@ jobs: - name: Build demo server run: npm run esbuild-demo-server - name: Integration tests (core) # Tests use 50% workers to reduce flakiness - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core - name: Integration tests (addon-attach) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach - name: Integration tests (addon-clipboard) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard - name: Integration tests (addon-fit) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit - name: Integration tests (addon-image) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image - name: Integration tests (addon-progress) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress - name: Integration tests (addon-search) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search - name: Integration tests (addon-serialize) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize - name: Integration tests (addon-unicode-graphemes) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes - name: Integration tests (addon-unicode11) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11 + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11 - name: Integration tests (addon-web-fonts) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts - name: Integration tests (addon-web-links) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links - name: Integration tests (addon-webgl) - run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl + run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl release-dry-run: needs: build From 7d69e730ca2299dce196161513a71389f9d67e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:24:18 +0200 Subject: [PATCH 39/90] fix web-fonts tests --- .../addon-web-fonts/test/WebFontsAddon.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 1315b1ee6..3438a4633 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => { test.describe('font loading at runtime', () => { test('loadFonts (JS)', async () => { await ctx.page.evaluate(` - const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')"); - const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')"); + const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')"); + const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')"); loadFonts([ff1, ff2]); `); deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]); }); test('loadFonts (CSS, unquoted)', async () => { await ctx.page.evaluate(` - document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0); - document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1); + document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0); + document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1); loadFonts(['Kongtext', 'BPdots']); `); deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]); @@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => { test('loadFonts (CSS, quoted)', async ({ browser }) => { // NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them await ctx.page.evaluate(` - document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0); - document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1); + document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0); + document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1); loadFonts(['Kongtext', 'BPdots']); `); if (browser.browserType().name() === 'firefox') { @@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => { test('FontFace hashing', async () => { // multiple calls of `loadFonts` with the same objects shall not bloat document.fonts await ctx.page.evaluate(` - const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')"); - const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')"); + const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')"); + const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')"); loadFonts([ff1, ff2]); loadFonts([ff1, ff2]); loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2])); @@ -76,7 +76,7 @@ test.describe('WebFontsAddon', () => { // to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range // now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage const data = await ctx.page.evaluate(` - document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0); + document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0); `); deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]); From 2794e32b3fc12b36e4269a4dfc2c9775720074e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:31:05 +0200 Subject: [PATCH 40/90] newer safari promotes different loading state --- addons/addon-web-fonts/test/WebFontsAddon.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 3438a4633..2d5216564 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -88,7 +88,7 @@ test.describe('WebFontsAddon', () => { // safari loads the font, firefox & chrome dont if (browser.browserType().name() === 'webkit') { - deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]); + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]); } else { deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]); } From 893cdd494ab653adebc7e0ba6634d2026a384f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:36:10 +0200 Subject: [PATCH 41/90] remove broken test condition --- addons/addon-web-fonts/test/WebFontsAddon.test.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 2d5216564..5b283f3a9 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -86,13 +86,6 @@ test.describe('WebFontsAddon', () => { window.helperTerm.open(term.element); `); - // safari loads the font, firefox & chrome dont - if (browser.browserType().name() === 'webkit') { - deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]); - } else { - deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]); - } - // good case: addon fixes layout for webfont in ctor // the relayout happens async, so wait a bit with a promise await ctx.page.evaluate(` From 7e70d6ad78a2b4b8c62bbebd977517e671e49c96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:45:06 +0200 Subject: [PATCH 42/90] fix safari --- addons/addon-web-fonts/test/WebFontsAddon.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 5b283f3a9..2d5216564 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -86,6 +86,13 @@ test.describe('WebFontsAddon', () => { window.helperTerm.open(term.element); `); + // safari loads the font, firefox & chrome dont + if (browser.browserType().name() === 'webkit') { + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]); + } else { + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]); + } + // good case: addon fixes layout for webfont in ctor // the relayout happens async, so wait a bit with a promise await ctx.page.evaluate(` From 7dd0dac85973df828c6ac936cf8d85967adebdc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:49:21 +0200 Subject: [PATCH 43/90] fix safari --- addons/addon-web-fonts/test/WebFontsAddon.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 2d5216564..02f01b8d5 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => { // safari loads the font, firefox & chrome dont if (browser.browserType().name() === 'webkit') { - deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]); + try { + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]); + } catch (e) { + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]); + } } else { deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]); } From d43b19c260bdd2d3b8eb7eedba7e988e553de692 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 19:54:49 +0200 Subject: [PATCH 44/90] fix safari --- addons/addon-web-fonts/test/WebFontsAddon.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 02f01b8d5..c94bff84d 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -107,7 +107,10 @@ test.describe('WebFontsAddon', () => { window.helperTerm.open(term.element); `); await timeout(100); - deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]); + if (browser.browserType().name() !== 'webkit') { + // safari sometimes reports 'unloadeed'? + deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]); + } // cleanup this messy test case await ctx.page.evaluate(` From 32ad3a18bf8812c369994d08f7fed8123ffd295c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 20:42:38 +0200 Subject: [PATCH 45/90] disable wrong webgl tests --- addons/addon-webgl/test/WebglRenderer.test.ts | 3 ++- addons/addon-webgl/test/playwright.config.ts | 5 ++++- test/playwright/SharedRendererTests.ts | 15 ++++++++++----- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/addons/addon-webgl/test/WebglRenderer.test.ts b/addons/addon-webgl/test/WebglRenderer.test.ts index af61992ce..f8488da32 100644 --- a/addons/addon-webgl/test/WebglRenderer.test.ts +++ b/addons/addon-webgl/test/WebglRenderer.test.ts @@ -24,8 +24,9 @@ test.afterAll(async () => await ctx.page.close()); test.describe('WebGL Renderer Integration Tests', async () => { // HACK: webgl2 is often not supported in headless firefox on Linux // https://github.com/microsoft/playwright/issues/11566 + // also disable safari due to #5852 if (platform() === 'linux') { - test.skip(({ browserName }) => browserName === 'firefox'); + test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit'); } injectSharedRendererTests(ctxWrapper); diff --git a/addons/addon-webgl/test/playwright.config.ts b/addons/addon-webgl/test/playwright.config.ts index e2fe314e5..8200a4a8f 100644 --- a/addons/addon-webgl/test/playwright.config.ts +++ b/addons/addon-webgl/test/playwright.config.ts @@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = { { name: 'Chromium', use: { - browserName: 'chromium' + browserName: 'chromium', + launchOptions: { + args: ['--enable-unsafe-swiftshader'] + } } }, { diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 6754dc6f9..db5c4c073 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -169,7 +169,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 1), [0, 0, 0, 255]); }); - test('background 0-15 invisible', async () => { + // due to #5852 + test.skip('background 0-15 invisible', async () => { const theme: ITheme = { black: '#010203', red: '#040506', @@ -335,7 +336,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void } }); - test('background 16-255 invisible', async () => { + // due to #5852 + test.skip('background 16-255 invisible', async () => { let data = ''; for (let y = 0; y < 240 / 16; y++) { for (let x = 0; x < 16; x++) { @@ -715,7 +717,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void } }); - test('background true color grey invisible', async () => { + // due to #5852 + test.skip('background true color grey invisible', async () => { let data = ''; for (let y = 0; y < 16; y++) { for (let x = 0; x < 16; x++) { @@ -872,7 +875,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 2), [64, 64, 64, 255]); }); - test('should enforce half the contrast for dim cells', async () => { + // due to #5852 + test.skip('should enforce half the contrast for dim cells', async () => { const theme: ITheme = { background: '#ffffff', black: '#2e3436', @@ -1177,7 +1181,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 3), [0, 0, 255, 255]); await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 3), [0, 0, 255, 255]); }); - test('#4758: multiple invisible text characters without SGR change should not be rendered', async () => { + // due to #5852 + test.skip('#4758: multiple invisible text characters without SGR change should not be rendered', async () => { // Regression test: #4758 when multiple invisible characters are used await ctx.value.proxy.writeln(`■\x1b[8m■■`); // Full refresh as the before result is the same as after From 066c227436c1f62df983671be2480c834e22fcc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Tue, 28 Apr 2026 21:10:52 +0200 Subject: [PATCH 46/90] disable firefox webgl --- addons/addon-webgl/test/WebglRenderer.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/addons/addon-webgl/test/WebglRenderer.test.ts b/addons/addon-webgl/test/WebglRenderer.test.ts index f8488da32..c425fa000 100644 --- a/addons/addon-webgl/test/WebglRenderer.test.ts +++ b/addons/addon-webgl/test/WebglRenderer.test.ts @@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => { await openTerminal(ctx); ctxWrapper.value = ctx; await ctx.page.evaluate(` - window.addon = new window.WebglAddon(true); - window.term.loadAddon(window.addon); + try { + window.addon = new window.WebglAddon(true); + window.term.loadAddon(window.addon); + } catch (e) {} `); }); test.afterAll(async () => await ctx.page.close()); @@ -32,8 +34,10 @@ test.describe('WebGL Renderer Integration Tests', async () => { injectSharedRendererTests(ctxWrapper); injectSharedRendererTestsStandalone(ctxWrapper, async () => { await ctx.page.evaluate(` - window.addon = new window.WebglAddon(true); - window.term.loadAddon(window.addon); + try { + window.addon = new window.WebglAddon(true); + window.term.loadAddon(window.addon); + } catch (e) {} `); }); }); From c5bf395b10f78998ae57dc3218ade21f955fc4cc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 19:25:56 +0000 Subject: [PATCH 47/90] Bump follow-redirects from 1.15.11 to 1.16.0 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6130135fd..a59044274 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4251,9 +4251,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -4261,6 +4261,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, 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 48/90] 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 49/90] 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 50/90] 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 51/90] 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 52/90] 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 53/90] 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 54/90] 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 55/90] 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 56/90] 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 57/90] 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 58/90] 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 59/90] 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 60/90] 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 61/90] 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 62/90] 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 63/90] 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 64/90] 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 65/90] 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 66/90] 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 67/90] 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 68/90] 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 69/90] 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} = {}; From c8dddddbbbd065db8d107d03b02249f0b3a0c634 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 10 May 2026 16:45:46 +0200 Subject: [PATCH 70/90] remove js-base64 dependency --- addons/addon-clipboard/package.json | 3 --- addons/addon-clipboard/src/ClipboardAddon.ts | 12 +++++------- addons/addon-clipboard/test/ClipboardAddon.test.ts | 2 +- package-lock.json | 10 +--------- 4 files changed, 7 insertions(+), 20 deletions(-) diff --git a/addons/addon-clipboard/package.json b/addons/addon-clipboard/package.json index 176fa861a..eb207679d 100644 --- a/addons/addon-clipboard/package.json +++ b/addons/addon-clipboard/package.json @@ -21,8 +21,5 @@ "package": "../../node_modules/.bin/webpack", "prepublishOnly": "npm run package", "start": "node ../../demo/start" - }, - "dependencies": { - "js-base64": "^3.7.5" } } diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 58425924f..123edfec5 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -5,7 +5,6 @@ import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm'; import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard'; -import { Base64 as JSBase64 } from 'js-base64'; export class ClipboardAddon implements ITerminalAddon { private _terminal?: Terminal; @@ -87,13 +86,12 @@ export class BrowserClipboardProvider implements IClipboardProvider { export class Base64 implements IBase64 { public encodeText(data: string): string { - return JSBase64.encode(data); + return btoa(data); } public decodeText(data: string): string { - const text = JSBase64.decode(data); - if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) { - return ''; - } - return text; + try { + return atob(data); + } catch (e) {} + return ''; } } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index d4d86d46b..fc14fc75c 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => { test.beforeEach(async ({}, testInfo) => { // DEBT: This test doesn't work since the migration to @playwright/test - if (ctx.browser.browserType().name() !== 'chromium') { + if (ctx.browser.browserType().name() === 'webkit') { testInfo.skip(); return; } diff --git a/package-lock.json b/package-lock.json index ebe3acbef..23f9bbc2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,10 +64,7 @@ "addons/addon-clipboard": { "name": "@xterm/addon-clipboard", "version": "0.2.0", - "license": "MIT", - "dependencies": { - "js-base64": "^3.7.5" - } + "license": "MIT" }, "addons/addon-fit": { "name": "@xterm/addon-fit", @@ -5420,11 +5417,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", From 6f5515048569c12fe8e1de9255d4324b1a77088d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 10 May 2026 17:06:08 +0200 Subject: [PATCH 71/90] make linter happy --- addons/addon-clipboard/src/ClipboardAddon.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 123edfec5..9e753a1a8 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -91,7 +91,7 @@ export class Base64 implements IBase64 { public decodeText(data: string): string { try { return atob(data); - } catch (e) {} + } catch {} return ''; } } From c5a11220c139ae83511a41e99cf1422083f1673f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rg=20Breitbart?= Date: Sun, 10 May 2026 18:00:26 +0200 Subject: [PATCH 72/90] always refer to navigator.clipboard in default provider --- addons/addon-clipboard/src/ClipboardAddon.ts | 16 +++------ .../test/ClipboardAddon.test.ts | 19 ++++++++++- .../typings/addon-clipboard.d.ts | 33 +++++++------------ 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 9e753a1a8..5e194880c 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -4,7 +4,7 @@ */ import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm'; -import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard'; +import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard'; export class ClipboardAddon implements ITerminalAddon { private _terminal?: Terminal; @@ -24,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon { return this._disposable?.dispose(); } - private _readText(sel: ClipboardSelectionType, data: string): void { + private _readText(sel: string, data: string): void { const b64 = this._base64.encodeText(data); this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false); } @@ -35,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon { return true; } - const pc = args[0] as ClipboardSelectionType; + const pc = args[0]; const pd = args[1]; if (pd === '?') { const text = this._provider.readText(pc); @@ -69,17 +69,11 @@ export class ClipboardAddon implements ITerminalAddon { } export class BrowserClipboardProvider implements IClipboardProvider { - public async readText(selection: ClipboardSelectionType): Promise { - if (selection !== 'c') { - return Promise.resolve(''); - } + public readText(selection: string): Promise { return navigator.clipboard.readText(); } - public async writeText(selection: ClipboardSelectionType, text: string): Promise { - if (selection !== 'c') { - return Promise.resolve(); - } + public writeText(selection: string, text: string): Promise { return navigator.clipboard.writeText(text); } } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index fc14fc75c..2c04e619b 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => { await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); }); + test('primary selection', async () => { + await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); + test('empty selection (default)', async () => { + await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`); + deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded); + }); test('invalid base64 string', async () => { await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`); deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), ''); @@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => { }); test.describe('read data', async function (): Promise { - test('simple string', async () => { + test.beforeAll(async () => { await ctx.page.evaluate(` window.data = []; window.term.onData(e => data.push(e)); `); + }); + test('simple string', async () => { + await ctx.page.evaluate('window.data.length = 0;'); await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world')); await ctx.proxy.write(`\x1b]52;c;?\x07`); deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]); }); + test('primary selection', async () => { + await ctx.page.evaluate('window.data.length = 0;'); + await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world')); + await ctx.proxy.write(`\x1b]52;p;?\x07`); + deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]); + }); test('clear clipboard', async () => { await ctx.proxy.write(`\x1b]52;c;!\x07`); await ctx.proxy.write(`\x1b]52;c;?\x07`); diff --git a/addons/addon-clipboard/typings/addon-clipboard.d.ts b/addons/addon-clipboard/typings/addon-clipboard.d.ts index 4ef12e0f7..f8e024ac3 100644 --- a/addons/addon-clipboard/typings/addon-clipboard.d.ts +++ b/addons/addon-clipboard/typings/addon-clipboard.d.ts @@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' { public dispose(): void } - /** - * Clipboard selection type. This is used to specify which selection buffer to - * read or write to. - * - SYSTEM `c`: The system clipboard. - * - PRIMARY `p`: The primary clipboard. This is provided for compatibility - * with Linux X11. - */ - export const enum ClipboardSelectionType { - SYSTEM = 'c', - PRIMARY = 'p', - } - export interface IBase64 { /** * Converts a utf-8 string to a base64 string. @@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' { export interface IClipboardProvider { /** * Gets the clipboard content. - * @param selection The clipboard selection to read. + * @param selection The clipboard selection to read (see OSC 52 spec of xterm). * @returns A promise that resolves with clipboard selection data. */ - readText(selection: ClipboardSelectionType): string | Promise; + readText(selection: string): string | Promise; /** * Sets the clipboard content. - * @param selection The clipboard selection to set. + * @param selection The clipboard selection to set (see OSC 52 spec of xterm). * @param data The clipboard text to write. */ - writeText(selection: ClipboardSelectionType, text: string): void | Promise; + writeText(selection: string, text: string): void | Promise; } /** - * The clipboard provider interface that enables xterm.js to access the system clipboard. + * The clipboard provider interface that enables xterm.js to access the browser clipboard. + * + * Note this this clipboard provider does not implement own cut buffers as xterm does, + * but redirect the selection parameter always to navigator.clipboard. */ export class BrowserClipboardProvider implements IClipboardProvider{ /** * Reads text from the clipboard. - * @param selection The selection type to read from. + * @param selection The selection type to read from (always refers to navigator.clipboard). * @returns A promise that resolves with the text from the clipboard. */ - public readText(selection: ClipboardSelectionType): Promise; + public readText(selection: string): Promise; /** * Writes text to the clipboard. - * @param selection The selection type to write to. + * @param selection The selection type to write to (always refers to navigator.clipboard). * @param data The text to write to the clipboard. * @returns A promise that resolves when the text has been written to the clipboard. */ - public writeText(selection: ClipboardSelectionType, data: string): Promise; + public writeText(selection: string, data: string): Promise; } } From b0f5bc2d280969d65bb2ae6a15e96a42ea5427b9 Mon Sep 17 00:00:00 2001 From: zeertzjq Date: Thu, 2 Apr 2026 13:01:55 +0800 Subject: [PATCH 73/90] Fix kitty keyboard "Report event types" behavior not matching spec According to spec: - The Enter, Tab and Backspace keys will not have release events unless "Report all keys as escape codes" is also set. - Key events that result in text are reported as plain UTF-8 text, so events are not supported for them, unless the application requests "key report" mode. I checked kitty's behavior. While it does report release events for keys that result in text as CSI sequences, press and repeat events for these are reported as plain UTF-8 text. This commit changes Xterm.js to match this behavior. --- src/common/input/KittyKeyboard.test.ts | 117 ++++++++++++++++++++++++- src/common/input/KittyKeyboard.ts | 7 +- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index b5aa9d643..7646c052b 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -453,9 +453,29 @@ describe('KittyKeyboard', () => { describe('event types (press/repeat/release)', () => { const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; - it('press event (default, no suffix)', () => { + it('UTF-8 text press event', () => { const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS); - assert.strictEqual(result.key, '\x1b[97u'); + assert.strictEqual(result.key, 'a'); + }); + + it('Escape key press event (default, no suffix)', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[27u'); + }); + + it('Enter key press event → legacy \\r', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\r'); + }); + + it('Tab key press event → legacy \\t', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\t'); + }); + + it('Backspace key press event → legacy \\x7f', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x7f'); }); it('press event explicit :1 when modifiers present', () => { @@ -463,9 +483,29 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, '\x1b[97;5u'); }); - it('repeat event → :2 suffix', () => { + it('UTF-8 text repeat event', () => { const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT); - assert.strictEqual(result.key, '\x1b[97;1:2u'); + assert.strictEqual(result.key, 'a'); + }); + + it('Escape key repeat event → :2 suffix', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[27;1:2u'); + }); + + it('Enter key repeat event → legacy \\r', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\r'); + }); + + it('Tab key repeat event → legacy \\t', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\t'); + }); + + it('Backspace key repeat event → legacy \\x7f', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x7f'); }); it('release event → :3 suffix', () => { @@ -473,6 +513,26 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, '\x1b[97;1:3u'); }); + it('Escape key release event → :3 suffix', () => { + const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[27;1:3u'); + }); + + it('Enter key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + + it('Tab key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + + it('Backspace key release event is not reported', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + it('release with modifier → mod:3', () => { const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[97;5:3u'); @@ -563,6 +623,55 @@ describe('KittyKeyboard', () => { }); }); + describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag with REPORT_EVENT_TYPES', () => { + const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('Enter key press event → CSI 13 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[13u'); + }); + + it('Tab key press event → CSI 9 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[9u'); + }); + + it('Backspace key press event → CSI 127 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[127u'); + }); + + it('Enter key repeat event → CSI 13;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[13;1:2u'); + }); + + it('Tab key repeat event → CSI 9;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[9;1:2u'); + }); + + it('Backspace key repeat event → CSI 127;1:2 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[127;1:2u'); + }); + + it('Enter key release event → CSI 13;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[13;1:3u'); + }); + + it('Tab key release event → CSI 9;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[9;1:3u'); + }); + + it('Backspace key release event → CSI 127;1:3 u', () => { + const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[127;1:3u'); + }); + }); + describe('REPORT_ASSOCIATED_TEXT flag', () => { const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index e34f47070..f138c6940 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -477,7 +477,12 @@ export class KittyKeyboard { if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) { useCsiU = true; - } else if (reportEventTypes) { + } else if (reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) { + // Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as + // escape codes" (which is handled by the branch above) is also set. + if (keyCode === 13 || keyCode === 9 || keyCode === 127) { + return result; + } useCsiU = true; } else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) { // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy From e0add7fe4b45b95b1a950953dd64bf803ae3eb71 Mon Sep 17 00:00:00 2001 From: zeertzjq Date: Mon, 11 May 2026 13:44:34 +0800 Subject: [PATCH 74/90] Move return earlier --- src/common/input/KittyKeyboard.ts | 51 +++++++++++++++---------------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index f138c6940..f35cfebd5 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -471,34 +471,33 @@ export class KittyKeyboard { return result; } + // Special handling for Enter/Tab/Backspace. + const specialKey = keyCode === 13 || keyCode === 9 || keyCode === 127; + + // Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as escape + // codes" is also set. + if (specialKey && eventType === KittyKeyboardEventType.RELEASE && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) { + return result; + } + const isFunc = this._functionalKeyCodes[ev.key] !== undefined || this._getNumpadKeyCode(ev) !== undefined; - let useCsiU = false; - - if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) { - useCsiU = true; - } else if (reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) { - // Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as - // escape codes" (which is handled by the branch above) is also set. - if (keyCode === 13 || keyCode === 9 || keyCode === 127) { - return result; - } - useCsiU = true; - } else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) { - // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy - // mode" and consider space to be a text-generating key, so these skip the isFunc fast-path - // and only get CSI u when modifiers are present (handled below). - const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127; - if (isFunc && !isDisambiguateLegacy) { - useCsiU = true; - } else if (modifiers > 0) { - if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) { - useCsiU = false; - } else { - useCsiU = true; - } - } - } + const useCsiU = !!( + flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES || + (reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) || + (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES && + ( + // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and + // consider space to be a text-generating key, so these skip the isFunc fast-path and only + // get CSI u when modifiers are present (handled below). + (isFunc && !specialKey) || + ( + (modifiers > 0 && ev.key.length !== 1) || + modifiers - 1 > KittyKeyboardModifiers.SHIFT + ) + ) + ) + ); if (useCsiU) { result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod); From 50e90ddf8a54b0a4f6577705b5a9e235d84ae6a4 Mon Sep 17 00:00:00 2001 From: zeertzjq Date: Tue, 12 May 2026 13:19:15 +0800 Subject: [PATCH 75/90] Fix keys getting swallowed when enabling only "Report event types" Per kovidgoyal/kitty#9999, enabling "Report event types" without "Disambiguate escape codes" doesn't really make sense, and terminals may choose whatever behavior they think makes sense. However, keys shouldn't get swallowed (which, after #5791, happens when the !useCsiU branch cannot handle the event), so instead make "Report event types" imply "Disambiguate escape codes". --- src/common/input/KittyKeyboard.test.ts | 23 ++++++++++++++++++++++- src/common/input/KittyKeyboard.ts | 5 ++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index 7646c052b..2a832d75f 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -478,7 +478,7 @@ describe('KittyKeyboard', () => { assert.strictEqual(result.key, '\x7f'); }); - it('press event explicit :1 when modifiers present', () => { + it('press event when modifiers present', () => { const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS); assert.strictEqual(result.key, '\x1b[97;5u'); }); @@ -554,6 +554,27 @@ describe('KittyKeyboard', () => { }); }); + // Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense and + // isn't specified in the spec, but press and repeat events shouldn't get swallowed. + describe('REPORT_EVENT_TYPES flag without DISAMBIGUATE_ESCAPE_CODES', () => { + const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('press event is not swallowed when modifiers present', () => { + const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS); + assert.strictEqual(result.key, '\x1b[97;5u'); + }); + + it('repeat event is not swallowed when modifiers present', () => { + const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.REPEAT); + assert.strictEqual(result.key, '\x1b[97;5:2u'); + }); + + it('release event is reported as CSI u sequence', () => { + const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, '\x1b[97;5:3u'); + }); + }); + describe('modifier-only reporting', () => { const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index f35cfebd5..ff57b4f89 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -485,7 +485,10 @@ export class KittyKeyboard { const useCsiU = !!( flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES || (reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) || - (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES && + // Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense, so + // just make REPORT_EVENT_TYPES imply DISAMBIGUATE_ESCAPE_CODES here for simplicity. + // See: https://github.com/kovidgoyal/kitty/issues/9999 + ((flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES || reportEventTypes) && ( // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and // consider space to be a text-generating key, so these skip the isFunc fast-path and only From 86291b6e96f54582f4889572bc81d8330c01ca3d Mon Sep 17 00:00:00 2001 From: Per Bothner Date: Tue, 12 May 2026 17:53:33 -0700 Subject: [PATCH 76/90] Highlight xterm-screen when size is non-auto --- demo/client/components/window/optionsWindow.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index 18ab9254d..31521a871 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -252,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { input.value = 'auto'; this._handlers.updateTerminalSize(); } + const screenElement = document.querySelector('.xterm-screen'); + if (screenElement instanceof HTMLElement) { + screenElement.style.outline = this._autoResize ? '' + : 'solid thin red'; + } } else if (o === 'theme') { value = this._getTheme(); } From c6e3492a4a42f143c82fce9b8d12824ec3def1d5 Mon Sep 17 00:00:00 2001 From: Maciej Ciemborowicz Date: Thu, 14 May 2026 04:45:52 +0200 Subject: [PATCH 77/90] Add 4bit to the Real-world uses section. --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 75ff03313..7107314b6 100644 --- a/README.md +++ b/README.md @@ -253,6 +253,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**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. +- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [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 06e5ac7ab449d21c57e6d2401bf699378813d4e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C5=8Dta?= Date: Mon, 18 May 2026 18:26:32 +0200 Subject: [PATCH 78/90] addon-image: Kitty graphics q=2 should suppress OK responses too --- .../src/kitty/KittyGraphicsHandler.ts | 4 +- addons/addon-image/test/KittyGraphics.test.ts | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index f449f3186..de889dfff 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -533,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void { const isOk = message === 'OK'; - if (isOk && quiet === 1) return; - if (!isOk && quiet === 2) return; + if (isOk && quiet >= 1) return; + if (!isOk && quiet >= 2) return; const pPart = placementId ? `,p=${placementId}` : ''; const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`; diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index ac5acae77..a9b5d6845 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -617,6 +617,18 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); }); + test('suppresses OK response when q=2', async () => { + await ctx.page.evaluate(() => { + (window as any).kittyGotResponse = false; + (window as any).term.onData(() => { (window as any).kittyGotResponse = true; }); + }); + + await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + test('suppresses error response when q=2', async () => { await ctx.page.evaluate(() => { (window as any).kittyGotResponse = false; @@ -797,6 +809,30 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); }); + + test('a=t OK suppressed by q=2', async () => { + await ctx.page.evaluate(() => { + (window as any).kittyGotResponse = false; + (window as any).term.onData(() => { (window as any).kittyGotResponse = true; }); + }); + + await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); + + test('a=T OK suppressed by q=2', async () => { + await ctx.page.evaluate(() => { + (window as any).kittyGotResponse = false; + (window as any).term.onData(() => { (window as any).kittyGotResponse = true; }); + }); + + await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + }); }); test.describe('Transmission medium rejection', () => { @@ -1146,6 +1182,22 @@ test.describe('Kitty Graphics Protocol', () => { strictEqual(await getImageStorageLength(), 1); }); + test('OK response suppressed by q=2', async () => { + await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`); + await timeout(100); + + await ctx.page.evaluate(() => { + (window as any).kittyGotResponse = false; + (window as any).term.onData(() => { (window as any).kittyGotResponse = true; }); + }); + + await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`); + await timeout(100); + + strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false); + strictEqual(await getImageStorageLength(), 1); + }); + test('ENOENT error suppressed by q=2', async () => { await ctx.page.evaluate(() => { (window as any).kittyGotResponse = false; From dc726a2ae6287e1aa09d410f4f11b4d73ed3fe4b Mon Sep 17 00:00:00 2001 From: Victor Sych Date: Sun, 17 May 2026 10:41:37 +0200 Subject: [PATCH 79/90] Fix webgl rendering corruption from atlas page merges --- addons/addon-webgl/src/GlyphRenderer.ts | 4 +++- addons/addon-webgl/src/TextureAtlas.ts | 20 +++++++++++++------- addons/addon-webgl/src/WebglRenderer.ts | 18 ++++++++++++++++++ 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 568d3a4f9..b412f2d89 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -358,7 +358,9 @@ export class GlyphRenderer extends Disposable { gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer); gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW); - // Bind the atlas page texture if they have changed + // Bind the atlas page texture if they have changed. AtlasPage.version is globally + // monotonic, so a page object swap at the same index (which happens after a page merge) + // is detected by the same comparison. for (let i = 0; i < this._atlas.pages.length; i++) { if (this._atlas.pages[i].version !== this._atlasTextures[i].version) { this._bindAtlasPageTexture(gl, this._atlas, i); diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 5629508f2..34188235c 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -133,7 +133,9 @@ export class TextureAtlas implements ITextureAtlas { private _requestClearModel = false; public beginFrame(): boolean { - return this._requestClearModel; + const result = this._requestClearModel; + this._requestClearModel = false; + return result; } public clearTexture(): void { @@ -193,7 +195,7 @@ export class TextureAtlas implements ITextureAtlas { // Merge into the new page const mergedPage = this._mergePages(mergingPages, mergedPageIndex); - mergedPage.version++; + mergedPage.version = ++AtlasPage.nextVersion; // Delete the pages, shifting glyph texture pages as needed for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) { @@ -251,7 +253,7 @@ export class TextureAtlas implements ITextureAtlas { for (const g of adjustingPage.glyphs) { g.texturePage--; } - adjustingPage.version++; + adjustingPage.version = ++AtlasPage.nextVersion; } } @@ -937,7 +939,7 @@ export class TextureAtlas implements ITextureAtlas { rasterizedGlyph.size.y ); activePage.addGlyph(rasterizedGlyph); - activePage.version++; + activePage.version = ++AtlasPage.nextVersion; return rasterizedGlyph; } @@ -1047,9 +1049,13 @@ class AtlasPage { } /** - * Used to check whether the canvas of the atlas page has changed. + * Monotonically increasing across all atlas pages globally. Used to detect when the texture + * unit at a given index needs to be re-uploaded — both for content changes within the same + * page and for a page object swap at the same index (which happens after a page merge, + * where a per-page counter could coincide with the previously-bound page's value). */ - public version = 0; + public static nextVersion: number = 0; + public version = ++AtlasPage.nextVersion; // Texture atlas current positioning data. The texture packing strategy used is to fill from // left-to-right and top-to-bottom. When the glyph being written is less than half of the current @@ -1092,7 +1098,7 @@ class AtlasPage { this.currentRow.y = 0; this.currentRow.height = 0; this.fixedRows.length = 0; - this.version++; + this.version = ++AtlasPage.nextVersion; } } diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 91867ea26..578955fc1 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -28,6 +28,16 @@ import { addDisposableListener } from 'browser/Dom'; import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; +const enum Constants { + /** + * Upper bound on how many times `renderRows` re-runs `_updateModel` after detecting a + * mid-update atlas page merge. A single retry should always suffice once every glyph + * needed by the frame is cached; the cap exists purely as a safety net against an + * unexpected runaway, not as a tuned value. + */ + MERGE_RETRY_LIMIT = 3 +} + export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; private _cursorBlinkStateManager: MutableDisposable = this._register(new MutableDisposable()); @@ -375,6 +385,14 @@ export class WebglRenderer extends Disposable implements IRenderer { this._updateModel(start, end); } + // A mid-update atlas page merge shifts cached glyphs to new texture pages, leaving + // stale texturePage values in the vertex buffer. Re-run a full update if that happened. + let mergeRetries = 0; + while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) { + this._clearModel(true); + this._updateModel(0, this._terminal.rows - 1); + } + // Render this._rectangleRenderer.value.renderBackgrounds(); this._glyphRenderer.value.render(this._model); From 3bcb57545e3d2db44555f9128f4fefb66555fd98 Mon Sep 17 00:00:00 2001 From: Victor Sych Date: Thu, 21 May 2026 04:59:37 +0200 Subject: [PATCH 80/90] Re-upload atlas textures after page merges --- addons/addon-webgl/src/GlyphRenderer.ts | 4 ++++ addons/addon-webgl/src/RectangleRenderer.ts | 5 +++-- addons/addon-webgl/src/WebglRenderer.ts | 17 ++++++++--------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index b412f2d89..46b8efeb9 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -373,6 +373,10 @@ export class GlyphRenderer extends Disposable { public setAtlas(atlas: ITextureAtlas): void { this._atlas = atlas; + this.invalidateAtlasTextures(); + } + + public invalidateAtlasTextures(): void { for (const glTexture of this._atlasTextures) { glTexture.version = -1; } diff --git a/addons/addon-webgl/src/RectangleRenderer.ts b/addons/addon-webgl/src/RectangleRenderer.ts index 4ad8d66ff..81e129e5c 100644 --- a/addons/addon-webgl/src/RectangleRenderer.ts +++ b/addons/addon-webgl/src/RectangleRenderer.ts @@ -336,8 +336,9 @@ export class RectangleRenderer extends Disposable { } } - if (vertices.attributes.length < offset + 4) { - vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE); + if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) { + // +1 for the viewport-clear rectangle at offset 0. + vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE); } $x1 = startX * this._dimensions.device.cell.width; $y1 = y * this._dimensions.device.cell.height; diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 578955fc1..500308bd1 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -29,13 +29,7 @@ import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; const enum Constants { - /** - * Upper bound on how many times `renderRows` re-runs `_updateModel` after detecting a - * mid-update atlas page merge. A single retry should always suffice once every glyph - * needed by the frame is cached; the cap exists purely as a safety net against an - * unexpected runaway, not as a tuned value. - */ - MERGE_RETRY_LIMIT = 3 + MERGE_RETRY_LIMIT = 32 } export class WebglRenderer extends Disposable implements IRenderer { @@ -385,13 +379,18 @@ export class WebglRenderer extends Disposable implements IRenderer { this._updateModel(start, end); } - // A mid-update atlas page merge shifts cached glyphs to new texture pages, leaving - // stale texturePage values in the vertex buffer. Re-run a full update if that happened. + // A mid-update atlas page merge invalidates vertex data and may not bump the host + // page's version, so re-run the update and force a full texture rebind. + let merged = false; let mergeRetries = 0; while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) { + merged = true; this._clearModel(true); this._updateModel(0, this._terminal.rows - 1); } + if (merged) { + this._glyphRenderer.value.invalidateAtlasTextures(); + } // Render this._rectangleRenderer.value.renderBackgrounds(); From b36001ee7b26e68134fca73a8feaee307411fc22 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 21 May 2026 02:17:43 -0700 Subject: [PATCH 81/90] Git ignore cursor nal-traces --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index e48470eff..81fe7a147 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ build/ yarn.lock test-results/ +# Tracing/debug files +.cursor/nal-trace/ + # Keep bundled code out of Git dist/ demo/dist/ From 57315e6cc997f281885dd75823929d649ae05d90 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 21 May 2026 02:58:43 -0700 Subject: [PATCH 82/90] Remove deep-equal/types packages --- package-lock.json | 642 ---------------------------------------------- package.json | 2 - 2 files changed, 644 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23f9bbc2b..e0ac4e728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "@stylistic/eslint-plugin": "^4.4.1", "@types/chai": "^4.2.22", "@types/debug": "^4.1.7", - "@types/deep-equal": "^1.0.1", "@types/express": "4", "@types/express-ws": "^3.0.1", "@types/jsdom": "^27.0.0", @@ -33,7 +32,6 @@ "chai": "^4.3.4", "concurrently": "^9.1.2", "cross-env": "^7.0.3", - "deep-equal": "^2.0.5", "esbuild": "~0.25.2", "eslint": "^9.39.2", "eslint-plugin-jsdoc": "^50.8.0", @@ -242,7 +240,6 @@ "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", @@ -556,7 +553,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" }, @@ -598,7 +594,6 @@ "url": "https://opencollective.com/csstools" } ], - "peer": true, "engines": { "node": ">=18" } @@ -1534,12 +1529,6 @@ "@types/ms": "*" } }, - "node_modules/@types/deep-equal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.4.tgz", - "integrity": "sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==", - "dev": true - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1796,7 +1785,6 @@ "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", @@ -2362,7 +2350,6 @@ "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" }, @@ -2569,22 +2556,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2606,21 +2577,6 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axios": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", @@ -2754,7 +2710,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2808,24 +2763,6 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3380,38 +3317,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3445,40 +3350,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3612,26 +3483,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -3750,7 +3601,6 @@ "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", @@ -4290,21 +4140,6 @@ "node": ">8.0.0" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4429,15 +4264,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4602,18 +4428,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4623,18 +4437,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4854,20 +4656,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -4893,54 +4681,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4953,34 +4693,6 @@ "node": ">=8" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4996,22 +4708,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5042,18 +4738,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5063,22 +4747,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -5106,51 +4774,6 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5163,39 +4786,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -5214,34 +4804,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -5251,12 +4813,6 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -6206,51 +5762,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6602,15 +6113,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6760,26 +6262,6 @@ "node": ">= 0.10" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", @@ -6956,23 +6438,6 @@ } ] }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7015,7 +6480,6 @@ "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", @@ -7132,38 +6596,6 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7428,19 +6860,6 @@ "node": ">= 0.8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7856,7 +7275,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8023,7 +7441,6 @@ "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", @@ -8072,7 +7489,6 @@ "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", @@ -8230,70 +7646,12 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", diff --git a/package.json b/package.json index 3f059d109..f664f5411 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "@stylistic/eslint-plugin": "^4.4.1", "@types/chai": "^4.2.22", "@types/debug": "^4.1.7", - "@types/deep-equal": "^1.0.1", "@types/express": "4", "@types/express-ws": "^3.0.1", "@types/jsdom": "^27.0.0", @@ -97,7 +96,6 @@ "chai": "^4.3.4", "concurrently": "^9.1.2", "cross-env": "^7.0.3", - "deep-equal": "^2.0.5", "esbuild": "~0.25.2", "eslint": "^9.39.2", "eslint-plugin-jsdoc": "^50.8.0", From f100e93ba58c51b3c74d3b007a3453fd8f1e81f5 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 21 May 2026 03:04:16 -0700 Subject: [PATCH 83/90] Remove utf8 dep --- package-lock.json | 14 ---------- package.json | 2 -- src/common/input/TextDecoder.test.ts | 38 ++++++++++++++++++++++++---- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0ac4e728..c454125c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,6 @@ "@types/mocha": "^9.0.0", "@types/node": "^22.19.3", "@types/trusted-types": "^1.0.6", - "@types/utf8": "^3.0.0", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", "@typescript-eslint/eslint-plugin": "^8.50.1", @@ -47,7 +46,6 @@ "ts-loader": "^9.3.1", "typescript": "^5.9.3", "typescript-eslint": "^8.50.1", - "utf8": "^3.0.0", "webpack": "^5.61.0", "webpack-cli": "^4.9.1", "ws": "^8.2.3", @@ -1726,12 +1724,6 @@ "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", "dev": true }, - "node_modules/@types/utf8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/utf8/-/utf8-3.0.3.tgz", - "integrity": "sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==", - "dev": true - }, "node_modules/@types/webpack": { "version": "5.28.5", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", @@ -7360,12 +7352,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf8": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", - "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==", - "dev": true - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index f664f5411..76287f33a 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,6 @@ "@types/mocha": "^9.0.0", "@types/node": "^22.19.3", "@types/trusted-types": "^1.0.6", - "@types/utf8": "^3.0.0", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", "@typescript-eslint/eslint-plugin": "^8.50.1", @@ -111,7 +110,6 @@ "ts-loader": "^9.3.1", "typescript": "^5.9.3", "typescript-eslint": "^8.50.1", - "utf8": "^3.0.0", "webpack": "^5.61.0", "webpack-cli": "^4.9.1", "ws": "^8.2.3", diff --git a/src/common/input/TextDecoder.test.ts b/src/common/input/TextDecoder.test.ts index a0a4a3230..1959e1591 100644 --- a/src/common/input/TextDecoder.test.ts +++ b/src/common/input/TextDecoder.test.ts @@ -5,7 +5,6 @@ import { assert } from 'chai'; import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32, utf32ToString } from 'common/input/TextDecoder'; -import { encode } from 'utf8'; // convert UTF32 codepoints to string @@ -29,6 +28,35 @@ function fromByteString(s: string): Uint8Array { return result; } +function stringToUtf8Bytes(s: string): Uint8Array { + const bytes: number[] = []; + for (let i = 0; i < s.length; i++) { + let cp = s.charCodeAt(i); + if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.length) { + const next = s.charCodeAt(i + 1); + if (next >= 0xDC00 && next <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (next - 0xDC00); + i++; + } + } + if (cp < 0x80) { + bytes.push(cp); + } else if (cp < 0x800) { + bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F)); + } else if (cp < 0x10000) { + bytes.push(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F)); + } else { + bytes.push( + 0xF0 | (cp >> 18), + 0x80 | ((cp >> 12) & 0x3F), + 0x80 | ((cp >> 6) & 0x3F), + 0x80 | (cp & 0x3F) + ); + } + } + return new Uint8Array(bytes); +} + function assertDecodedRange( min: number, max: number, @@ -172,7 +200,7 @@ describe('text encodings', () => { max, (i) => (i >= 0xD800 && i <= 0xDFFF) || i === 0xFEFF, (i) => String.fromCharCode(i), - (input, target) => decoder.decode(fromByteString(encode(input)), target), + (input, target) => decoder.decode(stringToUtf8Bytes(input), target), (data, length) => toString(data, length) ); }); @@ -187,7 +215,7 @@ describe('text encodings', () => { max, () => false, (i) => stringFromCodePoint(i), - (input, target) => decoder.decode(fromByteString(encode(input)), target), + (input, target) => decoder.decode(stringToUtf8Bytes(input), target), (data, length) => toString(data, length) ); }); @@ -196,7 +224,7 @@ describe('text encodings', () => { it('0xFEFF(BOM)', () => { const decoder = new Utf8ToUtf32(); const target = new Uint32Array(5); - const utf8Data = fromByteString(encode(String.fromCharCode(0xFEFF))); + const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF)); const length = decoder.decode(utf8Data, target); assert.equal(length, 0); decoder.clear(); @@ -207,7 +235,7 @@ describe('text encodings', () => { const decoder = new Utf8ToUtf32(); const target = new Uint32Array(500); for (let i = 0; i < TEST_STRINGS.length; ++i) { - const utf8Data = fromByteString(encode(TEST_STRINGS[i])); + const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]); const length = decoder.decode(utf8Data, target); assert.equal(toString(target, length), TEST_STRINGS[i]); decoder.clear(); From 41d9c4080f759e95139aa3094f2dcae7f7f797c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 10:06:23 +0000 Subject: [PATCH 84/90] Bump fast-uri from 3.1.0 to 3.1.2 Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- package-lock.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0ac4e728..9f35e360c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3945,9 +3945,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -3958,7 +3958,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", From df9fb7e473d449fbb00456930d27a7586be2fbd4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 21 May 2026 03:06:53 -0700 Subject: [PATCH 85/90] Remove cross-env dep --- package-lock.json | 19 ------------------- package.json | 2 -- 2 files changed, 21 deletions(-) diff --git a/package-lock.json b/package-lock.json index e0ac4e728..22ceab879 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,7 +31,6 @@ "@typescript/native-preview": "^7.0.0-dev.20260213.1", "chai": "^4.3.4", "concurrently": "^9.1.2", - "cross-env": "^7.0.3", "esbuild": "~0.25.2", "eslint": "^9.39.2", "eslint-plugin-jsdoc": "^50.8.0", @@ -3201,24 +3200,6 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "dev": true }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", diff --git a/package.json b/package.json index f664f5411..61795270b 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "test-unit": "node ./bin/test_unit.js", "test-unit-slow-tests": "npm run test-unit | grep \"ms)\"", "test-unit-coverage": "node ./bin/test_unit.js --coverage", - "test-unit-dev": "cross-env NODE_PATH='./out' mocha", "test-integration": "node ./bin/test_integration.js --workers=75%", "test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"", "test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"", @@ -95,7 +94,6 @@ "@typescript/native-preview": "^7.0.0-dev.20260213.1", "chai": "^4.3.4", "concurrently": "^9.1.2", - "cross-env": "^7.0.3", "esbuild": "~0.25.2", "eslint": "^9.39.2", "eslint-plugin-jsdoc": "^50.8.0", From 17e7dafa17af28b1e0aaf9ce789db287546d19a8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 10:06:53 +0000 Subject: [PATCH 86/90] Bump axios from 1.15.0 to 1.15.2 Bumps [axios](https://github.com/axios/axios) from 1.15.0 to 1.15.2. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.15.0...v1.15.2) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.2 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- addons/addon-ligatures/package.json | 2 +- package-lock.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index fc7a433de..0e06add18 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -37,7 +37,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" diff --git a/package-lock.json b/package-lock.json index e0ac4e728..a8072f3f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -89,7 +89,7 @@ "devDependencies": { "@types/lru-cache": "^5.1.0", "@types/opentype.js": "^0.7.0", - "axios": "^1.15.0", + "axios": "^1.15.2", "font-finder": "^1.1.0", "mkdirp": "0.5.5", "yauzl": "^3.2.1" @@ -2578,9 +2578,9 @@ "dev": true }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, "license": "MIT", "dependencies": { From 51f61863522da7f53bfad3870d6b9fb387ae7389 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 21 May 2026 03:09:41 -0700 Subject: [PATCH 87/90] Remove @types/debug and trusted-types --- package-lock.json | 23 ----------------------- package.json | 2 -- 2 files changed, 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index c454125c5..530c455ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,13 +16,11 @@ "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^4.4.1", "@types/chai": "^4.2.22", - "@types/debug": "^4.1.7", "@types/express": "4", "@types/express-ws": "^3.0.1", "@types/jsdom": "^27.0.0", "@types/mocha": "^9.0.0", "@types/node": "^22.19.3", - "@types/trusted-types": "^1.0.6", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", "@typescript-eslint/eslint-plugin": "^8.50.1", @@ -1518,15 +1516,6 @@ "@types/node": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "dependencies": { - "@types/ms": "*" - } - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1639,12 +1628,6 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true - }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -1718,12 +1701,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, - "node_modules/@types/trusted-types": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", - "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", - "dev": true - }, "node_modules/@types/webpack": { "version": "5.28.5", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", diff --git a/package.json b/package.json index 76287f33a..d2cd6b324 100644 --- a/package.json +++ b/package.json @@ -80,13 +80,11 @@ "@playwright/test": "^1.57.0", "@stylistic/eslint-plugin": "^4.4.1", "@types/chai": "^4.2.22", - "@types/debug": "^4.1.7", "@types/express": "4", "@types/express-ws": "^3.0.1", "@types/jsdom": "^27.0.0", "@types/mocha": "^9.0.0", "@types/node": "^22.19.3", - "@types/trusted-types": "^1.0.6", "@types/webpack": "^5.28.0", "@types/ws": "^8.2.0", "@typescript-eslint/eslint-plugin": "^8.50.1", From f484584b706d2a91363f17e98c61f07d713f1c34 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:30:25 -0700 Subject: [PATCH 88/90] Speed up decoration cell lookup with a per-line index Refs #5176. forEachDecorationAtCell and getDecorationsAtCell previously scanned every registered decoration on each call. With thousands of search highlights this becomes O(total decorations) per cell and blocks rendering during viewport refreshes. Maintain a Map of buffer line -> decorations that cover that line. Multi-line decorations are indexed on every line in their span, so mixed single-line and multi-line workloads stay fast without falling back to a full scan. Keep the index aligned with buffer mutations: - Register and unregister decorations in the line buckets on add/dispose. - On trim, shift line keys synchronously (before marker handlers run). - On insert/delete, re-index affected decorations in a microtask after marker line updates are applied. Inject IBufferService so line listeners follow the active buffer. Add unit tests for trim, dispose-on-trim, insert, and dense single-line plus multi-line lookup. Add DecorationService.benchmark.ts to measure sparse hits and viewport-sized grid scans with 20k decorations. Co-authored-by: Cursor --- src/common/services/DecorationService.test.ts | 105 +++++++++- src/common/services/DecorationService.ts | 185 ++++++++++++++++-- src/common/services/Services.ts | 2 + test/benchmark/DecorationService.benchmark.ts | 109 +++++++++++ 4 files changed, 376 insertions(+), 25 deletions(-) create mode 100644 test/benchmark/DecorationService.benchmark.ts diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e24..b70232f36 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,7 +8,9 @@ import { DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { Buffer } from 'common/buffer/Buffer'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker { }()); } +function createDecorationService(): DecorationService { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + return new DecorationService(new MockLogService(), bufferService); +} + const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -35,7 +42,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -48,7 +55,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -74,7 +81,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -99,11 +106,35 @@ describe('DecorationService', () => { service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d)); assert.strictEqual(foundAtX8.length, 0); }); + + it('should find multi-line decoration when single-line decorations exist on other lines', () => { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + for (let i = 0; i < 100; i++) { + serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(i), + width: 5 + }); + } + const multiLine = serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(10), + width: 10, + height: 3 + }); + assert.ok(multiLine); + + const found: typeof multiLine[] = []; + serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d)); + assert.include(found, multiLine); + }); }); describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -117,4 +148,66 @@ describe('DecorationService', () => { assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0); }); }); + + describe('line index maintenance', () => { + it('should keep lookups correct after buffer trim', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(buffer.lines.length - 1); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d)); + assert.strictEqual(found.length, 1); + }); + + it('should remove decoration from line index when marker is trimmed off buffer', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(0); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + assert.isTrue(marker.isDisposed); + assert.isTrue(decoration!.isDisposed); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d)); + assert.strictEqual(found.length, 0); + }); + + it('should keep multi-line decoration indexed after line insert', async () => { + const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(3); + const decoration = service.registerDecoration({ marker, width: 10, height: 3 }); + assert.ok(decoration); + + buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); + await new Promise(resolve => queueMicrotask(resolve)); + + const foundOnSpan: typeof decoration[] = []; + for (let line = marker.line; line < marker.line + 3; line++) { + service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d)); + } + assert.include(foundOnSpan, decoration); + + const foundOutsideSpan: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d)); + assert.strictEqual(foundOutsideSpan.length, 0); + }); + }); }); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985..2bac9717c 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -4,18 +4,17 @@ */ import { css } from 'common/Color'; -import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; // Work variables to avoid garbage collection let $xmin = 0; let $xmax = 0; -let $ymin = 0; -let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; @@ -27,6 +26,14 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; + /** + * Decorations indexed by buffer line. Multi-line decorations are present in every line bucket + * they span so cell lookup only iterates decorations relevant to that line. + */ + private readonly _decorationsByLine: Map = new Map(); + + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); @@ -34,12 +41,17 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor(@ILogService private readonly _logService: ILogService) { + constructor( + @ILogService private readonly _logService: ILogService, + @IBufferService private readonly _bufferService: IBufferService + ) { super(); this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); + this._register(this._bufferService.buffers.onBufferActivate(() => this._attachBufferLineListeners())); + this._attachBufferLineListeners(); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -53,12 +65,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { + this._removeFromLineIndex(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); + this._addToLineIndex(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -69,19 +83,17 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); + this._decorationsByLine.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + return; + } let xmin = 0; let xmax = 0; - let ymin = 0; - let ymax = 0; - for (const d of this._decorations.values()) { - ymin = d.marker.line; - ymax = ymin + (d.options.height ?? 1); - if (line < ymin || line >= ymax) { - continue; - } + for (const d of bucket) { xmin = d.options.x ?? 0; xmax = xmin + (d.options.width ?? 1); if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -91,12 +103,11 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - for (const d of this._decorations.values()) { - $ymin = d.marker.line; - $ymax = $ymin + (d.options.height ?? 1); - if (line < $ymin || line >= $ymax) { - continue; - } + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + return; + } + for (const d of bucket) { $xmin = d.options.x ?? 0; $xmax = $xmin + (d.options.width ?? 1); if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -104,12 +115,147 @@ export class DecorationService extends Disposable implements IDecorationService } } } + + private _attachBufferLineListeners(): void { + const store = new DisposableStore(); + this._bufferLineListeners.value = store; + const lines = this._bufferService.buffer.lines; + store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); + store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); + store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); + } + + private _getDecorationHeight(decoration: IInternalDecoration): number { + return decoration.options.height ?? 1; + } + + private _addToLineIndex(decoration: IInternalDecoration): void { + const start = decoration.marker.line; + if (start < 0) { + return; + } + decoration._indexedStartLine = start; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + let bucket = this._decorationsByLine.get(line); + if (!bucket) { + bucket = []; + this._decorationsByLine.set(line, bucket); + } + bucket.push(decoration); + } + } + + private _removeFromLineIndex(decoration: IInternalDecoration): void { + const start = decoration._indexedStartLine; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + continue; + } + const index = bucket.indexOf(decoration); + if (index !== -1) { + bucket.splice(index, 1); + } + if (bucket.length === 0) { + this._decorationsByLine.delete(line); + } + } + } + + private _reindexDecoration(decoration: IInternalDecoration): void { + this._removeFromLineIndex(decoration); + if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { + this._addToLineIndex(decoration); + } + } + + private _lineIndexSyncCallbacks: (() => void)[] = []; + + /** Re-index after marker line updates (buffer listeners may run before markers). */ + private _scheduleLineIndexSync(callback: () => void): void { + this._lineIndexSyncCallbacks.push(callback); + if (this._lineIndexSyncCallbacks.length > 1) { + return; + } + queueMicrotask(() => { + const callbacks = this._lineIndexSyncCallbacks; + this._lineIndexSyncCallbacks = []; + for (const cb of callbacks) { + cb(); + } + }); + } + + private _handleBufferLinesTrim(amount: number): void { + if (amount <= 0) { + return; + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line - amount; + if (newLine < 0) { + continue; + } + const existing = newMap.get(newLine); + if (existing) { + existing.push(...bucket); + } else { + newMap.set(newLine, bucket.slice()); + } + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations.values()) { + if (!d.marker.isDisposed) { + d._indexedStartLine -= amount; + } + } + } + + private _handleBufferLinesInsert(event: IInsertEvent): void { + this._scheduleLineIndexSync(() => { + for (const d of this._decorations.values()) { + if (d.marker.isDisposed) { + continue; + } + const height = this._getDecorationHeight(d); + if (d.marker.line + height > event.index) { + this._reindexDecoration(d); + } + } + }); + } + + private _handleBufferLinesDelete(event: IDeleteEvent): void { + const deleteEnd = event.index + event.amount; + this._scheduleLineIndexSync(() => { + for (const d of this._decorations.values()) { + if (d.marker.isDisposed) { + continue; + } + const start = d.marker.line; + const end = start + this._getDecorationHeight(d); + if (start >= deleteEnd) { + this._reindexDecoration(d); + } else if (start < event.index && end > event.index) { + this._reindexDecoration(d); + } + } + }); + } } class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + /** Start line used for line-index removal when marker.line is cleared on dispose. */ + public _indexedStartLine: number; + public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; private readonly _onDispose = this.add(new Emitter()); @@ -144,6 +290,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + this._indexedStartLine = options.marker.line; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc..a858193c3 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration { readonly backgroundColorRGB: IColor | undefined; readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; } diff --git a/test/benchmark/DecorationService.benchmark.ts b/test/benchmark/DecorationService.benchmark.ts new file mode 100644 index 000000000..1a2033de1 --- /dev/null +++ b/test/benchmark/DecorationService.benchmark.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { DecorationService } from 'common/services/DecorationService'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +const enum Constants { + COLS = 80, + ROWS = 30, + SINGLE_LINE_DECORATION_COUNT = 20_000, + MULTI_LINE_DECORATION_COUNT = 19_999, + MULTI_LINE_HEIGHT = 2, + VIEWPORT_SCAN_ITERATIONS = 20 +} + +function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void { + const buffer = bufferService.buffer; + for (let i = 0; i < count; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } +} + +function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void { + const buffer = bufferService.buffer; + for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } + const multiLineMarker = buffer.addMarker(10); + service.registerDecoration({ + marker: multiLineMarker, + width: Constants.COLS, + height: Constants.MULTI_LINE_HEIGHT + }); +} + +function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number { + let hitCount = 0; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + service.forEachDecorationAtCell(col, row, undefined, () => { + hitCount++; + }); + } + } + return hitCount; +} + +perfContext('DecorationService.forEachDecorationAtCell', () => { + perfContext('single-line dense / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 0, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('mixed single-line + multi-line / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerMixedDecorations(service, bufferService); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 10, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('viewport grid scan', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let totalHits = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits }; + }, { fork: false }).showAverageRuntime(); + }); +}); From 9e72a4b6d3376609216d6ba34624f9fbc0e6bce6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:47:11 -0700 Subject: [PATCH 89/90] Refactor decoration line index into DecorationLineCache Extract the per-line lookup index from DecorationService into an exported DecorationLineCache class in the same module, with DecorationService listed first for readability. Optimize buffer insert/delete maintenance to shift map keys in O(unique indexed lines) instead of full re-indexing every affected decoration. Only multi-line decorations that span the mutation point get a full re-index. Add a direct DecorationLineCache unit test in DecorationService.test.ts. Co-authored-by: Cursor --- src/common/services/DecorationService.test.ts | 9 +- src/common/services/DecorationService.ts | 187 +++++++++++++----- 2 files changed, 145 insertions(+), 51 deletions(-) diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index b70232f36..e3a6c2f04 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -4,7 +4,7 @@ */ import { assert } from 'chai'; -import { DecorationService } from './DecorationService'; +import { DecorationLineCache, DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; @@ -149,6 +149,13 @@ describe('DecorationService', () => { }); }); + describe('DecorationLineCache', () => { + it('should return undefined for lines with no indexed decorations', () => { + const cache = new DecorationLineCache(); + assert.isUndefined(cache.getDecorationsOnLine(0)); + }); + }); + describe('line index maintenance', () => { it('should keep lookups correct after buffer trim', () => { const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 2bac9717c..645a63818 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -3,14 +3,14 @@ * @license MIT */ +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; import { css } from 'common/Color'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, ICircularList } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; -import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; // Work variables to avoid garbage collection let $xmin = 0; @@ -26,13 +26,7 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; - /** - * Decorations indexed by buffer line. Multi-line decorations are present in every line bucket - * they span so cell lookup only iterates decorations relevant to that line. - */ - private readonly _decorationsByLine: Map = new Map(); - - private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _lineCache = this._register(new DecorationLineCache()); private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; @@ -50,8 +44,10 @@ export class DecorationService extends Disposable implements IDecorationService this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); - this._register(this._bufferService.buffers.onBufferActivate(() => this._attachBufferLineListeners())); - this._attachBufferLineListeners(); + this._register(this._bufferService.buffers.onBufferActivate(() => { + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); + })); + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -65,14 +61,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { - this._removeFromLineIndex(decoration); + this._lineCache.remove(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); - this._addToLineIndex(decoration); + this._lineCache.add(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -83,11 +79,11 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); - this._decorationsByLine.clear(); + this._lineCache.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { - const bucket = this._decorationsByLine.get(line); + const bucket = this._lineCache.getDecorationsOnLine(line); if (!bucket) { return; } @@ -103,7 +99,7 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - const bucket = this._decorationsByLine.get(line); + const bucket = this._lineCache.getDecorationsOnLine(line); if (!bucket) { return; } @@ -115,11 +111,44 @@ export class DecorationService extends Disposable implements IDecorationService } } } +} - private _attachBufferLineListeners(): void { +/** + * Per-logical-line index of decorations for fast cell lookup. + * + * Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots. + * Multi-line decorations appear in every line bucket they span. The index is kept aligned + * with marker.line updates via buffer line trim/insert/delete events. + */ +export class DecorationLineCache extends Disposable { + private readonly _decorationsByLine: Map = new Map(); + private readonly _decorations = new Set(); + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + + private _lineIndexSyncCallbacks: (() => void)[] = []; + + public clear(): void { + this._decorationsByLine.clear(); + this._decorations.clear(); + } + + public add(decoration: IInternalDecoration): void { + this._decorations.add(decoration); + this._addToLineBuckets(decoration); + } + + public remove(decoration: IInternalDecoration): void { + this._decorations.delete(decoration); + this._removeFromLineBuckets(decoration); + } + + public getDecorationsOnLine(line: number): ReadonlyArray | undefined { + return this._decorationsByLine.get(line); + } + + public attachToBufferLines(lines: ICircularList): void { const store = new DisposableStore(); this._bufferLineListeners.value = store; - const lines = this._bufferService.buffer.lines; store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); @@ -129,7 +158,7 @@ export class DecorationService extends Disposable implements IDecorationService return decoration.options.height ?? 1; } - private _addToLineIndex(decoration: IInternalDecoration): void { + private _addToLineBuckets(decoration: IInternalDecoration): void { const start = decoration.marker.line; if (start < 0) { return; @@ -146,7 +175,7 @@ export class DecorationService extends Disposable implements IDecorationService } } - private _removeFromLineIndex(decoration: IInternalDecoration): void { + private _removeFromLineBuckets(decoration: IInternalDecoration): void { const start = decoration._indexedStartLine; const height = this._getDecorationHeight(decoration); for (let line = start; line < start + height; line++) { @@ -165,14 +194,12 @@ export class DecorationService extends Disposable implements IDecorationService } private _reindexDecoration(decoration: IInternalDecoration): void { - this._removeFromLineIndex(decoration); + this._removeFromLineBuckets(decoration); if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { - this._addToLineIndex(decoration); + this._addToLineBuckets(decoration); } } - private _lineIndexSyncCallbacks: (() => void)[] = []; - /** Re-index after marker line updates (buffer listeners may run before markers). */ private _scheduleLineIndexSync(callback: () => void): void { this._lineIndexSyncCallbacks.push(callback); @@ -209,7 +236,7 @@ export class DecorationService extends Disposable implements IDecorationService for (const [line, bucket] of newMap) { this._decorationsByLine.set(line, bucket); } - for (const d of this._decorations.values()) { + for (const d of this._decorations) { if (!d.marker.isDisposed) { d._indexedStartLine -= amount; } @@ -217,35 +244,95 @@ export class DecorationService extends Disposable implements IDecorationService } private _handleBufferLinesInsert(event: IInsertEvent): void { - this._scheduleLineIndexSync(() => { - for (const d of this._decorations.values()) { - if (d.marker.isDisposed) { - continue; - } - const height = this._getDecorationHeight(d); - if (d.marker.line + height > event.index) { - this._reindexDecoration(d); - } - } - }); + this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event)); } private _handleBufferLinesDelete(event: IDeleteEvent): void { - const deleteEnd = event.index + event.amount; - this._scheduleLineIndexSync(() => { - for (const d of this._decorations.values()) { - if (d.marker.isDisposed) { - continue; - } - const start = d.marker.line; - const end = start + this._getDecorationHeight(d); - if (start >= deleteEnd) { - this._reindexDecoration(d); - } else if (start < event.index && end > event.index) { - this._reindexDecoration(d); - } + this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event)); + } + + private _mergeLineBucket(newMap: Map, line: number, bucket: IInternalDecoration[]): void { + const existing = newMap.get(line); + if (existing) { + existing.push(...bucket); + } else { + newMap.set(line, bucket.slice()); + } + } + + /** + * Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count). + * Decorations that span the insert point are re-indexed individually (rare vs single-line hits). + */ + private _applyBufferLinesInsert(event: IInsertEvent): void { + const { index, amount } = event; + const spanCrossers: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; } - }); + const start = d._indexedStartLine; + if (start < index && start + this._getDecorationHeight(d) > index) { + spanCrossers.push(d); + this._removeFromLineBuckets(d); + } + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line >= index ? line + amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + if (d._indexedStartLine >= index) { + d._indexedStartLine = d.marker.line; + } + } + for (const d of spanCrossers) { + this._addToLineBuckets(d); + } + } + + /** + * Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a + * multi-line decoration spans across the deleted range but survives. + */ + private _applyBufferLinesDelete(event: IDeleteEvent): void { + const deleteEnd = event.index + event.amount; + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + if (line >= event.index && line < deleteEnd) { + continue; + } + const newLine = line >= deleteEnd ? line - event.amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + const toReindex: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + const height = this._getDecorationHeight(d); + if (start >= deleteEnd) { + d._indexedStartLine = d.marker.line; + } else if (start < event.index && start + height > deleteEnd) { + toReindex.push(d); + } + } + for (const d of toReindex) { + this._reindexDecoration(d); + } } } From 4a0cb5ae6bd46bc7870d1c8351b754a57111e2d2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 26 May 2026 09:52:53 -0700 Subject: [PATCH 90/90] Add MicrotaskTimer and use it for decoration line index sync Introduce MicrotaskTimer as a TimeoutTimer-style helper that schedules a single runner via queueMicrotask, with set and cancel but no cancelAndSet. Use it in DecorationLineCache to batch insert/delete line-index updates after marker line adjustments, replacing ad-hoc queueMicrotask scheduling. Co-authored-by: Cursor --- src/common/Async.ts | 36 ++++++++++++++++++++++++ src/common/services/DecorationService.ts | 10 +++---- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/common/Async.ts b/src/common/Async.ts index 7345f19da..276532fb8 100644 --- a/src/common/Async.ts +++ b/src/common/Async.ts @@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable { } } +/** + * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled + * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not + * run yet. + */ +export class MicrotaskTimer implements IDisposable { + private _isScheduled = false; + private _isDisposed = false; + + public dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + public cancel(): void { + this._isScheduled = false; + } + + public set(runner: () => void): void { + if (this._isDisposed) { + throw new Error('Calling set on a disposed MicrotaskTimer'); + } + if (this._isScheduled) { + return; + } + this._isScheduled = true; + queueMicrotask(() => { + if (!this._isScheduled) { + return; + } + this._isScheduled = false; + runner(); + }); + } +} + export class IntervalTimer implements IDisposable { private _disposable: IDisposable | undefined; private _isDisposed = false; diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 645a63818..a582c18af 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -4,6 +4,7 @@ */ import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { MicrotaskTimer } from 'common/Async'; import { css } from 'common/Color'; import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; @@ -124,10 +125,12 @@ export class DecorationLineCache extends Disposable { private readonly _decorationsByLine: Map = new Map(); private readonly _decorations = new Set(); private readonly _bufferLineListeners = this._register(new MutableDisposable()); - + private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer()); private _lineIndexSyncCallbacks: (() => void)[] = []; public clear(): void { + this._lineIndexSyncCallbacks.length = 0; + this._lineIndexSyncTimer.cancel(); this._decorationsByLine.clear(); this._decorations.clear(); } @@ -203,10 +206,7 @@ export class DecorationLineCache extends Disposable { /** Re-index after marker line updates (buffer listeners may run before markers). */ private _scheduleLineIndexSync(callback: () => void): void { this._lineIndexSyncCallbacks.push(callback); - if (this._lineIndexSyncCallbacks.length > 1) { - return; - } - queueMicrotask(() => { + this._lineIndexSyncTimer.set(() => { const callbacks = this._lineIndexSyncCallbacks; this._lineIndexSyncCallbacks = []; for (const cb of callbacks) {