Compare commits

..
Author SHA1 Message Date
Daniel Imms ee2da690d9 Update ghostty submodule 2026-02-09 03:49:36 -08:00
Daniel Imms b4529f317b . 2026-02-08 20:18:30 -08:00
93 changed files with 2520 additions and 4428 deletions
-10
View File
@@ -1,10 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "npm run agent:setup-repo"
}
]
}
}
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/ghostty"]
path = vendor/ghostty
url = https://github.com/ghostty-org/ghostty
+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 -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": {
"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 -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": {
"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 -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 -3
View File
@@ -30,9 +30,7 @@ const customSettings: IImageAddonOptions = {
storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support
iipSizeLimit: 20000000, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
iipSizeLimit: 20000000 // size limit of a single IIP sequence
}
// initialization
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

+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.3.0"
"xterm-wasm-parts": "^0.1.0"
}
}
+16 -30
View File
@@ -4,21 +4,13 @@
*/
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 { 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
}
// limit hold memory in base64 decoder
const KEEP_DATA = 4194304;
// default IIP header values
const DEFAULT_HEADER: IHeaderFields = {
@@ -35,19 +27,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _dec = new Base64Decoder(KEEP_DATA);
private _metrics = UNSUPPORTED_TYPE;
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);
}
) {}
public reset(): void {}
@@ -62,7 +50,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;
}
@@ -78,8 +66,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._aborted = true;
return;
}
this._dec.init();
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;
}
@@ -97,15 +85,13 @@ export class IIPHandler implements IOscHandler, IResetHandler {
let cond: number | boolean = true;
if (cond = success) {
if (cond = !this._dec.end()) {
if (cond = this._dec.data8.length === this._header.size) {
this._metrics = imageType(this._dec.data8);
if (cond = this._metrics.mime !== 'unsupported') {
w = this._metrics.width;
h = this._metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit;
}
this._metrics = imageType(this._dec.data8);
if (cond = this._metrics.mime !== 'unsupported') {
w = this._metrics.width;
h = this._metrics.height;
if (cond = w && h && w * h < this._opts.pixelLimit) {
[w, h] = this._resize(w, h).map(Math.floor);
cond = w && h && w * h < this._opts.pixelLimit;
}
}
}
-26
View File
@@ -1,26 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
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 {
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, true);
}
}
+3 -23
View File
@@ -8,11 +8,7 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
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';
// default values of addon ctor options
@@ -26,9 +22,7 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
iipSizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -135,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)
@@ -145,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(0x47, kittyHandler)
);
}
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
+44 -88
View File
@@ -5,7 +5,7 @@
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const PLACEHOLDER_LENGTH = 4096;
@@ -18,9 +18,8 @@ const PLACEHOLDER_HEIGHT = 24;
* - 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());
@@ -87,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;
@@ -97,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;
@@ -141,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 - 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;
@@ -199,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)
@@ -239,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.
@@ -254,7 +241,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -273,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;
}
}
@@ -318,65 +304,35 @@ 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, desynchronized: 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 = PLACEHOLDER_HEIGHT): void {
+59 -119
View File
@@ -5,7 +5,7 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
// fallback default cell size
@@ -124,7 +124,6 @@ export class ImageStorage implements IDisposable {
private _pixelLimit: number = 2500000;
private _viewportMetrics: { cols: number, rows: number };
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor(
private _terminal: ITerminalExt,
@@ -190,13 +189,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);
}
/**
@@ -220,27 +217,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 scrolling - When true, cursor advances with the image (lineFeed per row).
* When false, image is placed at (0,0) and cursor is restored (DECSET 80 / sixel origin mode).
* @param layer - Which canvas layer to render on ('top' or 'bottom').
* @param zIndex - Z-index for image layering within the same layer.
* @returns The internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): number {
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
@@ -262,7 +259,7 @@ export class ImageStorage implements IDisposable {
let offset = originX;
let tileCount = 0;
if (!scrolling) {
if (!this._opts.sixelScrolling) {
buffer.x = 0;
buffer.y = 0;
offset = 0;
@@ -276,7 +273,7 @@ export class ImageStorage implements IDisposable {
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++;
}
if (scrolling) {
if (this._opts.sixelScrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else {
if (++buffer.y >= termRows) break;
@@ -286,7 +283,7 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes
if (scrolling) {
if (this._opts.sixelScrolling) {
buffer.x = offset;
} else {
buffer.x = originX;
@@ -329,14 +326,11 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer,
zIndex
bufferType: this._terminal.buffer.active.type
};
// finally add the image
this._images.set(imageId, imgSpec);
return imageId;
}
@@ -346,30 +340,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) {
@@ -377,25 +357,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();
@@ -410,77 +377,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++;
}
e = nextE;
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 {
+2 -2
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({
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt } 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 {
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._storage.addImage(img, this._opts.sixelScrolling);
}
/**
* 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();
}
}
}
}
+2 -8
View File
@@ -8,7 +8,7 @@ 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 { 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';
@@ -22,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.
@@ -38,8 +38,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -99,8 +97,6 @@ export interface ICellSize {
height: number;
}
export type ImageLayer = 'top' | 'bottom';
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
@@ -109,6 +105,4 @@ export interface IImageSpec {
marker: IMarker | undefined;
tileCount: number;
bufferType: 'alternate' | 'normal';
layer: ImageLayer;
zIndex: number;
}
@@ -1,813 +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,
BYTES_PER_PIXEL_RGB,
BYTES_PER_PIXEL_RGBA,
ALPHA_OPAQUE,
parseKittyCommand
} from './KittyGraphicsTypes';
// Memory limit for base64 decoder (4MB, same as IIPHandler)
const DECODER_KEEP_DATA = 4194304;
const DECODER_INITIAL_DATA = 4194304; // 4MB
// Local mirror of const enum (esbuild can't inline const enums from external packages)
const DECODER_OK: DecodeStatus.OK = 0;
// Maximum control data size
const MAX_CONTROL_DATA_SIZE = 512;
// Semicolon codepoint
const SEMICOLON = 0x3B;
// 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(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(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] === SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
}
}
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > 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(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 image = this._kittyStorage.getImage(cmd.id);
if (!image) {
this._sendResponse(cmd.id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
const result = this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(cmd.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 ? BYTES_PER_PIXEL_RGBA : 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':
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 = await this._createBitmap(image);
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) {
bitmap.close();
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) {
bitmap.close();
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';
let finalBitmap = bitmap;
if (w !== bitmap.width || h !== bitmap.height) {
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
}
// 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) : finalBitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : finalBitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
finalBitmap.close();
throw new Error('Failed to create offset canvas context');
}
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
finalBitmap.close();
finalBitmap = offsetBitmap;
w = finalBitmap.width;
h = finalBitmap.height;
if (w * h > this._opts.pixelLimit) {
finalBitmap.close();
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(finalBitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(finalBitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
// 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);
}
}
// 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 ? BYTES_PER_PIXEL_RGBA : 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 * BYTES_PER_PIXEL_RGBA), width, height));
}
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
// Assumes little-endian (all modern browsers/Node.js).
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
const 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++] = (b0 & 0x00FFFFFF) | 0xFF000000;
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
}
// Handle remaining 1–3 pixels
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
for (let i = alignedPixels; i < pixelCount; i++) {
data[dstByte] = bytes[srcByte];
data[dstByte + 1] = bytes[srcByte + 1];
data[dstByte + 2] = bytes[srcByte + 2];
data[dstByte + 3] = ALPHA_OPAQUE;
srcByte += BYTES_PER_PIXEL_RGB;
dstByte += BYTES_PER_PIXEL_RGBA;
}
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,193 +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 BYTES_PER_PIXEL_RGB = 3;
export const BYTES_PER_PIXEL_RGBA = 4;
export const 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);
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,142 +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 } 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();
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);
}
};
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);
}
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
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);
}
}
}
}
+2 -2
View File
@@ -11,11 +11,11 @@
"types": [
"../../../node_modules/@types/mocha"
],
"baseUrl": ".",
"paths": {
"browser/*": [ "../../../src/browser/*" ],
"common/*": [ "../../../src/common/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
"*": [ "./*" ]
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
}
},
"include": [
+2 -8
View File
@@ -23,8 +23,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -136,9 +134,7 @@ test.describe('ImageAddon', () => {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
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);
File diff suppressed because it is too large Load Diff
+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,
-9
View File
@@ -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 {
+2 -2
View File
@@ -14,8 +14,8 @@
"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",
+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",
+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"
+3
View File
@@ -64,6 +64,9 @@ export class SearchEngine {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
startRow = Math.max(0, startRow);
startCol = Math.max(0, startCol);
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
+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": "node",
"strict": false,
"target": "es2021",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+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
@@ -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"
]
@@ -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": "node",
"strict": false,
"target": "es2015",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+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",
@@ -11,6 +11,7 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -9,6 +9,7 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
+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 -3
View File
@@ -11,15 +11,13 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"@xterm/addon-unicode11": [
"../typings/addon-unicode11.d.ts"
],
"*": [
"./*"
]
},
"types": [
+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": {
"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 -3
View File
@@ -10,15 +10,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": {
"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 -3
View File
@@ -10,15 +10,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -17,7 +17,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",
@@ -7,35 +7,6 @@ import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType,
/* eslint-disable max-len */
const enum Shapes {
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
/** ─ */ LEFT_TO_RIGHT = 'M0,.5 L1,.5',
/** └ */ TOP_TO_RIGHT = 'M.5,0 L.5,.5 L1,.5',
/** ┘ */ TOP_TO_LEFT = 'M.5,0 L.5,.5 L0,.5',
/** ┐ */ LEFT_TO_BOTTOM = 'M0,.5 L.5,.5 L.5,1',
/** ┌ */ RIGHT_TO_BOTTOM = 'M0.5,1 L.5,.5 L1,.5',
/** ╵ */ MIDDLE_TO_TOP = 'M.5,.5 L.5,0',
/** ╴ */ MIDDLE_TO_LEFT = 'M.5,.5 L0,.5',
/** ╶ */ MIDDLE_TO_RIGHT = 'M.5,.5 L1,.5',
/** ╷ */ MIDDLE_TO_BOTTOM = 'M.5,.5 L.5,1',
/** ┴ */ T_TOP = 'M0,.5 L1,.5 M.5,.5 L.5,0',
/** ┤ */ T_LEFT = 'M.5,0 L.5,1 M.5,.5 L0,.5',
/** ├ */ T_RIGHT = 'M.5,0 L.5,1 M.5,.5 L1,.5',
/** ┬ */ T_BOTTOM = 'M0,.5 L1,.5 M.5,.5 L.5,1',
/** ┼ */ CROSS = 'M0,.5 L1,.5 M.5,0 L.5,1',
/** ╌ */ TWO_DASHES_HORIZONTAL = 'M.1,.5 L.4,.5 M.6,.5 L.9,.5', // .2 empty, .3 filled
/** ┄ */ THREE_DASHES_HORIZONTAL = 'M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5', // .1333 empty, .2 filled
/** ┉ */ FOUR_DASHES_HORIZONTAL = 'M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5', // .1 empty, .15 filled
/** ╎ */ TWO_DASHES_VERTICAL = 'M.5,.1 L.5,.4 M.5,.6 L.5,.9',
/** ┆ */ THREE_DASHES_VERTICAL = 'M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333',
/** ┊ */ FOUR_DASHES_VERTICAL = 'M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95',
}
namespace GitBranchSymbolsParts {
// Lines
export const LINE_H: CustomGlyphDefinitionPart = Object.freeze({ type: CustomGlyphDefinitionType.PATH_FUNCTION, data: Shapes.LEFT_TO_RIGHT, strokeWidth: 1 });
@@ -1017,3 +988,31 @@ function segmentedDigit(pattern: number): string {
return paths.join(' ');
}
const enum Shapes {
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
/** ─ */ LEFT_TO_RIGHT = 'M0,.5 L1,.5',
/** └ */ TOP_TO_RIGHT = 'M.5,0 L.5,.5 L1,.5',
/** ┘ */ TOP_TO_LEFT = 'M.5,0 L.5,.5 L0,.5',
/** ┐ */ LEFT_TO_BOTTOM = 'M0,.5 L.5,.5 L.5,1',
/** ┌ */ RIGHT_TO_BOTTOM = 'M0.5,1 L.5,.5 L1,.5',
/** ╵ */ MIDDLE_TO_TOP = 'M.5,.5 L.5,0',
/** ╴ */ MIDDLE_TO_LEFT = 'M.5,.5 L0,.5',
/** ╶ */ MIDDLE_TO_RIGHT = 'M.5,.5 L1,.5',
/** ╷ */ MIDDLE_TO_BOTTOM = 'M.5,.5 L.5,1',
/** ┴ */ T_TOP = 'M0,.5 L1,.5 M.5,.5 L.5,0',
/** ┤ */ T_LEFT = 'M.5,0 L.5,1 M.5,.5 L0,.5',
/** ├ */ T_RIGHT = 'M.5,0 L.5,1 M.5,.5 L1,.5',
/** ┬ */ T_BOTTOM = 'M0,.5 L1,.5 M.5,.5 L.5,1',
/** ┼ */ CROSS = 'M0,.5 L1,.5 M.5,0 L.5,1',
/** ╌ */ TWO_DASHES_HORIZONTAL = 'M.1,.5 L.4,.5 M.6,.5 L.9,.5', // .2 empty, .3 filled
/** ┄ */ THREE_DASHES_HORIZONTAL = 'M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5', // .1333 empty, .2 filled
/** ┉ */ FOUR_DASHES_HORIZONTAL = 'M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5', // .1 empty, .15 filled
/** ╎ */ TWO_DASHES_VERTICAL = 'M.5,.1 L.5,.4 M.5,.6 L.5,.9',
/** ┆ */ THREE_DASHES_VERTICAL = 'M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333',
/** ┊ */ FOUR_DASHES_VERTICAL = 'M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95',
}
+1 -3
View File
@@ -10,6 +10,7 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -19,9 +20,6 @@
],
"@xterm/addon-webgl": [
"../typings/addon-webgl.d.ts"
],
"*": [
"./*"
]
},
"strict": true,
+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,
-138
View File
@@ -1,138 +0,0 @@
// @ts-check
import { cpSync, existsSync, lstatSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { basename, dirname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const nodeModulesPath = resolve(repoRoot, 'node_modules');
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
/** @typedef {{ folder: string; reason: string }} Candidate */
/** @param {string} message */
function log(message) {
console.info(`[setup-fast] ${message}`);
}
/** @param {string[]} args */
function runNpm(args) {
log(`Running: npm ${args.join(' ')}`);
const result = spawnSync(npmExecutable, args, {
cwd: repoRoot,
stdio: 'inherit'
});
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
}
/**
* @param {Candidate[]} candidates
* @param {string | undefined} folder
* @param {string} reason
*/
function addCandidate(candidates, folder, reason) {
if (!folder) {
return;
}
if (candidates.some(candidate => candidate.folder === folder)) {
return;
}
candidates.push({ folder, reason });
log(`Candidate found (${reason}): ${folder}`);
}
function detectMainSiblingFolder() {
const currentFolderName = basename(repoRoot);
if (!currentFolderName.startsWith('xterm.js') || currentFolderName === 'xterm.js') {
log(`Current folder is "${currentFolderName}", skipping xterm.js sibling lookup.`);
return undefined;
}
const siblingFolder = resolve(dirname(repoRoot), 'xterm.js');
log(`Current folder is "${currentFolderName}", sibling main folder candidate: ${siblingFolder}`);
return siblingFolder;
}
function detectWorktreeMainFolder() {
const gitPath = resolve(repoRoot, '.git');
if (!existsSync(gitPath)) {
log('No .git entry found at repo root.');
return undefined;
}
const gitStat = lstatSync(gitPath);
if (!gitStat.isFile()) {
log('.git is not a file, this repo does not appear to be a worktree checkout.');
return undefined;
}
const gitFileContent = readFileSync(gitPath, 'utf8').trim();
const gitDirMatch = /^gitdir:\s*(.+)$/m.exec(gitFileContent);
if (!gitDirMatch) {
log('Could not parse gitdir from .git file.');
return undefined;
}
const gitDirPath = resolve(repoRoot, gitDirMatch[1].trim());
log(`Parsed gitdir from .git file: ${gitDirPath}`);
const normalizedGitDirPath = gitDirPath.replace(/\\/g, '/');
const worktreeMarker = '/.git/worktrees/';
const markerIndex = normalizedGitDirPath.indexOf(worktreeMarker);
if (markerIndex === -1) {
log('gitdir path does not contain /.git/worktrees/, skipping worktree main folder lookup.');
return undefined;
}
const normalizedMainFolder = normalizedGitDirPath.slice(0, markerIndex);
const mainFolder = sep === '/' ? normalizedMainFolder : normalizedMainFolder.split('/').join(sep);
log(`Worktree main folder candidate: ${mainFolder}`);
return mainFolder;
}
function resolveSourceFolder() {
/** @type {Candidate[]} */
const candidates = [];
addCandidate(candidates, detectMainSiblingFolder(), 'xterm.js sibling');
addCandidate(candidates, detectWorktreeMainFolder(), 'worktree main repo');
for (const candidate of candidates) {
const candidateNodeModulesPath = resolve(candidate.folder, 'node_modules');
if (existsSync(candidateNodeModulesPath)) {
log(`Using candidate (${candidate.reason}) with node_modules: ${candidate.folder}`);
return candidate.folder;
}
log(`Candidate skipped (${candidate.reason}), node_modules missing: ${candidateNodeModulesPath}`);
}
log('No candidate folder with node_modules was found.');
return undefined;
}
if (!existsSync(nodeModulesPath)) {
log(`node_modules missing: ${nodeModulesPath}`);
const sourceFolder = resolveSourceFolder();
if (sourceFolder) {
const sourceNodeModulesPath = resolve(sourceFolder, 'node_modules');
log(`Copying node_modules from ${sourceNodeModulesPath} to ${nodeModulesPath}`);
try {
cpSync(sourceNodeModulesPath, nodeModulesPath, { recursive: true });
log('node_modules copy completed.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`node_modules copy failed: ${message}`);
log('Falling back to npm ci.');
runNpm(['ci']);
}
} else {
log('No source folder available, running npm ci.');
runNpm(['ci']);
}
} else {
log(`node_modules already exists: ${nodeModulesPath}`);
}
runNpm(['run', 'setup']);
+42
View File
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { spawnSync } from 'child_process';
import { copyFileSync, mkdirSync, renameSync } from 'fs';
import path from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ghosttyDir = path.join(repoRoot, 'vendor', 'ghostty');
const wasmOutDir = path.join(repoRoot, 'vendor', 'ghostty-wasm');
const wasmBinDir = path.join(wasmOutDir, 'bin');
const wasmFile = path.join(wasmOutDir, 'ghostty-vt.wasm');
const wasmBinFile = path.join(wasmBinDir, 'ghostty-vt.wasm');
mkdirSync(wasmOutDir, { recursive: true });
const result = spawnSync('zig', [
'build',
'lib-vt',
'-Dtarget=wasm32-freestanding',
'-Doptimize=ReleaseSmall',
'-p',
path.relative(ghosttyDir, wasmOutDir)
], {
cwd: ghosttyDir,
stdio: 'inherit',
shell: true
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
try {
renameSync(wasmBinFile, wasmFile);
} catch {
copyFileSync(wasmBinFile, wasmFile);
}
+3
View File
@@ -6,6 +6,9 @@
const cp = require('child_process');
const path = require('path');
globalThis.require = require;
globalThis.XTERM_GHOSTTY_WASM_URL = path.resolve(__dirname, '../vendor/ghostty-wasm/ghostty-vt.wasm');
const COVERAGE_LINES_THRESHOLD = 40;
// Add `out` to the NODE_PATH so absolute paths can be resolved.
+3 -7
View File
@@ -328,9 +328,7 @@ function createTerminal(): Terminal {
}
const cols = size.cols;
const rows = size.rows;
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight;
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows;
fetch(url, { method: 'POST' });
});
@@ -381,9 +379,7 @@ function createTerminal(): Terminal {
if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) {
runFakeTerminal();
} else {
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight, { method: 'POST' });
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows, { method: 'POST' });
const processId = await res.text();
pid = processId;
socketURL += processId;
@@ -630,7 +626,7 @@ function updateTerminalSize(): void {
function getBox(width: number, height: number): any {
return {
string: '+',
style: 'font-size: 1px; padding: ' + Math.floor(height / 2) + 'px ' + Math.floor(width / 2) + 'px; line-height: ' + height + 'px;'
style: 'font-size: 1px; padding: ' + Math.floor(height/2) + 'px ' + Math.floor(width/2) + 'px; line-height: ' + height + 'px;'
};
}
if (source instanceof HTMLCanvasElement) {
@@ -51,30 +51,14 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
container.appendChild(document.createElement('br'));
container.appendChild(document.createElement('br'));
// Sixel demos
const dlSixel = document.createElement('dl');
const dtSixel = document.createElement('dt');
dtSixel.textContent = 'Sixel';
dlSixel.appendChild(dtSixel);
this._addDdWithButton(dlSixel, 'image-demo1', 'snake');
this._addDdWithButton(dlSixel, 'image-demo2', 'oranges');
container.appendChild(dlSixel);
// IIP demos
const dlIip = document.createElement('dl');
const dtIip = document.createElement('dt');
dtIip.textContent = 'IIP (iTerm)';
dlIip.appendChild(dtIip);
this._addDdWithButton(dlIip, 'image-demo3', 'palette');
container.appendChild(dlIip);
// Kitty demos
const dlKitty = document.createElement('dl');
const dtKitty = document.createElement('dt');
dtKitty.textContent = 'Kitty';
dlKitty.appendChild(dtKitty);
this._addDdWithButton(dlKitty, 'image-demo-kitty1', 'palette');
container.appendChild(dlKitty);
const dl = document.createElement('dl');
const dt = document.createElement('dt');
dt.textContent = 'Image Test';
dl.appendChild(dt);
this._addDdWithButton(dl, 'image-demo1', 'snake (sixel)');
this._addDdWithButton(dl, 'image-demo2', 'oranges (sixel)');
this._addDdWithButton(dl, 'image-demo3', 'palette (iip)');
container.appendChild(dl);
this._initImageAddonExposed();
}
@@ -141,25 +125,12 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
});
const kittyDemo = (url: string) => () => fetch(url)
.then(resp => resp.arrayBuffer())
.then(buffer => {
const data = new Uint8Array(buffer);
let sdata = '';
for (let i = 0; i < data.length; ++i) sdata += String.fromCharCode(data[i]);
const payload = btoa(sdata);
this._terminal.write('\r\n');
this._terminal.write(`\x1b_Ga=T,f=100;${payload}\x1b\\`);
});
document.getElementById('image-demo1')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/saitoha/libsixel/master/images/snake.six'));
document.getElementById('image-demo2')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
document.getElementById('image-demo3')!.addEventListener('click',
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
document.getElementById('image-demo-kitty1')!.addEventListener('click',
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
// demo for image retrieval API
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
+2 -3
View File
@@ -5,8 +5,8 @@
"outDir": "../out-demo/client",
"rootDir": ".",
"sourceMap": true,
"baseUrl": ".",
"strict": true,
"skipLibCheck": true,
"paths": {
"@xterm/addon-attach": ["../../addons/addon-attach"],
"@xterm/addon-clipboard": ["../../addons/addon-clipboard"],
@@ -20,8 +20,7 @@
"@xterm/addon-webgl": ["../../addons/addon-webgl"],
"@xterm/addon-unicode11": ["../../addons/addon-unicode11"],
"@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"],
"*": ["./*"]
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"]
}
},
"include": [
+1 -1
View File
@@ -19,7 +19,7 @@ import type { WebFontsAddon } from '@xterm/addon-web-fonts';
import type { WebLinksAddon } from '@xterm/addon-web-links';
import type { WebglAddon } from '@xterm/addon-webgl';
export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'ligatures' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl';
export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures';
export interface IDemoAddon<T extends AddonType> {
name: T;
+4 -18
View File
@@ -46,6 +46,7 @@ function startServer(): void {
});
app.use('/fonts', express.static(demoRoot + '/fonts'));
app.use('/vendor/ghostty-wasm', express.static(demoRoot + '/../vendor/ghostty-wasm'));
app.use('/dist', express.static(demoRoot + '/dist'));
app.use('/src', express.static(demoRoot + '/src'));
@@ -65,8 +66,6 @@ function startServer(): void {
}
const cols = parseInt(req.query.cols);
const rows = parseInt(req.query.rows);
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
const isWindows = process.platform === 'win32';
const term = pty.spawn(isWindows ? 'powershell.exe' : 'bash', [], {
name: 'xterm-256color',
@@ -79,13 +78,7 @@ function startServer(): void {
useConptyDll: isWindows,
});
// Set pixel dimensions immediately after spawn (pty.spawn doesn't support them)
if (pixelWidth > 0 && pixelHeight > 0) {
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
console.log('Created terminal with PID: ' + term.pid + ' (' + cols + 'x' + rows + ', ' + pixelWidth + 'px x ' + pixelHeight + 'px)');
} else {
console.log('Created terminal with PID: ' + term.pid);
}
console.log('Created terminal with PID: ' + term.pid);
terminals[term.pid] = term;
unsentOutput[term.pid] = '';
temporaryDisposable[term.pid] = term.onData(function(data) {
@@ -103,17 +96,10 @@ function startServer(): void {
const pid = parseInt(req.params.pid);
const cols = parseInt(req.query.cols);
const rows = parseInt(req.query.rows);
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
const term = terminals[pid];
if (pixelWidth > 0 && pixelHeight > 0) {
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols, ' + rows + ' rows, ' + pixelWidth + 'px x ' + pixelHeight + 'px');
} else {
term.resize(cols, rows);
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
}
term.resize(cols, rows);
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
res.end();
});
+1 -2
View File
@@ -5,8 +5,7 @@
"outDir": "../out-demo/server",
"rootDir": ".",
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
"esModuleInterop": true
},
"include": [
"./**/*",
+279 -129
View File
@@ -29,7 +29,6 @@
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
@@ -80,7 +79,7 @@
"license": "MIT",
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.3.0"
"xterm-wasm-parts": "^0.1.0"
}
},
"addons/addon-ligatures": {
@@ -1279,6 +1278,102 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -1451,6 +1546,16 @@
"pako": "^2.0.4"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
@@ -1971,123 +2076,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-TZ/0Tv954jRpn5IHQJZQF0gaNmw//ZTvkgr3nC2H8u9t4GxUhIk5vfAHVFgdl8JnNGbl7gZks37FVsEwXjdHqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260213.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-nqfuHgFZ8MvaHeb1XOkAGIGc6cR91dwQdlHmlbH918n63JguKzkYjzzulpr48IVn1SVewGwjtfs1moCpaDpWcg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-JDVDVLPIYhYZR12omTKwzKbdw+wnPEtLnkmM+nioqbISlV29fg7IBgFx0/mf1D+Gz1ztUQVHDTqw4GEgOZ9VTg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-O6iVB8tTnLZJVYrChH6VJuCwqAa2ObWVdMtrKQw5UtmLXBhsOhZ3isttSuGzQrPfTiGN091IdW1KuRi8Ft1lnQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-d7Gwl8W4aics0KI4H0zz3TP1ngnP8L5COND0TPBZ7ifWVrxKDNSLhF2V9OuStW43RPfA4Gzik9iIv+l0Lg+eGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-Aq55fID1fE5/8K4XvfVddgw8LKUgY3kp5IOb+QEklfyle4MuAV5gtat3MnEv9XMbiidk8iKVxYrYASlUQEQzDw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-VlyULgXApmGMSqAANjskGx0AcjHchf3I8rhZ47AxF/XKAkZPe/5ewfleJ97iln7dg1UL3lq2cJY2CXxU+JPcFQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-lEtWvZDa2oCgqJ2gbP6BRLUeTyHLyrt5BfJmVlvmVSTZzdoBpFROJdp9yprsa2Zry2o5GK8HFAxd3RlvxkOU5Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -3523,6 +3511,12 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -4874,12 +4868,54 @@
"node": ">= 0.10"
}
},
"node_modules/inwasm-runtime": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/inwasm-runtime/-/inwasm-runtime-0.1.2.tgz",
"integrity": "sha512-in+Lk4d7PGwQnG1zxRe6qHB2nvvc5LUOsI9ByoJRuaDONTY8xDlaF+CvapGoPiqreJ0cMHxRzKtS0SJBccpYfA==",
"node_modules/inwasm": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/inwasm/-/inwasm-0.0.13.tgz",
"integrity": "sha512-gmULhw1wfF3tQ19y0TvcNH6A5jN7IuTD51kbZuy+ittUU59d+ZTQMb53wbGQuciMrledgagL3/ohnjUj5qJikQ==",
"dev": true,
"license": "MIT"
"dependencies": {
"acorn": "^8.8.2",
"acorn-walk": "^8.2.0",
"chokidar": "^3.5.3",
"colorette": "^2.0.20",
"glob": "^10.0.0",
"wabt": "^1.0.32"
},
"bin": {
"inwasm": "lib/cli.js"
}
},
"node_modules/inwasm/node_modules/acorn-walk": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/inwasm/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"dev": true,
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
@@ -5379,6 +5415,21 @@
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/javascript-natural-sort": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz",
@@ -5827,6 +5878,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz",
@@ -6364,6 +6424,12 @@
"node": ">=8"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true
},
"node_modules/pako": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -6451,6 +6517,28 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -7444,6 +7532,21 @@
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -7456,6 +7559,19 @@
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
@@ -7974,6 +8090,23 @@
"node": ">=18"
}
},
"node_modules/wabt": {
"version": "1.0.39",
"resolved": "https://registry.npmjs.org/wabt/-/wabt-1.0.39.tgz",
"integrity": "sha512-ba+dRL/75VQQY7RkU/CgriGbkoWAfS8TDyUlJfJhJ8KhtXgMl5dhNvoPNUcQ9IWRhW8u41glMSuZeTvsYq2rRg==",
"dev": true,
"bin": {
"wasm-decompile": "bin/wasm-decompile",
"wasm-interp": "bin/wasm-interp",
"wasm-objdump": "bin/wasm-objdump",
"wasm-stats": "bin/wasm-stats",
"wasm-strip": "bin/wasm-strip",
"wasm-validate": "bin/wasm-validate",
"wasm2c": "bin/wasm2c",
"wasm2wat": "bin/wasm2wat",
"wat2wasm": "bin/wat2wasm"
}
},
"node_modules/watchpack": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz",
@@ -8317,6 +8450,24 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -8429,13 +8580,12 @@
}
},
"node_modules/xterm-wasm-parts": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz",
"integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==",
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.1.0.tgz",
"integrity": "sha512-GFE8yNJfdkytGpcsOZhkL3B8XyUqkR/Du3SdxRyFbJg+BBCKm3raaaflgIM4TwNQ2AOLz01BEEuB5FZXx8aNTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"inwasm-runtime": "^0.1.2"
"inwasm": "^0.0.13"
}
},
"node_modules/y18n": {
+4 -5
View File
@@ -34,8 +34,8 @@
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
"build": "npm run tsc",
"watch": "npm run tsc-watch",
"tsc": "tsgo -b ./tsconfig.all.json",
"tsc-watch": "tsgo -b -w ./tsconfig.all.json --preserveWatchOutput",
"tsc": "tsc -b ./tsconfig.all.json",
"tsc-watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput",
"esbuild": "node bin/esbuild_all.mjs",
"esbuild-watch": "node bin/esbuild_all.mjs --watch",
"esbuild-package": "node bin/esbuild_all.mjs --prod",
@@ -45,6 +45,7 @@
"esbuild-demo-client-watch": "node bin/esbuild.mjs --demo-client --watch",
"esbuild-demo-server": "node bin/esbuild.mjs --demo-server",
"esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch",
"ghostty-wasm": "node bin/build_ghostty_wasm.mjs",
"test": "npm run test-unit",
"posttest": "npm run lint",
"lint": "eslint --max-warnings 0 src/ addons/ demo/ test/",
@@ -72,8 +73,7 @@
"prepackage-headless": "npm run esbuild-package-headless-only",
"package-headless": "webpack --config ./webpack.config.headless.js",
"postpackage-headless": "node ./bin/package_headless.js",
"prepublishOnly": "npm run package",
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
"prepublishOnly": "npm run package"
},
"devDependencies": {
"@lunapaint/png-codec": "^0.2.0",
@@ -93,7 +93,6 @@
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
-10
View File
@@ -29,14 +29,4 @@ describe('evaluatePastedTextProcessing', () => {
assert.equal(unbracketedText, 'foo bar');
assert.equal(bracketedText, '\x1b[200~foo bar\x1b[201~');
});
it('should escape embedded escape sequences in pasted text only when bracketed', () => {
const ESC_SYMBOL = '\u241b';
const pastedText = '\x1b[201~foo\x1b[200~bar';
const unbracketedText = Clipboard.bracketTextForPaste(pastedText, false);
const bracketedText = Clipboard.bracketTextForPaste(pastedText, true);
assert.equal(unbracketedText, pastedText, 'non bracketed paste should remain unchanged');
assert.equal(bracketedText, `\x1b[200~${ESC_SYMBOL}[201~foo${ESC_SYMBOL}[200~bar\x1b[201~`);
});
});
+3 -6
View File
@@ -19,13 +19,10 @@ export function prepareTextForTerminal(text: string): string {
* @param text The pasted text to bracket
*/
export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): string {
if (!bracketedPasteMode) {
return text;
if (bracketedPasteMode) {
return '\x1b[200~' + text + '\x1b[201~';
}
// Sanitize pasted text to prevent injected escape sequences (e.g. exiting bracketed paste)
// by replacing ESC (\x1b) with its visible representation U+241B (␛).
const sanitizedText = text.replace(/\x1b/g, '\u241b');
return `\x1b[200~${sanitizedText}\x1b[201~`;
return text;
}
/**
+21 -6
View File
@@ -17,6 +17,21 @@ const INIT_ROWS = 24;
// grab wcwidth from mock unicode service (hardcoded to V6)
const wcwidth = (new MockUnicodeService()).wcwidth;
function assertLineEqual(actual: any, expected: any): void {
assert.isOk(actual);
assert.isOk(expected);
assert.equal(actual.length, expected.length);
assert.equal(actual.isWrapped, expected.isWrapped);
const actualCell = new CellData();
const expectedCell = new CellData();
for (let i = 0; i < actual.length; i++) {
assert.deepEqual(
actual.loadCell(i, actualCell).getAsCharData(),
expected.loadCell(i, expectedCell).getAsCharData()
);
}
}
describe('Terminal', () => {
let term: TestTerminal;
const termOptions = {
@@ -183,9 +198,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
it('should clear a buffer larger than rows', async () => {
@@ -198,9 +213,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
it('should not break the prompt when cleared twice', () => {
@@ -211,9 +226,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
});
@@ -51,6 +51,8 @@ export class OverviewRulerRenderer extends Disposable {
private _shouldUpdateAnchor: boolean | undefined = true;
private _lastKnownBufferLength: number = 0;
private _containerHeight: number | undefined;
constructor(
private readonly _viewportElement: HTMLElement,
private readonly _screenElement: HTMLElement,
@@ -89,7 +91,13 @@ export class OverviewRulerRenderer extends Disposable {
}
}));
this._register(this._renderService.onDimensionsChange(() => this._queueRefresh(true)));
// Container height changed
this._register(this._renderService.onRender((): void => {
if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) {
this._queueRefresh(true);
this._containerHeight = this._screenElement.clientHeight;
}
}));
this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true)));
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
@@ -136,12 +144,10 @@ export class OverviewRulerRenderer extends Disposable {
}
private _refreshCanvasDimensions(): void {
const cssCanvasHeight = this._renderService.dimensions.css.canvas.height;
const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height;
this._canvas.style.width = `${this._width}px`;
this._canvas.width = Math.round(this._width * this._coreBrowserService.dpr);
this._canvas.style.height = `${cssCanvasHeight}px`;
this._canvas.height = deviceCanvasHeight;
this._canvas.style.height = `${this._screenElement.clientHeight}px`;
this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowserService.dpr);
this._refreshDrawConstants();
this._refreshColorZonePadding();
}
+1 -3
View File
@@ -13,7 +13,7 @@ export class CoreBrowserService extends Disposable implements ICoreBrowserServic
private _isFocused = false;
private _cachedIsFocused: boolean | undefined = undefined;
private _screenDprMonitor: ScreenDprMonitor;
private _screenDprMonitor = this._register(new ScreenDprMonitor(this._window));
private readonly _onDprChange = this._register(new Emitter<number>());
public readonly onDprChange = this._onDprChange.event;
@@ -27,8 +27,6 @@ export class CoreBrowserService extends Disposable implements ICoreBrowserServic
) {
super();
this._screenDprMonitor = this._register(new ScreenDprMonitor(this._window));
// Monitor device pixel ratio
this._register(this.onWindowChange(w => this._screenDprMonitor.setWindow(w)));
this._register(EventUtils.forward(this._screenDprMonitor.onDprChange, this._onDprChange));
+2 -3
View File
@@ -9,10 +9,9 @@
"types": [
"../../node_modules/@types/mocha"
],
"skipLibCheck": true,
"baseUrl": "..",
"paths": {
"common/*": [ "./../common/*" ],
"*": [ "./../*" ]
"common/*": [ "./common/*" ]
}
},
"include": [
+9 -7
View File
@@ -7,13 +7,13 @@
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
import { C0, C1 } from 'common/data/EscapeSequences';
import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
import { GhosttyWasmParser } from 'common/parser/GhosttyWasmParser';
import { Disposable } from 'common/Lifecycle';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { AttributeData } from 'common/buffer/AttributeData';
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
@@ -109,6 +109,7 @@ const SLOW_ASYNC_LIMIT = 5000;
// Work variables to avoid garbage collection
let $temp = 0;
const $workCell = new CellData();
/**
* The terminal's standard implementation of IInputHandler, this handles all
@@ -180,7 +181,7 @@ export class InputHandler extends Disposable implements IInputHandler {
private readonly _oscLinkService: IOscLinkService,
private readonly _coreMouseService: ICoreMouseService,
private readonly _unicodeService: IUnicodeService,
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
private readonly _parser: IEscapeSequenceParser = new GhosttyWasmParser()
) {
super();
this._register(this._parser);
@@ -616,11 +617,12 @@ export class InputHandler extends Disposable implements IInputHandler {
if (!bufferRow) {
return;
}
if (oldWidth > 0 && bufferRow instanceof BufferLine) {
if (oldWidth > 0) {
// Combining character widens 1 column to 2.
// Move old character to next line.
bufferRow.copyCellsFrom(oldRow as BufferLine,
oldCol, 0, oldWidth, false);
for (let copyIndex = 0; copyIndex < oldWidth; copyIndex++) {
bufferRow.setCell(copyIndex, oldRow.loadCell(oldCol + copyIndex, $workCell));
}
}
// clear left over cells to the right
while (oldCol < cols) {
+3 -17
View File
@@ -5,7 +5,6 @@
import { assert } from 'chai';
import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
@@ -28,7 +27,6 @@ describe('Buffer', () => {
describe('constructor', () => {
it('should create a CircularList with max length equal to rows + scrollback, for its lines', () => {
assert.instanceOf(buffer.lines, CircularList);
assert.equal(buffer.lines.maxLength, bufferService.rows + INIT_SCROLLBACK);
});
it('should set the Buffer\'s scrollBottom value equal to the terminal\'s rows -1', () => {
@@ -1180,31 +1178,19 @@ describe('Buffer', () => {
});
describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
it('should keep line sizes consistent after shrinking', async () => {
buffer.fillViewportRows();
// shrink more than 2 times to trigger lazy memory cleanup
buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS);
// sync
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
// line memory is still at old size from initialization
assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4);
// array.length and .length get immediately adjusted
assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3);
assert.equal(line.length, INIT_COLS / 2 - 1);
assert.equal(line.cleanupMemory(), 0);
}
// wait for a bit to give IdleTaskQueue a chance to kick in
// and finish memory cleaning
await new Promise(r => setTimeout(r, 30));
// cleanup should have realigned memory with exact bytelength
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4);
}
await new Promise(r => setTimeout(r, 10));
});
});
});
+50 -11
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { IInsertEvent } from 'common/CircularList';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
@@ -13,11 +13,16 @@ import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
import { Marker } from 'common/buffer/Marker';
import { IBuffer } from 'common/buffer/Types';
import { GhosttyCircularList } from 'common/buffer/GhosttyCircularList';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { DEFAULT_CHARSET } from 'common/data/Charsets';
import { IBufferService, ILogService, IOptionsService } from 'common/services/Services';
export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
// Work variable to avoid garbage collection during reflow
const $workCell = new CellData();
/**
* This class represents a terminal buffer (an internal state of the terminal), where the
* following information is stored (in high-level):
@@ -26,7 +31,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
* - scroll position
*/
export class Buffer implements IBuffer {
public lines: CircularList<IBufferLine>;
public lines: GhosttyCircularList;
public ydisp: number = 0;
public ybase: number = 0;
public y: number = 0;
@@ -50,6 +55,7 @@ export class Buffer implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private _ghosttyBuffer: GhosttyWasmBuffer;
constructor(
private _hasScrollback: boolean,
@@ -59,7 +65,9 @@ export class Buffer implements IBuffer {
) {
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
const maxLength = this._getCorrectBufferLength(this._rows);
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
@@ -142,7 +150,10 @@ export class Buffer implements IBuffer {
this.ybase = 0;
this.y = 0;
this.x = 0;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
const maxLength = this._getCorrectBufferLength(this._rows);
this._ghosttyBuffer.dispose();
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
@@ -156,6 +167,8 @@ export class Buffer implements IBuffer {
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
const oldCols = this._cols;
const oldMaxLength = this.lines.maxLength;
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
@@ -163,6 +176,14 @@ export class Buffer implements IBuffer {
// Increase max length if needed before adjustments to allow space to fill
// as required.
const newMaxLength = this._getCorrectBufferLength(newRows);
const shouldDelayColResize = this._isReflowEnabled && newCols < oldCols;
const shouldDelayRowResize = newRows < this._rows;
const canDelayResize = newMaxLength <= oldMaxLength;
const shouldDelayResize = canDelayResize && (shouldDelayColResize || shouldDelayRowResize);
const initialCols = shouldDelayColResize ? oldCols : newCols;
const initialRows = shouldDelayRowResize ? this._rows : newRows;
const initialMaxLength = shouldDelayResize ? oldMaxLength : newMaxLength;
this._ghosttyBuffer.resize(initialCols, initialRows, initialMaxLength);
if (newMaxLength > this.lines.maxLength) {
this.lines.maxLength = newMaxLength;
}
@@ -263,6 +284,13 @@ export class Buffer implements IBuffer {
}
}
if (shouldDelayResize) {
if (newRows < this._rows || newMaxLength < oldMaxLength) {
this.lines.normalize();
}
this._ghosttyBuffer.resize(newCols, newRows, newMaxLength);
}
this._cols = newCols;
this._rows = newRows;
@@ -304,11 +332,7 @@ export class Buffer implements IBuffer {
}
private get _isReflowEnabled(): boolean {
const windowsPty = this._optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber) {
return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376;
}
return this._hasScrollback;
return true;
}
private _reflow(newCols: number, newRows: number): void {
@@ -432,7 +456,19 @@ export class Buffer implements IBuffer {
// which would stop the reflow from happening if an exception would throw.
break;
}
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true);
const destStart = destCol - cellsToCopy;
const srcStart = srcCol - cellsToCopy;
const sameLine = wrappedLines[destLineIndex] === wrappedLines[srcLineIndex];
const overlaps = sameLine && destStart < srcStart + cellsToCopy && srcStart < destStart + cellsToCopy;
if (overlaps && destStart > srcStart) {
for (let cell = cellsToCopy - 1; cell >= 0; cell--) {
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
}
} else {
for (let cell = 0; cell < cellsToCopy; cell++) {
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
}
}
destCol -= cellsToCopy;
if (destCol === 0) {
destLineIndex--;
@@ -488,7 +524,10 @@ export class Buffer implements IBuffer {
// Record original lines so they don't get overridden when we rearrange the list
const originalLines: BufferLine[] = [];
for (let i = 0; i < this.lines.length; i++) {
originalLines.push(this.lines.get(i) as BufferLine);
const line = this.lines.get(i);
if (line) {
originalLines.push(line.clone() as BufferLine);
}
}
const originalLinesLength = this.lines.length;
+28 -15
View File
@@ -3,9 +3,8 @@
* @license MIT
*/
import { BufferLine } from 'common/buffer/BufferLine';
import { CircularList } from 'common/CircularList';
import { IBufferLine, ICellData } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { ICircularList, IBufferLine, ICellData } from 'common/Types';
export interface INewLayoutResult {
layout: number[];
@@ -22,7 +21,7 @@ export interface INewLayoutResult {
* @param nullCell The cell data to use when filling in empty cells.
* @param reflowCursorLine Whether to reflow the line containing the cursor.
*/
export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
export function reflowLargerGetLinesToRemove(lines: ICircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
// Gather all BufferLines that need to be removed from the Buffer here so that they can be
// batched up and only committed once
const toRemove: number[] = [];
@@ -30,16 +29,16 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
for (let y = 0; y < lines.length - 1; y++) {
// Check if this row is wrapped
let i = y;
let nextLine = lines.get(++i) as BufferLine;
let nextLine = lines.get(++i) as IBufferLine;
if (!nextLine.isWrapped) {
continue;
}
// Check how many lines it's wrapped for
const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine];
const wrappedLines: IBufferLine[] = [lines.get(y) as IBufferLine];
while (i < lines.length && nextLine.isWrapped) {
wrappedLines.push(nextLine);
nextLine = lines.get(++i) as BufferLine;
nextLine = lines.get(++i) as IBufferLine;
}
if (!reflowCursorLine) {
@@ -62,7 +61,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
const destRemainingCells = newCols - destCol;
const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells);
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false);
copyCells(wrappedLines[srcLineIndex], wrappedLines[destLineIndex], srcCol, destCol, cellsToCopy);
destCol += cellsToCopy;
if (destCol === newCols) {
@@ -78,7 +77,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
// Make sure the last cell isn't wide, if it is copy it to the current dest
if (destCol === 0 && destLineIndex !== 0) {
if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) {
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false);
copyCells(wrappedLines[destLineIndex - 1], wrappedLines[destLineIndex], newCols - 1, destCol++, 1);
// Null out the end of the last row
wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell);
}
@@ -113,7 +112,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
* @param lines The buffer lines.
* @param toRemove The indexes to remove.
*/
export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
export function reflowLargerCreateNewLayout(lines: ICircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
const layout: number[] = [];
// First iterate through the list and get the actual indexes to use for rows
let nextToRemoveIndex = 0;
@@ -148,11 +147,11 @@ export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, to
* @param lines The buffer lines.
* @param newLayout The new layout to apply.
*/
export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, newLayout: number[]): void {
export function reflowLargerApplyNewLayout(lines: ICircularList<IBufferLine>, newLayout: number[]): void {
// Record original lines so they don't get overridden when we rearrange the list
const newLayoutLines: BufferLine[] = [];
const newLayoutLines: IBufferLine[] = [];
for (let i = 0; i < newLayout.length; i++) {
newLayoutLines.push(lines.get(newLayout[i]) as BufferLine);
newLayoutLines.push((lines.get(newLayout[i]) as IBufferLine).clone());
}
// Rearrange the list
@@ -176,7 +175,7 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
* @param oldCols The columns before resize.
* @param newCols The columns after resize.
*/
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
export function reflowSmallerGetNewLineLengths(wrappedLines: IBufferLine[], oldCols: number, newCols: number): number[] {
const newLineLengths: number[] = [];
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
@@ -209,7 +208,7 @@ export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCo
return newLineLengths;
}
export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number {
export function getWrappedLineTrimmedLength(lines: IBufferLine[], i: number, cols: number): number {
// If this is the last row in the wrapped line, get the actual trimmed length
if (i === lines.length - 1) {
return lines[i].getTrimmedLength();
@@ -224,3 +223,17 @@ export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols
}
return cols;
}
const workCell = new CellData();
function copyCells(src: IBufferLine, dest: IBufferLine, srcCol: number, destCol: number, length: number): void {
if (src === dest && destCol > srcCol) {
for (let i = length - 1; i >= 0; i--) {
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
}
return;
}
for (let i = 0; i < length; i++) {
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
}
}
@@ -0,0 +1,44 @@
import { assert } from 'chai';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
const WASM_PATH = 'vendor/ghostty-wasm/ghostty-vt.wasm';
describe('GhosttyBufferLine', () => {
before(() => {
(globalThis as any).XTERM_GHOSTTY_WASM_URL = WASM_PATH;
});
it('writes and reads simple cells', () => {
const buffer = new GhosttyWasmBuffer(5, 2, 5);
const line = new GhosttyBufferLine(buffer, 0);
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
assert.equal(line.translateToString(true), 'AB');
assert.equal(line.getCodePoint(0), 'A'.charCodeAt(0));
assert.equal(line.getCodePoint(1), 'B'.charCodeAt(0));
buffer.dispose();
});
it('supports insert and delete', () => {
const buffer = new GhosttyWasmBuffer(5, 1, 5);
const line = new GhosttyBufferLine(buffer, 0);
const fill = new CellData();
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.insertCells(1, 1, fill);
assert.equal(line.translateToString(true), 'A B');
line.deleteCells(1, 1, fill);
assert.equal(line.translateToString(true), 'AB');
buffer.dispose();
});
});
+352
View File
@@ -0,0 +1,352 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { BufferLine } from 'common/buffer/BufferLine';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
// Work variables to avoid garbage collection
const workCell = new CellData();
export class GhosttyBufferLine implements IBufferLine {
public get length(): number {
return this._buffer.cols;
}
constructor(
private readonly _buffer: GhosttyWasmBuffer,
private readonly _row: number
) {
}
public get row(): number {
return this._row;
}
public get _extendedAttrs(): { [key: number]: IExtendedAttrs | undefined } {
const rowMap = this._buffer.getExtendedRow(this._row);
if (!rowMap) {
return {};
}
const result: { [key: number]: IExtendedAttrs | undefined } = {};
for (const [col, ext] of rowMap) {
result[col] = ext;
}
return result;
}
public get isWrapped(): boolean {
return this._buffer.getRowWrap(this._row);
}
public set isWrapped(value: boolean) {
this._buffer.setRowWrap(this._row, value);
}
public get(index: number): CharData {
const [content, fg] = this._buffer.getCell(this._row, index);
const cp = content & Content.CODEPOINT_MASK;
const combined = (content & Content.IS_COMBINED_MASK)
? this._buffer.getCombined(this._row, index) ?? ''
: '';
return [
fg,
(content & Content.IS_COMBINED_MASK)
? combined
: (cp) ? stringFromCodePoint(cp) : '',
content >> Content.WIDTH_SHIFT,
(content & Content.IS_COMBINED_MASK)
? (combined.length ? combined.charCodeAt(combined.length - 1) : 0)
: cp
];
}
public set(index: number, value: CharData): void {
const fg = value[CHAR_DATA_ATTR_INDEX];
const chars = value[CHAR_DATA_CHAR_INDEX];
if (chars.length > 1) {
this._buffer.setCombined(this._row, index, chars);
this._buffer.setCell(this._row, index, index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
} else {
this._buffer.clearCombined(this._row, index);
this._buffer.setCell(this._row, index, chars.charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
}
}
public loadCell(index: number, cell: ICellData): ICellData {
const [content, fg, bg] = this._buffer.getCell(this._row, index);
cell.content = content;
cell.fg = fg;
cell.bg = bg;
if (cell.content & Content.IS_COMBINED_MASK) {
cell.combinedData = this._buffer.getCombined(this._row, index) ?? '';
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._buffer.getExtended(this._row, index)!;
}
return cell;
}
public setCell(index: number, cell: ICellData): void {
if (cell.content & Content.IS_COMBINED_MASK) {
this._buffer.setCombined(this._row, index, cell.combinedData);
} else {
this._buffer.clearCombined(this._row, index);
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(this._row, index, cell.extended);
} else {
this._buffer.clearExtended(this._row, index);
}
this._buffer.setCell(this._row, index, cell.content, cell.fg, cell.bg);
}
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(this._row, index, attrs.extended);
} else {
this._buffer.clearExtended(this._row, index);
}
this._buffer.clearCombined(this._row, index);
this._buffer.setCell(this._row, index, codePoint | (width << Content.WIDTH_SHIFT), attrs.fg, attrs.bg);
}
public addCodepointToCell(index: number, codePoint: number, width: number): void {
const [content, fg, bg] = this._buffer.getCell(this._row, index);
let newContent = content;
if (content & Content.IS_COMBINED_MASK) {
const existing = this._buffer.getCombined(this._row, index) ?? '';
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
} else {
if (content & Content.CODEPOINT_MASK) {
const existing = stringFromCodePoint(content & Content.CODEPOINT_MASK);
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
newContent &= ~Content.CODEPOINT_MASK;
newContent |= Content.IS_COMBINED_MASK;
} else {
newContent = codePoint | (1 << Content.WIDTH_SHIFT);
}
}
if (width) {
newContent &= ~Content.WIDTH_MASK;
newContent |= width << Content.WIDTH_SHIFT;
}
this._buffer.setCell(this._row, index, newContent, fg, bg);
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
pos %= this.length;
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
}
if (n < this.length - pos) {
for (let i = this.length - pos - n - 1; i >= 0; --i) {
this.setCell(pos + n + i, this.loadCell(pos + i, workCell));
}
for (let i = 0; i < n; ++i) {
this.setCell(pos + i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
if (this.getWidth(this.length - 1) === 2) {
this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData);
}
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
this.setCell(pos + i, this.loadCell(pos + n + i, workCell));
}
for (let i = this.length - n; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
}
if (this.getWidth(pos) === 0 && !this.hasContent(pos)) {
this.setCellFromCodepoint(pos, 0, 1, fillCellData);
}
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
}
if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) {
this.setCellFromCodepoint(end, 0, 1, fillCellData);
}
while (start < end && start < this.length) {
if (!this.isProtected(start)) {
this.setCell(start, fillCellData);
}
start++;
}
return;
}
if (start && this.getWidth(start - 1) === 2) {
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
}
if (end < this.length && this.getWidth(end - 1) === 2) {
this.setCellFromCodepoint(end, 0, 1, fillCellData);
}
while (start < end && start < this.length) {
this.setCell(start++, fillCellData);
}
}
public resize(_cols: number, _fillCellData: ICellData): boolean {
return false;
}
public cleanupMemory(): number {
return 0;
}
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
if (!this.isProtected(i)) {
this.setCell(i, fillCellData);
}
}
return;
}
for (let i = 0; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
public copyFrom(line: IBufferLine): void {
for (let i = 0; i < this.length; ++i) {
this.setCell(i, line.loadCell(i, workCell));
}
this.isWrapped = line.isWrapped;
}
public clone(): IBufferLine {
const clone = new BufferLine(this.length);
for (let i = 0; i < this.length; ++i) {
clone.setCell(i, this.loadCell(i, workCell));
}
clone.isWrapped = this.isWrapped;
return clone;
}
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._buffer.getCell(this._row, i)[0] & Content.HAS_CONTENT_MASK)) {
return i + (this._buffer.getCell(this._row, i)[0] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
const [content, , bg] = this._buffer.getCell(this._row, i);
if ((content & Content.HAS_CONTENT_MASK) || (bg & Attributes.CM_MASK)) {
return i + (content >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
startCol = startCol ?? 0;
endCol = endCol ?? this.length;
if (trimRight) {
endCol = Math.min(endCol, this.getTrimmedLength());
}
if (outColumns) {
outColumns.length = 0;
}
let result = '';
while (startCol < endCol) {
const content = this._buffer.getCell(this._row, startCol)[0];
const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK)
? (this._buffer.getCombined(this._row, startCol) ?? '')
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
result += chars;
if (outColumns) {
for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol);
}
}
startCol += (content >> Content.WIDTH_SHIFT) || 1;
}
if (outColumns) {
outColumns.push(startCol);
}
return result;
}
public getWidth(index: number): number {
return this._buffer.getCell(this._row, index)[0] >> Content.WIDTH_SHIFT;
}
public hasWidth(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.WIDTH_MASK;
}
public getFg(index: number): number {
return this._buffer.getCell(this._row, index)[1];
}
public getBg(index: number): number {
return this._buffer.getCell(this._row, index)[2];
}
public hasContent(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.HAS_CONTENT_MASK;
}
public getCodePoint(index: number): number {
const content = this._buffer.getCell(this._row, index)[0];
if (content & Content.IS_COMBINED_MASK) {
const combined = this._buffer.getCombined(this._row, index) ?? '';
return combined.charCodeAt(combined.length - 1);
}
return content & Content.CODEPOINT_MASK;
}
public isCombined(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.IS_COMBINED_MASK;
}
public getString(index: number): string {
const content = this._buffer.getCell(this._row, index)[0];
if (content & Content.IS_COMBINED_MASK) {
return this._buffer.getCombined(this._row, index) ?? '';
}
if (content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
}
return '';
}
public isProtected(index: number): number {
return this._buffer.getCell(this._row, index)[2] & BgFlags.PROTECTED;
}
}
+270
View File
@@ -0,0 +1,270 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICircularList, IBufferLine } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
import { CellData } from 'common/buffer/CellData';
import { BgFlags, Content } from 'common/buffer/Constants';
export class GhosttyCircularList extends Disposable implements ICircularList<IBufferLine> {
private _startIndex: number = 0;
private _length: number = 0;
private _lines: GhosttyBufferLine[];
public readonly onDeleteEmitter = this._register(new Emitter<IDeleteEvent>());
public readonly onDelete = this.onDeleteEmitter.event;
public readonly onInsertEmitter = this._register(new Emitter<IInsertEvent>());
public readonly onInsert = this.onInsertEmitter.event;
public readonly onTrimEmitter = this._register(new Emitter<number>());
public readonly onTrim = this.onTrimEmitter.event;
private _workCell = new CellData();
constructor(
private _buffer: GhosttyWasmBuffer,
private _maxLength: number
) {
super();
this._lines = new Array<GhosttyBufferLine>(_maxLength);
for (let i = 0; i < _maxLength; i++) {
this._lines[i] = new GhosttyBufferLine(this._buffer, i);
}
}
public get maxLength(): number {
return this._maxLength;
}
public set maxLength(newMaxLength: number) {
if (this._maxLength === newMaxLength) {
return;
}
const nextLength = Math.min(newMaxLength, this.length);
const oldLines = this._getLogicalLines(nextLength);
const newLines = new Array<GhosttyBufferLine>(newMaxLength);
for (let i = 0; i < newMaxLength; i++) {
newLines[i] = new GhosttyBufferLine(this._buffer, i);
}
for (let i = 0; i < nextLength; i++) {
this._buffer.copyRow(oldLines[i].row, newLines[i].row);
}
this._lines = newLines;
this._maxLength = newMaxLength;
this._startIndex = 0;
this._length = nextLength;
}
public get length(): number {
return this._length;
}
public set length(newLength: number) {
this._length = newLength;
}
public get isFull(): boolean {
return this._length === this._maxLength;
}
public get(index: number): IBufferLine | undefined {
if (index < 0 || index >= this._length) {
return undefined;
}
return this._lines[this._getCyclicIndex(index)];
}
public set(index: number, value: IBufferLine): void {
if (index < 0) {
return;
}
if (index >= this._length) {
this._length = Math.min(index + 1, this._maxLength);
}
this._setIndex(index, value);
}
public push(value: IBufferLine): void {
if (this._length === this._maxLength) {
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
} else {
this._length++;
}
this.set(this._length - 1, value);
}
public recycle(): GhosttyBufferLine {
if (!this.isFull) {
throw new Error('recycle is only supported when buffer is full');
}
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
return this._lines[this._getCyclicIndex(this._length - 1)];
}
public pop(): IBufferLine | undefined {
if (this._length === 0) {
return undefined;
}
const value = this._lines[this._getCyclicIndex(this._length - 1)];
this._length--;
return value;
}
public splice(start: number, deleteCount: number, ...items: IBufferLine[]): void {
if (deleteCount === 0 && items.length === 0) {
return;
}
const before = Math.max(0, Math.min(start, this._length));
const actualDeleteCount = Math.min(deleteCount, this._length - before);
if (actualDeleteCount) {
for (let i = before; i < this._length - actualDeleteCount; i++) {
const src = this.get(i + actualDeleteCount);
if (src) {
this._setIndex(i, src);
}
}
this._length -= actualDeleteCount;
this.onDeleteEmitter.fire({ index: before, amount: actualDeleteCount });
}
if (items.length) {
for (let i = this._length - 1; i >= before; i--) {
const src = this.get(i);
if (src) {
this._setIndex(i + items.length, src);
}
}
for (let i = 0; i < items.length; i++) {
this._setIndex(before + i, items[i]);
}
this.onInsertEmitter.fire({ index: before, amount: items.length });
if (this._length + items.length > this._maxLength) {
const countToTrim = this._length + items.length - this._maxLength;
this._startIndex = (this._startIndex + countToTrim) % this._maxLength;
this._length = this._maxLength;
this.onTrimEmitter.fire(countToTrim);
} else {
this._length += items.length;
}
}
}
public trimStart(count: number): void {
if (count <= 0) {
return;
}
count = Math.min(count, this._length);
if (count > 0) {
this._startIndex = (this._startIndex + count) % this._maxLength;
this._length -= count;
this.onTrimEmitter.fire(count);
}
}
public shiftElements(start: number, count: number, offset: number): void {
if (count <= 0) {
return;
}
if (start < 0 || start >= this._length) {
throw new Error('start argument out of range');
}
if (start + offset < 0) {
throw new Error('Cannot shift elements in list beyond index 0');
}
if (offset > 0) {
for (let i = count - 1; i >= 0; i--) {
const srcLine = this.get(start + i);
if (srcLine) {
this._setIndex(start + i + offset, srcLine);
}
}
const expandListBy = (start + count + offset) - this._length;
if (expandListBy > 0) {
this._length += expandListBy;
while (this._length > this._maxLength) {
this._length--;
this._startIndex = (this._startIndex + 1) % this._maxLength;
this.onTrimEmitter.fire(1);
}
}
} else if (offset < 0) {
for (let i = 0; i < count; i++) {
const srcLine = this.get(start + i);
if (srcLine) {
this._setIndex(start + i + offset, srcLine);
}
}
}
}
public normalize(): void {
if (this._startIndex === 0) {
return;
}
const snapshot = this._getLogicalLines(this._length).map(line => line.clone());
for (let i = 0; i < snapshot.length; i++) {
this._copyInto(this._lines[i].row, snapshot[i]);
}
this._startIndex = 0;
}
private _setIndex(index: number, value: IBufferLine): void {
const line = this._lines[this._getCyclicIndex(index)];
this._copyInto(line.row, value);
}
private _getCyclicIndex(index: number): number {
return (this._startIndex + index) % this._maxLength;
}
private _copyInto(rowIndex: number, value: IBufferLine): void {
const nullContent = 1 << Content.WIDTH_SHIFT;
if (value instanceof GhosttyBufferLine) {
this._buffer.copyRow(value.row, rowIndex);
return;
}
const colsToCopy = Math.min(this._buffer.cols, value.length);
for (let col = 0; col < colsToCopy; col++) {
const cell = value.loadCell(col, this._workCell);
this._buffer.setCell(rowIndex, col, cell.content, cell.fg, cell.bg);
if (cell.content & Content.IS_COMBINED_MASK) {
this._buffer.setCombined(rowIndex, col, cell.combinedData);
} else {
this._buffer.clearCombined(rowIndex, col);
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(rowIndex, col, cell.extended);
} else {
this._buffer.clearExtended(rowIndex, col);
}
}
for (let col = colsToCopy; col < this._buffer.cols; col++) {
this._buffer.setCell(rowIndex, col, nullContent, 0, 0);
this._buffer.clearCombined(rowIndex, col);
this._buffer.clearExtended(rowIndex, col);
}
this._buffer.setRowWrap(rowIndex, value.isWrapped);
}
private _getLogicalLines(count: number): GhosttyBufferLine[] {
const result: GhosttyBufferLine[] = [];
for (let i = 0; i < count; i++) {
result.push(this._lines[this._getCyclicIndex(i)]);
}
return result;
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
import { IExtendedAttrs } from 'common/Types';
const CELL_STRIDE = 3;
export class GhosttyWasmBuffer {
private readonly _wasm: IGhosttyWasmExports;
private _handle: number;
private _cols: number;
private _rows: number;
private _maxRows: number;
private _combined = new Map<number, Map<number, string>>();
private _extended = new Map<number, Map<number, IExtendedAttrs>>();
private _cellScratchPtr: number;
constructor(cols: number, rows: number, maxRows: number) {
this._wasm = GhosttyWasmRuntime.getInstanceSync();
this._cols = cols;
this._rows = rows;
this._maxRows = maxRows;
this._handle = this._wasm.ghostty_xterm_buffer_new(cols, rows, maxRows);
if (!this._handle) {
throw new Error('Failed to allocate Ghostty xterm buffer');
}
this._cellScratchPtr = this._wasm.ghostty_wasm_alloc_u8_array(CELL_STRIDE * 4);
if (!this._cellScratchPtr) {
this._wasm.ghostty_xterm_buffer_free(this._handle);
this._handle = 0;
throw new Error('Failed to allocate Ghostty cell scratch buffer');
}
}
public dispose(): void {
if (this._handle) {
this._wasm.ghostty_xterm_buffer_free(this._handle);
this._handle = 0;
}
if (this._cellScratchPtr) {
this._wasm.ghostty_wasm_free_u8_array(this._cellScratchPtr, CELL_STRIDE * 4);
this._cellScratchPtr = 0;
}
}
public get cols(): number {
return this._cols;
}
public get rows(): number {
return this._rows;
}
public get maxRows(): number {
return this._maxRows;
}
public resize(cols: number, rows: number, maxRows: number): void {
const oldCols = this._cols;
if (!this._wasm.ghostty_xterm_buffer_resize(this._handle, cols, rows, maxRows)) {
throw new Error('Failed to resize Ghostty xterm buffer');
}
this._cols = cols;
this._rows = rows;
this._maxRows = maxRows;
if (cols < oldCols) {
for (const [row, rowMap] of this._combined) {
for (const col of rowMap.keys()) {
if (col >= cols) {
rowMap.delete(col);
}
}
if (rowMap.size === 0) {
this._combined.delete(row);
}
}
for (const [row, rowMap] of this._extended) {
for (const col of rowMap.keys()) {
if (col >= cols) {
rowMap.delete(col);
}
}
if (rowMap.size === 0) {
this._extended.delete(row);
}
}
}
}
public clear(): void {
this._wasm.ghostty_xterm_buffer_clear(this._handle);
this._combined.clear();
this._extended.clear();
}
public getCell(row: number, col: number): [number, number, number] {
this._wasm.ghostty_xterm_buffer_get_cell(this._handle, row, col, this._cellScratchPtr);
const view = new DataView(this._wasm.memory.buffer, this._cellScratchPtr, CELL_STRIDE * 4);
const content = view.getUint32(0, true);
const fg = view.getUint32(4, true);
const bg = view.getUint32(8, true);
return [content, fg, bg];
}
public setCell(row: number, col: number, content: number, fg: number, bg: number): void {
this._wasm.ghostty_xterm_buffer_set_cell(this._handle, row, col, content, fg, bg);
}
public clearRow(row: number, content: number, fg: number, bg: number): void {
this._wasm.ghostty_xterm_buffer_clear_row(this._handle, row, content, fg, bg);
this._combined.delete(row);
this._extended.delete(row);
}
public copyRow(srcRow: number, dstRow: number): void {
this._wasm.ghostty_xterm_buffer_copy_row(this._handle, srcRow, dstRow);
const combined = this._combined.get(srcRow);
if (combined) {
this._combined.set(dstRow, new Map(combined));
} else {
this._combined.delete(dstRow);
}
const extended = this._extended.get(srcRow);
if (extended) {
this._extended.set(dstRow, new Map(extended));
} else {
this._extended.delete(dstRow);
}
}
public getRowWrap(row: number): boolean {
return this._wasm.ghostty_xterm_buffer_get_row_wrap(this._handle, row) !== 0;
}
public setRowWrap(row: number, wrap: boolean): void {
this._wasm.ghostty_xterm_buffer_set_row_wrap(this._handle, row, wrap ? 1 : 0);
}
public getCombined(row: number, col: number): string | undefined {
return this._combined.get(row)?.get(col);
}
public setCombined(row: number, col: number, data: string): void {
let rowMap = this._combined.get(row);
if (!rowMap) {
rowMap = new Map();
this._combined.set(row, rowMap);
}
rowMap.set(col, data);
}
public clearCombined(row: number, col: number): void {
const rowMap = this._combined.get(row);
if (!rowMap) {
return;
}
rowMap.delete(col);
if (rowMap.size === 0) {
this._combined.delete(row);
}
}
public getExtended(row: number, col: number): IExtendedAttrs | undefined {
return this._extended.get(row)?.get(col);
}
public getExtendedRow(row: number): Map<number, IExtendedAttrs> | undefined {
return this._extended.get(row);
}
public setExtended(row: number, col: number, data: IExtendedAttrs): void {
let rowMap = this._extended.get(row);
if (!rowMap) {
rowMap = new Map();
this._extended.set(row, rowMap);
}
rowMap.set(col, data);
}
public clearExtended(row: number, col: number): void {
const rowMap = this._extended.get(row);
if (!rowMap) {
return;
}
rowMap.delete(col);
if (rowMap.size === 0) {
this._extended.delete(row);
}
}
}
+1 -15
View File
@@ -474,22 +474,8 @@ describe('KittyKeyboard', () => {
});
it('modifier key release includes its own bit cleared', () => {
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags | KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[57441;1:3u');
});
});
describe('modifier-only reporting', () => {
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('does not report modifier press without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => {
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags);
assert.strictEqual(result.key, undefined);
});
it('does not report modifier release without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => {
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
assert.strictEqual(result.key, '\x1b[57441;1:3u');
});
});
+1 -1
View File
@@ -418,7 +418,7 @@ export class KittyKeyboard {
return result;
}
if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) {
if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) {
return result;
}
+831
View File
@@ -0,0 +1,831 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, IApcHandler, IApcParser, ApcFallbackHandlerType } from 'common/parser/Types';
import { OscParser } from 'common/parser/OscParser';
import { DcsParser } from 'common/parser/DcsParser';
import { ApcParser } from 'common/parser/ApcParser';
import { Params } from 'common/parser/Params';
import { StringToUtf32 } from 'common/input/TextDecoder';
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
const enum RawEventTag {
PRINT = 0,
EXECUTE = 1,
ESC = 2,
CSI = 3,
OSC = 4,
DCS_HOOK = 5,
DCS_PUT = 6,
DCS_UNHOOK = 7,
APC_START = 8,
APC_PUT = 9,
APC_END = 10
}
export class GhosttyWasmParser extends Disposable implements IEscapeSequenceParser {
public precedingJoinState = 0;
private _printHandler: PrintHandlerType;
private _executeHandlers: { [flag: number]: ExecuteHandlerType };
private _csiHandlers: IHandlerCollection<CsiHandlerType>;
private _escHandlers: IHandlerCollection<EscHandlerType>;
private readonly _oscParser: OscParser;
private readonly _dcsParser: IDcsParser;
private readonly _apcParser: IApcParser;
private _errorHandler: (state: IParsingState) => IParsingState;
private _printHandlerFb: PrintFallbackHandlerType;
private _executeHandlerFb: ExecuteFallbackHandlerType;
private _csiHandlerFb: CsiFallbackHandlerType;
private _escHandlerFb: EscFallbackHandlerType;
private _errorHandlerFb: (state: IParsingState) => IParsingState;
private _parseStack: IParserStackState = {
state: ParserStackType.NONE,
handlers: [],
handlerPos: 0,
transition: 0,
chunkPos: 0
};
private _wasm?: IGhosttyWasmExports;
private _parserHandle = 0;
private _wasmReady?: Promise<boolean>;
private _eventSize = 0;
private _eventMaxParams = 0;
private _eventMaxIntermediates = 0;
private readonly _ptrSize = 4;
private _eventIndex = 0;
private _pendingEvents: { ptr: number, len: number } | undefined;
private _resumeParams: IParams | undefined;
private _textEncoder = new TextEncoder();
private _textDecoder = new TextDecoder('utf-8');
private _utf32Scratch = new Uint32Array(4096);
private _oscBuffer = new StringToUtf32();
private _dcsPutBuffer = new Uint32Array(1);
private _apcPutBuffer = new Uint32Array(1);
constructor() {
super();
this._printHandlerFb = () => { };
this._executeHandlerFb = () => { };
this._csiHandlerFb = () => { };
this._escHandlerFb = () => { };
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
this._printHandler = this._printHandlerFb;
this._executeHandlers = Object.create(null);
this._csiHandlers = Object.create(null);
this._escHandlers = Object.create(null);
this._oscParser = this._register(new OscParser());
this._dcsParser = this._register(new DcsParser());
this._apcParser = this._register(new ApcParser());
this._errorHandler = this._errorHandlerFb;
this._register(toDisposable(() => {
this._csiHandlers = Object.create(null);
this._executeHandlers = Object.create(null);
this._escHandlers = Object.create(null);
}));
this._register(toDisposable(() => {
if (this._wasm && this._parserHandle) {
this._wasm.ghostty_raw_parser_free(this._parserHandle);
}
this._parserHandle = 0;
}));
this.registerEscHandler({ final: '\\' }, () => true);
}
public identToString(ident: number): string {
const res: string[] = [];
while (ident) {
res.push(String.fromCharCode(ident & 0xFF));
ident >>= 8;
}
return res.reverse().join('');
}
public setPrintHandler(handler: PrintHandlerType): void {
this._printHandler = handler;
}
public clearPrintHandler(): void {
this._printHandler = this._printHandlerFb;
}
public registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable {
const ident = this._identifier(id, [0x30, 0x7e]);
this._escHandlers[ident] ??= [];
const handlerList = this._escHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearEscHandler(id: IFunctionIdentifier): void {
if (this._escHandlers[this._identifier(id, [0x30, 0x7e])]) delete this._escHandlers[this._identifier(id, [0x30, 0x7e])];
}
public setEscHandlerFallback(handler: EscFallbackHandlerType): void {
this._escHandlerFb = handler;
}
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
this._executeHandlers[flag.charCodeAt(0)] = handler;
}
public clearExecuteHandler(flag: string): void {
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
}
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
this._executeHandlerFb = handler;
}
public registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable {
const ident = this._identifier(id);
this._csiHandlers[ident] ??= [];
const handlerList = this._csiHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearCsiHandler(id: IFunctionIdentifier): void {
if (this._csiHandlers[this._identifier(id)]) delete this._csiHandlers[this._identifier(id)];
}
public setCsiHandlerFallback(callback: CsiFallbackHandlerType): void {
this._csiHandlerFb = callback;
}
public registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable {
return this._dcsParser.registerHandler(this._identifier(id), handler);
}
public clearDcsHandler(id: IFunctionIdentifier): void {
this._dcsParser.clearHandler(this._identifier(id));
}
public setDcsHandlerFallback(handler: DcsFallbackHandlerType): void {
this._dcsParser.setHandlerFallback(handler);
}
public registerOscHandler(ident: number, handler: IOscHandler): IDisposable {
return this._oscParser.registerHandler(ident, handler);
}
public clearOscHandler(ident: number): void {
this._oscParser.clearHandler(ident);
}
public setOscHandlerFallback(handler: OscFallbackHandlerType): void {
this._oscParser.setHandlerFallback(handler);
}
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
return this._apcParser.registerHandler(ident, handler);
}
public clearApcHandler(ident: number): void {
this._apcParser.clearHandler(ident);
}
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
this._apcParser.setHandlerFallback(handler);
}
public setErrorHandler(handler: (state: IParsingState) => IParsingState): void {
this._errorHandler = handler;
}
public clearErrorHandler(): void {
this._errorHandler = this._errorHandlerFb;
}
public reset(): void {
this._oscParser.reset();
this._dcsParser.reset();
this._apcParser.reset();
this.precedingJoinState = 0;
if (this._wasm && this._parserHandle) {
this._wasm.ghostty_raw_parser_reset(this._parserHandle);
}
if (this._parseStack.state !== ParserStackType.NONE) {
this._parseStack.state = ParserStackType.RESET;
this._parseStack.handlers = [];
}
}
public parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise<boolean> {
if (!this._wasm) {
this._wasmReady ??= this._initWasm();
return this._wasmReady;
}
if (this._parseStack.state !== ParserStackType.NONE) {
return this._resume(promiseResult);
}
const bytes = this._encodeUtf32(data, length);
const result = this._writeToWasm(bytes);
if (result) {
return result;
}
return this._drainEvents();
}
private _resume(promiseResult?: boolean): void | Promise<boolean> {
if (promiseResult === undefined || this._parseStack.state === ParserStackType.FAIL) {
this._parseStack.state = ParserStackType.FAIL;
throw new Error('improper continuation due to previous async handler, giving up parsing');
}
switch (this._parseStack.state) {
case ParserStackType.CSI:
case ParserStackType.ESC:
return this._resumeHandlerLoop(promiseResult);
case ParserStackType.OSC:
return this._resumeOsc(promiseResult);
case ParserStackType.DCS:
return this._resumeDcs(promiseResult);
case ParserStackType.APC:
return this._resumeApc(promiseResult);
case ParserStackType.RESET:
this._parseStack.state = ParserStackType.NONE;
return;
default:
this._parseStack.state = ParserStackType.NONE;
return;
}
}
private _resumeHandlerLoop(promiseResult: boolean): void | Promise<boolean> {
const handlers = this._parseStack.handlers as CsiHandlerType[] | EscHandlerType[];
let handlerPos = this._parseStack.handlerPos - 1;
if (promiseResult === false && handlerPos > -1) {
if (this._parseStack.state === ParserStackType.CSI) {
const params = this._resumeParams ?? new Params();
for (; handlerPos >= 0; handlerPos--) {
const handlerResult = (handlers as CsiHandlerType[])[handlerPos](params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
} else {
for (; handlerPos >= 0; handlerPos--) {
const handlerResult = (handlers as EscHandlerType[])[handlerPos]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
}
}
this._resumeParams = undefined;
this._parseStack.handlers = [];
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeOsc(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._oscParser.end(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeDcs(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._dcsParser.unhook(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeApc(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._apcParser.end(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _identifier(id: IFunctionIdentifier, finalRange: number[] = [0x40, 0x7e]): number {
let res = 0;
if (id.prefix) {
if (id.prefix.length > 1) {
throw new Error('only one byte as prefix supported');
}
res = id.prefix.charCodeAt(0);
if (res && 0x3c > res || res > 0x3f) {
throw new Error('prefix must be in range 0x3c .. 0x3f');
}
}
if (id.intermediates) {
if (id.intermediates.length > 2) {
throw new Error('only two bytes as intermediates are supported');
}
for (let i = 0; i < id.intermediates.length; ++i) {
const intermediate = id.intermediates.charCodeAt(i);
if (0x20 > intermediate || intermediate > 0x2f) {
throw new Error('intermediate must be in range 0x20 .. 0x2f');
}
res <<= 8;
res |= intermediate;
}
}
if (id.final.length !== 1) {
throw new Error('final must be a single byte');
}
const finalCode = id.final.charCodeAt(0);
if (finalRange[0] > finalCode || finalCode > finalRange[1]) {
throw new Error(`final must be in range ${finalRange[0]} .. ${finalRange[1]}`);
}
res <<= 8;
res |= finalCode;
return res;
}
private async _initWasm(): Promise<boolean> {
this._wasm = await GhosttyWasmRuntime.getInstance();
this._parserHandle = this._wasm.ghostty_raw_parser_new(0);
this._eventSize = this._wasm.ghostty_raw_parser_event_size();
this._eventMaxParams = this._wasm.ghostty_raw_parser_max_params();
this._eventMaxIntermediates = this._wasm.ghostty_raw_parser_max_intermediates();
return true;
}
private _encodeUtf32(data: Uint32Array, length: number): Uint8Array {
if (length === 0) {
return new Uint8Array(0);
}
const bytes: number[] = [];
for (let i = 0; i < length; i++) {
const codePoint = data[i];
if (codePoint === 0x9c) {
bytes.push(0x1b, 0x5c);
continue;
}
if (codePoint <= 0x7f || (codePoint >= 0x80 && codePoint <= 0x9f)) {
bytes.push(codePoint);
} else if (codePoint <= 0x7ff) {
bytes.push(
0xc0 | (codePoint >> 6),
0x80 | (codePoint & 0x3f)
);
} else if (codePoint <= 0xffff) {
bytes.push(
0xe0 | (codePoint >> 12),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
);
} else {
bytes.push(
0xf0 | (codePoint >> 18),
0x80 | ((codePoint >> 12) & 0x3f),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
);
}
}
return new Uint8Array(bytes);
}
private _writeToWasm(bytes: Uint8Array): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle) {
return;
}
if (!bytes.length) {
return;
}
const dataPtr = this._wasm.ghostty_wasm_alloc_u8_array(bytes.length);
if (!dataPtr) {
return;
}
const mem = new Uint8Array(this._wasm.memory.buffer, dataPtr, bytes.length);
mem.set(bytes);
this._wasm.ghostty_raw_parser_write(this._parserHandle, dataPtr, bytes.length);
this._wasm.ghostty_wasm_free_u8_array(dataPtr, bytes.length);
}
private _drainEvents(): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle) {
return;
}
const eventsPtr = this._wasm.ghostty_raw_parser_events_ptr(this._parserHandle);
const eventsLen = this._wasm.ghostty_raw_parser_events_len(this._parserHandle);
if (!eventsPtr || !eventsLen) {
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
return;
}
this._pendingEvents = { ptr: eventsPtr, len: eventsLen };
this._eventIndex = 0;
const result = this._processEvents();
if (result) {
return result;
}
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
this._pendingEvents = undefined;
}
private _continueAfterAsync(): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle || !this._pendingEvents) {
return;
}
this._eventIndex++;
const result = this._processEvents();
if (result) {
return result;
}
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
this._pendingEvents = undefined;
}
private _processEvents(): void | Promise<boolean> {
if (!this._pendingEvents || !this._wasm) {
return;
}
const { ptr, len } = this._pendingEvents;
const mem = this._wasm.memory.buffer;
const view = new DataView(mem);
let textStart = 0;
let textLen = 0;
for (; this._eventIndex < len; this._eventIndex++) {
const base = ptr + this._eventIndex * this._eventSize;
const tag = view.getInt32(base, true);
if (tag !== RawEventTag.PRINT && textLen > 0) {
this._flushText(view, ptr, textStart, textLen);
textLen = 0;
}
switch (tag) {
case RawEventTag.PRINT: {
if (textLen === 0) {
textStart = this._eventIndex;
}
textLen++;
break;
}
case RawEventTag.EXECUTE: {
const code = view.getUint32(base + 4, true);
this._handleExecute(code);
break;
}
case RawEventTag.ESC: {
const event = this._readEvent(view, base);
const ident = this._buildIdent(event.intermediates, event.final, false, [0x30, 0x7e]);
const handlers = this._escHandlers[ident];
const handlerResult = this._callEscHandlers(handlers, ident);
if (handlerResult) {
return handlerResult;
}
break;
}
case RawEventTag.CSI: {
const event = this._readEvent(view, base);
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
const ident = this._buildIdent(event.intermediates, event.final, true);
const handlers = this._csiHandlers[ident];
const handlerResult = this._callCsiHandlers(handlers, ident, params);
if (handlerResult) {
return handlerResult;
}
break;
}
case RawEventTag.OSC: {
const event = this._readEvent(view, base);
const oscResult = this._handleOsc(event.code, event.dataPtr, event.dataLen);
if (oscResult) {
return oscResult;
}
break;
}
case RawEventTag.DCS_HOOK: {
const event = this._readEvent(view, base);
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
const ident = this._buildIdent(event.intermediates, event.final, true);
this._dcsParser.hook(ident, params);
this.precedingJoinState = 0;
break;
}
case RawEventTag.DCS_PUT: {
const code = view.getUint32(base + 4, true);
this._dcsPutBuffer[0] = code;
this._dcsParser.put(this._dcsPutBuffer, 0, 1);
break;
}
case RawEventTag.DCS_UNHOOK: {
const dcsResult = this._dcsParser.unhook(true);
if (dcsResult) {
this._parseStack.state = ParserStackType.DCS;
this.precedingJoinState = 0;
return dcsResult as Promise<boolean>;
}
this.precedingJoinState = 0;
break;
}
case RawEventTag.APC_START: {
this._apcParser.start();
this.precedingJoinState = 0;
break;
}
case RawEventTag.APC_PUT: {
const code = view.getUint32(base + 4, true);
this._apcPutBuffer[0] = code;
this._apcParser.put(this._apcPutBuffer, 0, 1);
break;
}
case RawEventTag.APC_END: {
const apcResult = this._apcParser.end(true);
if (apcResult) {
this._parseStack.state = ParserStackType.APC;
this.precedingJoinState = 0;
return apcResult as Promise<boolean>;
}
this.precedingJoinState = 0;
break;
}
}
}
if (textLen > 0) {
this._flushText(view, ptr, textStart, textLen);
}
return;
}
private _flushText(view: DataView, ptr: number, start: number, count: number): void {
let remaining = count;
let offset = 0;
while (remaining > 0) {
const chunk = Math.min(remaining, this._utf32Scratch.length);
for (let i = 0; i < chunk; i++) {
const base = ptr + (start + offset + i) * this._eventSize;
const code = view.getUint32(base + 4, true);
this._utf32Scratch[i] = code;
}
this._printHandler(this._utf32Scratch, 0, chunk);
remaining -= chunk;
offset += chunk;
}
}
private _handleExecute(code: number): void {
if (this._executeHandlers[code]) {
this._executeHandlers[code]();
} else {
this._executeHandlerFb(code);
}
this.precedingJoinState = 0;
}
private _callEscHandlers(handlers: EscHandlerType[] | undefined, ident: number): void | Promise<boolean> {
if (!handlers) {
this._escHandlerFb(ident);
this.precedingJoinState = 0;
return;
}
let j = handlers.length - 1;
for (; j >= 0; j--) {
const handlerResult = handlers[j]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.state = ParserStackType.ESC;
this._parseStack.handlers = handlers;
this._parseStack.handlerPos = j;
this.precedingJoinState = 0;
return handlerResult;
}
}
if (j < 0) {
this._escHandlerFb(ident);
}
this.precedingJoinState = 0;
}
private _callCsiHandlers(handlers: CsiHandlerType[] | undefined, ident: number, params: IParams): void | Promise<boolean> {
if (!handlers) {
this._csiHandlerFb(ident, params);
this.precedingJoinState = 0;
return;
}
let j = handlers.length - 1;
for (; j >= 0; j--) {
const handlerResult = handlers[j](params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.state = ParserStackType.CSI;
this._resumeParams = params;
this._parseStack.handlers = handlers;
this._parseStack.handlerPos = j;
this.precedingJoinState = 0;
return handlerResult;
}
}
if (j < 0) {
this._csiHandlerFb(ident, params);
}
this.precedingJoinState = 0;
}
private _handleOsc(oscId: number, dataPtr: number, dataLen: number): void | Promise<boolean> {
console.log('handle osc');
if (!this._wasm) {
return;
}
let payload = '';
if (dataLen > 0) {
if (!dataPtr || dataPtr + dataLen > this._wasm.memory.buffer.byteLength) {
return;
}
const bytes = new Uint8Array(this._wasm.memory.buffer, dataPtr, dataLen);
payload = this._textDecoder.decode(bytes);
}
if (payload.indexOf('\u0000') !== -1) {
if (oscId === 8) {
const parts = payload.split('\u0000').filter((part, index, all) => {
if (part.length > 0) {
return true;
}
return index < all.length - 1;
});
if (parts.length > 0) {
const uri = parts[parts.length - 1] ?? '';
const params = parts.slice(0, -1).join(':');
payload = `${params};${uri}`;
} else {
payload = '';
}
} else {
payload = payload.replace(/\u0000+/g, '');
}
}
const oscPrefix = `${oscId};`;
const rawPayload = payload.startsWith(oscPrefix)
? payload.slice(oscPrefix.length)
: payload;
let oscPayload = rawPayload;
if (oscId === 8 && oscPayload && !oscPayload.startsWith(';')) {
const sepIndex = oscPayload.indexOf(';');
const paramsPart = sepIndex === -1 ? oscPayload : oscPayload.slice(0, sepIndex);
if (!paramsPart.includes('=')) {
oscPayload = `;${oscPayload}`;
}
}
const oscString = `${oscPrefix}${oscPayload}`;
this._oscParser.start();
const length = this._decodeToScratch(oscString);
this._oscParser.put(this._utf32Scratch, 0, length);
const handlerResult = this._oscParser.end(true);
if (handlerResult) {
this._parseStack.state = ParserStackType.OSC;
this.precedingJoinState = 0;
return handlerResult as Promise<boolean>;
}
this.precedingJoinState = 0;
}
private _buildParams(params: Uint16Array, length: number, sepBits: number): Params {
const result = new Params(this._eventMaxParams, 256);
if (length === 0) {
result.addParam(0);
return result;
}
let i = 0;
while (i < length) {
result.addParam(params[i]);
if (sepBits & (1 << i)) {
i++;
while (i < length) {
result.addSubParam(params[i]);
if ((sepBits & (1 << i)) === 0) {
break;
}
i++;
}
}
i++;
}
return result;
}
private _buildIdent(intermediates: string, final: string, allowPrefix: boolean, finalRange: number[] = [0x40, 0x7e]): number {
let prefix: string | undefined;
let inters = intermediates;
if (allowPrefix && intermediates.length) {
const first = intermediates.charCodeAt(0);
if (first >= 0x3c && first <= 0x3f) {
prefix = intermediates.charAt(0);
inters = intermediates.substring(1);
}
}
return this._identifier({ prefix, intermediates: inters || undefined, final }, finalRange);
}
private _readEvent(view: DataView, base: number): {
final: string;
intermediates: string;
params: Uint16Array;
paramsLen: number;
paramsSep: number;
code: number;
dataPtr: number;
dataLen: number;
} {
const code = view.getUint32(base + 4, true);
const final = String.fromCharCode(view.getUint8(base + 8));
const intermediatesLen = Math.min(view.getUint8(base + 9), this._eventMaxIntermediates);
const paramsLen = Math.min(view.getUint8(base + 10), this._eventMaxParams);
const paramsSep = view.getUint32(base + 12, true);
let intermediates = '';
const interOffset = base + 16;
for (let i = 0; i < intermediatesLen; i++) {
intermediates += String.fromCharCode(view.getUint8(interOffset + i));
}
const paramsOffset = base + 16 + this._eventMaxIntermediates;
const params = new Uint16Array(paramsLen);
for (let i = 0; i < paramsLen; i++) {
params[i] = view.getUint16(paramsOffset + i * 2, true);
}
const rawDataPtrOffset = paramsOffset + this._eventMaxParams * 2;
const dataPtrOffset = (rawDataPtrOffset + (this._ptrSize - 1)) & ~(this._ptrSize - 1);
const dataPtr = view.getUint32(dataPtrOffset, true);
const dataLen = view.getUint32(dataPtrOffset + this._ptrSize, true);
return {
final,
intermediates,
params,
paramsLen,
paramsSep,
code,
dataPtr,
dataLen
};
}
private _decodeToScratch(text: string): number {
if (this._utf32Scratch.length < text.length * 2) {
this._utf32Scratch = new Uint32Array(text.length * 2);
}
return this._oscBuffer.decode(text, this._utf32Scratch);
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
/* eslint-disable @typescript-eslint/naming-convention */
export interface IGhosttyWasmExports {
memory: WebAssembly.Memory;
ghostty_raw_parser_new(allocPtr: number): number;
ghostty_raw_parser_free(parser: number): void;
ghostty_raw_parser_reset(parser: number): void;
ghostty_raw_parser_write(parser: number, dataPtr: number, len: number): void;
ghostty_raw_parser_events_ptr(parser: number): number;
ghostty_raw_parser_events_len(parser: number): number;
ghostty_raw_parser_events_clear(parser: number): void;
ghostty_raw_parser_event_size(): number;
ghostty_raw_parser_max_params(): number;
ghostty_raw_parser_max_intermediates(): number;
ghostty_wasm_alloc_u8_array(len: number): number;
ghostty_wasm_free_u8_array(ptr: number, len: number): void;
ghostty_xterm_buffer_new(cols: number, rows: number, maxRows: number): number;
ghostty_xterm_buffer_free(buffer: number): void;
ghostty_xterm_buffer_resize(buffer: number, cols: number, rows: number, maxRows: number): number;
ghostty_xterm_buffer_clear(buffer: number): void;
ghostty_xterm_buffer_get_cols(buffer: number): number;
ghostty_xterm_buffer_get_rows(buffer: number): number;
ghostty_xterm_buffer_get_max_rows(buffer: number): number;
ghostty_xterm_buffer_get_cell(buffer: number, row: number, col: number, outPtr: number): void;
ghostty_xterm_buffer_set_cell(buffer: number, row: number, col: number, content: number, fg: number, bg: number): void;
ghostty_xterm_buffer_clear_row(buffer: number, row: number, content: number, fg: number, bg: number): void;
ghostty_xterm_buffer_copy_row(buffer: number, srcRow: number, dstRow: number): void;
ghostty_xterm_buffer_get_row_wrap(buffer: number, row: number): number;
ghostty_xterm_buffer_set_row_wrap(buffer: number, row: number, wrap: number): void;
}
/* eslint-enable @typescript-eslint/naming-convention */
export class GhosttyWasmRuntime {
private static _instancePromise: Promise<IGhosttyWasmExports> | undefined;
private static _instanceSync: IGhosttyWasmExports | undefined;
public static getInstance(): Promise<IGhosttyWasmExports> {
this._instancePromise ??= this._loadAsync();
return this._instancePromise;
}
public static getInstanceSync(): IGhosttyWasmExports {
this._instanceSync ??= this._loadSync();
return this._instanceSync;
}
private static async _loadAsync(): Promise<IGhosttyWasmExports> {
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
if (isNode) {
const requireFn = (globalThis as any).require
?? (globalThis as any).process?.mainModule?.require
?? (globalThis as any).__xtermRequire;
const fs = requireFn?.('fs') as { readFile(path: string, cb: (err: Error | null, data?: Uint8Array) => void): void } | undefined;
if (!fs) {
throw new Error('Node.js async WASM loading requires require("fs")');
}
const wasmBytes = await new Promise<Uint8Array>((resolve, reject) => {
fs.readFile(wasmUrl, (err, data) => {
if (err || !data) {
reject(err ?? new Error(`Failed to load Ghostty WASM from ${wasmUrl}`));
return;
}
resolve(data);
});
});
const env = { log: () => {} };
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
const response = await fetch(wasmUrl);
if (!response.ok) {
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
}
const wasmBytes = await response.arrayBuffer();
const env = { log: () => {} };
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
private static _loadSync(): IGhosttyWasmExports {
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
if (!isNode && typeof document !== 'undefined') {
const request = new XMLHttpRequest();
request.open('GET', wasmUrl, false);
request.overrideMimeType('text/plain; charset=x-user-defined');
request.send(null);
if (request.status !== 200 || !request.responseText) {
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
}
const binary = request.responseText;
const wasmBytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
wasmBytes[i] = binary.charCodeAt(i) & 0xFF;
}
const module = new WebAssembly.Module(wasmBytes);
const env = { log: () => {} };
const instance = new WebAssembly.Instance(module, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
// Node.js sync load for tests and headless usage.
const requireFn = (globalThis as any).require
?? (globalThis as any).process?.mainModule?.require
?? (globalThis as any).__xtermRequire;
const fs = requireFn?.('fs') as { readFileSync(path: string): Uint8Array } | undefined;
if (!fs) {
throw new Error('Node.js sync WASM loading requires require("fs")');
}
const wasmBytes = fs.readFileSync(wasmUrl);
const module = new WebAssembly.Module(wasmBytes);
const env = { log: () => {} };
const instance = new WebAssembly.Instance(module, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
private static _getWasmUrl(): string {
const override = (globalThis as any).XTERM_GHOSTTY_WASM_URL as string | undefined;
if (override) {
return override;
}
if (typeof document !== 'undefined') {
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
}
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm';
import { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, type IOverviewRulerOptions } from '@xterm/xterm';
import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { createDecorator } from 'common/services/ServiceRegistry';
+2 -2
View File
@@ -9,9 +9,9 @@
"types": [
"../../node_modules/@types/mocha"
],
"baseUrl": "..",
"paths": {
"common/*": [ "./../common/*" ],
"*": [ "./../*" ]
"common/*": [ "./common/*" ]
}
},
"include": [
+2 -2
View File
@@ -10,9 +10,9 @@
"../../node_modules/@types/mocha",
"../../node_modules/@types/node"
],
"baseUrl": "../",
"paths": {
"common/*": [ "./../common/*" ],
"*": [ "./../*" ]
"common/*": [ "./common/*" ]
}
},
"include": [
+1
View File
@@ -12,6 +12,7 @@
"strict": false,
"target": "es2021",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": [ "../../src/common/*" ],
"browser/*": [ "../../src/browser/*" ],
+8 -9
View File
@@ -9,10 +9,9 @@ import type { IRenderDimensions as IRenderDimensionsInternal } from 'browser/ren
import type { IRenderService } from 'browser/services/Services';
import type { ICoreTerminal, IDisposable, IMarker } from 'common/Types';
import * as playwright from '@playwright/test';
import { PageFunction } from 'playwright-core/types/structs';
import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, IRenderDimensions, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm';
type PageFunction<Arg, R> = (arg: Arg) => R | Promise<R>;
export interface ITestContext {
browser: Browser;
page: Page;
@@ -107,7 +106,7 @@ type PlaywrightApiProxy<TBaseInterface, TAsyncPropOverrides extends keyof TBaseI
);
interface ITerminalProxyCustomMethods {
evaluate<T>(pageFunction: PageFunction<Terminal[], T>): Promise<T>;
evaluate<T>(pageFunction: PageFunction<JSHandle<Terminal>[], T>): Promise<T>;
write(data: string | Uint8Array): Promise<void>;
}
@@ -289,11 +288,11 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi
public async clearTextureAtlas(): Promise<void> { return this.evaluate(([term]) => term.clearTextureAtlas()); }
// #endregion
public async evaluate<T>(pageFunction: PageFunction<Terminal[], T>): Promise<T> {
public async evaluate<T>(pageFunction: PageFunction<JSHandle<Terminal>[], T>): Promise<T> {
return this._page.evaluate(pageFunction, [await this.getHandle()]);
}
public async evaluateHandle<T>(pageFunction: PageFunction<Terminal[], T>): Promise<JSHandle<T>> {
public async evaluateHandle<T>(pageFunction: PageFunction<JSHandle<Terminal>[], T>): Promise<JSHandle<T>> {
return this._page.evaluateHandle(pageFunction, [await this.getHandle()]);
}
@@ -343,7 +342,7 @@ class TerminalBufferProxy /* implements EnsureAsyncProperties<IBuffer>*/ {
return undefined;
}
public async evaluate<T>(pageFunction: PageFunction<IBuffer[], T>): Promise<T> {
public async evaluate<T>(pageFunction: PageFunction<JSHandle<IBuffer>[], T>): Promise<T> {
return this._page.evaluate(pageFunction, [await this._handle]);
}
}
@@ -374,7 +373,7 @@ class TerminalBufferLine {
return undefined;
}
public async evaluate<T>(pageFunction: PageFunction<IBufferLine[], T>): Promise<T> {
public async evaluate<T>(pageFunction: PageFunction<JSHandle<IBufferLine>[], T>): Promise<T> {
return this._page.evaluate(pageFunction, [this._handle]);
}
}
@@ -415,7 +414,7 @@ class TerminalBufferCell {
public isAttributeDefault(): Promise<boolean> { return this.evaluate(([cell]) => cell.isAttributeDefault()); }
public async evaluate<T>(pageFunction: PageFunction<IBufferCell[], T>): Promise<T> {
public async evaluate<T>(pageFunction: PageFunction<JSHandle<IBufferCell>[], T>): Promise<T> {
return this._page.evaluate(pageFunction, [this._handle]);
}
}
@@ -439,7 +438,7 @@ class TerminalCoreProxy {
return this._proxy.evaluateHandle(([term]) => (term as any)._core as ICoreTerminal);
}
public async evaluate<T>(pageFunction: PageFunction<ICoreTerminal[], T>): Promise<T> {
public async evaluate<T>(pageFunction: PageFunction<JSHandle<ICoreTerminal>[], T>): Promise<T> {
return this._page.evaluate(pageFunction, [await this._getCoreHandle()]);
}
}
+1 -2
View File
@@ -14,9 +14,8 @@
"removeComments": true,
"pretty": true,
"strict": true,
"noImplicitAny": false,
"declaration": true,
"moduleResolution": "node",
"baseUrl": ".",
"paths": {
"browser/*": [
"../../src/browser/*"
Vendored Submodule
+1
Submodule vendor/ghostty added at 64f78b7063
Binary file not shown.