Compare commits

...
Author SHA1 Message Date
Anthony Kim 2c4997f529 Add test for 200x100 pixel 2026-02-11 10:27:12 -08:00
Anthony Kim 16ea2ec0ef Add image demo test for kitty 2026-02-10 21:32:41 -08:00
Anthony Kim eb0f5383c3 question.. 2026-02-09 16:43:55 -08:00
Anthony Kim a4502a6d00 Impelement cursor movement after image placement 2026-02-09 14:30:35 -08:00
Anthony Kim c11a8f43f4 abort in-flight kitty chunks on delete 2026-02-09 13:34:27 -08:00
Anthony Kim d7ba56cd28 default format png -> rgba perspec 2026-02-09 13:19:41 -08:00
Anthony Kim be44833f12 send OK response for a=t, a=T transmissions when image id present 2026-02-09 13:04:34 -08:00
Anthony Kim fcb3cc1a21 add delelteImage, map kitty ID to storage IDs 2026-02-09 11:31:20 -08:00
Anthony Kim 376268157c add f=24 rgb, f=32 rgba format tests 2026-02-09 09:35:00 -08:00
Anthony Kim 5ceb02b9c6 Release decoder after command ahandler consumes bytes 2026-02-08 00:16:01 -08:00
Anthony Kim d320898231 offload raw image data to Blob for off-heap storage 2026-02-07 23:30:06 -08:00
Anthony Kim cf3939af4a Remove redundant _decodedImages bitmap cache 2026-02-07 23:11:19 -08:00
Anthony Kim 596ab845d0 Make copy of bytes with Uint8Array since they can be reused by decoder 2026-02-05 22:04:04 -08:00
Anthony Kim a59d527d1a Mirror const enum since esbuild cant inline const enum 2026-02-05 21:38:33 -08:00
Anthony Kim 08f85cf826 Use wasm-parts v0.3.0., remove manual sharding/leftover, update iip! 2026-02-05 00:20:39 -08:00
Anthony Kim e95ec8d4bc feed to decoder directly, try to clean things before getting upgraded base64 decoder 2026-02-03 17:26:59 -08:00
Anthony Kim d15b426bd4 Readme edit for kittySupport 2026-02-03 13:53:47 -08:00
Anthony Kim c907edd75f Separate out test, add failing//skip test for cursor placements 2026-02-03 13:53:33 -08:00
Anthony Kim 1029e19b57 Pre-calculate + fix bug to make sure we dont reset per chunk 2026-02-02 21:27:47 -08:00
Anthony Kim d44900faa2 Parse control data early as suggested 2026-02-02 20:47:29 -08:00
Anthony Kim 5f724c5924 Reduce MAX_CONTROL_DATA_SIZE + EINVAL for i and I 2026-02-02 20:17:21 -08:00
Anthony Kim ae58176bcb Lint 2026-02-01 13:22:02 -08:00
Anthony Kim 429b2882f1 Terminal eixst 2026-02-01 13:18:46 -08:00
Anthony Kim ffac8c03ab Merge branch 'master' into anthonykim1/scaffoldKittyAddon 2026-02-01 13:11:40 -08:00
Anthony Kim a7c4a160a8 flip inControlData thing like in C 2026-02-01 13:08:41 -08:00
Anthony Kim c9dc994090 TODO for file system stufff 2026-02-01 12:57:45 -08:00
Anthony Kim e4710d3286 Use sharding, decode with streaming 2026-02-01 12:37:09 -08:00
Anthony Kim bae314f67c Try using the wasm base64 decoder 2026-01-29 01:12:09 -08:00
Anthony Kim cb8159c4f2 Start integrating KittyGraphics stuff into imageAddon as suggested 2026-01-29 00:53:10 -08:00
Anthony Kim 492d950b64 Add TODOs from jerch's comments 2026-01-28 09:17:44 -08:00
Anthony Kim a1b69e5ac8 TODO on wasm stuff 2026-01-27 23:26:38 -08:00
Anthony Kim d77711936f comments on file system 2026-01-27 23:22:31 -08:00
Anthony Kim 5a79b08661 stale comments 2026-01-27 23:12:13 -08:00
Anthony Kim 3e8fc48b33 Small details 2026-01-27 22:20:21 -08:00
Anthony Kim 8c9afdb364 Outsource a bunch to KittyApcHandler. TODO: go over this 2026-01-27 22:13:47 -08:00
Anthony Kim f89524e577 Create KittyApchandler.ts 2026-01-27 21:25:31 -08:00
Anthony Kim a1c1702508 Assemble and use dataArray for ImageData, make hot-loop faster 2026-01-27 20:25:10 -08:00
Anthony Kim 15c71e28c8 Replace more magic numbers with readable symbols 2026-01-27 19:51:16 -08:00
Anthony Kim fd641370ec Dont use magic number, use symbol 2026-01-27 19:22:59 -08:00
Anthony Kim 259cc67f12 Handle actions, compression first, then parseInt before Switch 2026-01-27 19:20:11 -08:00
Anthony Kim b0b0868e90 Conste num for keys, describe in jsdoc 2026-01-27 19:14:06 -08:00
Anthony Kim d58e8597cd Use Terminal.dimensions intead 2026-01-27 19:04:17 -08:00
Anthony Kim 17d439515f switch from triggerDataEvnet to this._terminal.input 2026-01-27 18:59:48 -08:00
Anthony Kim ae171acb79 Dont forget about action o for compression 2026-01-27 18:44:40 -08:00
Anthony Kim e2dabc77fd Prefer ?? and move disposable location 2026-01-27 17:58:10 -08:00
Anthony Kim 40342ecd1e remove send-png 2026-01-27 17:26:06 -08:00
Anthony Kim 1442f84e4e Fix chunking for large data, allow pixels 2026-01-27 17:21:38 -08:00
Anthony Kim 5e95ef652d more comments 2026-01-23 11:11:02 -08:00
Anthony Kim bc1d87dade comments 2026-01-22 23:38:10 -08:00
Anthony Kim 849110d300 add query to say OK responses 2026-01-22 18:40:37 -08:00
Anthony Kim 8b6f8f3172 Rename 2026-01-22 16:16:30 -08:00
Anthony Kim 27825f9ed8 param in kittyGraphicsAddon.ts , ci.yml 2026-01-22 15:59:46 -08:00
Anthony Kim a507afb388 Add rendering, tweak send-png 2026-01-22 15:41:48 -08:00
Anthony Kim ec5b421dfb Renderer + og formatting for ci.yml 2026-01-22 15:40:59 -08:00
Anthony Kim 8f0e711a79 ci.yml 2026-01-22 13:09:44 -08:00
Anthony Kim fc0dc3fb07 Dont mess with og formatting for ci.yml 2026-01-22 13:06:42 -08:00
Anthony Kim bea1628015 Edit ci.yml ? 2026-01-22 12:02:46 -08:00
Anthony Kim 447746314e npm install to update pckage-lock.json 2026-01-22 11:58:51 -08:00
Anthony Kim 6a02d7c9f5 Add some playwright tests that will first fail 2026-01-22 11:56:48 -08:00
Anthony Kim 304943658d Support Kitty graphics protocol 2026-01-22 10:44:38 -08:00
22 changed files with 2122 additions and 322 deletions
+3 -1
View File
@@ -30,7 +30,9 @@ 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
iipSizeLimit: 20000000, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
}
// initialization
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 B

