From 48df6117ed402ca4470373b3436efad44cb1391f Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 08:31:07 -0800 Subject: [PATCH 01/12] Add ApcParser --- src/common/InputHandler.ts | 8 + src/common/parser/ApcParser.test.ts | 349 ++++++++++++++++++ src/common/parser/ApcParser.ts | 245 ++++++++++++ src/common/parser/Constants.ts | 20 +- .../parser/EscapeSequenceParser.test.ts | 52 ++- src/common/parser/EscapeSequenceParser.ts | 68 +++- src/common/parser/Types.ts | 40 +- 7 files changed, 754 insertions(+), 28 deletions(-) create mode 100644 src/common/parser/ApcParser.test.ts create mode 100644 src/common/parser/ApcParser.ts diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index b2b75143..16343e88 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -19,6 +19,7 @@ import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseS import { UnicodeService } from 'common/services/UnicodeService'; import { OscHandler } from 'common/parser/OscParser'; import { DcsHandler } from 'common/parser/DcsParser'; +import { ApcHandler } from 'common/parser/ApcParser'; import { IBuffer } from 'common/buffer/Types'; import { parseColor } from 'common/input/XParseColor'; import { Emitter } from 'vs/base/common/event'; @@ -712,6 +713,13 @@ export class InputHandler extends Disposable implements IInputHandler { return this._parser.registerOscHandler(ident, new OscHandler(callback)); } + /** + * Forward registerApcHandler from parser. + */ + public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + return this._parser.registerApcHandler(ident, new ApcHandler(callback)); + } + /** * BEL * Bell (Ctrl-G). diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts new file mode 100644 index 00000000..3401baab --- /dev/null +++ b/src/common/parser/ApcParser.test.ts @@ -0,0 +1,349 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { assert } from 'chai'; +import { ApcParser, ApcHandler } from 'common/parser/ApcParser'; +import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder'; +import { IApcHandler } from 'common/parser/Types'; +import { PAYLOAD_LIMIT } from 'common/parser/Constants'; + +function toUtf32(s: string): Uint32Array { + const utf32 = new Uint32Array(s.length); + const decoder = new StringToUtf32(); + const length = decoder.decode(s, utf32); + return utf32.subarray(0, length); +} + +class TestHandler implements IApcHandler { + public id: number; + public output: [string, number, string, (boolean | string)?][]; + public msg: string; + public returnFalse: boolean; + + constructor( + id: number, + output: [string, number, string, (boolean | string)?][], + msg: string, + returnFalse: boolean = false + ) { + this.id = id; + this.output = output; + this.msg = msg; + this.returnFalse = returnFalse; + } + public start(): void { + this.output.push([this.msg, this.id, 'START']); + } + public put(data: Uint32Array, start: number, end: number): void { + this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]); + } + public end(success: boolean): boolean { + this.output.push([this.msg, this.id, 'END', success]); + if (this.returnFalse) { + return false; + } + return true; + } +} + +describe('ApcParser', () => { + let parser: ApcParser; + let reports: [number, string, (boolean | string | undefined)?][] = []; + + beforeEach(() => { + reports = []; + parser = new ApcParser(); + parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => { + reports.push([id, action, data]); + }); + }); + + describe('identifier parsing', () => { + it('single character identifier', () => { + parser.start(); + const data = toUtf32('Gf=100,a=T;payload'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + [0x47, 'START', undefined], // 0x47 = 'G' + [0x47, 'PUT', 'f=100,a=T;payload'], + [0x47, 'END', true] + ]); + }); + + it('identifier with no payload', () => { + parser.start(); + const data = toUtf32('G'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + [0x47, 'START', undefined], + [0x47, 'END', true] + ]); + }); + + it('identifier with chunked payload', () => { + parser.start(); + let data = toUtf32('Gf=100'); + parser.put(data, 0, data.length); + data = toUtf32(',a=T'); + parser.put(data, 0, data.length); + data = toUtf32(';payload'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(reports, [ + [0x47, 'START', undefined], + [0x47, 'PUT', 'f=100'], + [0x47, 'PUT', ',a=T'], + [0x47, 'PUT', ';payload'], + [0x47, 'END', true] + ]); + }); + + it('empty APC sequence', () => { + parser.start(); + parser.end(true); + assert.deepEqual(reports, []); + }); + }); + + describe('handler registration', () => { + let handlerReports: [string, number, string, (boolean | string)?][]; + + beforeEach(() => { + handlerReports = []; + }); + + it('registerHandler for specific identifier', () => { + const G_CODE = 0x47; // 'G' + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); + parser.start(); + const data = toUtf32('Gf=100,a=T;imagedata'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, [ + ['kitty', G_CODE, 'START'], + ['kitty', G_CODE, 'PUT', 'f=100,a=T;imagedata'], + ['kitty', G_CODE, 'END', true] + ]); + assert.deepEqual(reports, []); + }); + + it('unregistered identifier falls back', () => { + const G_CODE = 0x47; // 'G' + const X_CODE = 0x58; // 'X' + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); + parser.start(); + const data = toUtf32('Xsome data'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, []); + assert.deepEqual(reports, [ + [X_CODE, 'START', undefined], + [X_CODE, 'PUT', 'some data'], + [X_CODE, 'END', true] + ]); + }); + + it('clearHandler removes handler', () => { + const G_CODE = 0x47; + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); + parser.clearHandler(G_CODE); + parser.start(); + const data = toUtf32('Gf=100'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, []); + assert.deepEqual(reports, [ + [G_CODE, 'START', undefined], + [G_CODE, 'PUT', 'f=100'], + [G_CODE, 'END', true] + ]); + }); + + it('multiple handlers for same identifier', () => { + const G_CODE = 0x47; + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); + parser.start(); + const data = toUtf32('Gdata'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, [ + ['handler2', G_CODE, 'START'], + ['handler1', G_CODE, 'START'], + ['handler2', G_CODE, 'PUT', 'data'], + ['handler1', G_CODE, 'PUT', 'data'], + ['handler2', G_CODE, 'END', true], + ['handler1', G_CODE, 'END', false] + ]); + }); + + it('handler returning false allows fallthrough', () => { + const G_CODE = 0x47; + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true)); + parser.start(); + const data = toUtf32('Gdata'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, [ + ['handler2', G_CODE, 'START'], + ['handler1', G_CODE, 'START'], + ['handler2', G_CODE, 'PUT', 'data'], + ['handler1', G_CODE, 'PUT', 'data'], + ['handler2', G_CODE, 'END', true], + ['handler1', G_CODE, 'END', true] + ]); + }); + + it('dispose removes handler', () => { + const G_CODE = 0x47; + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1')); + const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2')); + disposable.dispose(); + parser.start(); + const data = toUtf32('Gdata'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(handlerReports, [ + ['handler1', G_CODE, 'START'], + ['handler1', G_CODE, 'PUT', 'data'], + ['handler1', G_CODE, 'END', true] + ]); + }); + }); + + describe('ApcHandler convenience class', () => { + it('should be called once on end(true)', () => { + const G_CODE = 0x47; + const results: [number, string][] = []; + parser.registerHandler(G_CODE, new ApcHandler((data: string) => { + results.push([G_CODE, data]); + return true; + })); + parser.start(); + let data = toUtf32('Gf=100'); + parser.put(data, 0, data.length); + data = toUtf32(',a=T;payload'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(results, [[G_CODE, 'f=100,a=T;payload']]); + }); + + it('should not be called on end(false)', () => { + const G_CODE = 0x47; + const results: [number, string][] = []; + parser.registerHandler(G_CODE, new ApcHandler((data: string) => { + results.push([G_CODE, data]); + return true; + })); + parser.start(); + const data = toUtf32('Gf=100,a=T;payload'); + parser.put(data, 0, data.length); + parser.end(false); + assert.deepEqual(results, []); + }); + + it('should handle payload up to limit', function(): void { + this.timeout(30000); + const G_CODE = 0x47; + const results: [number, string][] = []; + parser.registerHandler(G_CODE, new ApcHandler((data: string) => { + results.push([G_CODE, data]); + return true; + })); + parser.start(); + let data = toUtf32('G'); + parser.put(data, 0, data.length); + data = toUtf32('A'.repeat(1000)); + for (let i = 0; i < PAYLOAD_LIMIT; i += 1000) { + parser.put(data, 0, data.length); + } + parser.end(true); + assert.deepEqual(results, [[G_CODE, 'A'.repeat(PAYLOAD_LIMIT)]]); + }); + + it('should abort for payload over limit', function(): void { + this.timeout(30000); + const G_CODE = 0x47; + const results: [number, string][] = []; + parser.registerHandler(G_CODE, new ApcHandler((data: string) => { + results.push([G_CODE, data]); + return true; + })); + parser.start(); + let data = toUtf32('G'); + parser.put(data, 0, data.length); + data = toUtf32('A'.repeat(1000)); + for (let i = 0; i < PAYLOAD_LIMIT; i += 1000) { + parser.put(data, 0, data.length); + } + data = toUtf32('A'); + parser.put(data, 0, data.length); + parser.end(true); + assert.deepEqual(results, []); + }); + }); + + describe('reset behavior', () => { + let handlerReports: [string, number, string, (boolean | string)?][]; + + beforeEach(() => { + handlerReports = []; + }); + + it('reset during payload cleans up handlers', () => { + const G_CODE = 0x47; + parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty')); + parser.start(); + const data = toUtf32('Gf=100'); + parser.put(data, 0, data.length); + parser.reset(); + assert.deepEqual(handlerReports, [ + ['kitty', G_CODE, 'START'], + ['kitty', G_CODE, 'PUT', 'f=100'], + ['kitty', G_CODE, 'END', false] + ]); + }); + }); +}); + +describe('ApcParser - async tests', () => { + let parser: ApcParser; + let reports: [number, string, (boolean | string | undefined)?][] = []; + + beforeEach(() => { + reports = []; + parser = new ApcParser(); + parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => { + reports.push([id, action, data]); + }); + }); + + async function endP(parser: ApcParser, success: boolean): Promise { + let result: void | Promise; + let prev: boolean | undefined; + while (result = parser.end(success, prev)) { + prev = await result; + } + } + + describe('async ApcHandler', () => { + it('should handle async handler', async () => { + const G_CODE = 0x47; + const results: [number, string][] = []; + parser.registerHandler(G_CODE, new ApcHandler(async (data: string) => { + await new Promise(res => setTimeout(res, 10)); + results.push([G_CODE, data]); + return true; + })); + parser.start(); + const data = toUtf32('Gf=100,a=T'); + parser.put(data, 0, data.length); + await endP(parser, true); + assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]); + }); + }); +}); diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts new file mode 100644 index 00000000..81f26860 --- /dev/null +++ b/src/common/parser/ApcParser.ts @@ -0,0 +1,245 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, ISubParserStackState } from 'common/parser/Types'; +import { ApcState, PAYLOAD_LIMIT } from 'common/parser/Constants'; +import { utf32ToString } from 'common/input/TextDecoder'; +import { IDisposable } from 'common/Types'; + +const EMPTY_HANDLERS: IApcHandler[] = []; + +/** + * APC Parser for handling Application Program Command sequences. + * APC sequences use the format: ESC _ ESC \ + * + * Unlike OSC which uses numeric identifiers (e.g., OSC 1337), + * APC uses the first character as the identifier (e.g., 'G' for Kitty graphics). + * The identifier is the character code of the first byte after ESC _. + */ +export class ApcParser implements IApcParser { + private _state = ApcState.START; + private _active = EMPTY_HANDLERS; + private _id = -1; + private _handlers: IHandlerCollection = Object.create(null); + private _handlerFb: ApcFallbackHandlerType = () => { }; + private _stack: ISubParserStackState = { + paused: false, + loopPosition: 0, + fallThrough: false + }; + + /** + * Register an APC handler for a specific identifier. + * @param ident The character code of the first byte (e.g., 0x47 for 'G') + * @param handler The handler to register + */ + public registerHandler(ident: number, handler: IApcHandler): IDisposable { + if (this._handlers[ident] === undefined) { + this._handlers[ident] = []; + } + const handlerList = this._handlers[ident]; + handlerList.push(handler); + return { + dispose: () => { + const handlerIndex = handlerList.indexOf(handler); + if (handlerIndex !== -1) { + handlerList.splice(handlerIndex, 1); + } + } + }; + } + + public clearHandler(ident: number): void { + if (this._handlers[ident]) delete this._handlers[ident]; + } + + public setHandlerFallback(handler: ApcFallbackHandlerType): void { + this._handlerFb = handler; + } + + public dispose(): void { + this._handlers = Object.create(null); + this._handlerFb = () => { }; + this._active = EMPTY_HANDLERS; + } + + public reset(): void { + // force cleanup handlers if payload was already sent + if (this._state === ApcState.PAYLOAD) { + for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) { + this._active[j].end(false); + } + } + this._stack.paused = false; + this._active = EMPTY_HANDLERS; + this._id = -1; + this._state = ApcState.START; + } + + private _start(): void { + this._active = this._handlers[this._id] || EMPTY_HANDLERS; + if (!this._active.length) { + this._handlerFb(this._id, 'START'); + } else { + for (let j = this._active.length - 1; j >= 0; j--) { + this._active[j].start(); + } + } + } + + private _put(data: Uint32Array, start: number, end: number): void { + if (!this._active.length) { + this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end)); + } else { + for (let j = this._active.length - 1; j >= 0; j--) { + this._active[j].put(data, start, end); + } + } + } + + public start(): void { + // always reset leftover handlers + this.reset(); + this._state = ApcState.ID; + } + + /** + * Put data to current APC command. + * For APC, the first character is used as the identifier. + * Format: ESC _ ESC \ + * Example: ESC _ G f=100,a=T;... ESC \ (Kitty graphics, identifier='G') + */ + public put(data: Uint32Array, start: number, end: number): void { + if (this._state === ApcState.ABORT) { + return; + } + if (this._state === ApcState.ID) { + // The first character is the identifier + if (start < end) { + this._id = data[start++]; + this._state = ApcState.PAYLOAD; + this._start(); + } + } + if (this._state === ApcState.PAYLOAD && end - start > 0) { + this._put(data, start, end); + } + } + + /** + * Indicates end of an APC command. + * Whether the APC got aborted or finished normally + * is indicated by `success`. + */ + public end(success: boolean, promiseResult: boolean = true): void | Promise { + if (this._state === ApcState.START) { + return; + } + // do nothing if command was faulty + if (this._state !== ApcState.ABORT) { + // if we are still in ID state and get an early end + // means we got an empty APC sequence with no identifier, + // which is invalid - just reset and return + if (this._state === ApcState.ID) { + this._active = EMPTY_HANDLERS; + this._id = -1; + this._state = ApcState.START; + return; + } + + if (!this._active.length) { + this._handlerFb(this._id, 'END', success); + } else { + let handlerResult: boolean | Promise = false; + let j = this._active.length - 1; + let fallThrough = false; + if (this._stack.paused) { + j = this._stack.loopPosition - 1; + handlerResult = promiseResult; + fallThrough = this._stack.fallThrough; + this._stack.paused = false; + } + if (!fallThrough && handlerResult === false) { + for (; j >= 0; j--) { + handlerResult = this._active[j].end(success); + if (handlerResult === true) { + break; + } else if (handlerResult instanceof Promise) { + this._stack.paused = true; + this._stack.loopPosition = j; + this._stack.fallThrough = false; + return handlerResult; + } + } + j--; + } + // cleanup left over handlers + // we always have to call .end for proper cleanup, + // here we use `success` to indicate whether a handler should execute + for (; j >= 0; j--) { + handlerResult = this._active[j].end(false); + if (handlerResult instanceof Promise) { + this._stack.paused = true; + this._stack.loopPosition = j; + this._stack.fallThrough = true; + return handlerResult; + } + } + } + + } + this._active = EMPTY_HANDLERS; + this._id = -1; + this._state = ApcState.START; + } +} + +/** + * Convenient class to allow attaching string based handler functions + * as APC handlers. + */ +export class ApcHandler implements IApcHandler { + private _data = ''; + private _hitLimit: boolean = false; + + constructor(private _handler: (data: string) => boolean | Promise) { } + + public start(): void { + this._data = ''; + this._hitLimit = false; + } + + public put(data: Uint32Array, start: number, end: number): void { + if (this._hitLimit) { + return; + } + this._data += utf32ToString(data, start, end); + if (this._data.length > PAYLOAD_LIMIT) { + this._data = ''; + this._hitLimit = true; + } + } + + public end(success: boolean): boolean | Promise { + let ret: boolean | Promise = false; + if (this._hitLimit) { + ret = false; + } else if (success) { + ret = this._handler(this._data); + if (ret instanceof Promise) { + // need to hold data until `ret` got resolved + // dont care for errors, data will be freed anyway on next start + return ret.then(res => { + this._data = ''; + this._hitLimit = false; + return res; + }); + } + } + this._data = ''; + this._hitLimit = false; + return ret; + } +} diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 7fe24f34..f9c9c347 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -20,7 +20,8 @@ export const enum ParserState { DCS_PARAM = 10, DCS_IGNORE = 11, DCS_INTERMEDIATE = 12, - DCS_PASSTHROUGH = 13 + DCS_PASSTHROUGH = 13, + APC_STRING = 14 } /** @@ -41,7 +42,10 @@ export const enum ParserAction { CLEAR = 11, DCS_HOOK = 12, DCS_PUT = 13, - DCS_UNHOOK = 14 + DCS_UNHOOK = 14, + APC_START = 15, + APC_PUT = 16, + APC_END = 17 } /** @@ -54,5 +58,17 @@ export const enum OscState { ABORT = 3 } +// I actually don't know if below is correct.. +// If it is.. it seems like dup of OscState. +/** + * Internal states of ApcParser. + */ +export const enum ApcState { + START = 0, + ID = 1, + PAYLOAD = 2, + ABORT = 3 +} + // payload limit for OSC and DCS export const PAYLOAD_LIMIT = 10000000; diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 76e88dea..5c754a20 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -705,17 +705,17 @@ describe('EscapeSequenceParser', () => { }); it('trans ANYWHERE/ESCAPE --> SOS_PM_APC_STRING', () => { parser.reset(); - // C0 - let initializers = ['\x58', '\x5e', '\x5f']; + // C0 (only SOS and PM, APC has separate handling) + let initializers = ['\x58', '\x5e']; for (let i = 0; i < initializers.length; ++i) { parse(parser, '\x1b' + initializers[i]); assert.equal(parser.currentState, ParserState.SOS_PM_APC_STRING); parser.reset(); } - // C1 + // C1 (only SOS and PM, APC has separate handling) for (state in states) { parser.currentState = state; - initializers = ['\x98', '\x9e', '\x9f']; + initializers = ['\x98', '\x9e']; for (let i = 0; i < initializers.length; ++i) { parse(parser, initializers[i]); assert.equal(parser.currentState, ParserState.SOS_PM_APC_STRING); @@ -723,6 +723,20 @@ describe('EscapeSequenceParser', () => { } } }); + it('trans ANYWHERE/ESCAPE --> APC_STRING', () => { + parser.reset(); + // C0 (ESC _) + parse(parser, '\x1b_'); + assert.equal(parser.currentState, ParserState.APC_STRING); + parser.reset(); + // C1 + for (state in states) { + parser.currentState = state; + parse(parser, '\x9f'); + assert.equal(parser.currentState, ParserState.APC_STRING); + parser.reset(); + } + }); it('state SOS_PM_APC_STRING ignore rules', () => { parser.reset(); let ignored = r(0x00, 0x18); @@ -1207,7 +1221,7 @@ describe('EscapeSequenceParser', () => { clearAccu(); }); it('print handler', () => { - parser2.setPrintHandler(function (data: Uint32Array, start: number, end: number): void { + parser2.setPrintHandler(function(data: Uint32Array, start: number, end: number): void { for (let i = start; i < end; ++i) { print += stringFromCodePoint(data[i]); } @@ -1221,11 +1235,11 @@ describe('EscapeSequenceParser', () => { assert.equal(print, ''); }); it('ESC handler', () => { - parser2.registerEscHandler({ intermediates: '%', final: 'G' }, function (): boolean { + parser2.registerEscHandler({ intermediates: '%', final: 'G' }, function(): boolean { esc.push('%G'); return true; }); - parser2.registerEscHandler({ final: 'E' }, function (): boolean { + parser2.registerEscHandler({ final: 'E' }, function(): boolean { esc.push('E'); return true; }); @@ -1293,7 +1307,7 @@ describe('EscapeSequenceParser', () => { }); }); it('CSI handler', () => { - parser2.registerCsiHandler({ final: 'm' }, function (params: IParams): boolean { + parser2.registerCsiHandler({ final: 'm' }, function(params: IParams): boolean { csi.push(['m', params.toArray(), '']); return true; }); @@ -1373,11 +1387,11 @@ describe('EscapeSequenceParser', () => { }); }); it('EXECUTE handler', () => { - parser2.setExecuteHandler('\n', function (): boolean { + parser2.setExecuteHandler('\n', function(): boolean { exe.push('\n'); return true; }); - parser2.setExecuteHandler('\r', function (): boolean { + parser2.setExecuteHandler('\r', function(): boolean { exe.push('\r'); return true; }); @@ -1390,7 +1404,7 @@ describe('EscapeSequenceParser', () => { assert.deepEqual(exe, ['\n']); }); it('OSC handler', () => { - parser2.registerOscHandler(1, new OscHandler(function (data: string): boolean { + parser2.registerOscHandler(1, new OscHandler(function(data: string): boolean { osc.push([1, data]); return true; })); @@ -1471,17 +1485,17 @@ describe('EscapeSequenceParser', () => { }); it('DCS handler', () => { parser2.registerDcsHandler({ intermediates: '+', final: 'p' }, { - hook: function (params: IParams): void { + hook: function(params: IParams): void { dcs.push(['hook', '', params.toArray(), 0]); }, - put: function (data: Uint32Array, start: number, end: number): void { + put: function(data: Uint32Array, start: number, end: number): void { let s = ''; for (let i = start; i < end; ++i) { s += stringFromCodePoint(data[i]); } dcs.push(['put', s]); }, - unhook: function (): boolean { + unhook: function(): boolean { dcs.push(['unhook']); return true; } @@ -1560,7 +1574,7 @@ describe('EscapeSequenceParser', () => { }); it('ERROR handler', () => { let errorState: IParsingState | null = null; - parser2.setErrorHandler(function (state: IParsingState): IParsingState { + parser2.setErrorHandler(function(state: IParsingState): IParsingState { errorState = state; return state; }); @@ -1788,7 +1802,7 @@ describe('EscapeSequenceParser - async', () => { parser.setExecuteHandler('\r', () => { callstack.push(['EXE \r']); return true; }); parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; }); parser.registerOscHandler(1, new OscHandler(data => { callstack.push(['OSC 1', data]); return true; })); - parser.registerDcsHandler({final: 'a'}, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true;})); + parser.registerDcsHandler({ final: 'a' }, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; })); }); it('sync handlers keep being parsed in sync mode', () => { @@ -1823,7 +1837,7 @@ describe('EscapeSequenceParser - async', () => { parser.setExecuteHandler('\r', () => { callstack.push(['EXE \r']); return true; }); parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; }); parser.registerOscHandler(1, new OscHandler(async data => { callstack.push(['OSC 1', data]); return true; })); - parser.registerDcsHandler({final: 'a'}, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true;})); + parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; })); }); it('sync parse call does not work anymore', () => { @@ -2156,7 +2170,7 @@ describe('EscapeSequenceParser - async', () => { }); it('multiple async DCS handlers', async () => { // register with fallback - const DCS2 = parser.registerDcsHandler({final: 'a'}, new DcsHandler(async (data, params) => { callstack.push(['#2 DCS a', [data, params.toArray()]]); return false;})); + const DCS2 = parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['#2 DCS a', [data, params.toArray()]]); return false; })); await parseP(parser, INPUT); for (let i = 0; i < callstack.length; ++i) { const entry = callstack[i]; @@ -2187,7 +2201,7 @@ describe('EscapeSequenceParser - async', () => { clearAccu(); // register without fallback - const DCS22 = parser.registerDcsHandler({final: 'a'}, new DcsHandler(async (data, params) => { callstack.push(['#2 DCS a', [data, params.toArray()]]); return true;})); + const DCS22 = parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['#2 DCS a', [data, params.toArray()]]); return true; })); await parseP(parser, INPUT); for (let i = 0; i < callstack.length; ++i) { const entry = callstack[i]; diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index e13d67f3..710ac10f 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -3,13 +3,14 @@ * @license MIT */ -import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, IOscParser, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types'; +import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, IOscParser, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, ResumableHandlersType, IApcHandler, IApcParser, ApcFallbackHandlerType } from 'common/parser/Types'; import { ParserState, ParserAction } from 'common/parser/Constants'; import { Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { IDisposable } from 'common/Types'; import { Params } from 'common/parser/Params'; import { OscParser } from 'common/parser/OscParser'; import { DcsParser } from 'common/parser/DcsParser'; +import { ApcParser } from 'common/parser/ApcParser'; /** * Table values are generated like this: @@ -75,7 +76,7 @@ const NON_ASCII_PRINTABLE = 0xA0; * VT500 compatible transition table. * Taken from https://vt100.net/emu/dec_ansi_parser. */ -export const VT500_TRANSITION_TABLE = (function (): TransitionTable { +export const VT500_TRANSITION_TABLE = (function(): TransitionTable { const table: TransitionTable = new TransitionTable(4095); // range macro for byte @@ -104,7 +105,8 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(0x9c, state, ParserAction.IGNORE, ParserState.GROUND); // ST as terminator table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC - table.addMany([0x98, 0x9e, 0x9f], state, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); + table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); // SOS, PM + table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS } @@ -128,8 +130,9 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.add(0x7f, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING); table.addMany([0x9c, 0x1b, 0x18, 0x1a, 0x07], ParserState.OSC_STRING, ParserAction.OSC_END, ParserState.GROUND); table.addMany(r(0x1c, 0x20), ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING); - // sos/pm/apc does nothing - table.addMany([0x58, 0x5e, 0x5f], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); + // sos/pm/apc - sos/pm does nothing, apc gets separate handling + table.addMany([0x58, 0x5e], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); // SOS, PM + table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING); // APC ('_') table.addMany(PRINTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); table.addMany(EXECUTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); table.add(0x9c, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.GROUND); @@ -187,12 +190,18 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable { table.addMany(PRINTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH); table.add(0x7f, ParserState.DCS_PASSTHROUGH, ParserAction.IGNORE, ParserState.DCS_PASSTHROUGH); table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.DCS_PASSTHROUGH, ParserAction.DCS_UNHOOK, ParserState.GROUND); + // apc passthrough - similar to DCS passthrough + table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); + table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); + table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); + table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND); // special handling of unicode chars table.add(NON_ASCII_PRINTABLE, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND); table.add(NON_ASCII_PRINTABLE, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING); table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE); table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE); table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH); + table.add(NON_ASCII_PRINTABLE, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); return table; })(); @@ -243,6 +252,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP protected _escHandlers: IHandlerCollection; protected readonly _oscParser: IOscParser; protected readonly _dcsParser: IDcsParser; + protected readonly _apcParser: IApcParser; protected _errorHandler: (state: IParsingState) => IParsingState; // fallback handlers @@ -290,6 +300,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP })); this._oscParser = this._register(new OscParser()); this._dcsParser = this._register(new DcsParser()); + this._apcParser = this._register(new ApcParser()); this._errorHandler = this._errorHandlerFb; // swallow 7bit ST (ESC+\) @@ -425,6 +436,16 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._oscParser.setHandlerFallback(handler); } + public registerApcHandler(ident: number, handler: IApcHandler): IDisposable { + return this._apcParser.registerHandler(ident, handler); + } + public clearApcHandler(ident: number): void { + this._apcParser.clearHandler(ident); + } + public setApcHandlerFallback(handler: ApcFallbackHandlerType): void { + this._apcParser.setHandlerFallback(handler); + } + public setErrorHandler(callback: (state: IParsingState) => IParsingState): void { this._errorHandler = callback; } @@ -445,6 +466,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this.currentState = this.initialState; this._oscParser.reset(); this._dcsParser.reset(); + this._apcParser.reset(); this._params.reset(); this._params.addParam(0); // ZDM this._collect = 0; @@ -606,6 +628,17 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._params.addParam(0); // ZDM this._collect = 0; break; + case ParserStackType.APC: + code = data[this._parseStack.chunkPos]; + handlerResult = this._apcParser.end(code !== 0x18 && code !== 0x1a, promiseResult); + if (handlerResult) { + return handlerResult; + } + if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE; + this._params.reset(); + this._params.addParam(0); // ZDM + this._collect = 0; + break; } // cleanup before continuing with the main sync loop this._parseStack.state = ParserStackType.NONE; @@ -785,6 +818,31 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP this._collect = 0; this.precedingJoinState = 0; break; + case ParserAction.APC_START: + this._apcParser.start(); + break; + case ParserAction.APC_PUT: + // inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c + for (let j = i + 1; ; ++j) { + if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) { + this._apcParser.put(data, i, j); + i = j - 1; + break; + } + } + break; + case ParserAction.APC_END: + handlerResult = this._apcParser.end(code !== 0x18 && code !== 0x1a); + if (handlerResult) { + this._preserveStack(ParserStackType.APC, [], 0, transition, i); + return handlerResult; + } + if (code === 0x1b) transition |= ParserState.ESCAPE; + this._params.reset(); + this._params.addParam(0); // ZDM + this._collect = 0; + this.precedingJoinState = 0; + break; } this.currentState = transition & TableAccess.TRANSITION_STATE_MASK; } diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 2ed4acdc..474d8160 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -36,7 +36,7 @@ export interface IParams { addSubParam(value: number): void; hasSubParams(idx: number): boolean; getSubParams(idx: number): Int32Array | null; - getSubParamsAll(): {[idx: number]: Int32Array}; + getSubParamsAll(): { [idx: number]: Int32Array }; } /** @@ -134,6 +134,32 @@ export interface IOscHandler { } export type OscFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void; +/** + * APC handler types. + * APC (Application Program Command) sequences are used by protocols like + * Kitty Graphics Protocol. Format: ESC _ ESC \ + */ +export interface IApcHandler { + /** + * Announces start of this APC command. + * Prepare needed data structures here. + */ + start(): void; + /** + * Incoming data chunk. + * Note: Data is borrowed. + */ + put(data: Uint32Array, start: number, end: number): void; + /** + * End of APC command. `success` indicates whether the + * command finished normally or got aborted, thus final + * execution of the command should depend on `success`. + * To save memory also cleanup data structures here. + */ + end(success: boolean): boolean | Promise; +} +export type ApcFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void; + /** * PRINT handler types. */ @@ -196,6 +222,10 @@ export interface IEscapeSequenceParser extends IDisposable { clearOscHandler(ident: number): void; setOscHandlerFallback(handler: OscFallbackHandlerType): void; + registerApcHandler(ident: number, handler: IApcHandler): IDisposable; + clearApcHandler(ident: number): void; + setApcHandlerFallback(handler: ApcFallbackHandlerType): void; + setErrorHandler(handler: (state: IParsingState) => IParsingState): void; clearErrorHandler(): void; } @@ -223,6 +253,11 @@ export interface IDcsParser extends ISubParser; } +export interface IApcParser extends ISubParser { + start(): void; + end(success: boolean, promiseResult?: boolean): void | Promise; +} + /** * Interface to denote a specific ESC, CSI or DCS handler slot. * The values are used to create an integer respresentation during handler @@ -252,7 +287,8 @@ export const enum ParserStackType { CSI, ESC, OSC, - DCS + DCS, + APC } // aggregate of resumable handler lists From 8e29591b9f72c980dfe21f2a527b1627cf201393 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 10:42:36 -0800 Subject: [PATCH 02/12] Add registerApcHandler to public api --- src/browser/TestUtils.test.ts | 3 +++ src/common/CoreTerminal.ts | 5 +++++ src/common/Types.ts | 2 ++ src/common/public/ParserApi.ts | 3 +++ typings/xterm.d.ts | 18 ++++++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 5777d147..44a9bc77 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -107,6 +107,9 @@ export class MockTerminal implements ITerminal { public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { throw new Error('Method not implemented.'); } + public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + throw new Error('Method not implemented.'); + } public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { throw new Error('Method not implemented.'); } diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index dd312af1..01dded5e 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -242,6 +242,11 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { return this._inputHandler.registerOscHandler(ident, callback); } + /** Add handler for APC escape sequence. See xterm.d.ts for details. */ + public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + return this._inputHandler.registerApcHandler(ident, callback); + } + protected _setup(): void { this._handleWindowsPtyOptionChange(); } diff --git a/src/common/Types.ts b/src/common/Types.ts index 269b30c8..25d39d5f 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -22,6 +22,7 @@ export interface ICoreTerminal { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; } export interface IDisposable { @@ -475,6 +476,7 @@ export interface IInputHandler { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; /** C0 BEL */ bell(): boolean; /** C0 LF */ lineFeed(): boolean; diff --git a/src/common/public/ParserApi.ts b/src/common/public/ParserApi.ts index afcc01be..a9b97188 100644 --- a/src/common/public/ParserApi.ts +++ b/src/common/public/ParserApi.ts @@ -34,4 +34,7 @@ export class ParserApi implements IParser { public addOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { return this.registerOscHandler(ident, callback); } + public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + return this._core.registerApcHandler(ident, callback); + } } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index ee3364dd..604e9504 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1932,6 +1932,24 @@ declare module '@xterm/xterm' { * @returns An IDisposable you can call to remove this handler. */ registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + + /** + * Adds a handler for APC escape sequences. + * @param ident The identifier (first character) of the sequence as a + * character code, e.g. 71 for 'G' (Kitty graphics protocol). + * @param callback The function to handle the sequence. Note that the + * function will only be called once if the sequence finished successfully. + * There is currently no way to intercept smaller data chunks, data chunks + * will be stored up until the sequence is finished. Since APC sequences are + * not limited by the amount of data this might impose a problem for big + * payloads. Currently xterm.js limits APC payload to 10 MB which should + * give enough room for most use cases. The callback is called with APC data + * string (excluding the identifier character). Return `true` if the + * sequence was handled, `false` if the parser should try a previous + * handler. The most recently added handler is tried first. + * @returns An IDisposable you can call to remove this handler. + */ + registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; } /** From 1b2d361b59afdc035c9d9dada105c6cadc8fd415 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 10:59:33 -0800 Subject: [PATCH 03/12] Remove comment on Constant.ts for ApcState --- src/common/parser/Constants.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index f9c9c347..1468c3bb 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -58,8 +58,6 @@ export const enum OscState { ABORT = 3 } -// I actually don't know if below is correct.. -// If it is.. it seems like dup of OscState. /** * Internal states of ApcParser. */ From 63ebe2df5bb7209292e7084b56b8b2efc61b6ae7 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 11:00:10 -0800 Subject: [PATCH 04/12] Stop messing with existing formatting --- src/common/parser/Types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 474d8160..04eabbea 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -36,7 +36,7 @@ export interface IParams { addSubParam(value: number): void; hasSubParams(idx: number): boolean; getSubParams(idx: number): Int32Array | null; - getSubParamsAll(): { [idx: number]: Int32Array }; + getSubParamsAll(): {[idx: number]: Int32Array}; } /** From 5ecd980a83660d0939f1a486c1a11502f25c2e36 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 11:01:09 -0800 Subject: [PATCH 05/12] Comment --- src/common/parser/Types.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 04eabbea..2405dcb8 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -136,8 +136,6 @@ export type OscFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | ' /** * APC handler types. - * APC (Application Program Command) sequences are used by protocols like - * Kitty Graphics Protocol. Format: ESC _ ESC \ */ export interface IApcHandler { /** @@ -147,7 +145,6 @@ export interface IApcHandler { start(): void; /** * Incoming data chunk. - * Note: Data is borrowed. */ put(data: Uint32Array, start: number, end: number): void; /** From 42d408a9a76eea9ad7a2b9d07acfd65a8687a74b Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 11:29:04 -0800 Subject: [PATCH 06/12] Change ordering in escapeSequnceParser.ts --- src/common/parser/Constants.ts | 2 +- .../parser/EscapeSequenceParser.test.ts | 14 +++++----- src/common/parser/EscapeSequenceParser.ts | 26 +++++++++---------- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 1468c3bb..fec3cee3 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -14,7 +14,7 @@ export const enum ParserState { CSI_PARAM = 4, CSI_INTERMEDIATE = 5, CSI_IGNORE = 6, - SOS_PM_APC_STRING = 7, + SOS_PM_STRING = 7, OSC_STRING = 8, DCS_ENTRY = 9, DCS_PARAM = 10, diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 5c754a20..799b43d2 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -156,7 +156,7 @@ const states: number[] = [ ParserState.CSI_PARAM, ParserState.CSI_INTERMEDIATE, ParserState.CSI_IGNORE, - ParserState.SOS_PM_APC_STRING, + ParserState.SOS_PM_STRING, ParserState.OSC_STRING, ParserState.DCS_ENTRY, ParserState.DCS_PARAM, @@ -703,13 +703,13 @@ describe('EscapeSequenceParser', () => { testTerminal.clear(); } }); - it('trans ANYWHERE/ESCAPE --> SOS_PM_APC_STRING', () => { + it('trans ANYWHERE/ESCAPE --> SOS_PM_STRING', () => { parser.reset(); // C0 (only SOS and PM, APC has separate handling) let initializers = ['\x58', '\x5e']; for (let i = 0; i < initializers.length; ++i) { parse(parser, '\x1b' + initializers[i]); - assert.equal(parser.currentState, ParserState.SOS_PM_APC_STRING); + assert.equal(parser.currentState, ParserState.SOS_PM_STRING); parser.reset(); } // C1 (only SOS and PM, APC has separate handling) @@ -718,7 +718,7 @@ describe('EscapeSequenceParser', () => { initializers = ['\x98', '\x9e']; for (let i = 0; i < initializers.length; ++i) { parse(parser, initializers[i]); - assert.equal(parser.currentState, ParserState.SOS_PM_APC_STRING); + assert.equal(parser.currentState, ParserState.SOS_PM_STRING); parser.reset(); } } @@ -737,16 +737,16 @@ describe('EscapeSequenceParser', () => { parser.reset(); } }); - it('state SOS_PM_APC_STRING ignore rules', () => { + it('state SOS_PM_STRING ignore rules', () => { parser.reset(); let ignored = r(0x00, 0x18); ignored = ignored.concat(['\x19']); ignored = ignored.concat(r(0x1c, 0x20)); ignored = ignored.concat(r(0x20, 0x80)); for (let i = 0; i < ignored.length; ++i) { - parser.currentState = ParserState.SOS_PM_APC_STRING; + parser.currentState = ParserState.SOS_PM_STRING; parse(parser, ignored[i]); - assert.equal(parser.currentState, ParserState.SOS_PM_APC_STRING); + assert.equal(parser.currentState, ParserState.SOS_PM_STRING); parser.reset(); } }); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 710ac10f..60396c1e 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -105,7 +105,7 @@ export const VT500_TRANSITION_TABLE = (function(): TransitionTable { table.add(0x9c, state, ParserAction.IGNORE, ParserState.GROUND); // ST as terminator table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC - table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); // SOS, PM + table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // SOS, PM table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS @@ -130,13 +130,18 @@ export const VT500_TRANSITION_TABLE = (function(): TransitionTable { table.add(0x7f, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING); table.addMany([0x9c, 0x1b, 0x18, 0x1a, 0x07], ParserState.OSC_STRING, ParserAction.OSC_END, ParserState.GROUND); table.addMany(r(0x1c, 0x20), ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING); - // sos/pm/apc - sos/pm does nothing, apc gets separate handling - table.addMany([0x58, 0x5e], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); // SOS, PM - table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING); // APC ('_') - table.addMany(PRINTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); - table.addMany(EXECUTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); - table.add(0x9c, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.GROUND); - table.add(0x7f, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING); + // sos/pm + table.addMany([0x58, 0x5e], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_STRING); + table.addMany(PRINTABLES, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING); + table.addMany(EXECUTABLES, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING); + table.add(0x9c, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.GROUND); + table.add(0x7f, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING); + // apc + table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING); + table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); + table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); + table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); + table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND); // csi entries table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY); table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND); @@ -190,11 +195,6 @@ export const VT500_TRANSITION_TABLE = (function(): TransitionTable { table.addMany(PRINTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH); table.add(0x7f, ParserState.DCS_PASSTHROUGH, ParserAction.IGNORE, ParserState.DCS_PASSTHROUGH); table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.DCS_PASSTHROUGH, ParserAction.DCS_UNHOOK, ParserState.GROUND); - // apc passthrough - similar to DCS passthrough - table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING); - table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); - table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING); - table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND); // special handling of unicode chars table.add(NON_ASCII_PRINTABLE, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND); table.add(NON_ASCII_PRINTABLE, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING); From 03b015d5d3298251c57578290cf1e0f0d54f69ff Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 11:45:21 -0800 Subject: [PATCH 07/12] Add registerApcHandler to xterm-headless.d.ts --- typings/xterm-headless.d.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index c12d6ba9..35bfac07 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1312,6 +1312,24 @@ declare module '@xterm/headless' { * @returns An IDisposable you can call to remove this handler. */ registerOscHandler(ident: number, callback: (data: string) => boolean): IDisposable; + + /** + * Adds a handler for APC escape sequences. + * @param ident The identifier (first character) of the sequence as a + * character code, e.g. 71 for 'G' (Kitty graphics protocol). + * @param callback The function to handle the sequence. Note that the + * function will only be called once if the sequence finished successfully. + * There is currently no way to intercept smaller data chunks, data chunks + * will be stored up until the sequence is finished. Since APC sequences are + * not limited by the amount of data this might impose a problem for big + * payloads. Currently xterm.js limits APC payload to 10 MB which should + * give enough room for most use cases. The callback is called with APC data + * string (excluding the identifier character). Return true if the sequence + * was handled; false if we should try a previous handler. The most recently + * added handler is tried first. + * @returns An IDisposable you can call to remove this handler. + */ + registerApcHandler(ident: number, callback: (data: string) => boolean): IDisposable; } /** From 1c73006e39ee70332f8c48fa521bddf5a33df64d Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 13:41:29 -0800 Subject: [PATCH 08/12] Switch TransitionTable to Uint16Array --- src/common/parser/EscapeSequenceParser.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 60396c1e..2eb36bbb 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -18,8 +18,8 @@ import { ApcParser } from 'common/parser/ApcParser'; * value: action << TableValue.TRANSITION_ACTION_SHIFT | nextState */ const enum TableAccess { - TRANSITION_ACTION_SHIFT = 4, - TRANSITION_STATE_MASK = 15, + TRANSITION_ACTION_SHIFT = 8, + TRANSITION_STATE_MASK = 255, INDEX_STATE_SHIFT = 8 } @@ -27,10 +27,10 @@ const enum TableAccess { * Transition table for EscapeSequenceParser. */ export class TransitionTable { - public table: Uint8Array; + public table: Uint16Array; constructor(length: number) { - this.table = new Uint8Array(length); + this.table = new Uint16Array(length); } /** From b01ba7db28ce9fd10d103d2bbcf6f9fa3db5d85c Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 14:17:01 -0800 Subject: [PATCH 09/12] Add apc tests parser.test.ts --- test/playwright/Parser.test.ts | 107 +++++++++++++++++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/test/playwright/Parser.test.ts b/test/playwright/Parser.test.ts index b793b00e..989d7fd3 100644 --- a/test/playwright/Parser.test.ts +++ b/test/playwright/Parser.test.ts @@ -21,6 +21,7 @@ declare global { customDcsHandlerCallStack?: [string, (number | number[])[], string][]; customEscHandlerCallStack?: string[]; customOscHandlerCallStack?: string[][]; + customApcHandlerCallStack?: string[][]; disposable?: IDisposable; disposables?: IDisposable[]; } @@ -241,4 +242,110 @@ test.describe('Parser Integration Tests', () => { ]); }); }); + test.describe('registerApcHandler', () => { + // TODO: Remove when Kitty Graphics tests added (real-world usage replaces this) + test('should call custom APC handler with identifier', async () => { + await ctx.proxy.evaluate(([term]) => { + window.customApcHandlerCallStack = []; + // APC uses first character as identifier (e.g., 0x41 = 'A') + window.disposable = term.parser.registerApcHandler(0x41, data => { + window.customApcHandlerCallStack!.push(['handler', data]); + return true; + }); + }); + // APC format: ESC _ ESC \ + await ctx.proxy.write('\x1b_Asome data here\x1b\\'); + deepStrictEqual(await ctx.page.evaluate(() => window.customApcHandlerCallStack), [ + ['handler', 'some data here'] + ]); + }); + // TODO: Remove when Kitty Graphics tests added + test('should handle short data', async () => { + await ctx.proxy.evaluate(([term]) => { + window.customApcHandlerCallStack = []; + window.disposable = term.parser.registerApcHandler(0x42, data => { + window.customApcHandlerCallStack!.push(['handler', data]); + return true; + }); + }); + // Short APC with minimal data + await ctx.proxy.write('\x1b_Bhi\x1b\\'); + deepStrictEqual(await ctx.page.evaluate(() => window.customApcHandlerCallStack), [ + ['handler', 'hi'] + ]); + }); + test('should respect return value', async () => { + await ctx.proxy.evaluate(([term]) => { + window.customApcHandlerCallStack = []; + window.disposables = [ + term.parser.registerApcHandler(0x43, data => { + window.customApcHandlerCallStack!.push(['A', data]); + return false; + }), + term.parser.registerApcHandler(0x43, data => { + window.customApcHandlerCallStack!.push(['B', data]); + return true; + }), + term.parser.registerApcHandler(0x43, data => { + window.customApcHandlerCallStack!.push(['C', data]); + return false; + }) + ]; + }); + await ctx.proxy.write('\x1b_Csome data\x1b\\'); + deepStrictEqual(await ctx.page.evaluate(() => window.customApcHandlerCallStack), [ + ['C', 'some data'], + ['B', 'some data'] + ]); + }); + test('async', async () => { + await ctx.proxy.evaluate(([term]) => { + window.customApcHandlerCallStack = []; + window.disposables = [ + term.parser.registerApcHandler(0x44, data => { + window.customApcHandlerCallStack!.push(['A', data]); + return false; + }), + term.parser.registerApcHandler(0x44, data => { + return new Promise(res => setTimeout(res, 50)).then(() => { + window.customApcHandlerCallStack!.push(['B', data]); + return false; + }); + }), + term.parser.registerApcHandler(0x44, data => { + window.customApcHandlerCallStack!.push(['C', data]); + return false; + }) + ]; + }); + await ctx.proxy.write('\x1b_Dsome data\x1b\\'); + deepStrictEqual(await ctx.page.evaluate(() => window.customApcHandlerCallStack), [ + ['C', 'some data'], + ['B', 'some data'], + ['A', 'some data'] + ]); + }); + // TODO: Remove when Kitty Graphics tests added + test('should handle different identifiers independently', async () => { + await ctx.proxy.evaluate(([term]) => { + window.customApcHandlerCallStack = []; + window.disposables = [ + term.parser.registerApcHandler(0x46, data => { // 'F' + window.customApcHandlerCallStack!.push(['F', data]); + return true; + }), + term.parser.registerApcHandler(0x58, data => { // 'X' + window.customApcHandlerCallStack!.push(['X', data]); + return true; + }) + ]; + }); + await ctx.proxy.write('\x1b_Ffirst data\x1b\\'); + await ctx.proxy.write('\x1b_Xsecond data\x1b\\'); + deepStrictEqual(await ctx.page.evaluate(() => window.customApcHandlerCallStack), [ + ['F', 'first data'], + ['X', 'second data'] + ]); + }); + }); }); From 3d92a7e2edc502fa236a3d029219c38ecda00c40 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 15:13:03 -0800 Subject: [PATCH 10/12] Dont be silly with original formatting --- .../parser/EscapeSequenceParser.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 799b43d2..9cc5bc88 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -1221,7 +1221,7 @@ describe('EscapeSequenceParser', () => { clearAccu(); }); it('print handler', () => { - parser2.setPrintHandler(function(data: Uint32Array, start: number, end: number): void { + parser2.setPrintHandler(function (data: Uint32Array, start: number, end: number): void { for (let i = start; i < end; ++i) { print += stringFromCodePoint(data[i]); } @@ -1235,11 +1235,11 @@ describe('EscapeSequenceParser', () => { assert.equal(print, ''); }); it('ESC handler', () => { - parser2.registerEscHandler({ intermediates: '%', final: 'G' }, function(): boolean { + parser2.registerEscHandler({ intermediates: '%', final: 'G' }, function (): boolean { esc.push('%G'); return true; }); - parser2.registerEscHandler({ final: 'E' }, function(): boolean { + parser2.registerEscHandler({ final: 'E' }, function (): boolean { esc.push('E'); return true; }); @@ -1307,7 +1307,7 @@ describe('EscapeSequenceParser', () => { }); }); it('CSI handler', () => { - parser2.registerCsiHandler({ final: 'm' }, function(params: IParams): boolean { + parser2.registerCsiHandler({ final: 'm' }, function (params: IParams): boolean { csi.push(['m', params.toArray(), '']); return true; }); @@ -1387,11 +1387,11 @@ describe('EscapeSequenceParser', () => { }); }); it('EXECUTE handler', () => { - parser2.setExecuteHandler('\n', function(): boolean { + parser2.setExecuteHandler('\n', function (): boolean { exe.push('\n'); return true; }); - parser2.setExecuteHandler('\r', function(): boolean { + parser2.setExecuteHandler('\r', function (): boolean { exe.push('\r'); return true; }); @@ -1404,7 +1404,7 @@ describe('EscapeSequenceParser', () => { assert.deepEqual(exe, ['\n']); }); it('OSC handler', () => { - parser2.registerOscHandler(1, new OscHandler(function(data: string): boolean { + parser2.registerOscHandler(1, new OscHandler(function (data: string): boolean { osc.push([1, data]); return true; })); @@ -1485,17 +1485,17 @@ describe('EscapeSequenceParser', () => { }); it('DCS handler', () => { parser2.registerDcsHandler({ intermediates: '+', final: 'p' }, { - hook: function(params: IParams): void { + hook: function (params: IParams): void { dcs.push(['hook', '', params.toArray(), 0]); }, - put: function(data: Uint32Array, start: number, end: number): void { + put: function (data: Uint32Array, start: number, end: number): void { let s = ''; for (let i = start; i < end; ++i) { s += stringFromCodePoint(data[i]); } dcs.push(['put', s]); }, - unhook: function(): boolean { + unhook: function (): boolean { dcs.push(['unhook']); return true; } @@ -1574,7 +1574,7 @@ describe('EscapeSequenceParser', () => { }); it('ERROR handler', () => { let errorState: IParsingState | null = null; - parser2.setErrorHandler(function(state: IParsingState): IParsingState { + parser2.setErrorHandler(function (state: IParsingState): IParsingState { errorState = state; return state; }); From b286f21f649d432d8942751d242f9125556c3de6 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 15:50:51 -0800 Subject: [PATCH 11/12] Add APC_STRING since DCS_PASSTHROUGH isnt the last in ParserState anymore --- src/common/parser/EscapeSequenceParser.test.ts | 6 ++++-- src/common/parser/EscapeSequenceParser.ts | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 9cc5bc88..08d47f06 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -162,7 +162,8 @@ const states: number[] = [ ParserState.DCS_PARAM, ParserState.DCS_IGNORE, ParserState.DCS_INTERMEDIATE, - ParserState.DCS_PASSTHROUGH + ParserState.DCS_PASSTHROUGH, + ParserState.APC_STRING ]; let state: any; @@ -276,7 +277,8 @@ describe('EscapeSequenceParser', () => { ]; const exceptions: { [key: number]: { [key: string]: any[] } } = { 8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING - 13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] } // abort DCS_PASSTHROUGH + 13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH + 14: { '\x18': [], '\x1a': [] } // abort APC_STRING }; parser.reset(); testTerminal.clear(); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 2eb36bbb..3757ac93 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -90,7 +90,7 @@ export const VT500_TRANSITION_TABLE = (function(): TransitionTable { EXECUTABLES.push(0x19); EXECUTABLES.push.apply(EXECUTABLES, r(0x1c, 0x20)); - const states: number[] = r(ParserState.GROUND, ParserState.DCS_PASSTHROUGH + 1); + const states: number[] = r(ParserState.GROUND, ParserState.APC_STRING + 1); let state: any; // set default transition From ee3b3626e8c1a3a8b30e1a52b6bf684cd2114e94 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Wed, 21 Jan 2026 16:27:19 -0800 Subject: [PATCH 12/12] Again, format --- src/common/parser/EscapeSequenceParser.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index 3757ac93..ef627c9d 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -76,7 +76,7 @@ const NON_ASCII_PRINTABLE = 0xA0; * VT500 compatible transition table. * Taken from https://vt100.net/emu/dec_ansi_parser. */ -export const VT500_TRANSITION_TABLE = (function(): TransitionTable { +export const VT500_TRANSITION_TABLE = (function (): TransitionTable { const table: TransitionTable = new TransitionTable(4095); // range macro for byte