Compare commits

..
602 changed files with 28612 additions and 26085 deletions
-28
View File
@@ -1,28 +0,0 @@
---
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
## Running Benchmarks
- Full suite: `npm run benchmark`
- Single benchmark file:
- Tree: `npm run benchmark -- -t out-test/benchmark/Event.benchmark.js`
- Run file: `npm run benchmark -- -s "out-test/benchmark/Event.benchmark.js" out-test/benchmark/Event.benchmark.js`
- Single context/case:
- Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
## Benchmark Case Selection
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script).
+2 -22
View File
@@ -22,11 +22,10 @@ npm run build && npm run esbuild # Build all TypeScript and bundle
**Testing**:
- Unit tests: `npm run test-unit` (Mocha)
- Unit tests filtering to file: `npm run test-unit -- **/fileName.ts
- Per-addon unit tests: `npm run test-unit -- addons/addon-image/out-esbuild/*.test.js`
- Per-addon unit tests: `npm run test-unit addons/addon-image/out-esbuild/*.test.js`
- Integration tests: `npm run test-integration` (Playwright across Chrome/Firefox/WebKit)
- Integration tests by file: `npm run test-integration -- test/playwright/InputHandler.test.ts`. Never use grep to filter tests, it doesn't work
- Integration tests by addon: `npm run test-integration -- --suite=addon-search`. Suites always follow the format `addon-<something>`
- Lint: `npm run lint` (oxlint with type-aware rules, then ESLint for `naming-convention` only), `npm run lint-api` for `typings/`, `npm run lint-fix` for oxlint auto-fix
- Integration tests by addon: `npm run test-integration --suite=addon-search`. Suites always follow the format `addon-<something>`
- Lint changes: `npm run lint-changes` to lint only changed files, `npm run lint-changes-fix` to fix them
## Addon Development Pattern
@@ -58,13 +57,6 @@ 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
@@ -99,15 +91,3 @@ const cell = line?.getCell(0);
## Writing unit tests
- Unit tests live alongside the source code file of the thing it's testing with a .test.ts suffix.
## Cursor Cloud specific instructions
**Demo server**: Start with `npm start` (port 3000). The demo server uses node-pty to spawn real shell sessions over WebSocket. Integration tests auto-start it via Playwright's `webServer` config, so you don't need to start it manually for `npm run test-integration`.
**Build before testing**: Always run `npm run build && npm run esbuild` before `npm run test-unit`. Integration tests also need `npm run esbuild-demo-client` and `npm run esbuild-demo-server`. The update script handles this automatically on session start.
**No external services**: This project has zero external dependencies (no databases, Docker, or APIs). Everything runs locally with Node.js.
**TypeScript compiler**: The project uses `tsgo` (native TypeScript compiler preview) rather than standard `tsc`. It's installed via the `@typescript/native-preview` package.
**Lint only changed files**: Prefer `npm run lint-changes` over `npm run lint` when iterating on code changes — it's significantly faster.
@@ -1,11 +1,7 @@
---
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.
applyTo: '**/*.test.ts'
---
# Unit Test Instructions
When writing unit tests, follow these rules:
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.
+40 -32
View File
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
@@ -69,7 +69,7 @@ jobs:
./addons/addon-webgl/out/* \
./addons/addon-webgl/out-*st/*
- name: Upload artifacts
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: compressed-build.zip
@@ -79,9 +79,9 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
@@ -100,16 +100,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
- name: Install dependencies
run: |
npm ci
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
@@ -129,7 +129,6 @@ jobs:
exit $EXIT_CODE
test-unit:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -137,16 +136,21 @@ jobs:
runs-on: [ubuntu, macos, windows]
runs-on: ${{ matrix.runs-on }}-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
- name: Install dependencies
run: |
npm ci
- uses: actions/download-artifact@v7
- name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
@@ -162,7 +166,6 @@ jobs:
run: npm run test-unit --forbid-only
test-integration:
needs: build
timeout-minutes: 20
strategy:
matrix:
@@ -171,9 +174,9 @@ jobs:
browser: [chromium, firefox, webkit]
runs-on: ${{ matrix.runs-on }}
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -182,7 +185,12 @@ jobs:
npm ci
- name: Install playwright
run: npx playwright install --with-deps ${{ matrix.browser }}
- uses: actions/download-artifact@v7
- name: Wait for build job
uses: NathanFirmo/wait-for-other-job@v1.1.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
job: build
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
@@ -199,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
@@ -232,9 +240,9 @@ jobs:
matrix:
node-version: [22]
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js ${{ matrix.node-version }}.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}.x
cache: 'npm'
@@ -243,7 +251,7 @@ jobs:
npm ci
- name: Install playwright
run: npx playwright install
- uses: actions/download-artifact@v7
- uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Unzip artifacts
+4 -4
View File
@@ -24,12 +24,12 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v5
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v4
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@v2
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
+2 -2
View File
@@ -10,9 +10,9 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v3
- name: Use Node.js 22.x
uses: actions/setup-node@v5
uses: actions/setup-node@v3
with:
node-version: 22.x
cache: 'npm'
-5
View File
@@ -17,16 +17,11 @@ docs/
npm-debug.log
/.idea/
.env
mise.toml
build/
.DS_Store
yarn.lock
test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git
dist/
demo/dist/
-374
View File
@@ -1,374 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"import",
"typescript"
],
"categories": {
"correctness": "off"
},
"options": {
"typeAware": true,
"maxWarnings": 0
},
"env": {
"builtin": true
},
"ignorePatterns": [
"addons/*/src/third-party/*.ts",
"**/out/*",
"**/out-test/*",
"**/out-esbuild/*",
"**/out-esbuild-test/*",
"**/inwasm-sdks/*",
"**/typings/*.d.ts",
"**/node_modules",
"**/*.js",
"**/*.mjs"
],
"rules": {
"constructor-super": "error",
"for-direction": "error",
"getter-return": "error",
"no-async-promise-executor": "error",
"no-case-declarations": "error",
"no-class-assign": "error",
"no-compare-neg-zero": "error",
"no-cond-assign": "error",
"no-const-assign": "error",
"no-constant-binary-expression": "error",
"no-constant-condition": "error",
"no-control-regex": "error",
"no-debugger": "error",
"no-delete-var": "error",
"no-dupe-class-members": "error",
"no-dupe-else-if": "error",
"no-dupe-keys": "error",
"no-duplicate-case": "error",
"no-empty": "error",
"no-empty-character-class": "error",
"no-empty-pattern": "error",
"no-empty-static-block": "error",
"no-ex-assign": "error",
"no-extra-boolean-cast": "error",
"no-fallthrough": "error",
"no-func-assign": "error",
"no-global-assign": "error",
"no-import-assign": "error",
"no-invalid-regexp": "error",
"no-irregular-whitespace": "error",
"no-loss-of-precision": "error",
"no-misleading-character-class": "error",
"no-new-native-nonconstructor": "error",
"no-nonoctal-decimal-escape": "error",
"no-obj-calls": "error",
"no-prototype-builtins": "error",
"no-redeclare": "error",
"no-regex-spaces": "error",
"no-self-assign": "error",
"no-setter-return": "error",
"no-shadow-restricted-names": "error",
"no-sparse-arrays": "error",
"no-this-before-super": "error",
"no-unassigned-vars": "error",
"no-unexpected-multiline": "error",
"no-unreachable": "error",
"no-unsafe-finally": "error",
"no-unsafe-negation": "error",
"no-unsafe-optional-chaining": "error",
"no-unused-labels": "error",
"no-unused-private-class-members": "error",
"no-unused-vars": "error",
"no-useless-backreference": "error",
"no-useless-catch": "error",
"no-useless-escape": "error",
"no-with": "error",
"preserve-caught-error": "error",
"require-yield": "error",
"use-isnan": "error",
"valid-typeof": "error",
"no-array-constructor": "error",
"no-unused-expressions": "error",
"typescript/ban-ts-comment": "error",
"typescript/no-duplicate-enum-values": "error",
"typescript/no-empty-object-type": "error",
"typescript/no-explicit-any": "error",
"typescript/no-extra-non-null-assertion": "error",
"typescript/no-misused-new": "error",
"typescript/no-namespace": "error",
"typescript/no-non-null-asserted-optional-chain": "error",
"typescript/no-require-imports": "error",
"typescript/no-this-alias": "error",
"typescript/no-unnecessary-type-constraint": "error",
"typescript/no-unsafe-declaration-merging": "error",
"typescript/no-unsafe-function-type": "error",
"typescript/no-wrapper-object-types": "error",
"typescript/prefer-as-const": "error",
"typescript/prefer-namespace-keyword": "error",
"typescript/triple-slash-reference": "error",
"import/no-cycle": "error"
},
"overrides": [
{
"files": [
"**/*.ts",
"**/*.tsx",
"**/*.mts",
"**/*.cts"
],
"rules": {
"constructor-super": "off",
"getter-return": "off",
"no-class-assign": "off",
"no-const-assign": "off",
"no-dupe-class-members": "off",
"no-dupe-keys": "off",
"no-func-assign": "off",
"no-import-assign": "off",
"no-new-native-nonconstructor": "off",
"no-obj-calls": "off",
"no-redeclare": "off",
"no-setter-return": "off",
"no-this-before-super": "off",
"no-unreachable": "off",
"no-unsafe-negation": "off",
"no-var": "error",
"no-with": "off",
"prefer-const": "error",
"prefer-rest-params": "error",
"prefer-spread": "error"
}
},
{
"files": [
"**/*.ts"
],
"jsPlugins": [
"@stylistic/eslint-plugin",
{
"name": "jsdoc-js",
"specifier": "eslint-plugin-jsdoc"
},
"oxlint-plugin-eslint"
],
"rules": {
"@stylistic/comma-spacing": [
"warn",
{
"before": false,
"after": true
}
],
"@stylistic/indent": [
"warn",
2
],
"@stylistic/semi": [
"warn",
"always"
],
"@stylistic/quotes": [
"warn",
"single",
{
"allowTemplateLiterals": "always"
}
],
"@stylistic/member-delimiter-style": [
"warn",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}
],
"@stylistic/type-annotation-spacing": "warn",
"@stylistic/space-unary-ops": "warn",
"@stylistic/keyword-spacing": "warn",
"@stylistic/max-len": [
"warn",
{
"code": 1000,
"comments": 100,
"ignoreTrailingComments": true,
"ignoreUrls": true,
"ignorePattern": "^ *((?<vt_comment>(//|\\*) @vt)|(?<table_comment>\\* \\| )|(?<commented_code>// ))"
}
],
"@stylistic/new-parens": "warn",
"@stylistic/no-extra-semi": "error",
"@stylistic/no-trailing-spaces": "warn",
"eslint-js/object-curly-spacing": [
"warn",
"always"
],
"@stylistic/spaced-comment": [
"warn",
"always",
{
"markers": [
"/"
],
"exceptions": [
"-"
]
}
],
"jsdoc-js/check-alignment": "warn",
"jsdoc-js/check-param-names": "warn",
"jsdoc-js/multiline-blocks": "warn",
"jsdoc-js/no-multi-asterisks": "warn",
"eslint-js/one-var": [
"warn",
"never"
],
"eslint-js/no-restricted-syntax": [
"warn",
{
"selector": "CallExpression[callee.name='requestAnimationFrame']",
"message": "The global requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression[callee.name='cancelAnimationFrame']",
"message": "The global cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='requestAnimationFrame']",
"message": "window.requestAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "CallExpression > MemberExpression[object.name='window'][property.name='cancelAnimationFrame']",
"message": "window.cancelAnimationFrame() should be avoided, call it on the parent window from ICoreBrowserService."
},
{
"selector": "MemberExpression[object.name='window'][property.name='devicePixelRatio']",
"message": "window.devicePixelRatio should be avoided, get it from ICoreBrowserService."
}
],
"curly": [
"warn",
"multi-line"
],
"eqeqeq": [
"warn",
"always"
],
"no-duplicate-imports": "warn",
"no-else-return": [
"warn",
{
"allowElseIf": false
}
],
"no-eval": "warn",
"no-irregular-whitespace": "warn",
"no-restricted-imports": [
"warn",
{
"patterns": [
".*\\/out\\/.*"
]
}
],
"no-unsafe-finally": "warn",
"no-unused-vars": "off",
"no-var": "warn",
"no-empty": "off",
"no-empty-pattern": "off",
"no-cond-assign": "off",
"no-case-declarations": "off",
"for-direction": "off",
"no-prototype-builtins": "off",
"no-useless-escape": "off",
"no-self-assign": "off",
"no-async-promise-executor": "off",
"prefer-rest-params": "off",
"no-control-regex": "off",
"no-fallthrough": "off",
"prefer-spread": "off",
"prefer-const": "warn",
"no-unused-expressions": "off",
"typescript/array-type": [
"warn",
{
"default": "array",
"readonly": "generic"
}
],
"typescript/consistent-type-assertions": "warn",
"typescript/consistent-type-definitions": "warn",
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"typescript/explicit-member-accessibility": [
"warn",
{
"accessibility": "explicit",
"overrides": {
"constructors": "off"
}
}
],
"typescript/no-confusing-void-expression": [
"warn",
{
"ignoreArrowShorthand": true
}
],
"typescript/no-useless-constructor": "warn",
"typescript/prefer-nullish-coalescing": [
"warn",
{
"ignorePrimitives": true
}
],
"typescript/prefer-namespace-keyword": "warn",
"typescript/no-unused-vars": [
"warn",
{
"vars": "all",
"args": "none"
}
],
"typescript/no-require-imports": "off",
"typescript/no-explicit-any": "off",
"typescript/no-wrapper-object-types": "off",
"typescript/no-empty-object-type": "off",
"typescript/no-unsafe-function-type": "off",
"typescript/no-this-alias": "off",
"typescript/no-namespace": "off",
"typescript/no-duplicate-enum-values": "off"
}
},
{
"files": [
"**/*.api.ts",
"**/*.test.ts"
],
"rules": {
"eslint-js/object-curly-spacing": "off",
"@stylistic/max-len": "off",
"typescript/no-unused-vars": "off",
"typescript/explicit-function-return-type": "off",
"typescript/explicit-member-accessibility": "off"
}
},
{
"files": [
"demo/**/*.ts",
"**/*.benchmark.ts"
],
"rules": {
"typescript/prefer-nullish-coalescing": "off"
}
}
]
}
-120
View File
@@ -1,120 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"options": {
"maxWarnings": 0
},
"env": {
"builtin": true
},
"overrides": [
{
"files": [
"typings/**/*.d.ts"
],
"plugins": [
"typescript"
],
"jsPlugins": [
"@stylistic/eslint-plugin",
{
"name": "jsdoc-js",
"specifier": "eslint-plugin-jsdoc"
},
"oxlint-plugin-eslint"
],
"rules": {
"@stylistic/indent": [
"warn",
2
],
"@stylistic/semi": [
"warn",
"always"
],
"@stylistic/quotes": [
"warn",
"single",
{
"allowTemplateLiterals": "always"
}
],
"@stylistic/member-delimiter-style": [
"warn",
{
"multiline": {
"delimiter": "semi",
"requireLast": true
},
"singleline": {
"delimiter": "comma",
"requireLast": false
}
}
],
"@stylistic/type-annotation-spacing": "warn",
"@stylistic/keyword-spacing": "warn",
"@stylistic/max-len": [
"warn",
{
"code": 1000,
"comments": 80,
"ignoreUrls": true,
"ignorePattern": "^ *(?<ps_description>\\* Ps=)"
}
],
"@stylistic/no-extra-semi": "error",
"@stylistic/no-trailing-spaces": "warn",
"eslint-js/object-curly-spacing": [
"warn",
"always"
],
"@stylistic/spaced-comment": [
"warn",
"always",
{
"markers": [
"/"
],
"exceptions": [
"-"
]
}
],
"@stylistic/comma-dangle": [
"warn",
{
"objects": "never",
"arrays": "never",
"functions": "never"
}
],
"@stylistic/eol-last": "warn",
"curly": [
"warn",
"multi-line"
],
"eqeqeq": [
"warn",
"always"
],
"jsdoc-js/check-alignment": "warn",
"jsdoc-js/check-param-names": "warn",
"no-irregular-whitespace": "warn",
"typescript/array-type": [
"warn",
{
"default": "array",
"readonly": "generic"
}
],
"typescript/explicit-function-return-type": [
"warn",
{
"allowExpressions": true
}
],
"typescript/prefer-namespace-keyword": "warn"
}
}
]
}
+3
View File
@@ -15,6 +15,9 @@
"--recursive",
"${workspaceRoot}/out/**/*.test.js"
],
"env": {
"NODE_PATH": "${workspaceRoot}/out"
},
"sourceMaps": true,
"outFiles": [ "${workspaceRoot}/out/**/*.js" ],
"internalConsoleOptions": "openOnSessionStart"
+1 -1
View File
@@ -39,7 +39,7 @@ The preferred way to report bugs or request features is to use
It's pretty common for maintainers of large open source projects to suffer from burnout, especially when needing to triage a large number of incoming issues instead of actually building things. Here are some of the steps we take to try mitigate this:
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transferred there without further comment and core maintainers may or may not participate in discussions.
- Support questions live in [GH discussions](https://github.com/xtermjs/xterm.js/discussions), issues may be transfered there without further comment and core maintainers may or may not participate in discussions.
- Issues are strictly for well defined features or bugs that are _actionable_.
- Sometimes features are out of scope. A common example of this is a niche feature that the pricipal implementation ([VS Code](https://code.visualstudio.com/)) won't leverage and therefore would be difficult to maintain and likely suffer from bitrot. The reporter may not agree with this, but you could always create an addon if that works or maintain your own fork if it comes to that.
- If a feature does not have a clear way forward or needs more discussion it may be closed ro moved to a discussion.
+11 -37
View File
@@ -24,36 +24,7 @@ First, you need to install the module. We ship exclusively through [npm](https:/
npm install --save @xterm/xterm
```
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package.
```javascript
import { Terminal } from '@xterm/xterm';
```
Then instantiate a `Terminal` object, open it on an element and write to it:
```javascript
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
```
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
```javascript
pty.onData(data => term.write(data));
term.onData(data => pty.write(data));
```
Then make sure to also include the `css/xterm.css` file, for example in HTML:
```html
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
```
### Standalone example
Here is a complete standalone example in a HTML file:
To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `<div id="terminal"></div>` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
```html
<!doctype html>
@@ -65,7 +36,7 @@ Here is a complete standalone example in a HTML file:
<body>
<div id="terminal"></div>
<script>
const term = new Terminal();
var term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>
@@ -73,6 +44,14 @@ Here is a complete standalone example in a HTML file:
</html>
```
### Importing
The recommended way to load xterm.js is via the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/xterm';
```
### Addons
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
@@ -115,7 +94,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
### Node.js Support
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
## API
@@ -250,11 +229,6 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
- [**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.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty.
- [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.
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -1
View File
@@ -12,7 +12,7 @@ interface IAttachOptions {
bidirectional?: boolean;
}
export class AttachAddon implements ITerminalAddon, IAttachApi {
export class AttachAddon implements ITerminalAddon , IAttachApi {
private _socket: WebSocket;
private _bidirectional: boolean;
private _disposables: IDisposable[] = [];
+1 -1
View File
@@ -15,7 +15,7 @@ test.beforeAll(async ({ browser }) => {
});
test.afterAll(async () => await ctx.page.close());
test.describe('AttachAddon', () => {
test.describe('Search Tests', () => {
test.beforeEach(async () => {
await ctx.proxy.reset();
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+4 -1
View File
@@ -16,10 +16,13 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"dependencies": {
"js-base64": "^3.7.5"
}
}
+16 -38
View File
@@ -4,7 +4,8 @@
*/
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
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;
@@ -24,7 +25,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose();
}
private _readText(sel: string, data: string): void {
private _readText(sel: ClipboardSelectionType, data: string): void {
const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
}
@@ -35,7 +36,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true;
}
const pc = args[0];
const pc = args[0] as ClipboardSelectionType;
const pd = args[1];
if (pd === '?') {
const text = this._provider.readText(pc);
@@ -69,53 +70,30 @@ export class ClipboardAddon implements ITerminalAddon {
}
export class BrowserClipboardProvider implements IClipboardProvider {
public readText(selection: string): Promise<string> {
public async readText(selection: ClipboardSelectionType): Promise<string> {
if (selection !== 'c') {
return Promise.resolve('');
}
return navigator.clipboard.readText();
}
public writeText(selection: string, text: string): Promise<void> {
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
if (selection !== 'c') {
return Promise.resolve();
}
return navigator.clipboard.writeText(text);
}
}
/**
* TODO: Once the base64 handling on Uint8Arrays is more common,
* remove the btoa/atob fallbacks below.
*/
interface IUint8ArrayB64 extends Uint8Array {
toBase64(): string;
}
interface IUint8ArrayB64Ctor extends Uint8ArrayConstructor {
fromBase64(s: string): IUint8ArrayB64;
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
const bytes = new TextEncoder().encode(data) as IUint8ArrayB64;
if (bytes.toBase64 !== undefined) {
return bytes.toBase64();
}
let bin = '';
for (let i = 0; i < bytes.length; i++) {
bin += String.fromCharCode(bytes[i]);
}
return btoa(bin);
return JSBase64.encode(data);
}
public decodeText(data: string): string {
if ((Uint8Array as IUint8ArrayB64Ctor).fromBase64 !== undefined) {
try {
return new TextDecoder().decode((Uint8Array as IUint8ArrayB64Ctor).fromBase64(data));
} catch {}
const text = JSBase64.decode(data);
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return '';
}
try {
const bin = atob(data);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; ++i) {
bytes[i] = bin.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
} catch {}
return '';
return text;
}
}
@@ -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() === 'webkit') {
if (ctx.browser.browserType().name() !== 'chromium') {
testInfo.skip();
return;
}
@@ -29,7 +29,7 @@ test.describe('ClipboardAddon', () => {
await ctx.page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
}
await ctx.page.evaluate(`
window.term.reset();
window.term.reset()
window.clipboard?.dispose();
window.clipboard = new ClipboardAddon();
window.term.loadAddon(window.clipboard);
@@ -48,14 +48,6 @@ 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()), '');
@@ -68,46 +60,19 @@ test.describe('ClipboardAddon', () => {
});
test.describe('read data', async function (): Promise<any> {
test.beforeAll(async () => {
test('simple string', 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`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
});
});
test.describe('non-ASCII', () => {
const testDataEncoded = '4oKsbWzDpMO8dMOf';
const testDataDecoded = '€mläütß';
test('write simple string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('read simple string', async () => {
await ctx.page.evaluate(`
window.data2 = [];
window.term.onData(e => data2.push(e));
`);
await ctx.page.evaluate((d) => window.navigator.clipboard.writeText(d), testDataDecoded);
await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data2'), [`\x1b]52;c;${testDataEncoded}\x07`]);
});
});
});
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+21 -12
View File
@@ -30,6 +30,18 @@ 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.
@@ -66,39 +78,36 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider {
/**
* Gets the clipboard content.
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
* @param selection The clipboard selection to read.
* @returns A promise that resolves with clipboard selection data.
*/
readText(selection: string): string | Promise<string>;
readText(selection: ClipboardSelectionType): string | Promise<string>;
/**
* Sets the clipboard content.
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
* @param selection The clipboard selection to set.
* @param data The clipboard text to write.
*/
writeText(selection: string, text: string): void | Promise<void>;
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
}
/**
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
*
* Note that this clipboard provider does not implement own cut buffers as xterm does,
* but redirect the selection parameter always to navigator.clipboard.
* The clipboard provider interface that enables xterm.js to access the system clipboard.
*/
export class BrowserClipboardProvider implements IClipboardProvider{
/**
* Reads text from the clipboard.
* @param selection The selection type to read from (always refers to navigator.clipboard).
* @param selection The selection type to read from.
* @returns A promise that resolves with the text from the clipboard.
*/
public readText(selection: string): Promise<string>;
public readText(selection: ClipboardSelectionType): Promise<string>;
/**
* Writes text to the clipboard.
* @param selection The selection type to write to (always refers to navigator.clipboard).
* @param selection The selection type to write to.
* @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: string, data: string): Promise<void>;
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+17 -17
View File
@@ -19,10 +19,8 @@ interface ITerminalDimensions {
cols: number;
}
const enum Constants {
MINIMUM_COLS = 2,
MINIMUM_ROWS = 1
}
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) {
@@ -35,7 +33,7 @@ function _getComputedStyle(el: HTMLElement): CSSStyleDeclaration {
return getWindow(el).getComputedStyle(el, null);
}
export class FitAddon implements ITerminalAddon, IFitApi {
export class FitAddon implements ITerminalAddon , IFitApi {
private _terminal: Terminal | undefined;
public activate(terminal: Terminal): void {
@@ -50,7 +48,10 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return;
}
this._terminal.resize(dims.cols, dims.rows);
// Force a full render
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
this._terminal.resize(dims.cols, dims.rows);
}
}
public proposeDimensions(): ITerminalDimensions | undefined {
@@ -68,28 +69,27 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return undefined;
}
const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true;
const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar
const scrollbarWidth = (this._terminal.options.scrollback === 0
? 0
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
: (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
const parentElementHeight = Math.max(0, parseInt(parentElementStyle.getPropertyValue('height'), 10) || 0);
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'), 10) || 0);
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')));
const elementStyle = _getComputedStyle(this._terminal.element);
const elementPadding = {
top: parseInt(elementStyle.getPropertyValue('padding-top'), 10) || 0,
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom'), 10) || 0,
right: parseInt(elementStyle.getPropertyValue('padding-right'), 10) || 0,
left: parseInt(elementStyle.getPropertyValue('padding-left'), 10) || 0
top: parseInt(elementStyle.getPropertyValue('padding-top')),
bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')),
right: parseInt(elementStyle.getPropertyValue('padding-right')),
left: parseInt(elementStyle.getPropertyValue('padding-left'))
};
const elementPaddingVer = elementPadding.top + elementPadding.bottom;
const elementPaddingHor = elementPadding.right + elementPadding.left;
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
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))
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
return geometry;
}
-11
View File
@@ -119,17 +119,6 @@ test.describe('FitAddon', () => {
strictEqual(await ctx.proxy.cols, 2);
strictEqual(await ctx.proxy.rows, 1);
});
test('same dimensions', async function(): Promise<any> {
await setDimensions();
await ctx.page.evaluate(`window.fit.fit()`);
const cols: number = await ctx.proxy.cols;
const rows: number = await ctx.proxy.rows;
// Calling fit() again at the same container size should not throw
await ctx.page.evaluate(`window.fit.fit()`);
strictEqual(await ctx.proxy.cols, cols);
strictEqual(await ctx.proxy.rows, rows);
});
});
});
+3 -2
View File
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+42 -33
View File
@@ -1,7 +1,6 @@
## @xterm/addon-image
Inline image output in xterm.js. Supports SIXEL, iTerm's inline image protocol (IIP)
and partially Kitty's terminal graphics prtocol (TGP).
Inline image output in xterm.js. Supports SIXEL and iTerm's inline image protocol (IIP).
![](fixture/example.png)
@@ -26,14 +25,12 @@ 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: 4096, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 25000000, // 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: 33554432, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
iipSizeLimit: 20000000 // size limit of a single IIP sequence
}
// initialization
@@ -44,6 +41,9 @@ terminal.loadAddon(imageAddon);
### General Notes
- *IMPORTANT:* The worker approach as done in previous releases got removed.
The addon contructor no longer expects a worker path as first argument.
- By default the addon will activate these `windowOptions` reports on the terminal:
- getWinSizePixels (CSI 14 t)
- getCellSizePixels (CSI 16 t)
@@ -71,7 +71,7 @@ terminal.loadAddon(imageAddon);
- **Cursor Positioning**
If scrolling is set, the cursor will be placed at the first image column of the last image row (VT340 mode).
Other cursor positioning modes as used by xterm or mintty are not supported for sixel.
Other cursor positioning modes as used by xterm or mintty are not supported.
- **SIXEL Palette Handling**
By default the addon limits the palette size to 256 registers (as demanded by the DEC specification).
@@ -90,7 +90,7 @@ terminal.loadAddon(imageAddon);
therefore palette animations cannot be used.
- **SIXEL Raster Attributes Handling**
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extent. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extent. Both is discouraged by the >30 years old specification.
If raster attributes were found in the SIXEL data (level 2), the image will always be truncated to the given height/width extend. We deviate here from the specification on purpose, as it allows several processing optimizations. For level 1 SIXEL data without any raster attributes the image can freely grow in width and height up to the last data byte, which has a much higher processing penalty. In general encoding libraries should not create level 1 data anymore and should not produce pixel information beyond the announced height/width extend. Both is discouraged by the >30 years old specification.
Currently the SIXEL implementation of the addon does not take custom pixel sizes into account, a SIXEL pixel will map 1:1 to a screen pixel.
@@ -98,24 +98,17 @@ terminal.loadAddon(imageAddon);
Set by default, change it with `{iipSupport: true}`.
The IIP implementation has the following features / restrictions (sequence will silently fail for unmet conditions):
- Supported formats: PNG, JPEG, GIF and QOI
- Supported formats: PNG, JPEG and GIF
- No animation support.
- Image type hinting is not supported (always deducted from data header).
- File download is not supported.
- Filename gets parsed but not used.
- Strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding, no separator bytes allowed).
- Payload size may not exceed CEIL(sizeParameter * 4 / 3).
- Image scaling beyond terminal viewport size is allowed (e.g. `width=200%`).
- Image pixel size is restricted by `pixelLimit` (pre- and post resizing).
- Playload size is restricted by `iipSizeLimit`.
- size parameter is not mandatory anymore.
- Cursor gets positioned at the next cell of the last image line.
- ReportCellSize sequence is supported.
- Hi-res display support is currently limited (image gets blurred).
- **Kitty Graphics Support (TGP)**
Set by default, change it with `{kittySupport: true}`.
Note that the kitty graphics support is still WIP.
- Size parameter is restricted by `iipSizeLimit`.
- Cursor positioning behaves the same as for sixel (see above).
### Storage and Drawing Settings
@@ -132,7 +125,6 @@ The addon exposes two properties to interact with the storage limits at runtime:
By default the addon will show a placeholder pattern for evicted images that are still part
of the terminal (e.g. in the scrollback). The pattern can be deactivated by toggling `showPlaceholder`.
### Image Data Retrieval
The addon provides the following API endpoints to retrieve raw image data as canvas:
@@ -146,12 +138,11 @@ The addon provides the following API endpoints to retrieve raw image data as can
The buffer position is the 0-based absolute index (including scrollback at top).
Note that the canvas gets created and data copied over for every call, thus it is not suitable for performance critical actions.
### Memory Usage
The addon does most image processing in JavaScript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, let's look at the data flow and processing steps:
- Incoming image data chunk at `term.write` (terminal)
`term.write` might stack up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
The addon does most image processing in Javascript and therefore can occupy a rather big amount of memory. To get an idea where the memory gets eaten, lets look at the data flow and processing steps:
- Incomming image data chunk at `term.write` (terminal)
`term.write` might stock up incoming chunks. To circumvent this, you will need proper flow control (see xterm.js docs). Note that with image output it is more likely to run into this issue, as it can create lots of MBs in very short time.
- Sequence Parser (terminal)
The parser operates on a buffer containing up to 2^17 codepoints (~0.5 MB).
- Sequence Handler - Chunk Decoding (addon)
@@ -177,18 +168,18 @@ const totalActive = storageBytes + decodingBytes;
```
Note that browsers have offloading tricks for rarely touched memory segments, esp. `storageBytes` might not directly translate into real memory usage. Usage peaks will happen during active decoding of multiple big images due to the need of 2 full pixel buffers at the same time, which cannot be offloaded. Thus you may want to keep an eye on `pixelLimit` under limited memory conditions.
Further note that the formulas above do not respect the JavaScript object's overhead. Compared to the raw buffer needs the bookkeeping by these objects is rather small (<<5%).
Further note that the formulas above do not respect the Javascript object's overhead. Compared to the raw buffer needs the book keeping by these objects is rather small (<<5%).
_Why should I care about memory usage at all?_
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the JavaScript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
Well you don't have to, and it probably will just work fine with the addon defaults. But for bigger integrations, where much more data is held in the Javascript context (like multiple terminals on one page), it is likely to hit the engine's memory limit sooner or later under decoding and/or storage pressure.
_How can I adjust the memory usage?_
- `pixelLimit`
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage only peaks temporarily, a rather high value should work even with multiple terminals in one page.
A constructor setting, thus you would have to anticipate, whether (multiple) terminals in your page gonna do lots of concurrent decoding. Since this is normally not the case and the memory usage is only temporarily peaking, a rather high value should work even with multiple terminals in one page.
- `storageLimit`
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for peak memory use during decoding.
- `sixelSizeLimit | iipSizeLimit | kittySizeLimit`
Constructor settings. This has only a small impact on peak memory during decoding. It is meant to avoid processing of overly big or broken image sequences at an earlier phase, thus may stop the invoked decoders from entering memory intensive tasks for potentially invalid data.
A constructor and a runtime setting. In conjunction with `storageUsage` you can do runtime checks and adjust the limit to your needs. If you have to lower the limit below the current usage, images will be removed in FIFO order and may turn into a placeholder in the terminal's scrollback (if `showPlaceholder` is set). When adjusting keep in mind to leave enough room for memory peaking for decoding.
- `sixelSizeLimit`
A constructor setting. This has only a small impact on peaking memory during decoding. It is meant to avoid processing of overly big or broken SIXEL sequences at an earlier phase, thus may stop the decoder from entering its memory intensive task for potentially invalid data.
### Terminal Interaction
@@ -218,5 +209,23 @@ _How can I adjust the memory usage?_
### Status
- Sixel and IIP support and image handling in xterm.js is considered beta quality.
- Kitty support is in alpha stage. Please file a bug for any awkwardities or missing features.
- Sixel support and image handling in xterm.js is considered beta quality.
- IIP support is in alpha stage. Please file a bug for any awkwardities.
### Changelog
- 0.5.0 integrate with xtermjs base repo (at v0.4.3)
- 0.4.3 defer canvas creation
- 0.4.2 fix image canvas resize
- 0.4.1 compat release for xterm.js 5.2.0
- 0.4.0 IIP support
- 0.3.1 compat release for xterm.js 5.1.0
- 0.3.0 important change: worker removed from addon
- 0.2.0 compat release for xterm.js 5.0.0
- 0.1.3 bugfix: avoid striping
- 0.1.2 bugfix: reset clear flag
- 0.1.1 bugfixes:
- clear sticky image tiles on render
- create folder from bootstrap.sh
- fix peer dependency in package.json
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

Binary file not shown.
+2 -2
View File
@@ -18,13 +18,13 @@
"xterm.js"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.4.1"
"xterm-wasm-parts": "^0.1.0"
}
}
+41 -109
View File
@@ -4,26 +4,20 @@
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage';
import { ImageStorage, 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, SequenceType } from './IIPHeaderParser';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// Local const enum mirror - esbuild can't inline const enums from external packages
const enum DecoderConst {
// Limit held memory in base64 decoder (encoded bytes).
KEEP_DATA = 4194304,
// Initial buffer allocation for the decoder.
INITIAL_DATA = 1048576,
// Local mirror of const enum (esbuild can't inline const enums from external packages)
OK = 0
}
// eslint-disable-next-line
declare const Buffer: any;
// limit hold memory in base64 decoder
const KEEP_DATA = 4194304;
// default IIP header values
const DEFAULT_HEADER: IHeaderFields = {
type: SequenceType.INVALID,
name: 'Unnamed file',
size: 0,
width: 'auto',
@@ -37,32 +31,21 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _qoiDec: QoiDecoder;
private _dec = new Base64Decoder(KEEP_DATA);
private _metrics = UNSUPPORTED_TYPE;
private _isMultipart = false;
private _abortMulti = false;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
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 {
this._hp.reset();
this._dec.release();
this._qoiDec.release();
}
public reset(): void {}
public start(): void {
this._aborted = false;
this._header = DEFAULT_HEADER;
this._metrics = UNSUPPORTED_TYPE;
this._hp.reset();
}
@@ -71,7 +54,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (this._aborted) return;
if (this._hp.state === HeaderState.END) {
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {
if (this._dec.put(data, start, end)) {
this._dec.release();
this._aborted = true;
}
@@ -82,27 +65,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return;
}
if (dataPos > 0) {
const seqType = this._hp.fields.type;
if (seqType === SequenceType.FILE) {
if (this._isMultipart) {
this._isMultipart = false;
this._abortMulti = false;
this._dec.release();
}
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
if (!this._header.inline) {
this._aborted = true;
return;
}
this._dec.init();
} else if (this._abortMulti) {
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
if (!this._header.inline || !this._header.size || this._header.size > this._opts.iipSizeLimit) {
this._aborted = true;
return;
}
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
this._dec.init(this._header.size);
if (this._dec.put(data, dataPos, end)) {
this._dec.release();
this._aborted = true;
if (this._isMultipart) this._abortMulti = true;
}
}
}
@@ -111,49 +82,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted) return true;
if (this._hp.state !== HeaderState.END) {
if (this._hp.end()) return true;
}
const seqType = this._hp.fields.type;
if (seqType === SequenceType.FILEPART) return true;
if (seqType === SequenceType.REPORTCELLSIZE) {
// OSC 1337 ; ReportCellSize=[height];[width];[scale] ST
let width = CELL_SIZE_DEFAULT.width;
let height = CELL_SIZE_DEFAULT.height;
if (this._renderer.dimensions) {
width = this._renderer.dimensions.css.canvas.width / this._coreTerminal.cols;
height = this._renderer.dimensions.css.canvas.height / this._coreTerminal.rows;
}
const scale = this._coreTerminal._core._coreBrowserService?.dpr ?? 1;
const report = `\x1b]1337;ReportCellSize=${height.toFixed(3)};${width.toFixed(3)};${scale.toFixed(3)}\x1b\\`;
this._coreTerminal.input(report, false);
return true;
}
if (seqType === SequenceType.MULTIPARTFILE) {
this._header = Object.assign({}, DEFAULT_HEADER, this._hp.fields);
this._isMultipart = true;
this._abortMulti = false;
this._dec.release();
this._dec.init();
return true;
}
if (seqType === SequenceType.FILEEND) {
if (!this._isMultipart) return true;
this._isMultipart = false;
if (this._abortMulti || this._header.type !== SequenceType.MULTIPARTFILE) return true;
}
// fallthrough for SequenceType.FILE & SequenceType.FILEEND
let w = 0;
let h = 0;
// early exit condition chain
let cond: number | boolean;
let cond: number | boolean = true;
if (cond = success) {
if (cond = !this._dec.end()) {
this._metrics = imageType(this._dec.data8);
@@ -172,27 +105,26 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
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 });
}
const blob = new Blob([new Uint8Array(this._dec.data8)], { type: this._metrics.mime });
this._dec.release();
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<boolean>(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);
});
}
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
this._storage.addImage(bm);
@@ -222,8 +154,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _dim(s: string, total: number, cdim: number): number {
if (s === 'auto') return 0;
if (s.endsWith('%')) return parseInt(s.slice(0, -1), 10) * total / 100;
if (s.endsWith('px')) return parseInt(s.slice(0, -2), 10);
return parseInt(s, 10) * cdim;
if (s.endsWith('%')) return parseInt(s.slice(0, -1)) * total / 100;
if (s.endsWith('px')) return parseInt(s.slice(0, -2));
return parseInt(s) * cdim;
}
}
+7 -129
View File
@@ -4,20 +4,19 @@
*/
import { assert } from 'chai';
import { HeaderParser, HeaderState, IHeaderFields, SequenceType } from './IIPHeaderParser';
import { HeaderParser, HeaderState, IHeaderFields } from './IIPHeaderParser';
const CASES: [string, IHeaderFields][] = [
['File=size=123456;name=dGVzdA==:', {type: SequenceType.FILE, name: 'test', size: 123456}],
['File=size=123456;name=dGVzdA:', {type: SequenceType.FILE, name: 'test', size: 123456}],
['File=size=123456;name=dGVzdA==:', {name: 'test', size: 123456}],
['File=size=123456;name=dGVzdA:', {name: 'test', size: 123456}],
// utf-8 encoding in name
['File=size=123456;name=w7xtbMOkdXTDnw==:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
['File=size=123456;name=w7xtbMOkdXTDnw:', {type: SequenceType.FILE, name: 'ümläutß', size: 123456}],
['File=size=123456;name=w7xtbMOkdXTDnw==:', {name: 'ümläutß', size: 123456}],
['File=size=123456;name=w7xtbMOkdXTDnw:', {name: 'ümläutß', size: 123456}],
// full header spec
[
'File=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{
type: SequenceType.FILE,
inline: 1,
width: '10px',
height: '20%',
@@ -29,7 +28,6 @@ const CASES: [string, IHeaderFields][] = [
[
'File=inline=1;width=auto;height=20;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw:',
{
type: SequenceType.FILE,
inline: 1,
width: 'auto',
height: '20',
@@ -103,7 +101,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: 'test', size: 0});
assert.deepEqual(hp.fields, {name: 'test', size: 0});
hp.reset();
inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length);
@@ -117,7 +115,7 @@ describe('IIPHeaderParser', () => {
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, inp.length);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, {type: SequenceType.FILE, name: '', size: 123456});
assert.deepEqual(hp.fields, {name: '', size: 123456});
hp.reset();
inp = fromBs(CASES[0][0]);
res = hp.parse(inp, 0, inp.length);
@@ -137,124 +135,4 @@ describe('IIPHeaderParser', () => {
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, CASES[0][1]);
});
it('FilePart sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
const res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, 9);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
});
it('FilePart sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('FilePart=w7xtbMOkdXTDnw');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, 1);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEPART);
});
it('MultipartFile sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.VALUE);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(
hp.fields,
{
type: SequenceType.MULTIPARTFILE,
inline: 1,
width: '10px',
height: '20%',
preserveAspectRatio: 1,
size: 123456,
name: 'ümläutß'
}
);
});
it('MultipartFile sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('MultipartFile=inline=1;width=10px;height=20%;preserveAspectRatio=1;size=123456;name=w7xtbMOkdXTDnw');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.VALUE);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(
hp.fields,
{
type: SequenceType.MULTIPARTFILE,
inline: 1,
width: '10px',
height: '20%',
preserveAspectRatio: 1,
size: 123456,
name: 'ümläutß'
}
);
});
it('FileEnd sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('FileEnd');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
});
it('FileEnd sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('FileEnd');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields.type, SequenceType.FILEEND);
});
it('ReportCellSize sequence', () => {
const hp = new HeaderParser();
const inp = fromBs('ReportCellSize');
let res = hp.parse(inp, 0, inp.length);
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
});
it('ReportCellSize sequence - bytewise', () => {
const hp = new HeaderParser();
const inp = fromBs('ReportCellSize');
let res = -2;
let pos = 0;
while (res === -2 && pos < inp.length) {
res = hp.parse(new Uint32Array([inp[pos++]]), 0, 1);
}
assert.strictEqual(res, -2);
assert.strictEqual(hp.state, HeaderState.START);
res = hp.end();
assert.strictEqual(res, 0);
assert.strictEqual(hp.state, HeaderState.END);
assert.deepEqual(hp.fields, { type: SequenceType.REPORTCELLSIZE });
});
});
+13 -89
View File
@@ -6,27 +6,8 @@
// eslint-disable-next-line
declare const Buffer: any;
export const enum HeaderState {
START = 0,
ABORT = 1,
KEY = 2,
VALUE = 3,
END = 4
}
export const enum SequenceType {
INVALID = 0,
FILE = 1,
MULTIPARTFILE = 2,
FILEPART = 3,
FILEEND = 4,
REPORTCELLSIZE = 5
}
export interface IHeaderFields {
[key: string]: number | string | Uint32Array | null | undefined;
// sequence type
type: SequenceType;
// base-64 encoded filename. Defaults to "Unnamed file".
name: string;
// File size in bytes. The file transfer will be canceled if this size is exceeded.
@@ -47,6 +28,14 @@ export interface IHeaderFields {
inline?: number;
}
export const enum HeaderState {
START = 0,
ABORT = 1,
KEY = 2,
VALUE = 3,
END = 4
}
// field value decoders
// ASCII bytes to string
@@ -92,7 +81,7 @@ function toName(data: Uint32Array): string {
return new TextDecoder().decode(b);
}
const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
const DECODERS: {[key: string]: (v: Uint32Array) => any} = {
inline: toInt,
size: toInt,
name: toName,
@@ -102,19 +91,7 @@ const DECODERS: {[key: string]: (v: Uint32Array) => number | string} = {
};
// sequence type markers
// File
const FILE_MARKER = [70, 105, 108, 101];
// MultipartFile
const MULTIPARTFILE_MARKER = [77, 117, 108, 116, 105, 112, 97, 114, 116, 70, 105, 108, 101];
// FilePart
const FILEPART_MARKER = [70, 105, 108, 101, 80, 97, 114, 116];
// FileEnd
const FILEEND_MARKER = [70, 105, 108, 101, 69, 110, 100];
// ReportCellSize
const REPORTCELLSIZE_MARKER = [82, 101, 112, 111, 114, 116, 67, 101, 108, 108, 83, 105, 122, 101];
// max allowed chars for sequence header
const MAX_FIELDCHARS = 1024;
@@ -123,7 +100,7 @@ export class HeaderParser {
private _buffer = new Uint32Array(MAX_FIELDCHARS);
private _position = 0;
private _key = '';
public fields: {[key: string]: number | string | Uint32Array | null | undefined} = {};
public fields: {[key: string]: any} = {};
public reset(): void {
this._buffer.fill(0);
@@ -133,43 +110,12 @@ export class HeaderParser {
this._key = '';
}
public end(): number {
if (this.state === HeaderState.START) {
if (this._position === FILEEND_MARKER.length) {
for (let k = 0; k < FILEEND_MARKER.length; ++k) {
if (this._buffer[k] !== FILEEND_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILEEND;
this.state = HeaderState.END;
return 0;
}
if (this._position === REPORTCELLSIZE_MARKER.length) {
for (let k = 0; k < REPORTCELLSIZE_MARKER.length; ++k) {
if (this._buffer[k] !== REPORTCELLSIZE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.REPORTCELLSIZE;
this.state = HeaderState.END;
return 0;
}
return this._a();
}
if (this.state === HeaderState.END) return 0;
if (this.state === HeaderState.VALUE
&& this.fields.type === SequenceType.MULTIPARTFILE
) {
if (!this._storeValue(this._position)) return this._a();
this.state = HeaderState.END;
return 0;
}
return this._a();
}
public parse(data: Uint32Array, start: number, end: number): number {
let state = this.state;
let pos = this._position;
const buffer = this._buffer;
if (state === HeaderState.ABORT || state === HeaderState.END) return -1;
if (state === HeaderState.START && pos > 14) return -1;
if (state === HeaderState.START && pos > 6) return -1;
for (let i = start; i < end; ++i) {
const c = data[i];
switch (c) {
@@ -180,29 +126,8 @@ export class HeaderParser {
break;
case 61: // =
if (state === HeaderState.START) {
if (buffer[0] === 70) {
// 'File' or 'FilePart'
let k = 0;
for (; k < FILE_MARKER.length; ++k) {
if (buffer[k] !== FILE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILE;
if (pos === FILEPART_MARKER.length) {
for (; k < FILEPART_MARKER.length; ++k) {
if (buffer[k] !== FILEPART_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.FILEPART;
this.state = HeaderState.END;
return i + 1;
}
} else if (buffer[0] === 77) {
// 'MultipartFile'
for (let k = 0; k < MULTIPARTFILE_MARKER.length; ++k) {
if (buffer[k] !== MULTIPARTFILE_MARKER[k]) return this._a();
}
this.fields['type'] = SequenceType.MULTIPARTFILE;
} else {
return this._a();
for (let k = 0; k < FILE_MARKER.length; ++k) {
if (buffer[k] !== FILE_MARKER[k]) return this._a();
}
state = HeaderState.KEY;
pos = 0;
@@ -232,7 +157,6 @@ export class HeaderParser {
}
private _a(): number {
this.fields.type = SequenceType.INVALID;
this.state = HeaderState.ABORT;
return -1;
}
-28
View File
@@ -1,28 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IAddImageOpts } from './Types';
import { ImageStorage } from './ImageStorage';
/**
* IIP (iTerm Image Protocol) specific image storage controller.
*
* Wraps the shared ImageStorage with IIP protocol semantics:
* - Always uses scrolling mode (cursor advances with image)
*/
export class IIPImageStorage {
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
constructor(
private readonly _storage: ImageStorage
) {}
/**
* Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._storage.addImage(img, this._addImageOpts);
}
}
+1 -2
View File
@@ -30,8 +30,7 @@ const TEST_IMAGES: [string, IMetrics][] = [
['iphone_hdr_YES.jpg', { mime: 'image/jpeg', width: 3264, height: 2448 }],
['nikon-e950.jpg', { mime: 'image/jpeg', width: 800, height: 600 }],
['agfa-makernotes.jpg', { mime: 'image/jpeg', width: 8, height: 8 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }],
['dice.qoi', { mime: 'image/qoi', width: 800, height: 600 }],
['sony-alpha-6000.jpg', { mime: 'image/jpeg', width: 6000, height: 4000 }]
];
+1 -9
View File
@@ -4,7 +4,7 @@
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
@@ -45,14 +45,6 @@ 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;
}
+13 -74
View File
@@ -5,72 +5,24 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
import { KittyImageStorage } from './kitty/KittyImageStorage';
import { SixelHandler } from './SixelHandler';
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 by the addon @xterm/addon-image with
* the following limitations:
* - sequence:
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> 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."
*
* 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
const DEFAULT_OPTIONS: IImageAddonOptions = {
enableSizeReports: true,
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 4096,
sixelSizeLimit: 33554432,
sixelPaletteLimit: 256,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -96,7 +48,7 @@ const enum GaStatus {
}
export class ImageAddon implements ITerminalAddon, IImageApi {
export class ImageAddon implements ITerminalAddon , IImageApi {
private _opts: IImageAddonOptions;
private _defaultOpts: IImageAddonOptions;
private _storage: ImageStorage | undefined;
@@ -104,8 +56,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
constructor(opts?: Partial<IImageAddonOptions>) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -118,7 +68,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
this._disposables.length = 0;
this._handlers.clear();
this._onImageAdded.dispose();
}
private _disposeLater(...args: IDisposable[]): void {
@@ -133,11 +82,15 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// internal data structures
this._renderer = new ImageRenderer(terminal);
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
this._storage.onImageAdded = () => this._onImageAdded.fire();
// enable size reports
if (this._opts.enableSizeReports) {
const windowOps = terminal.options.windowOptions ?? {};
// const windowOptions = terminal.getOption('windowOptions');
// windowOptions.getWinSizePixels = true;
// windowOptions.getCellSizePixels = true;
// windowOptions.getWinSizeChars = true;
// terminal.setOption('windowOptions', windowOptions);
const windowOps = terminal.options.windowOptions || {};
windowOps.getWinSizePixels = true;
windowOps.getCellSizePixels = true;
windowOps.getWinSizeChars = true;
@@ -176,8 +129,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// SIXEL handler
if (this._opts.sixelSupport) {
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
this._handlers.set('sixel', sixelHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
@@ -186,25 +138,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
this._handlers.set('iip', iipHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
);
}
// Kitty graphics handler
if (this._opts.kittySupport) {
const kittyStorage = new KittyImageStorage(this._storage!);
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
this._handlers.set('kitty', kittyHandler);
this._disposeLater(
kittyStorage,
kittyHandler,
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
}
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
@@ -255,7 +194,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
private _report(s: string): void {
this._terminal?._core.input(s, false);
this._terminal?._core.coreService.triggerDataEvent(s);
}
private _decset(params: (number | number[])[]): boolean {
+53 -99
View File
@@ -5,13 +5,11 @@
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
const PLACEHOLDER_LENGTH = 4096;
const PLACEHOLDER_HEIGHT = 24;
/**
* ImageRenderer - terminal frontend extension:
@@ -20,9 +18,8 @@ const enum Constants {
* - draw image tiles onRender
*/
export class ImageRenderer extends Disposable implements IDisposable {
/** @deprecated Kept for backward compat — points to top layer canvas. */
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
public canvas: HTMLCanvasElement | undefined;
private _ctx: CanvasRenderingContext2D | null | undefined;
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
@@ -41,7 +38,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Only the DOM output canvas should be on the terminal's document,
* which gets explicitly checked in `insertLayerToDom`.
*/
const canvas = (localDocument ?? document).createElement('canvas');
const canvas = (localDocument || document).createElement('canvas');
canvas.width = width | 0;
canvas.height = height | 0;
return canvas;
@@ -89,7 +86,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
});
this._register(toDisposable(() => {
this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
if (this._terminal._core && this._oldOpen) {
this._terminal._core.open = this._oldOpen;
this._oldOpen = undefined;
@@ -99,7 +95,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined;
}
this._renderService = undefined;
this._layers.clear();
this.canvas = undefined;
this._ctx = undefined;
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
@@ -112,7 +109,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, Constants.PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
@@ -143,38 +140,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0);
const w = this.dimensions?.css.canvas.width || 0;
const h = (end + 1 - start) * (this.dimensions?.css.cell.height || 0);
if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h);
}
if (!layer || layer === 'bottom') {
this._layers.get('bottom')?.clearRect(0, y, w, h);
}
public clearLines(start: number, end: number): void {
this._ctx?.clearRect(
0,
start * (this.dimensions?.css.cell.height || 0),
this.dimensions?.css.canvas.width || 0,
(++end - start) * (this.dimensions?.css.cell.height || 0)
);
}
/**
* Clear whole image canvas.
*/
public clearAll(layer?: ImageLayer): void {
if (!layer || layer === 'top') {
const ctx = this._layers.get('top');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
if (!layer || layer === 'bottom') {
const ctx = this._layers.get('bottom');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
public clearAll(): void {
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
}
/**
* Draw neighboring tiles on the image layer canvas.
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer);
if (!ctx) {
if (!this._ctx) {
return;
}
const { width, height } = this.cellSize;
@@ -201,7 +187,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
// Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage(
this._ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
@@ -241,8 +227,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Draw a line with placeholder on the image layer canvas.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top');
if (ctx) {
if (this._ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -251,13 +236,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
this._ctx.drawImage(
this._placeholderBitmap || this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
width * count,
@@ -275,13 +260,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Checked once from `ImageStorage.render`.
*/
public rescaleCanvas(): void {
const w = this.dimensions?.css.canvas.width || 0;
const h = this.dimensions?.css.canvas.height || 0;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
ctx.canvas.width = w;
ctx.canvas.height = h;
}
if (!this.canvas) {
return;
}
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
this.canvas.width = this.dimensions!.css.canvas.width || 0;
this.canvas.height = this.dimensions!.css.canvas.height || 0;
}
}
@@ -320,68 +304,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._renderService = this._terminal._core._renderService;
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
this._renderService.setRenderer = (renderer: any) => {
for (const key of [...this._layers.keys()]) {
this.removeLayerFromDom(key);
}
this.removeLayerFromDom();
this._oldSetRenderer?.call(this._renderService, renderer);
};
}
public insertLayerToDom(layer: ImageLayer = 'top'): void {
public insertLayerToDom(): void {
// make sure that the terminal is attached to a document and to DOM
if (!this.document || !this._terminal._core.screenElement) {
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
return;
}
if (this._layers.has(layer)) {
return;
}
const canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
// Use isolation to create a stacking context without overriding z-index,
// which would conflict with integrators (e.g. VS Code) that set their
// own z-index on the screen element.
screenElement.style.isolation = 'isolate';
if (layer === 'bottom') {
// Use z-index:-1 so it paints behind non-positioned text elements.
// The screen element needs to be a stacking context (via isolation)
// to contain the negative z-index, otherwise it would go behind the
// entire terminal.
canvas.style.zIndex = '-1';
screenElement.insertBefore(canvas, screenElement.firstChild);
if (this.document && this._terminal._core.screenElement) {
if (!this.canvas) {
this.canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
this.canvas.classList.add('xterm-image-layer');
this._terminal._core.screenElement.appendChild(this.canvas);
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
this.clearAll();
}
} else {
// Explicit z-index ensures the image canvas reliably stacks above
// the text layer (DOM renderer rows). z-index: 0 is below the
// selection overlay (z-index: 1).
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
}
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
return;
}
this._layers.set(layer, ctx);
this.clearAll(layer);
}
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
const ctx = this._layers.get(layer);
if (ctx) {
ctx.canvas.remove();
this._layers.delete(layer);
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
}
}
public hasLayer(layer: ImageLayer): boolean {
return this._layers.has(layer);
public removeLayerFromDom(): void {
if (this.canvas) {
this._ctx = undefined;
this.canvas.remove();
this.canvas = undefined;
}
}
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
@@ -405,7 +359,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) || Constants.PLACEHOLDER_LENGTH;
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
+65 -138
View File
@@ -5,11 +5,7 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import {
ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec,
IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes,
UnderlineStyle, IAddImageOpts
} from './Types';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
// fallback default cell size
@@ -128,8 +124,6 @@ export class ImageStorage implements IDisposable {
private _pixelLimit: number = 2500000;
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor(
private _terminal: ITerminalExt,
@@ -138,10 +132,8 @@ export class ImageStorage implements IDisposable {
) {
try {
this.setLimit(this._opts.storageLimit);
} catch (e: unknown) {
if (e instanceof Error) {
console.error(e.message);
}
} catch (e: any) {
console.error(e.message);
console.warn(`storageLimit is set to ${this.getLimit()} MB`);
}
this._viewportMetrics = {
@@ -195,13 +187,11 @@ export class ImageStorage implements IDisposable {
private _delImg(id: number): void {
const spec = this._images.get(id);
if (!spec) return;
this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :(
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
this.onImageDeleted?.(id);
}
/**
@@ -225,29 +215,27 @@ export class ImageStorage implements IDisposable {
}
/**
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
* Only advance text cursor.
* This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/
public deleteImage(id: number): void {
const spec = this._images.get(id);
if (spec) {
spec.marker?.dispose();
this._delImg(id);
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
/**
* Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
* @param opts - Options for addImage:
* - scrolling: When true, cursor advances with the image.
* When false, image is placed at ORIGIN and cursor does not move.
* - layer: Which canvas layer to render on ('top' or 'bottom').
* - zIndex: Z-index for image layering within the same layer.
* - cursorPos: 'vt340' for bottom-left, 'iip' for bottom.right.
* @returns The internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap, opts: IAddImageOpts): number {
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
@@ -269,7 +257,7 @@ export class ImageStorage implements IDisposable {
let offset = originX;
let tileCount = 0;
if (!opts.scrolling) {
if (!this._opts.sixelScrolling) {
buffer.x = 0;
buffer.y = 0;
offset = 0;
@@ -283,7 +271,7 @@ export class ImageStorage implements IDisposable {
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++;
}
if (opts.scrolling) {
if (this._opts.sixelScrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else {
if (++buffer.y >= termRows) break;
@@ -293,12 +281,8 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes
if (opts.scrolling) {
if (opts.cursorPos === 'iip') {
buffer.x = Math.min(offset + cols, termCols);
} else {
buffer.x = offset;
}
if (this._opts.sixelScrolling) {
buffer.x = offset;
} else {
buffer.x = originX;
buffer.y = originY;
@@ -340,15 +324,11 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer: opts.layer,
zIndex: opts.zIndex
bufferType: this._terminal.buffer.active.type
};
// finally add the image
this._images.set(imageId, imgSpec);
this.onImageAdded?.();
return imageId;
}
@@ -358,30 +338,16 @@ export class ImageStorage implements IDisposable {
*/
// TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void {
// Determine which layers have images
let hasTopImages = false;
let hasBottomImages = false;
for (const spec of this._images.values()) {
if (spec.layer === 'bottom') {
hasBottomImages = true;
} else {
hasTopImages = true;
// setup image canvas in case we have none yet, but have images in store
if (!this._renderer.canvas && this._images.size) {
this._renderer.insertLayerToDom();
// safety measure - in case we cannot spawn a canvas at all, just exit
if (!this._renderer.canvas) {
return;
}
if (hasTopImages && hasBottomImages) break;
}
// Lazily insert layers that are needed
if (hasTopImages && !this._renderer.hasLayer('top')) {
this._renderer.insertLayerToDom('top');
if (!this._renderer.hasLayer('top')) return;
}
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
this._renderer.insertLayerToDom('bottom');
}
// rescale if needed
this._renderer.rescaleCanvas();
// exit early if we dont have any images to test for
if (!this._images.size) {
if (!this._fullyCleared) {
@@ -389,25 +355,12 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true;
this._needsFullClear = false;
}
if (this._renderer.hasLayer('top')) {
this._renderer.removeLayerFromDom('top');
}
if (this._renderer.hasLayer('bottom')) {
this._renderer.removeLayerFromDom('bottom');
if (this._renderer.canvas) {
this._renderer.removeLayerFromDom();
}
return;
}
// Remove layers no longer needed
if (!hasTopImages && this._renderer.hasLayer('top')) {
this._renderer.clearAll('top');
this._renderer.removeLayerFromDom('top');
}
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
this._renderer.clearAll('bottom');
this._renderer.removeLayerFromDom('bottom');
}
// buffer switches force a full clear
if (this._needsFullClear) {
this._renderer.clearAll();
@@ -422,76 +375,50 @@ export class ImageStorage implements IDisposable {
// clear drawing area
this._renderer.clearLines(start, end);
// Collect draw calls so we can sort by z-index (lower z drawn first).
const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];
const placeholderCalls: { col: number, row: number, count: number }[] = [];
// walk all cells in viewport and collect tiles found
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
// because text writes clear the BG flag but leave image tile data intact.
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
// walk all cells in viewport and draw tiles found
for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
if (!line) return;
for (let col = 0; col < cols; ++col) {
let e: IExtendedAttrsImage;
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
} else {
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
let e: IExtendedAttrsImage = line._extendedAttrs[col] || EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
e = maybeImg;
}
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
*/
while (++col < cols) {
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
*/
while (
++col < cols
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
&& (e = line._extendedAttrs[col] || EMPTY_ATTRS)
&& (e.imageId === imageId)
&& (e.tileId === startTile + count)
) {
count++;
}
count++;
}
col--;
if (imgSpec) {
if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
col--;
if (imgSpec) {
if (imgSpec.actual) {
this._renderer.draw(imgSpec, startTile, startCol, row, count);
}
} else if (this._opts.showPlaceholder) {
this._renderer.drawPlaceholder(startCol, row, count);
}
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
this._fullyCleared = false;
}
this._fullyCleared = false;
}
}
}
// Sort by z-index so lower z draws first (higher z renders on top)
drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);
// Draw placeholders first (lowest priority)
for (const call of placeholderCalls) {
this._renderer.drawPlaceholder(call.col, call.row, call.count);
}
// Draw images in z-index order
for (const call of drawCalls) {
this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);
}
}
public viewportResize(metrics: { cols: number, rows: number }): void {
@@ -515,7 +442,7 @@ export class ImageStorage implements IDisposable {
for (let row = 0; row < rows; ++row) {
const line = buffer.lines.get(row) as IBufferLineExt;
if (line.getBg(oldCol) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] ?? EMPTY_ATTRS;
const e: IExtendedAttrsImage = line._extendedAttrs[oldCol] || EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
@@ -560,7 +487,7 @@ export class ImageStorage implements IDisposable {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1) {
const orig = this._images.get(e.imageId)?.orig;
if (window.ImageBitmap && orig instanceof ImageBitmap) {
@@ -580,7 +507,7 @@ export class ImageStorage implements IDisposable {
const buffer = this._terminal._core.buffer;
const line = buffer.lines.get(y) as IBufferLineExt;
if (line && line.getBg(x) & BgFlags.HAS_EXTENDED) {
const e: IExtendedAttrsImage = line._extendedAttrs[x] ?? EMPTY_ATTRS;
const e: IExtendedAttrsImage = line._extendedAttrs[x] || EMPTY_ATTRS;
if (e.imageId && e.imageId !== -1 && e.tileId !== -1) {
const spec = this._images.get(e.imageId);
if (spec) {
+3 -3
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { SixelImageStorage } from './SixelImageStorage';
import { ImageStorage } from './ImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types';
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
DecoderAsync({
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
const height = this._dec.height;
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
if (!width || !height) {
if (!width || ! height) {
if (height) {
this._storage.advanceCursor(height);
}
@@ -1,52 +0,0 @@
/**
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt, IAddImageOpts } from './Types';
import { ImageRenderer } from './ImageRenderer';
/**
* Sixel-specific image storage controller.
*
* Wraps the shared ImageStorage with sixel protocol semantics:
* - Cursor behavior governed by DECSET 80 (sixelScrolling option)
* - advanceCursor for empty sixels carrying only height
*/
export class SixelImageStorage {
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'vt340' };
constructor(
private readonly _storage: ImageStorage,
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _terminal: ITerminalExt
) {}
/**
* Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._addImageOpts.scrolling = this._opts.sixelScrolling;
this._storage.addImage(img, this._addImageOpts);
}
/**
* Only advance text cursor.
* This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
}
+3 -18
View File
@@ -8,9 +8,8 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
// private imports from base repo we build against
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IInputHandler } from 'common/Types';
import type { IBufferLine, IExtendedAttrs } from 'common/buffer/Types';
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
import type { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
@@ -23,7 +22,7 @@ export const enum Cell {
}
// export some privates for local usage
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/**
* Plugin ctor options.
@@ -39,8 +38,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -100,16 +97,6 @@ export interface ICellSize {
height: number;
}
export type ImageLayer = 'top' | 'bottom';
export type CursorPos = 'vt340' | 'iip';
export interface IAddImageOpts {
scrolling: boolean;
layer: ImageLayer;
zIndex: number;
cursorPos: CursorPos;
}
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
@@ -118,6 +105,4 @@ export interface IImageSpec {
marker: IMarker | undefined;
tileCount: number;
bufferType: 'alternate' | 'normal';
layer: ImageLayer;
zIndex: number;
}
@@ -1,819 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
KittyAction,
KittyFormat,
KittyCompression,
IKittyCommand,
IPendingTransmission,
IKittyImageData,
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
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
}
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 {
private _aborted = false;
private _decodeError = false;
private _activeDecoder: Base64Decoder | null = null;
private readonly _maxEncodedBytes: number;
private readonly _initialEncodedBytes: number;
// Streaming related states
// True while receiving control data (before semicolon).
private _inControlData = true;
// Buffer for control data.
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
private _controlLength = 0;
// Pre-calculated encoded size limit
private _encodedSizeLimit = 0;
private _totalEncodedSize = 0;
// Parsed command. These are the control data before semicolon.
private _parsedCommand: IKittyCommand | null = null;
// Storage related states
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
// Tracks the pending key of the most recently started chunked upload.
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
// When a chunk arrives with no i=, this key is used to find the pending upload.
private _lastPendingKey: number | undefined;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _kittyStorage: KittyImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
// 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(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
}
public reset(): void {
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
this._kittyStorage.reset();
}
public dispose(): void {
this.reset();
}
private _removePendingEntry(key: number): void {
this._pendingTransmissions.delete(key);
if (this._lastPendingKey === key) {
this._lastPendingKey = undefined;
}
}
private _cleanupAllPending(): void {
for (const pending of this._pendingTransmissions.values()) {
pending.decoder.release();
}
this._pendingTransmissions.clear();
this._lastPendingKey = undefined;
}
public start(): void {
this._aborted = false;
this._decodeError = false;
this._inControlData = true;
this._controlLength = 0;
this._parsedCommand = null;
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
this._encodedSizeLimit = this._maxEncodedBytes;
this._totalEncodedSize = 0;
this._activeDecoder = null;
}
public put(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
if (!this._inControlData) {
this._streamPayload(data, start, end);
} else {
// Scan for semicolon
let controlEnd = end;
for (let i = start; i < end; i++) {
if (data[i] === Constants.SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
}
}
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
this._aborted = true;
return;
}
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
this._controlLength += copyLength;
if (!this._inControlData) {
// Found semicolon - parse control data early for validation
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
// Early validation: i+I conflict
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
this._aborted = true;
return;
}
// Delete action doesn't need payload - skip streaming
if (this._parsedCommand.action === KittyAction.DELETE) {
return;
}
// Stream remaining as payload
const payloadStart = controlEnd + 1;
if (payloadStart < end) {
this._streamPayload(data, payloadStart, end);
}
}
}
}
// Stream payload bytes into the base64 decoder.
private _streamPayload(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
// Check size limit (compare encoded bytes against pre-calculated limit)
// Include cumulative size from pending transmission for multi-chunk images.
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
const pending = this._pendingTransmissions.get(pendingKey);
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
this._totalEncodedSize += end - start;
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
if (cumulativeEncodedSize > this._encodedSizeLimit) {
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
if (decoderToRelease) {
decoderToRelease.release();
}
this._activeDecoder = null;
if (pending) {
this._removePendingEntry(pendingKey);
}
this._aborted = true;
return;
}
if (this._decodeError) return;
if (pending?.decoder && !this._activeDecoder) {
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
this._activeDecoder.release();
this._activeDecoder = null;
this._decodeError = true;
if (pending) {
this._removePendingEntry(pendingKey);
}
}
}
public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted || !success) {
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
return true;
}
// No semicolon = no payload (delete, capability query)
if (this._inControlData) {
return this._handleNoPayloadCommand();
}
// Use command parsed early in put() - i+I already validated there
const cmd = this._parsedCommand!;
// Delete action was handled by skipping payload - just execute
if (cmd.action === KittyAction.DELETE) {
return this._handleDelete(cmd);
}
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
const isMoreComing = cmd.more === 1;
const pending = this._pendingTransmissions.get(pendingKey);
if (isMoreComing) {
if (this._activeDecoder) {
if (pending) {
pending.totalEncodedSize += this._totalEncodedSize;
pending.decodeError = pending.decodeError || this._decodeError;
} else {
this._pendingTransmissions.set(pendingKey, {
cmd: { ...cmd },
decoder: this._activeDecoder,
totalEncodedSize: this._totalEncodedSize,
decodeError: this._decodeError
});
}
this._lastPendingKey = pendingKey;
this._activeDecoder = null;
}
return true;
}
// Final chunk received — clear the last pending key
if (pending) {
this._lastPendingKey = undefined;
}
let decodeError = this._decodeError;
let finalCmd = cmd;
let decoder = this._activeDecoder;
if (pending) {
finalCmd = pending.cmd;
decoder = pending.decoder;
decodeError = decodeError || pending.decodeError;
this._pendingTransmissions.delete(pendingKey);
}
let imageBytes = new Uint8Array(0);
if (decoder) {
if (decoder.end() !== DECODER_OK) {
decodeError = true;
}
imageBytes = decoder.data8;
}
this._activeDecoder = null;
// Handle command first — handlers create Blob/ImageData from imageBytes,
// which copies the data. Only then is it safe to release the decoder's
// wasm memory that imageBytes points into.
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
if (decoder) {
decoder.release();
}
return result;
}
// Command handling
private _parseControlDataString(): string {
let str = '';
for (let i = 0; i < this._controlLength; i++) {
str += String.fromCodePoint(this._controlData[i]);
}
return str;
}
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
const cmd = parseKittyCommand(this._parseControlDataString());
// Per spec: specifying both i and I is an error
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
return true;
}
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.DELETE:
return this._handleDelete(cmd);
case KittyAction.QUERY:
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
return true;
case KittyAction.PLACEMENT:
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.TRANSMIT: {
const result = this._handleTransmit(cmd, bytes, decodeError);
// Only send response when _handleTransmit didn't already respond
// (it handles unsupported transmission medium responses internally)
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
if (decodeError) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
} else if (bytes.length > 0) {
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
}
}
return result;
}
case KittyAction.TRANSMIT_DISPLAY:
return this._handleTransmitDisplay(cmd, bytes, decodeError);
case KittyAction.QUERY:
return this._handleQuery(cmd, bytes, decodeError);
case KittyAction.PLACEMENT:
// a=p ignores any payload — image data was already transmitted
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
if (cmd.id === undefined) {
return true;
}
const id = cmd.id;
const image = this._kittyStorage.getImage(id);
if (!image) {
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
const result = this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
return true;
});
}
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
// Currently only supports direct transmission (t=d, the default).
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
// 1. Check cmd.transmission (t key) before treating bytes as image data
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
// 3. For t=d: treat bytes as image data (current behavior)
// When implementing, also update _handleQuery to accept these transmission mediums.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
}
return true;
}
if (decodeError || bytes.length === 0) return true;
this._kittyStorage.storeImage(cmd.id, {
data: new Blob([bytes as BlobPart]),
width: cmd.width ?? 0,
height: cmd.height ?? 0,
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
compression: cmd.compression ?? ''
});
return true;
}
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
if (decodeError) {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
}
return true;
}
this._handleTransmit(cmd, bytes, decodeError);
const id = cmd.id ?? this._kittyStorage.lastImageId;
const image = this._kittyStorage.getImage(id);
if (image) {
const result = this._displayImage(image, cmd);
if (cmd.id !== undefined) {
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
return true;
});
}
return result.then(() => true);
}
return true;
}
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
const id = cmd.id ?? 0;
const quiet = cmd.quiet ?? 0;
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
return true;
}
// Check decode error first (invalid base64)
if (decodeError) {
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
return true;
}
// Capability query (no payload) - just respond OK
if (bytes.length === 0) {
this._sendResponse(id, 'OK', quiet);
return true;
}
const format = cmd.format ?? KittyFormat.RGBA;
if (format === KittyFormat.PNG) {
this._sendResponse(id, 'OK', quiet);
} else {
const width = cmd.width ?? 0;
const height = cmd.height ?? 0;
if (!width || !height) {
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
return true;
}
this._sendResponse(id, 'OK', quiet);
}
return true;
}
private _handleDelete(cmd: IKittyCommand): boolean {
// Per spec: default delete selector is 'a' (delete all visible placements)
const selector = cmd.deleteSelector ?? 'a';
// TODO: Distinguish lowercase (delete placements only) from uppercase
// (delete placements + free stored image data). Currently both variants
// free everything since we don't separate stored data from placements.
switch (selector) {
case 'a':
case 'A':
this._cleanupAllPending();
this._kittyStorage.deleteAll();
break;
case 'i':
case 'I':
// TODO: When placement id tracking is implemented (see TODO in
// KittyImageStorage), d=i with p=<pid> should delete only that
// specific placement, while d=i without p should delete all
// placements for the image.
if (cmd.id !== undefined) {
const pending = this._pendingTransmissions.get(cmd.id);
if (pending) {
pending.decoder.release();
}
this._removePendingEntry(cmd.id);
this._kittyStorage.deleteById(cmd.id);
}
break;
default:
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
break;
}
return true;
}
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;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
this._coreTerminal._core.coreService.triggerDataEvent(response);
}
// Image display
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
return this._decodeAndDisplay(image, cmd)
.then(() => true)
.catch(() => false);
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
const maxCropW = Math.max(0, bitmap.width - cropX);
const maxCropH = Math.max(0, bitmap.height - cropY);
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
if (finalCropW === 0 || finalCropH === 0) {
throw new Error('invalid source rectangle');
}
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
bitmap.close();
bitmap = cropped;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
let imgCols: number;
let imgRows: number;
if (cmd.columns !== undefined && cmd.rows !== undefined) {
imgCols = cmd.columns;
imgRows = cmd.rows;
} else if (cmd.columns !== undefined) {
imgCols = cmd.columns;
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
} else if (cmd.rows !== undefined) {
imgRows = cmd.rows;
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
} else {
imgCols = Math.ceil(bitmap.width / cw);
imgRows = Math.ceil(bitmap.height / ch);
}
let w = bitmap.width;
let h = bitmap.height;
// Scale bitmap to fit placement rectangle when c/r are specified
if (cmd.columns !== undefined || cmd.rows !== undefined) {
w = Math.round(imgCols * cw);
h = Math.round(imgRows * ch);
}
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
// Save cursor position before addImage modifies it
const buffer = this._coreTerminal._core.buffer;
const savedX = buffer.x;
const savedY = buffer.y;
const savedYbase = buffer.ybase;
// Determine layer based on z-index: negative = behind text, 0+ = on top.
// When z<0 we always use the bottom layer even without allowTransparency —
// the image will simply be hidden behind the opaque text background, which
// is the correct behavior (client asked for "behind text").
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
if (w !== bitmap.width || h !== bitmap.height) {
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
bitmap = scaled;
}
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
if (xOffset !== 0 || yOffset !== 0) {
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
// total placement area remains c*cw × r*ch pixels and the offset image
// is clipped to fit. When c/r are unset, the padded canvas determines
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
}
offsetCtx.drawImage(bitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
bitmap.close();
bitmap = offsetBitmap;
w = bitmap.width;
h = bitmap.height;
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(bitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(bitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
// Kitty cursor movement
// Per spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
if (cmd.cursorMovement === 1) {
// C=1: restore cursor to position before image was placed
const scrolled = buffer.ybase - savedYbase;
buffer.x = savedX;
// Can't restore cursor to scrollback?
buffer.y = Math.max(savedY - scrolled, 0);
} else {
// Default (C=0): advance cursor horizontally past the image
// addImage already positioned cursor on the last row via lineFeeds
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
}
} catch (e) {
bitmap?.close();
throw e;
}
}
// Create ImageBitmap from already-decoded image data.
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
if (image.compression === KittyCompression.ZLIB) {
bytes = await this._decompressZlib(bytes);
}
if (image.format === KittyFormat.PNG) {
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
img.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
});
img.src = url;
});
}
return createImageBitmap(blob);
}
// Raw pixel data
const width = image.width;
const height = image.height;
if (!width || !height) {
throw new Error('Width and height required for raw pixel data');
}
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) {
throw new Error('Insufficient pixel data');
}
const pixelCount = width * height;
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 * 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 * 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
let srcOffset = 0;
let dstOffset = 0;
for (let i = 0; i < alignedPixels; i += 4) {
const b0 = src32[srcOffset++];
const b1 = src32[srcOffset++];
const b2 = src32[srcOffset++];
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
dst32[dstOffset++] = 0xFF000000 | b0;
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
}
// Handle remaining 1–3 pixels
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] = KittyPixelConstants.ALPHA_OPAQUE;
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
}
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
} catch {
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
const ds = new DecompressionStream(format);
const writer = ds.writable.getWriter();
writer.write(compressed as BufferSource);
writer.close();
const chunks: Uint8Array[] = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._kittyStorage.images;
}
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyStorage.kittyIdToStorageId;
}
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
return this._pendingTransmissions;
}
}
@@ -1,149 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
describe('KittyGraphicsTypes', () => {
describe('parseKittyCommand', () => {
it('should parse control data with action and format', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.action, 'T');
assert.strictEqual(cmd.format, 100);
});
it('should parse control data with all options', () => {
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.id, 5);
assert.strictEqual(cmd.width, 10);
assert.strictEqual(cmd.height, 20);
assert.strictEqual(cmd.columns, 3);
assert.strictEqual(cmd.rows, 2);
assert.strictEqual(cmd.more, 1);
assert.strictEqual(cmd.quiet, 2);
});
it('should handle empty control data', () => {
const cmd = parseKittyCommand('');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, undefined);
});
it('should parse transmit action', () => {
const cmd = parseKittyCommand('a=t,f=100');
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
assert.strictEqual(cmd.format, KittyFormat.PNG);
});
it('should parse delete action', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.action, KittyAction.DELETE);
assert.strictEqual(cmd.id, 5);
});
it('should parse empty action as empty string', () => {
const cmd = parseKittyCommand('a=,f=100');
assert.strictEqual(cmd.action, '');
assert.strictEqual(cmd.format, 100);
});
it('should leave action undefined when key is not present', () => {
const cmd = parseKittyCommand('f=100,i=5');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, 100);
assert.strictEqual(cmd.id, 5);
});
it('should parse compression key', () => {
const cmd = parseKittyCommand('a=t,f=32,o=z');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.compression, 'z');
});
it('should parse cursor movement key', () => {
const cmd = parseKittyCommand('a=T,f=100,C=1');
assert.strictEqual(cmd.cursorMovement, 1);
});
it('should parse cursor movement key C=0', () => {
const cmd = parseKittyCommand('a=T,f=100,C=0');
assert.strictEqual(cmd.cursorMovement, 0);
});
it('should parse x and y offset', () => {
const cmd = parseKittyCommand('a=T,x=10,y=20');
assert.strictEqual(cmd.x, 10);
assert.strictEqual(cmd.y, 20);
});
it('should handle keys without values', () => {
const cmd = parseKittyCommand('a=t,f=,i=5');
assert.strictEqual(cmd.action, 't');
assert.ok(isNaN(cmd.format!));
assert.strictEqual(cmd.id, 5);
});
it('should parse z-index key with positive value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=10');
assert.strictEqual(cmd.zIndex, 10);
});
it('should parse z-index key with zero', () => {
const cmd = parseKittyCommand('a=T,f=100,z=0');
assert.strictEqual(cmd.zIndex, 0);
});
it('should parse z-index key with negative value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=-1');
assert.strictEqual(cmd.zIndex, -1);
});
it('should leave zIndex undefined when not specified', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.zIndex, undefined);
});
it('should parse delete selector key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.action, 'd');
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should parse uppercase delete selector', () => {
const cmd = parseKittyCommand('a=d,d=A');
assert.strictEqual(cmd.deleteSelector, 'A');
});
it('should parse delete selector d=a (all)', () => {
const cmd = parseKittyCommand('a=d,d=a');
assert.strictEqual(cmd.deleteSelector, 'a');
});
it('should leave deleteSelector undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.deleteSelector, undefined);
});
it('should parse placement id key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
assert.strictEqual(cmd.placementId, 3);
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should leave placementId undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.placementId, undefined);
});
it('should parse image number key', () => {
const cmd = parseKittyCommand('a=t,f=100,I=42');
assert.strictEqual(cmd.imageNumber, 42);
});
});
});
@@ -1,195 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*
* Kitty graphics protocol types, constants, and parsing utilities.
*/
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
// Kitty graphics protocol action types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
export const enum KittyAction {
TRANSMIT = 't',
TRANSMIT_DISPLAY = 'T',
QUERY = 'q',
PLACEMENT = 'p',
DELETE = 'd'
}
// Kitty graphics protocol format types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyFormat {
RGB = 24,
RGBA = 32,
PNG = 100
}
// Kitty graphics protocol compression types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
export const enum KittyCompression {
NONE = '',
ZLIB = 'z'
}
// Kitty graphics protocol control data keys.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyKey {
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
ACTION = 'a',
// Image format (24=RGB, 32=RGBA, 100=PNG)
FORMAT = 'f',
// Image ID for referencing stored images
ID = 'i',
// Image number (alternative to ID, terminal assigns ID)
IMAGE_NUMBER = 'I',
// Source image width in pixels
WIDTH = 's',
// Source image height in pixels
HEIGHT = 'v',
// The left edge (in pixels) of the image area to display
X_OFFSET = 'x',
// The top edge (in pixels) of the image area to display
Y_OFFSET = 'y',
// Width (in pixels) of the source rectangle to display
SOURCE_WIDTH = 'w',
// Height (in pixels) of the source rectangle to display
SOURCE_HEIGHT = 'h',
// Horizontal offset (in pixels) within the first cell
X_PLACEMENT_OFFSET = 'X',
// Vertical offset (in pixels) within the first cell
Y_PLACEMENT_OFFSET = 'Y',
// Number of terminal columns to display the image over
COLUMNS = 'c',
// Number of terminal rows to display the image over
ROWS = 'r',
// More data flag (1=more chunks coming, 0=final chunk)
MORE = 'm',
// Compression type (z=zlib). This is essential for chunking larger images.
COMPRESSION = 'o',
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
QUIET = 'q',
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
CURSOR_MOVEMENT = 'C',
// Z-index for image layering (negative = behind text, 0+ = on top)
Z_INDEX = 'z',
// Transmission medium (d=direct, f=file, t=temp file, s=shared memory)
TRANSMISSION = 't',
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
DELETE_SELECTOR = 'd',
// Placement ID for targeting specific placements
PLACEMENT_ID = 'p'
}
// Pixel format constants
export const enum KittyPixelConstants {
BYTES_PER_PIXEL_RGB = 3,
BYTES_PER_PIXEL_RGBA = 4,
ALPHA_OPAQUE = 255
}
// Parsed Kitty graphics command.
export interface IKittyCommand {
action?: string;
format?: number;
id?: number;
imageNumber?: number;
width?: number;
height?: number;
x?: number;
y?: number;
sourceWidth?: number;
sourceHeight?: number;
xOffset?: number;
yOffset?: number;
columns?: number;
rows?: number;
more?: number;
quiet?: number;
cursorMovement?: number;
zIndex?: number;
transmission?: string;
deleteSelector?: string;
placementId?: number;
compression?: string;
payload?: string;
}
// Pending chunked transmission state.
// Stores metadata from the first chunk while accumulating decoded payload data.
export interface IPendingTransmission {
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
cmd: IKittyCommand;
// Decoder used across chunked payloads
decoder: Base64Decoder;
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
totalEncodedSize: number;
// Whether any chunk has failed to decode
decodeError: boolean;
}
// Stored Kitty image data.
export interface IKittyImageData {
id: number;
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
data: Blob;
width: number;
height: number;
format: 24 | 32 | 100;
compression?: string;
}
// Parses Kitty graphics control data into a command object.
export function parseKittyCommand(data: string): IKittyCommand {
const cmd: IKittyCommand = {};
const parts = data.split(',');
for (const part of parts) {
const eqIdx = part.indexOf('=');
if (eqIdx === -1) continue;
const key = part.substring(0, eqIdx);
const value = part.substring(eqIdx + 1);
// Handle string keys first
if (key === KittyKey.ACTION) {
cmd.action = value;
continue;
}
if (key === KittyKey.COMPRESSION) {
cmd.compression = value;
continue;
}
if (key === KittyKey.TRANSMISSION) {
cmd.transmission = value;
continue;
}
if (key === KittyKey.DELETE_SELECTOR) {
cmd.deleteSelector = value;
continue;
}
const numValue = parseInt(value, 10);
switch (key) {
case KittyKey.FORMAT: cmd.format = numValue; break;
case KittyKey.ID: cmd.id = numValue; break;
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
case KittyKey.WIDTH: cmd.width = numValue; break;
case KittyKey.HEIGHT: cmd.height = numValue; break;
case KittyKey.X_OFFSET: cmd.x = numValue; break;
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
case KittyKey.COLUMNS: cmd.columns = numValue; break;
case KittyKey.ROWS: cmd.rows = numValue; break;
case KittyKey.MORE: cmd.more = numValue; break;
case KittyKey.QUIET: cmd.quiet = numValue; break;
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
}
}
return cmd;
}
@@ -1,155 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage';
import { ImageLayer, IAddImageOpts } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes';
// Kitty-specific image storage controller.
//
// Wraps shared ImageStorage with kitty protocol semantics:
// - tracks transmitted image payloads by kitty image id
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
// - mirrors shared-storage evictions into kitty maps
// - applies protocol-level undisplayed-image eviction policy
export class KittyImageStorage implements IDisposable {
private static readonly _maxStoredImages = 256;
private _nextImageId = 1;
private readonly _images: Map<number, IKittyImageData> = new Map();
// TODO: Support multiple placements per image. The kitty spec identifies
// placements by an (image id, placement id) pair — same i + different p
// values should coexist, and same i + same p should replace the prior
// placement. Currently we track only one storage entry per kitty image id,
// so multiple placements of the same image overwrite each other. Fixing
// this requires changing these maps to Map<number, Map<number, number>>
// (kittyId → placementId → storageId) and updating addImage/deleteById
// accordingly. The underlying shared ImageStorage would also need to
// support multiple entries per logical image.
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
private readonly _storageIdToKittyId: Map<number, number> = new Map();
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
private readonly _handleStorageImageDeleted = (storageId: number): void => {
const kittyId = this._storageIdToKittyId.get(storageId);
if (kittyId !== undefined) {
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
this._images.delete(kittyId);
}
};
private _addImageOpts: IAddImageOpts = { scrolling: true, layer: 'top', zIndex: 0, cursorPos: 'iip' };
constructor(
private readonly _storage: ImageStorage
) {
this._previousOnImageDeleted = this._storage.onImageDeleted;
this._wrappedOnImageDeleted = (storageId: number) => {
this._previousOnImageDeleted?.(storageId);
this._handleStorageImageDeleted(storageId);
};
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
}
public reset(): void {
this._nextImageId = 1;
this._images.clear();
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public dispose(): void {
this.reset();
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
this._storage.onImageDeleted = this._previousOnImageDeleted;
}
}
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
const imageId = id ?? this._nextImageId++;
const oldStorageId = this._kittyIdToStorageId.get(imageId);
if (oldStorageId !== undefined) {
this._storage.deleteImage(oldStorageId);
this._kittyIdToStorageId.delete(imageId);
this._storageIdToKittyId.delete(oldStorageId);
}
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
this._evictUndisplayedImages();
}
this._images.set(imageId, {
...imageData,
id: imageId
});
return imageId;
}
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
// Clean up stale reverse-mapping from a previous placement of the same
// kitty image. The old shared-storage entry is kept (it may still be
// visible on screen) but its reverse mapping is removed so that eviction
// of the old entry won't incorrectly delete the kitty image data.
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
if (oldStorageId !== undefined) {
this._storageIdToKittyId.delete(oldStorageId);
}
this._addImageOpts.scrolling = scrolling;
this._addImageOpts.layer = layer;
this._addImageOpts.zIndex = zIndex;
const storageId = this._storage.addImage(image, this._addImageOpts);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
public getImage(kittyId: number): IKittyImageData | undefined {
return this._images.get(kittyId);
}
public deleteById(kittyId: number): void {
this._images.delete(kittyId);
const storageId = this._kittyIdToStorageId.get(kittyId);
if (storageId !== undefined) {
this._storage.deleteImage(storageId);
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
}
}
public deleteAll(): void {
this._images.clear();
for (const storageId of this._kittyIdToStorageId.values()) {
this._storage.deleteImage(storageId);
}
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._images;
}
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyIdToStorageId;
}
public get lastImageId(): number {
return this._nextImageId - 1;
}
private _evictUndisplayedImages(): void {
for (const [kittyId] of this._images) {
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
break;
}
if (!this._kittyIdToStorageId.has(kittyId)) {
this._images.delete(kittyId);
}
}
}
}
+5 -3
View File
@@ -11,11 +11,12 @@
"types": [
"../../../node_modules/@types/mocha"
],
"baseUrl": ".",
"paths": {
"browser/*": [ "../../../src/browser/*" ],
"common/*": [ "../../../src/common/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
"*": [ "./*" ]
"vs/*": [ "../../../src/vs/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
}
},
"include": [
@@ -24,6 +25,7 @@
],
"references": [
{ "path": "../../../src/browser" },
{ "path": "../../../src/common" }
{ "path": "../../../src/common" },
{ "path": "../../../src/vs" }
]
}
+8 -153
View File
@@ -8,7 +8,6 @@ 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.
@@ -24,8 +23,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -75,10 +72,6 @@ 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 }) => {
@@ -90,6 +83,11 @@ 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();
@@ -132,13 +130,11 @@ test.describe('ImageAddon', () => {
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 33554432,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -153,9 +149,7 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
iipSizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -196,18 +190,6 @@ test.describe('ImageAddon', () => {
});
test.describe('image lifecycle & eviction', () => {
test('onImageAdded fires for each image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 3);
});
test('delete image once scrolled off', async () => {
await ctx.proxy.write(SIXEL_SEQ_0);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
@@ -290,67 +272,6 @@ test.describe('ImageAddon', () => {
});
});
test.describe('IIP support', () => {
test('size params is optional/informative', async () => {
// too small size param
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=5:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
// no size aparm at all
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
});
test.skip('FilePart support - bytewise', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// byte-wise
for (let i = 0; i < PALETTE_PNG_BASE64.length; ++i) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64[i]}\x07`);
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
});
test('FilePart support - chunks', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// chunks
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
// finalizer
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
});
test('FilePart support - edgecases', async () => {
// multiple opener do not harm
for (let i = 0; i < 10; ++i) {
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
}
// multiple finalizer do not harm
for (let i = 0; i < 5; ++i) {
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
}
// chunks w'o opener are ignored
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
await ctx.proxy.write(`\x1b]1337;FileEnd\x07`);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 0);
});
test('FilePart support - other IIP sequence cancels chunked image', async () => {
// opener
await ctx.proxy.write(`\x1b]1337;MultipartFile=inline=1\x07`);
// chunks
for (let i = 0; i < PALETTE_PNG_BASE64.length; i += 256) {
await ctx.proxy.write(`\x1b]1337;FilePart=${PALETTE_PNG_BASE64.slice(i, i + 256)}\x07`);
}
// finalizer skipped
// write spinfox.png
await ctx.proxy.write(TESTDATA_IIP[1][0]);
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[1][1]);
});
});
test.describe('IIP support - testimages', () => {
test('palette.png', async () => {
await ctx.proxy.write(TESTDATA_IIP[0][0]);
@@ -373,68 +294,6 @@ test.describe('ImageAddon', () => {
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
});
});
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]);
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);
});
});
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 = '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(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
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(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
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(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
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 = '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]);
});
}
});
});
/**
@@ -468,7 +327,3 @@ async function getOrigSize(id: number): Promise<[number, number]> {
window.imageAddon._storage._images.get(${id}).orig.height
]`);
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -15
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
import { Terminal, ITerminalAddon } from '@xterm/xterm';
declare module '@xterm/addon-image' {
export interface IImageAddonOptions {
@@ -76,15 +76,6 @@ declare module '@xterm/addon-image' {
iipSupport?: boolean;
/** IIP sequence size limit (default 20000000 bytes). */
iipSizeLimit?: number;
/**
* Kitty graphics protocol settings
*/
/** Whether Kitty graphics protocol is enabled (default is true). */
kittySupport?: boolean;
/** Kitty image size limit in bytes (default 20000000 bytes). */
kittySizeLimit?: number;
}
export class ImageAddon implements ITerminalAddon {
@@ -116,11 +107,6 @@ declare module '@xterm/addon-image' {
*/
public showPlaceholder: boolean;
/**
* Event fired whenever a new image is added to storage.
*/
public readonly onImageAdded: IEvent<void>;
/**
* Get original image canvas at buffer position.
*/
+11 -7
View File
@@ -14,13 +14,13 @@
"node": ">8.0.0"
},
"scripts": {
"build": "tsgo -p src",
"watch": "tsgo -w -p src",
"build": "tsc -p src",
"watch": "tsc -w -p src",
"prepackage": "npm run build",
"package": "webpack",
"pretest": "npm run build",
"test": "nyc mocha out/**/*.test.js",
"prepublishOnly": "npm run package"
"prepublish": "npm run package"
},
"keywords": [
"font",
@@ -31,11 +31,15 @@
],
"license": "MIT",
"dependencies": {
"lru-cache": "^11.3.6",
"opentype.js": "^2.0.0"
"lru-cache": "^6.0.0",
"opentype.js": "^0.8.0"
},
"devDependencies": {
"@types/opentype.js": "^1.3.9",
"font-finder": "^1.1.0"
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.6.0",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^2.10.0"
}
}
+2 -2
View File
@@ -13,7 +13,7 @@ export interface ITerminalAddon {
dispose(): void;
}
export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
export class LigaturesAddon implements ITerminalAddon , ILigaturesApi {
private readonly _fallbackLigatures: string[];
private readonly _fontFeatureSettings?: string;
@@ -22,7 +22,7 @@ export class LigaturesAddon implements ITerminalAddon, ILigaturesApi {
constructor(options?: Partial<ILigatureOptions>) {
// Source: calt set from https://github.com/be5invis/Iosevka?tab=readme-ov-file#ligations
this._fallbackLigatures = (options?.fallbackLigatures ?? [
this._fallbackLigatures = (options?.fallbackLigatures || [
'<--', '<---', '<<-', '<-', '->', '->>', '-->', '--->',
'<==', '<===', '<<=', '<=', '=>', '=>>', '==>', '===>', '>=', '>>=',
'<->', '<-->', '<--->', '<---->', '<=>', '<==>', '<===>', '<====>', '::', ':::',
+9 -11
View File
@@ -42,11 +42,11 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
if (status && status.state !== 'granted') {
throw new Error('Permission to access local fonts not granted.');
}
} catch (err: unknown) {
} catch (err: any) {
// A `TypeError` indicates the 'local-fonts'
// permission is not yet implemented, so
// only `throw` if this is _not_ the problem.
if (err instanceof Error && err.name !== 'TypeError') {
if (err.name !== 'TypeError') {
throw err;
}
}
@@ -60,10 +60,8 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
fonts[metadata.family].push(metadata);
}
fontsPromise = Promise.resolve(fonts);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err.name, err.message);
}
} catch (err: any) {
console.error(err.name, err.message);
}
}
// Latest proposal https://bugs.chromium.org/p/chromium/issues/detail?id=1312603
@@ -78,13 +76,13 @@ export default async function load(fontFamily: string, cacheSize: number): Promi
fonts[metadata.family].push(metadata);
}
fontsPromise = Promise.resolve(fonts);
} catch (err: unknown) {
if (err instanceof Error) {
console.error(err.name, err.message);
}
} catch (err: any) {
console.error(err.name, err.message);
}
}
fontsPromise ??= Promise.resolve({});
if (!fontsPromise) {
fontsPromise = Promise.resolve({});
}
}
const fonts = await fontsPromise;
@@ -1,5 +1,5 @@
import * as opentype from 'opentype.js';
import { LRUCache } from 'lru-cache';
import LRUCache = require('lru-cache');
import { IFont, ILigatureData, IFlattenedLookupTree, ILookupTree, IOptions } from './types';
import mergeTrees from './merge';
@@ -23,8 +23,8 @@ class FontImpl implements IFont {
if (options.cacheSize > 0) {
this._cache = new LRUCache({
maxSize: options.cacheSize,
sizeCalculation: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
max: options.cacheSize,
length: ((val: ILigatureData | [number, number][], key: string) => key.length) as any
});
}
@@ -44,6 +44,7 @@ export default function mergeRange(ranges: [number, number][], newRangeStart: nu
// current range
ranges[i - 1][1] = Math.max(newRangeEnd, range[1]);
ranges.splice(i, 1);
inRange = false;
return ranges;
}
// Case 7: New range extends from previous range past the
@@ -52,10 +52,12 @@ export function processLookaheadPosition(
}
processedEntries.add(currentEntry.entry);
currentEntry.entry.forward ??= {
individual: {},
range: []
};
if (!currentEntry.entry.forward) {
currentEntry.entry.forward = {
individual: {},
range: []
};
}
// All glyphs at this position share ONE entry - lookahead just needs to match,
// all paths lead to the same result
@@ -95,10 +97,12 @@ export function processBacktrackPosition(
}
processedEntries.add(currentEntry.entry);
currentEntry.entry.reverse ??= {
individual: {},
range: []
};
if (!currentEntry.entry.reverse) {
currentEntry.entry.reverse = {
individual: {},
range: []
};
}
// All glyphs at this position share ONE entry - backtrack just needs to match,
// all paths lead to the same result
@@ -54,6 +54,9 @@ export function getIndividualSubstitutionGlyph(table: SubstitutionTable, glyphId
return (glyphId + table.deltaGlyphId) % (2 ** 16);
// https://docs.microsoft.com/en-us/typography/opentype/spec/gsub#12-single-substitution-format-2
case 2:
return table.substitute[coverageIndex] ?? null;
// eslint-disable-next-line eqeqeq
return table.substitute[coverageIndex] != null
? table.substitute[coverageIndex]
: null;
}
}
+2 -2
View File
@@ -21,7 +21,7 @@ const CACHE_SIZE = 100000;
/**
* Enable ligature support for the provided Terminal instance. To function
* properly, this must be called after `open()` is called on the terminal. If
* properly, this must be called after `open()` is called on the therminal. If
* the font currently in use supports ligatures, the terminal will automatically
* start to render them.
* @param term Terminal instance from xterm.js
@@ -30,7 +30,7 @@ export function enableLigatures(term: Terminal, fallbackLigatures: string[] = []
let currentFontName: string | undefined = undefined;
let font: Font | undefined = undefined;
let loadingState: LoadingState = LoadingState.UNLOADED;
let loadError: unknown = undefined;
let loadError: any | undefined = undefined;
return term.registerCharacterJoiner((text: string): [number, number][] => {
// If the font hasn't been loaded yet, load it and return an empty result
+1 -5
View File
@@ -9,12 +9,8 @@
"outDir": "../out-esbuild-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"strict": true,
"paths": {
"*": [
"./*"
]
},
"types": [
"../../../node_modules/@types/mocha",
"../../../node_modules/@types/node"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+2 -2
View File
@@ -5,7 +5,7 @@
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
import type { Emitter, IEvent } from 'common/Event';
import type { Emitter, Event } from 'vs/base/common/event';
const enum ProgressType {
@@ -39,7 +39,7 @@ export class ProgressAddon implements ITerminalAddon, IProgressApi {
private _pr = 0;
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
private _onChange!: Emitter<IProgressState>;
public onChange!: IEvent<IProgressState>;
public onChange!: Event<IProgressState>;
public dispose(): void {
this._seqHandler?.dispose();
+4 -4
View File
@@ -15,12 +15,12 @@
"../../../node_modules/@types/mocha"
],
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"vs/*": [
"../../../src/vs/*"
],
"@xterm/addon-progress": [
"../typings/addon-progress.d.ts"
]
@@ -35,7 +35,7 @@
"path": "../../../src/browser"
},
{
"path": "../../../src/common"
"path": "../../../src/vs"
}
]
}
@@ -9,18 +9,15 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
ctx.page.setViewportSize({ width: 1024, height: 768 });
await openTerminal(ctx);
});
test.afterAll(async () => await ctx.page.close());
test.describe('ProgressAddon', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async ({ browser }) => {
ctx = await createTestContext(browser);
await openTerminal(ctx);
});
test.afterAll(async () => {
await ctx?.page?.close();
});
test.beforeEach(async function(): Promise<any> {
await ctx.page.evaluate(`
window.progressStack = [];
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
@@ -1,81 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { DecorationManager } from './DecorationManager';
import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import type { IDecorationOptions } from '@xterm/xterm';
import { DisposableStore } from 'common/Lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
}
describe('DecorationManager', () => {
let store: DisposableStore;
let terminal: Terminal;
let decorationManager: DecorationManager;
beforeEach(() => {
store = new DisposableStore();
terminal = store.add(new Terminal({ cols: 10, rows: 5 }));
decorationManager = store.add(new DecorationManager(terminal));
});
it('should split highlight decorations for a wrapped match', async () => {
await writeP(terminal, '0123456789abcde');
const searchEngine = new SearchEngine(terminal, store.add(new SearchLineCache(terminal)));
const match = searchEngine.find('9abc', 0, 0);
assert.ok(match);
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([match], options);
assert.strictEqual(decorationOptions.length, 2);
assert.strictEqual(decorationOptions[0].x, 9);
assert.strictEqual(decorationOptions[0].width, 1);
assert.strictEqual(decorationOptions[1].x, 0);
assert.strictEqual(decorationOptions[1].width, 3);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 2);
});
it('should only add one overview ruler marker per buffer line', async () => {
await writeP(terminal, 'abcdefghij');
const decorationOptions: IDecorationOptions[] = [];
const registerDecoration = terminal.registerDecoration.bind(terminal);
terminal.registerDecoration = (options: IDecorationOptions) => {
decorationOptions.push(options);
return registerDecoration(options);
};
const options: ISearchDecorationOptions = {
matchOverviewRuler: '#ff0000',
activeMatchColorOverviewRuler: '#00ff00'
};
decorationManager.createHighlightDecorations([
{ term: 'a', col: 0, row: 0, size: 1 },
{ term: 'f', col: 5, row: 0, size: 1 }
], options);
const withOverviewRuler = decorationOptions.filter(o => o.overviewRulerOptions !== undefined);
assert.strictEqual(withOverviewRuler.length, 1);
assert.strictEqual(withOverviewRuler[0].x, 0);
});
});
+1 -1
View File
@@ -5,7 +5,7 @@
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
import type { ISearchDecorationOptions } from '@xterm/addon-search';
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
+12 -14
View File
@@ -4,10 +4,10 @@
*/
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
import { Emitter, type IEvent } from 'common/Event';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { disposableTimeout } from 'common/Async';
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent } from '@xterm/addon-search';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
import { SearchLineCache } from './SearchLineCache';
import { SearchState } from './SearchState';
import { SearchEngine, type ISearchResult } from './SearchEngine';
@@ -47,7 +47,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
private readonly _onBeforeSearch = this._register(new Emitter<void>());
public readonly onBeforeSearch = this._onBeforeSearch.event;
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> {
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
return this._resultTracker.onDidChangeResults;
}
@@ -142,14 +142,12 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
}
prevResult = result;
results.push(prevResult);
const cols = this._terminal.cols;
let nextCol = prevResult.col + prevResult.size;
let nextRow = prevResult.row;
if (nextCol >= cols) {
nextRow += Math.floor(nextCol / cols);
nextCol = nextCol % cols;
}
result = this._engine.find(term, nextRow, nextCol, searchOptions);
result = this._engine.find(
term,
prevResult.col + prevResult.term.length >= this._terminal.cols ? prevResult.row + 1 : prevResult.row,
prevResult.col + prevResult.term.length >= this._terminal.cols ? 0 : prevResult.col + 1,
searchOptions
);
}
this._resultTracker.updateResults(results, this._highlightLimit);
@@ -224,7 +222,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
* @param result The result to select.
* @returns Whether a result was selected.
*/
private _selectResult(result: ISearchResult | undefined, options?: ISearchDecorationOptions, noScroll?: boolean): boolean {
private _selectResult(result: ISearchResult | undefined, options?: any, noScroll?: boolean): boolean {
if (!this._terminal || !this._decorationManager) {
return false;
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { SearchEngine } from './SearchEngine';
import { SearchLineCache } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import type { ISearchOptions } from '@xterm/addon-search';
import { DisposableStore } from 'common/Lifecycle';
import { DisposableStore } from 'vs/base/common/lifecycle';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
+7 -5
View File
@@ -60,7 +60,7 @@ export class SearchEngine {
this._terminal.clearSelection();
return undefined;
}
if (startCol >= this._terminal.cols) {
if (startCol > this._terminal.cols) {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
@@ -174,7 +174,7 @@ export class SearchEngine {
this._terminal.clearSelection();
let startRow = this._terminal.buffer.active.baseY + this._terminal.rows - 1;
const startCol = this._terminal.cols;
let startCol = this._terminal.cols;
const isReverseSearch = true;
this._lineCache.initLinesCache();
@@ -186,19 +186,21 @@ export class SearchEngine {
let result: ISearchResult | undefined;
if (prevSelectedPos) {
searchPosition.startRow = startRow = prevSelectedPos.start.y;
searchPosition.startCol = prevSelectedPos.start.x;
searchPosition.startCol = startCol = prevSelectedPos.start.x;
if (cachedSearchTerm !== term) {
// Try to expand selection to right first.
result = this._findInLine(term, searchPosition, searchOptions, false);
if (!result) {
// If selection was not able to be expanded to the right, then try reverse search
searchPosition.startRow = startRow = prevSelectedPos.end.y;
searchPosition.startCol = prevSelectedPos.end.x;
searchPosition.startCol = startCol = prevSelectedPos.end.x;
}
}
}
result ??= this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
if (!result) {
result = this._findInLine(term, searchPosition, searchOptions, isReverseSearch);
}
// Search from startRow - 1 to top
if (!result) {
@@ -5,7 +5,7 @@
import { assert } from 'chai';
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
import { Terminal } from 'browser/public/Terminal';
import { timeout } from 'common/Async';
import { timeout } from 'vs/base/common/async';
function writeP(terminal: Terminal, data: string): Promise<void> {
return new Promise(r => terminal.write(data, r));
+2 -2
View File
@@ -4,8 +4,8 @@
*/
import type { Terminal } from '@xterm/xterm';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { disposableTimeout } from 'common/Async';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
import { disposableTimeout } from 'vs/base/common/async';
export type LineCacheEntry = [
/**
@@ -5,8 +5,8 @@
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
import type { IDisposable } from '@xterm/xterm';
import { Emitter, type IEvent } from 'common/Event';
import { Disposable } from 'common/Lifecycle';
import { Emitter, Event } from 'vs/base/common/event';
import { Disposable } from 'vs/base/common/lifecycle';
import type { ISearchResult } from './SearchEngine';
/**
@@ -25,7 +25,7 @@ export class SearchResultTracker extends Disposable {
private _selectedDecoration: ISelectedDecoration | undefined;
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
/**
* Gets the current search results.
+6
View File
@@ -21,6 +21,9 @@
"browser/*": [
"../../../src/browser/*"
],
"vs/*": [
"../../../src/vs/*"
],
"@xterm/addon-search" : [
"../typings/addon-search.d.ts"
]
@@ -36,6 +39,9 @@
},
{
"path": "../../../src/browser"
},
{
"path": "../../../src/vs"
}
]
}
+2 -16
View File
@@ -414,24 +414,10 @@ test.describe('Search Tests', () => {
});
test.describe('Regression tests', () => {
test('should advance highlight-all scan by buffer match size for wide characters', async () => {
await ctx.page.evaluate(`
window.calls = [];
window.search.onDidChangeResults(e => window.calls.push(e));
`);
await ctx.proxy.resize(3, 5);
await ctx.proxy.write('𝄞𝄞𝄞');
strictEqual(await ctx.page.evaluate(`window.search.findNext('𝄞', { decorations: { activeMatchColorOverviewRuler: '#ff0000', matchOverviewRuler: '#ffff00' } })`), true);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 3, resultIndex: 0 }
]);
await ctx.proxy.resize(80, 24);
});
test.describe('#2444 wrapped line content not being found', () => {
let fixture: string;
test.beforeAll(async () => {
fixture = (await new Promise<Buffer>((res, rej) => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => err ? rej(err) : res(data)))).toString();
fixture = (await new Promise<Buffer>(r => readFile(resolve(__dirname, '../fixtures/issue-2444'), (err, data) => r(data)))).toString();
if (process.platform !== 'win32') {
fixture = fixture.replace(/\n/g, '\n\r');
}
@@ -515,7 +501,7 @@ test.describe('Search Tests', () => {
window.search.onDidChangeResults(e => window.calls.push(e));
`);
// Move cursor forward 1 time to create a null character, as opposed to regular whitespace
await ctx.proxy.write('\x1b[CHi Hi');
await ctx.proxy.write('\\x1b[CHi Hi');
strictEqual(await ctx.page.evaluate(`window.search.findPrevious('h', { decorations: { activeMatchColorOverviewRuler: '#ff0000' } })`), true);
deepStrictEqual(await ctx.page.evaluate('window.calls'), [
{ resultCount: 2, resultIndex: 1 }
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
@@ -4,13 +4,13 @@
"dom",
"es2021"
],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "nodenext",
"moduleResolution": "node",
"strict": false,
"target": "es2021",
"module": "nodenext",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+4 -4
View File
@@ -16,13 +16,13 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start",
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json",
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
}
}
@@ -116,7 +116,7 @@ describe('SerializeAddon', () => {
describe('underline styles', () => {
it('should serialize single underline with style', async () => {
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m');
assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m');
});
it('should serialize double underline', async () => {
@@ -228,7 +228,7 @@ describe('SerializeAddon', () => {
it('empty terminal with selection turned off', () => {
const output = serializeAddon.serializeAsHTML();
assert.notEqual(output, '');
assert.equal((output.match(/<div><span> {10}<\/span><\/div>/g) ?? []).length, 2);
assert.equal((output.match(/<div><span> {10}<\/span><\/div>/g) || []).length, 2);
});
it('empty terminal with no selection', () => {
@@ -245,7 +245,7 @@ describe('SerializeAddon', () => {
const output = serializeAddon.serializeAsHTML({
onlySelection: true
});
assert.equal((output.match(/<div><span>terminal<\/span><\/div>/g) ?? []).length, 1, output);
assert.equal((output.match(/<div><span>terminal<\/span><\/div>/g) || []).length, 1, output);
});
it('basic terminal with html unsafe chars', async () => {
@@ -255,7 +255,7 @@ describe('SerializeAddon', () => {
const output = serializeAddon.serializeAsHTML({
onlySelection: true
});
assert.equal((output.match(/<div><span>&lt;a>&amp;pi;<\/span><\/div>/g) ?? []).length, 1, output);
assert.equal((output.match(/<div><span>&lt;a>&amp;pi;<\/span><\/div>/g) || []).length, 1, output);
});
it('serializes rows within a provided range', async () => {
@@ -267,7 +267,7 @@ describe('SerializeAddon', () => {
startCol: 4
}
});
const rowMatches = output.match(/<div><span>.*?<\/span><\/div>/g) ?? [];
const rowMatches = output.match(/<div><span>.*?<\/span><\/div>/g) || [];
assert.equal(rowMatches.length, 1, output);
assert.ok(rowMatches[0]?.includes('hello'));
assert.ok(!output.includes('bye'));
@@ -278,56 +278,56 @@ describe('SerializeAddon', () => {
await writeP(terminal, ' ' + sgr('1') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-weight: bold;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='font-weight: bold;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with italic styling', async () => {
await writeP(terminal, ' ' + sgr('3') + 'terminal' + sgr('23') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-style: italic;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='font-style: italic;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with inverse styling', async () => {
await writeP(terminal, ' ' + sgr('7') + 'terminal' + sgr('27') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #000000; background-color: #BFBFBF;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='color: #000000; background-color: #BFBFBF;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with underline styling', async () => {
await writeP(terminal, ' ' + sgr('4') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: underline;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with double underline styling', async () => {
await writeP(terminal, ' ' + sgr('4:2') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline double;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: underline double;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with curly underline styling', async () => {
await writeP(terminal, ' ' + sgr('4:3') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline wavy;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: underline wavy;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with dotted underline styling', async () => {
await writeP(terminal, ' ' + sgr('4:4') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline dotted;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: underline dotted;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with dashed underline styling', async () => {
await writeP(terminal, ' ' + sgr('4:5') + 'terminal' + sgr('24') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: underline dashed;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: underline dashed;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with underline color (palette)', async () => {
@@ -350,49 +350,49 @@ describe('SerializeAddon', () => {
await writeP(terminal, ' ' + sgr('8') + 'terminal' + sgr('28') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='visibility: hidden;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='visibility: hidden;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with dim styling', async () => {
await writeP(terminal, ' ' + sgr('2') + 'terminal' + sgr('22') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='opacity: 0.5;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='opacity: 0.5;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with strikethrough styling', async () => {
await writeP(terminal, ' ' + sgr('9') + 'terminal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='text-decoration: line-through;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: line-through;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with combined styling', async () => {
await writeP(terminal, sgr('1') + ' ' + sgr('9') + 'termi' + sgr('22') + 'nal' + sgr('29') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='font-weight: bold;'> <\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='font-weight: bold; text-decoration: line-through;'>termi<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: line-through;'>nal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='font-weight: bold;'> <\/span>/g) || []).length, 1, output);
assert.equal((output.match(/<span style='font-weight: bold; text-decoration: line-through;'>termi<\/span>/g) || []).length, 1, output);
assert.equal((output.match(/<span style='text-decoration: line-through;'>nal<\/span>/g) || []).length, 1, output);
});
it('cells with color styling', async () => {
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with background styling', async () => {
await writeP(terminal, ' ' + sgr('48;5;46') + 'terminal' + sgr('49') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='background-color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='background-color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
});
it('empty terminal with default options', async () => {
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: monospace; font-size: 15px;/g) ?? []).length, 1, output);
assert.equal((output.match(/color: #000000; background-color: #ffffff; font-family: monospace; font-size: 15px;/g) || []).length, 1, output);
});
it('empty terminal with custom options', async () => {
@@ -405,14 +405,14 @@ describe('SerializeAddon', () => {
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
});
assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) ?? []).length, 1, output);
assert.equal((output.match(/color: #ff00ff; background-color: #00ff00; font-family: verdana; font-size: 20px;/g) || []).length, 1, output);
});
it('empty terminal with background included', async () => {
const output = serializeAddon.serializeAsHTML({
includeGlobalBackground: true
});
assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: monospace; font-size: 15px;/g) ?? []).length, 1, output);
assert.equal((output.match(/color: #ffffff; background-color: #000000; font-family: monospace; font-size: 15px;/g) || []).length, 1, output);
});
it('cells with custom color styling', async () => {
@@ -422,7 +422,7 @@ describe('SerializeAddon', () => {
await writeP(terminal, ' ' + sgr('38;5;0') + 'terminal' + sgr('39') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #ffa500;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='color: #ffa500;'>terminal<\/span>/g) || []).length, 1, output);
});
it('cells with color styling - xterm headless', async () => {
@@ -432,7 +432,7 @@ describe('SerializeAddon', () => {
await writeP(terminal, ' ' + sgr('38;5;46') + 'terminal' + sgr('39') + ' ');
const output = serializeAddon.serializeAsHTML();
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) ?? []).length, 1, output);
assert.equal((output.match(/<span style='color: #00ff00;'>terminal<\/span>/g) || []).length, 1, output);
});
});
});
+38 -49
View File
@@ -7,8 +7,7 @@
import type { IBuffer, IBufferCell, IBufferRange, ITerminalAddon, Terminal } from '@xterm/xterm';
import type { IHTMLSerializeOptions, SerializeAddon as ISerializeApi, ISerializeOptions, ISerializeRange } from '@xterm/addon-serialize';
import { IColor } from 'common/Types';
import { IAttributeData } from 'common/buffer/Types';
import { IAttributeData, IColor } from 'common/Types';
import { DEFAULT_ANSI_COLORS } from 'browser/Types';
import { UnderlineStyle } from 'common/buffer/Constants';
@@ -90,19 +89,11 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell)
if (!cell1.isUnderline() && !cell2.isUnderline()) {
return true;
}
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
return false;
}
const cell1Default = cell1.isUnderlineColorDefault();
const cell2Default = cell2.isUnderlineColorDefault();
if (cell1Default && cell2Default) {
return true;
}
if (cell1Default !== cell2Default) {
return false;
}
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
const cell1Data = cell1 as unknown as IAttributeData;
const cell2Data = cell2 as unknown as IAttributeData;
return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle()
&& cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor()
&& cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode();
}
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
@@ -118,16 +109,6 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo
&& cell1.isStrikethrough() === cell2.isStrikethrough();
}
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
const cell1AsBufferCell = cell1 as IBufferCell;
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
return cell1AsBufferCell.attributesEquals(cell2);
}
return equalFg(cell1, cell2)
&& equalBg(cell1, cell2)
&& equalFlags(cell1, cell2);
}
class StringSerializeHandler extends BaseSerializeHandler {
private _rowIndex: number = 0;
private _allRows: string[] = new Array<string>();
@@ -173,7 +154,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
private _thisRowLastSecondChar: IBufferCell = this._buffer.getNullCell();
private _nextRowFirstChar: IBufferCell = this._buffer.getNullCell();
protected _rowEnd(row: number, isLastRow: boolean): void {
// if there is colorful empty cell at line end, we must pad it back, or the color block
// if there is colorful empty cell at line end, whe must pad it back, or the the color block
// will missing
if (this._nullCellCount > 0 && !equalBg(this._cursorStyle, this._backgroundCell)) {
// use clear right to set background.
@@ -221,7 +202,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
// you can't use control sequence to move cursor to (x === row)
(thisRowLastChar.getChars() || thisRowLastChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simplify the process to determine line separator
// so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar)
) {
isValid = true;
@@ -233,7 +214,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
isNextRowFirstCharDoubleWidth &&
(thisRowLastSecondChar.getChars() || thisRowLastSecondChar.getWidth() === 0) &&
// change background of the first wrapped cell also affects BCE
// so we mark it as invalid to simplify the process to determine line separator
// so we mark it as invalid to simply the process to determine line separator
equalBg(thisRowLastChar, nextRowFirstChar) &&
equalBg(thisRowLastSecondChar, nextRowFirstChar)
) {
@@ -277,9 +258,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
const sgrSeq: number[] = [];
if (attributesEquals(cell, oldCell)) {
return sgrSeq;
}
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
const flagsChanged = !equalFlags(cell, oldCell);
@@ -312,18 +290,17 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
if (!equalUnderline(cell, oldCell)) {
const style = cell.getUnderlineStyle();
const cellData = cell as unknown as IAttributeData;
const style = cellData.getUnderlineStyle();
if (style === UnderlineStyle.NONE) {
sgrSeq.push(24);
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
sgrSeq.push(4);
} else {
// Use SGR 4:X format for underline styles
sgrSeq.push('4:' + style as unknown as number);
// Handle underline color
if (!cell.isUnderlineColorDefault()) {
const color = cell.getUnderlineColor();
if (cell.isUnderlineColorRGB()) {
if (!cellData.isUnderlineColorDefault()) {
const color = cellData.getUnderlineColor();
if (cellData.isUnderlineColorRGB()) {
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
} else {
sgrSeq.push('58:5:' + color as unknown as number);
@@ -480,7 +457,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
export class SerializeAddon implements ITerminalAddon, ISerializeApi {
export class SerializeAddon implements ITerminalAddon , ISerializeApi {
private _terminal: Terminal | undefined;
public activate(terminal: Terminal): void {
@@ -624,7 +601,7 @@ export class SerializeAddon implements ITerminalAddon, ISerializeApi {
throw new Error('Cannot use addon until it has been loaded');
}
return this._serializeBufferAsHTML(this._terminal, options ?? {});
return this._serializeBufferAsHTML(this._terminal, options || {});
}
public dispose(): void { }
@@ -653,6 +630,20 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
}
}
private _padStart(target: string, targetLength: number, padString: string): string {
targetLength = targetLength >> 0;
padString = padString ?? ' ';
if (target.length > targetLength) {
return target;
}
targetLength -= target.length;
if (targetLength > padString.length) {
padString += padString.repeat(targetLength / padString.length);
}
return padString.slice(0, targetLength) + target;
}
protected _beforeSerialize(rows: number, start: number, end: number): void {
this._htmlContent += '<html><body><!--StartFragment--><pre>';
@@ -689,7 +680,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
(color >> 8) & 255,
(color ) & 255
];
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
return '#' + rgb.map(x => this._padStart(x.toString(16), 2, '0')).join('');
}
if (isFg ? cell.isFgPalette() : cell.isBgPalette()) {
return this._ansiColors[color].css;
@@ -698,24 +689,26 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
}
private _getUnderlineColor(cell: IBufferCell): string | undefined {
if (cell.isUnderlineColorDefault()) {
const cellData = cell as unknown as IAttributeData;
if (cellData.isUnderlineColorDefault()) {
return undefined;
}
const color = cell.getUnderlineColor();
if (cell.isUnderlineColorRGB()) {
const color = cellData.getUnderlineColor();
if (cellData.isUnderlineColorRGB()) {
const rgb = [
(color >> 16) & 255,
(color >> 8) & 255,
(color ) & 255
];
return '#' + rgb.map(x => x.toString(16).padStart(2, '0')).join('');
return '#' + rgb.map(x => this._padStart(x.toString(16), 2, '0')).join('');
}
// Palette color
return this._ansiColors[color].css;
}
private _getUnderlineStyle(cell: IBufferCell): string {
switch (cell.getUnderlineStyle()) {
const cellData = cell as unknown as IAttributeData;
switch (cellData.getUnderlineStyle()) {
case UnderlineStyle.SINGLE:
return 'underline';
case UnderlineStyle.DOUBLE:
@@ -734,10 +727,6 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
const content: string[] = [];
if (attributesEquals(cell, oldCell)) {
return undefined;
}
const fgChanged = !equalFg(cell, oldCell);
const bgChanged = !equalBg(cell, oldCell);
const flagsChanged = !equalFlags(cell, oldCell);
+1 -1
View File
@@ -10,6 +10,7 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -22,7 +23,6 @@
]
},
"strict": true,
"skipLibCheck": true,
"types": [
"../../../node_modules/@types/mocha"
]
@@ -49,14 +49,7 @@ 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, (key, value) => {
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
lines.push(JSON.stringify(bufferLine));
}
return {
x: buffer.cursorX,
@@ -192,7 +185,7 @@ test.describe('SerializeAddon', () => {
const cols = 10;
const line = '+'.repeat(cols);
const lines: string[] = [
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags at the end, serialize will use \x1b[0m to clear instead of the specific disable sequence
sgr(FG_P16_GREEN) + line, // Workaround: If we clear all flags a the end, serialize will use \x1b[0m to clear instead of the sepcific disable sequence
sgr(INVERSE) + line,
sgr(BOLD) + line,
sgr(UNDERLINED) + line,
@@ -210,46 +203,6 @@ test.describe('SerializeAddon', () => {
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
});
test('buffer cell attributesEquals compares underline style and color', async () => {
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
const sameAttributes = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(sameAttributes, true);
await ctx.page.evaluate(`window.term.reset()`);
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`);
const differentColor = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(differentColor, false);
await ctx.page.evaluate(`window.term.reset()`);
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
const differentStyle = await ctx.page.evaluate(`(() => {
const line = window.term.buffer.active.getLine(0);
const cellA = line?.getCell(0);
const cellB = line?.getCell(1);
if (!cellA || !cellB) {
return undefined;
}
return cellA.attributesEquals(cellB);
})()`);
strictEqual(differentStyle, false);
});
test('serialize all rows of content with color256', async function(): Promise<any> {
const rows = 32;
const cols = 10;
@@ -649,9 +602,6 @@ const BOLD = '1';
const DIM = '2';
const ITALIC = '3';
const UNDERLINED = '4';
const UNDERLINE_DOUBLE = '4:2';
const UNDERLINE_COLOR_RED = '58;5;196';
const UNDERLINE_COLOR_GREEN = '58;5;46';
const BLINK = '5';
const INVERSE = '7';
const INVISIBLE = '8';
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
@@ -9,6 +9,7 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -1,13 +1,13 @@
{
"compilerOptions": {
"lib": ["dom", "es6"],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "nodenext",
"moduleResolution": "node",
"strict": false,
"target": "es2015",
"module": "nodenext",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+4 -4
View File
@@ -16,13 +16,13 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start",
"benchmark": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
"benchmark-baseline": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
"benchmark": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json out-benchmark/benchmark/*benchmark.js",
"benchmark-baseline": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --baseline out-benchmark/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=../../out:./out:./out-benchmark/ ../../node_modules/.bin/xterm-benchmark -r 5 -c benchmark/benchmark.json --eval out-benchmark/benchmark/*benchmark.js"
}
}
@@ -2,22 +2,26 @@
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*
* UnicodeVersionProvider for V15 with grapheme cluster handling.
* UnicodeVersionProvider for V15 with grapeme cluster handleing.
*/
import type { Terminal, ITerminalAddon, IUnicodeHandling } from '@xterm/xterm';
import type { UnicodeGraphemesAddon as IUnicodeGraphemesApi } from '@xterm/addon-unicode-graphemes';
import { UnicodeGraphemeProvider } from './UnicodeGraphemeProvider';
export class UnicodeGraphemesAddon implements ITerminalAddon, IUnicodeGraphemesApi {
export class UnicodeGraphemesAddon implements ITerminalAddon , IUnicodeGraphemesApi {
private _provider15Graphemes?: UnicodeGraphemeProvider;
private _provider15?: UnicodeGraphemeProvider;
private _unicode?: IUnicodeHandling;
private _oldVersion: string = '';
public activate(terminal: Terminal): void {
this._provider15 ??= new UnicodeGraphemeProvider(false);
this._provider15Graphemes ??= new UnicodeGraphemeProvider(true);
if (! this._provider15) {
this._provider15 = new UnicodeGraphemeProvider(false);
}
if (! this._provider15Graphemes) {
this._provider15Graphemes = new UnicodeGraphemeProvider(true);
}
const unicode = terminal.unicode;
this._unicode = unicode;
unicode.register(this._provider15);
@@ -1,7 +1,7 @@
import UnicodeTrie from './unicode-trie';
const trieRaw = "AAARAAAAAABwxwAAAb4LQfTtmw+sVmUdx58LL/ffe/kjzNBV80gW1F3yR+6CvbJiypoZa0paWmAWSluErSBbFtYkkuZykq6QamGJ4WRqo2kFGy6dYWtEq6G1MFAJbRbOVTQr+x7f5+x97q/n/3me87wXzm/3s+f/7/d7/p7znnvOlvGMbQM7wIPgEbAPHABPgcPgefAS+BfYwuv/F/Q2OulBxKcK6TMRPxu8FcwFbwcjYCFYDC4Cl4ArwNXgGvBJsA58UdBDwy+jbBO4La8DtoEd4H7wkNBuN+KPgn3gADgIngaHwFHwF/AyeAWMm4C+TGi3LdiJ/EnIex04A2RgFpgD5oKFYDG4CLwHXAo+IKSvAqt4/evA9bz9jWA6+Cq3dyvCP8HWNwX93wF38/ROcD94SCjP2+1B+BiPP4HwgOD/7xD/I08fRniMx48jPAFeBeuF+n29jE0G08FZvaPHYWZvh9mcEfAOjlhXx/qGfd2QvLO3zccmtMnzliC9lPt+GenD1nyMiK/LNf1cycs+gfAzPJ6vtxe4jhuQtx5sBLeA28G3eb3v8/Beif4HkPewxu5G6N/rMP4qfgEdvwZPgj+AZ8Cx3nYfxiE8Dk6AV0FfH/YEOB28AbwJDIPzQAtcAC4Gl/Z19F+J+NVCehWPr0b46b7RvixvdPg8yr7U10l/BfFN4La8DdgGdoAHwU/AI2AfOACeAofB8+AlcAKwfvyBKeCM/o7NrF9PXmdWv9/Ynot2I7ztIg8dF5I2a8i63CjZU+9Fm2Wcy4U4ZQVYyeOrwVoev57UuxHcJKRvFuJXgnU8/nUebtbYrKmpCUOx31P7UVNTU1NTU1NTU1OGLTz8Xr/77+W7+9vP0or0MxPMbXaizY8FW3sQ3wseB/t5/kGEh8DR/vbzwL8i/Af4Dy8fP8BYE0weaKenI/wV/DhrQG97JspngzlgLpgHzgPzwUhdVpfVZXVZXRa87HxwAVgQ4Pn5WEd85l5TUzOasvezFw/E3b/LoP9D4CpwrcTWWsGXNQOj748/G9k3G56d1KYxmbELwQbwKFiJvBM8nDWlHa5E+AOwCzwLzjkNeeB28NvTeB1OYyr0gQ1g99R23nGE50xj7MPgc+A+8K5Bxj4FHgB/G2z/T9XEzCZjd/S0WYX4Pc3/r/Nn5I0f6qQXIP5x8ENwBMyYyNhHJ3b0pOCuLrBvM941NTU1JyNHEp+BrC8dMyalt1/m3uWfhmeULzRGp9d3wf0WZSN8+prCr60Wz09tuNmx35sl9Y825HXvRN39KNveaL8flb9f913kbec67kHeTsR3gYcH2uV7ED4m2HhCYi/X9ZuBzvuXv0f8iKIfx5B/XCg7gTgbVPdvAsomCuWnD45eK28UyvL3Jt+s0fU2TVnOXJQvJHUWIb0ELAWXgCt4+UcMumSsEtpch/g6ouMGpG/ieZsc9N/q4YsLd3D9WyPbsWEbfNgO7hN82TWY/n8xKbmsC3xQsYKf+7sjrx2TH+u4H3vhx+OO6+X9hmtXN7C/4r15EPaeBs9J7L7YBeeED/k7wn8fbIf/Rji+yVizmd4vW6bB19cb/PU9w7MxMA60bzPHgM8+zG623+OnzOf55yNc3Gw/k303wveBy3nZcoTXgNVgLfiCRNcG5N3SbIebwZ08fhe4l8d/BH7K4yI/4+HPwS/BAfBks+PzIaHuc3x+ivSL4GUyZ68I6fwZYRNMG2qnz+Th2QjfMtTx/1zE5w61nyN+Q7C3aKgdin1dgrylYBn4INdhGn/Z2FfFiqH01/SUXMvnPD+jC+j85N/RqRhR/DYaS6T+P09K1mD+vzW+5zVqqeVUl0wTz2lK8odJHRGXfBufdGLSoSo3+ZFJ6sl0qvJVNmhI4z4i06mrZ6uT1le1z5h5HE3tMiHPtQ5javu+ItMXUr/MXpmwmyRL3D6U7UwIMyYfczGu0qdqb2pbhcw4xQkhWQBMerrZ/liXrGTbsQwTwrEu4zSczKLrd7fCSKiKn+zSo8BWXMe8myXWOivrUxWi60OPoQ7VIasbQ0S/Ukk3rZVullNhHEL1rYoxUF0PTfm6elWJzq54ZsU4z11ohOy0oxT2izFqCNj4TesXcWZo6+Jfqr1O+1O1beqDagypj2J9F1u2daucj3Eknmq/6PaHrK7Mb1o35DiW1a/a76LuhlDXZX25SOz11S33ErKxDb2/fc/bFKI6axskn+4/W90u9mOtbRf7smsoTdvOfwoRz0t6DaP9k81v6P7Re5aUQudTd303rX+bZzBl97/KR7E+Xbux9lLI+aNr1PfaYLpPDiW2/vrYTX1drMIeXbMye6HXlw8292Jl7ZXxLxRlxXbcaH9drjFlxfa3Qozx8NWRi834lPVZbD+SmN7EJPzc9TVCSVXXDps9L+513b2J7fMu176V2YOhx1A3JrJ8KrLxUumpcu5j/lYT+2tzLRVDZmhjO442a1Clu0ox9VPVXzE/lcS4V0k1D6LI1pJsz8fct9SGbO5l/rmKzTlvsxdj3IvRtC2uv0t1fotltvd2VaCy5Sp5m0EhnZG4CCNxXZrWp/VUIrOjapfnNw11ZNI0V/GWzKNuxtzGKKTEtJeR0NVmpojbtBuW5On0u0is9ZMxvU8ZM+8vEyadtu10oqtP9Q4rcJEm85+Two/QkpGwjI6YkgkhtUfzZOW6fFVexuRri+qj9TJJHZkdmW5abiu0rs6uj2TMfmx06bISUj9tZ9Lja8dVQtox6WpxTJKfW3M4MSTmvU4sWy1CU6BF4jIfdNeDjHWuO1lCWIm2Jr2ixNZvklD2fP0Q6+vsmO4hqN1hJvfDtV5G8mTlsvau4qPP1a64L1skT6QYEzEtq0PzGZOfCbSdSmcKTP7Qs86Ej/1hEpelaV6IMdT5ayu2+nT9tmnnO746XbLxE8t0qOrYtJWhmk9bvaLfsrotRVw1PnR+bcafSUKZ6Mps7smobybJLH2R6WqRkJa1DHV0UmbfUcksiSF0HExSpp+uY0zbTklMaCm7blzEtg8h1rNMXNaYi05ZXsbC75sQ/4+aUxFV2jL50Q3jE0rK2rVtN09By8OHoo1vH2LPSdE323mr2sdu0pUZiDkWLRKWnfeQY6taKzHF9n/GPv8jd/0/egiRvYMR24fU79iY3s9Qva9RlYR8n8HHtq9fMcT1HRWfdZXiHd9YInt/iI4PTaf+BimXKvdXYU+3hlRpHzs2dVK/cxhDn+xs0I2jzxjL5kpXz1VU72aLtkK/97sALKyQqu25SshvG6h08/cLrlKswRklKXvvXfa+pZt+y8nah5YUv2Oo/ap/X2URdRfico9K69hcp6r6XaCz5Wo/hs/iNTGF6N6tV92/9ZS0Wba9SlT3pKF/e6W674+x9ly+VRL73cPU8ygb31D3eSqfVd+iqET0y3YMYojoO11XqrTt2nPxmeq1HYeqxkmUMt8DiesjpoTSr+qDrD+qPZDiOZxMdH0pRPX8MFUfQtv0Xbs+a1a1NnRryNZ/2+tsaPG5ZoX0RXZei88yZGdo4UMPj/cwv/kMJboxLISuQbE+1VW12Mx7FWOrW3M9Hv7Y+uxyraPSo8B2TGPuLdOeZha+hBKf8Sjsm/oR+7pmsx/oeOraFWdXleeV6oyl41zm+mgSuq9C6ox1TsU8D+m4dwMmf8v2nz7Tm+fYfj7HV1K/x1HWjquvY+2dllxM64ue87Su772zzbXIVC+WxLZTRR9MdkMTypZNH1z6G0tUvoccwxA+hfLNdV+a7MaQqscztMi+7QnxDZXvd1dldWQOyMbApb1Jd2h91Ffx+y9Xfb7tClokboOvrRhrbVpFFO8z+65t2/u4su9MUx028znH01/TGVDmHAj13W1o+1USw+eUfYtpO+b82rRNsb6oPpV+1fdBqddB6n3WDXvdJDZrJ0QfQp6bsc/kqq4BIddHWXGdN1pmWveh58F1zYUW1zmOITHXWOg1XrZvZSWUf77tq1ofqear6muaT1lIQp3bofabSafJVlnfYo9B6LGr8uzz2Xchvzfw+T9PlgiV/A8=";
declare const Buffer: { from(s: string, encoding: string): Uint8Array } | undefined;
declare const Buffer: any;
function _dec(s: string): Uint8Array {
if (typeof Buffer !== 'undefined') return Buffer.from(s, 'base64');
const bs = atob(s);
@@ -11,6 +11,7 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -5,9 +5,10 @@ const config: PlaywrightTestConfig = {
timeout: 10000,
projects: [
{
name: 'Chromium',
name: 'ChromeStable',
use: {
browserName: 'chromium'
browserName: 'chromium',
channel: 'chrome'
}
},
{
@@ -9,6 +9,7 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"

Some files were not shown because too many files have changed in this diff Show More