+1 -1
View File
@@ -25,6 +25,6 @@
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.1.0"
"xterm-wasm-parts": "^0.3.0"
}
}
+24 -14
View File
@@ -5,12 +5,16 @@
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
// limit hold memory in base64 decoder
// limit hold memory in base64 decoder (encoded bytes)
const KEEP_DATA = 4194304;
const INITIAL_DATA = 1048576;
// Local mirror of const enum (esbuild can't inline const enums from external packages)
const DECODER_OK: DecodeStatus.OK = 0;
// default IIP header values
const DEFAULT_HEADER: IHeaderFields = {
@@ -27,7 +31,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _aborted = false;
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec = new Base64Decoder(KEEP_DATA);
private _dec: Base64Decoder;
private _metrics = UNSUPPORTED_TYPE;
constructor(
@@ -35,7 +39,11 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private readonly _renderer: ImageRenderer,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {}
) {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes);
this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes);
}
public reset(): void {}
@@ -50,7 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
if (this._aborted) return;
if (this._hp.state === HeaderState.END) {
if (this._dec.put(data, start, end)) {
if (this._dec.put(data.subarray(start, end)) !== DECODER_OK) {
this._dec.release();
this._aborted = true;
}
@@ -66,8 +74,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
this._aborted = true;
return;
}
this._dec.init(this._header.size);
if (this._dec.put(data, dataPos, end)) {
this._dec.init();
if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) {
this._dec.release();
this._aborted = true;
}
@@ -85,13 +93,15 @@ export class IIPHandler implements IOscHandler, IResetHandler {
let cond: number | boolean = true;
if (cond = success) {
if (cond = !this._dec.end()) {
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;
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;
}
}
}
}
+13 -1
View File
@@ -8,6 +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 { SixelHandler } from './SixelHandler';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
@@ -22,7 +23,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -144,6 +147,15 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
);
}
// Kitty graphics handler
if (this._opts.kittySupport) {
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
this._handlers.set('kitty', kittyHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
);
}
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
+16 -2
View File
@@ -235,9 +235,22 @@ export class ImageStorage implements IDisposable {
}
/**
* Method to add an image to the storage.
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
public deleteImage(id: number): void {
const spec = this._images.get(id);
if (spec) {
spec.marker?.dispose();
this._delImg(id);
}
}
/**
* Method to add an image to the storage.
* Returns the internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
@@ -331,6 +344,7 @@ export class ImageStorage implements IDisposable {
// finally add the image
this._images.set(imageId, imgSpec);
return imageId;
}
+4 -2
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, IEscapeSequenceParser } from 'common/parser/Types';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, 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, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/**
* Plugin ctor options.
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -0,0 +1,634 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
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 {
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();
private _nextImageId = 1;
/** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */
private _kittyIdToStorageId: Map<number, number> = new Map();
// TODO: Eliminate double storage — raw image data lives here (as Blob) AND rendered
// ImageBitmaps live in ImageStorage. Currently we only use ImageStorage.addImage(bitmap)
// for tiling + cursor movement + marker-based eviction.
//
// See: https://github.com/xtermjs/xterm.js/pull/5619#issuecomment-3853678815
private _images: Map<number, IKittyImageData> = new Map();
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: ImageStorage,
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 {
for (const pending of this._pendingTransmissions.values()) {
pending.decoder.release();
}
this._pendingTransmissions.clear();
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
this._images.clear();
this._kittyIdToStorageId.clear();
}
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
const pendingKey = this._parsedCommand?.id ?? 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._pendingTransmissions.delete(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._pendingTransmissions.delete(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);
}
const pendingKey = cmd.id ?? 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._activeDecoder = null;
}
return true;
}
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;
default:
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);
if (cmd.id !== undefined && !decodeError && 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);
default:
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)
if (decodeError || bytes.length === 0) return true;
const id = cmd.id ?? this._nextImageId++;
const image: IKittyImageData = {
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 ?? ''
};
this._images.set(id, image);
return true;
}
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
if (decodeError) return true;
const pendingKey = cmd.id ?? 0;
this._handleTransmit(cmd, bytes, decodeError);
// If still accumulating chunks, don't display yet
if (this._pendingTransmissions.has(pendingKey)) return true;
// Display the completed image
const id = cmd.id ?? this._nextImageId - 1;
const image = this._images.get(id);
if (image) {
const result = this._displayImage(image, cmd);
if (cmd.id !== undefined) {
return (result as Promise<boolean>).then(r => {
this._sendResponse(id, 'OK', cmd.quiet ?? 0);
return r;
});
}
return result;
}
return true;
}
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
const id = cmd.id ?? 0;
const quiet = cmd.quiet ?? 0;
// 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 {
const id = cmd.id;
if (id !== undefined) {
// Abort in-flight chunked upload for this specific image
const pending = this._pendingTransmissions.get(id);
if (pending) {
pending.decoder.release();
this._pendingTransmissions.delete(id);
}
this._images.delete(id);
const storageId = this._kittyIdToStorageId.get(id);
if (storageId !== undefined) {
this._storage.deleteImage(storageId);
this._kittyIdToStorageId.delete(id);
}
} else {
// Abort all in-flight chunked uploads
for (const pending of this._pendingTransmissions.values()) {
pending.decoder.release();
}
this._pendingTransmissions.clear();
this._images.clear();
for (const storageId of this._kittyIdToStorageId.values()) {
this._storage.deleteImage(storageId);
}
this._kittyIdToStorageId.clear();
}
return true;
}
private _sendResponse(id: number, message: string, quiet: number): void {
const isOk = message === 'OK';
if (isOk && quiet === 1) return;
if (!isOk && quiet === 2) return;
const response = `\x1b_Gi=${id};${message}\x1b\\`;
this._coreTerminal._core.coreService.triggerDataEvent(response);
}
// Image display
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): boolean | Promise<boolean> {
return this._decodeAndDisplay(image, cmd)
.then(() => true)
.catch(() => true);
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
const bitmap = await this._createBitmap(image);
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
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
const imgRows = cmd.rows ?? 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) return;
// 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;
let storageId: number;
if (w !== bitmap.width || h !== bitmap.height) {
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
storageId = this._storage.addImage(resized);
} else {
storageId = this._storage.addImage(bitmap);
}
this._kittyIdToStorageId.set(image.id, storageId);
// 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', reject);
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;
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
const isRgba = image.format === KittyFormat.RGBA;
let srcOffset = 0;
let dstOffset = 0;
for (let i = 0; i < pixelCount; i++) {
data[dstOffset] = bytes[srcOffset];
data[dstOffset + 1] = bytes[srcOffset + 1];
data[dstOffset + 2] = bytes[srcOffset + 2];
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
srcOffset += bytesPerPixel;
dstOffset += 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._images;
}
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
return this._pendingTransmissions;
}
}
@@ -0,0 +1,90 @@
/**
* Copyright (c) 2025 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);
});
});
});
@@ -0,0 +1,171 @@
/**
* Copyright (c) 2025 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',
// 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'
}
// 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;
columns?: number;
rows?: number;
more?: number;
quiet?: number;
cursorMovement?: 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;
}
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.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;
}
}
return cmd;
}
+8 -2
View File
@@ -23,6 +23,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -134,7 +136,9 @@ test.describe('ImageAddon', () => {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000
iipSizeLimit: 20000000,
kittySupport: true,
kittySizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -149,7 +153,9 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
iipSizeLimit: 1000
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
File diff suppressed because it is too large Load Diff
+9
View File
@@ -76,6 +76,15 @@ 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 {
+1
View File
@@ -25,6 +25,7 @@ const addons = [
'clipboard',
'fit',
'image',
'kitty-graphics',
'progress',
'search',
'serialize',
+7 -3
View File
@@ -327,7 +327,9 @@ function createTerminal(): Terminal {
}
const cols = size.cols;
const rows = size.rows;
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + 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;
fetch(url, { method: 'POST' });
});
@@ -378,7 +380,9 @@ function createTerminal(): Terminal {
if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) {
runFakeTerminal();
} else {
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows, { method: 'POST' });
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 processId = await res.text();
pid = processId;
socketURL += processId;
@@ -625,7 +629,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,14 +51,30 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
container.appendChild(document.createElement('br'));
container.appendChild(document.createElement('br'));
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);
// 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);
this._initImageAddonExposed();
}
@@ -125,12 +141,25 @@ 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) => {
+4 -5
View File
@@ -19,12 +19,11 @@
"@xterm/addon-web-links": ["../../addons/addon-web-links"],
"@xterm/addon-webgl": ["../../addons/addon-webgl"],
"@xterm/addon-unicode11": ["../../addons/addon-unicode11"],
"@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
"@xterm/addon-unicode-graphemes": [
"../../addons/addon-unicode-graphemes"
],
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"]
}
},
"include": [
"./**/*",
"../../typings/xterm.d.ts"
]
"include": ["./**/*", "../../typings/xterm.d.ts"]
}
+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' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures';
export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'ligatures' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl';
export interface IDemoAddon<T extends AddonType> {
name: T;
+18 -3
View File
@@ -65,6 +65,8 @@ 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',
@@ -77,7 +79,13 @@ function startServer(): void {
useConptyDll: isWindows,
});
console.log('Created terminal with PID: ' + term.pid);
// 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);
}
terminals[term.pid] = term;
unsentOutput[term.pid] = '';
temporaryDisposable[term.pid] = term.onData(function(data) {
@@ -95,10 +103,17 @@ 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];
term.resize(cols, rows);
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
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.');
}
res.end();
});
+17 -279
View File
@@ -79,9 +79,15 @@
"license": "MIT",
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.1.0"
"xterm-wasm-parts": "^0.3.0"
}
},
"addons/addon-kitty-graphics": {
"name": "@xterm/addon-kitty-graphics",
"version": "0.1.0",
"extraneous": true,
"license": "MIT"
},
"addons/addon-ligatures": {
"name": "@xterm/addon-ligatures",
"version": "0.10.0",
@@ -1278,102 +1284,6 @@
"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",
@@ -1546,16 +1456,6 @@
"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",
@@ -3511,12 +3411,6 @@
"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",
@@ -4868,54 +4762,12 @@
"node": ">= 0.10"
}
},
"node_modules/inwasm": {
"version": "0.0.13",
"resolved": "https://registry.npmjs.org/inwasm/-/inwasm-0.0.13.tgz",
"integrity": "sha512-gmULhw1wfF3tQ19y0TvcNH6A5jN7IuTD51kbZuy+ittUU59d+ZTQMb53wbGQuciMrledgagL3/ohnjUj5qJikQ==",
"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==",
"dev": true,
"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"
}
"license": "MIT"
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
@@ -5415,21 +5267,6 @@
"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",
@@ -5878,15 +5715,6 @@
"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",
@@ -6424,12 +6252,6 @@
"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",
@@ -6517,28 +6339,6 @@
"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",
@@ -7532,21 +7332,6 @@
"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",
@@ -7559,19 +7344,6 @@
"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",
@@ -8090,23 +7862,6 @@
"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",
@@ -8450,24 +8205,6 @@
"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",
@@ -8580,12 +8317,13 @@
}
},
"node_modules/xterm-wasm-parts": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.1.0.tgz",
"integrity": "sha512-GFE8yNJfdkytGpcsOZhkL3B8XyUqkR/Du3SdxRyFbJg+BBCKm3raaaflgIM4TwNQ2AOLz01BEEuB5FZXx8aNTQ==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz",
"integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"inwasm": "^0.0.13"
"inwasm-runtime": "^0.1.2"
}
},
"node_modules/y18n": {