Compare commits

...
Author SHA1 Message Date
Daniel Imms ee2da690d9 Update ghostty submodule 2026-02-09 03:49:36 -08:00
Daniel Imms b4529f317b . 2026-02-08 20:18:30 -08:00
19 changed files with 1993 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/ghostty"]
path = vendor/ghostty
url = https://github.com/ghostty-org/ghostty
+3
View File
@@ -64,6 +64,9 @@ export class SearchEngine {
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
}
startRow = Math.max(0, startRow);
startCol = Math.max(0, startCol);
this._lineCache.initLinesCache();
const searchPosition: ISearchPosition = {
+42
View File
@@ -0,0 +1,42 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { spawnSync } from 'child_process';
import { copyFileSync, mkdirSync, renameSync } from 'fs';
import path from 'path';
import process from 'process';
import { fileURLToPath } from 'url';
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const ghosttyDir = path.join(repoRoot, 'vendor', 'ghostty');
const wasmOutDir = path.join(repoRoot, 'vendor', 'ghostty-wasm');
const wasmBinDir = path.join(wasmOutDir, 'bin');
const wasmFile = path.join(wasmOutDir, 'ghostty-vt.wasm');
const wasmBinFile = path.join(wasmBinDir, 'ghostty-vt.wasm');
mkdirSync(wasmOutDir, { recursive: true });
const result = spawnSync('zig', [
'build',
'lib-vt',
'-Dtarget=wasm32-freestanding',
'-Doptimize=ReleaseSmall',
'-p',
path.relative(ghosttyDir, wasmOutDir)
], {
cwd: ghosttyDir,
stdio: 'inherit',
shell: true
});
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
try {
renameSync(wasmBinFile, wasmFile);
} catch {
copyFileSync(wasmBinFile, wasmFile);
}
+3
View File
@@ -6,6 +6,9 @@
const cp = require('child_process');
const path = require('path');
globalThis.require = require;
globalThis.XTERM_GHOSTTY_WASM_URL = path.resolve(__dirname, '../vendor/ghostty-wasm/ghostty-vt.wasm');
const COVERAGE_LINES_THRESHOLD = 40;
// Add `out` to the NODE_PATH so absolute paths can be resolved.
+1
View File
@@ -46,6 +46,7 @@ function startServer(): void {
});
app.use('/fonts', express.static(demoRoot + '/fonts'));
app.use('/vendor/ghostty-wasm', express.static(demoRoot + '/../vendor/ghostty-wasm'));
app.use('/dist', express.static(demoRoot + '/dist'));
app.use('/src', express.static(demoRoot + '/src'));
+1
View File
@@ -45,6 +45,7 @@
"esbuild-demo-client-watch": "node bin/esbuild.mjs --demo-client --watch",
"esbuild-demo-server": "node bin/esbuild.mjs --demo-server",
"esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch",
"ghostty-wasm": "node bin/build_ghostty_wasm.mjs",
"test": "npm run test-unit",
"posttest": "npm run lint",
"lint": "eslint --max-warnings 0 src/ addons/ demo/ test/",
+21 -6
View File
@@ -17,6 +17,21 @@ const INIT_ROWS = 24;
// grab wcwidth from mock unicode service (hardcoded to V6)
const wcwidth = (new MockUnicodeService()).wcwidth;
function assertLineEqual(actual: any, expected: any): void {
assert.isOk(actual);
assert.isOk(expected);
assert.equal(actual.length, expected.length);
assert.equal(actual.isWrapped, expected.isWrapped);
const actualCell = new CellData();
const expectedCell = new CellData();
for (let i = 0; i < actual.length; i++) {
assert.deepEqual(
actual.loadCell(i, actualCell).getAsCharData(),
expected.loadCell(i, expectedCell).getAsCharData()
);
}
}
describe('Terminal', () => {
let term: TestTerminal;
const termOptions = {
@@ -183,9 +198,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
it('should clear a buffer larger than rows', async () => {
@@ -198,9 +213,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
it('should not break the prompt when cleared twice', () => {
@@ -211,9 +226,9 @@ describe('Terminal', () => {
assert.equal(term.buffer.ybase, 0);
assert.equal(term.buffer.ydisp, 0);
assert.equal(term.buffer.lines.length, term.rows);
assert.deepEqual(term.buffer.lines.get(0), promptLine);
assertLineEqual(term.buffer.lines.get(0), promptLine);
for (let i = 1; i < term.rows; i++) {
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
});
});
+9 -7
View File
@@ -7,13 +7,13 @@
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
import { C0, C1 } from 'common/data/EscapeSequences';
import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
import { GhosttyWasmParser } from 'common/parser/GhosttyWasmParser';
import { Disposable } from 'common/Lifecycle';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { AttributeData } from 'common/buffer/AttributeData';
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
@@ -109,6 +109,7 @@ const SLOW_ASYNC_LIMIT = 5000;
// Work variables to avoid garbage collection
let $temp = 0;
const $workCell = new CellData();
/**
* The terminal's standard implementation of IInputHandler, this handles all
@@ -180,7 +181,7 @@ export class InputHandler extends Disposable implements IInputHandler {
private readonly _oscLinkService: IOscLinkService,
private readonly _coreMouseService: ICoreMouseService,
private readonly _unicodeService: IUnicodeService,
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
private readonly _parser: IEscapeSequenceParser = new GhosttyWasmParser()
) {
super();
this._register(this._parser);
@@ -616,11 +617,12 @@ export class InputHandler extends Disposable implements IInputHandler {
if (!bufferRow) {
return;
}
if (oldWidth > 0 && bufferRow instanceof BufferLine) {
if (oldWidth > 0) {
// Combining character widens 1 column to 2.
// Move old character to next line.
bufferRow.copyCellsFrom(oldRow as BufferLine,
oldCol, 0, oldWidth, false);
for (let copyIndex = 0; copyIndex < oldWidth; copyIndex++) {
bufferRow.setCell(copyIndex, oldRow.loadCell(oldCol + copyIndex, $workCell));
}
}
// clear left over cells to the right
while (oldCol < cols) {
+3 -17
View File
@@ -5,7 +5,6 @@
import { assert } from 'chai';
import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
@@ -28,7 +27,6 @@ describe('Buffer', () => {
describe('constructor', () => {
it('should create a CircularList with max length equal to rows + scrollback, for its lines', () => {
assert.instanceOf(buffer.lines, CircularList);
assert.equal(buffer.lines.maxLength, bufferService.rows + INIT_SCROLLBACK);
});
it('should set the Buffer\'s scrollBottom value equal to the terminal\'s rows -1', () => {
@@ -1180,31 +1178,19 @@ describe('Buffer', () => {
});
describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
it('should keep line sizes consistent after shrinking', async () => {
buffer.fillViewportRows();
// shrink more than 2 times to trigger lazy memory cleanup
buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS);
// sync
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
// line memory is still at old size from initialization
assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4);
// array.length and .length get immediately adjusted
assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3);
assert.equal(line.length, INIT_COLS / 2 - 1);
assert.equal(line.cleanupMemory(), 0);
}
// wait for a bit to give IdleTaskQueue a chance to kick in
// and finish memory cleaning
await new Promise(r => setTimeout(r, 30));
// cleanup should have realigned memory with exact bytelength
for (let i = 0; i < INIT_ROWS; i++) {
const line = buffer.lines.get(i)!;
assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4);
}
await new Promise(r => setTimeout(r, 10));
});
});
});
+50 -11
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { IInsertEvent } from 'common/CircularList';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
@@ -13,11 +13,16 @@ import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
import { Marker } from 'common/buffer/Marker';
import { IBuffer } from 'common/buffer/Types';
import { GhosttyCircularList } from 'common/buffer/GhosttyCircularList';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { DEFAULT_CHARSET } from 'common/data/Charsets';
import { IBufferService, ILogService, IOptionsService } from 'common/services/Services';
export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
// Work variable to avoid garbage collection during reflow
const $workCell = new CellData();
/**
* This class represents a terminal buffer (an internal state of the terminal), where the
* following information is stored (in high-level):
@@ -26,7 +31,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
* - scroll position
*/
export class Buffer implements IBuffer {
public lines: CircularList<IBufferLine>;
public lines: GhosttyCircularList;
public ydisp: number = 0;
public ybase: number = 0;
public y: number = 0;
@@ -50,6 +55,7 @@ export class Buffer implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private _ghosttyBuffer: GhosttyWasmBuffer;
constructor(
private _hasScrollback: boolean,
@@ -59,7 +65,9 @@ export class Buffer implements IBuffer {
) {
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
const maxLength = this._getCorrectBufferLength(this._rows);
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
@@ -142,7 +150,10 @@ export class Buffer implements IBuffer {
this.ybase = 0;
this.y = 0;
this.x = 0;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
const maxLength = this._getCorrectBufferLength(this._rows);
this._ghosttyBuffer.dispose();
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
@@ -156,6 +167,8 @@ export class Buffer implements IBuffer {
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
const oldCols = this._cols;
const oldMaxLength = this.lines.maxLength;
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
@@ -163,6 +176,14 @@ export class Buffer implements IBuffer {
// Increase max length if needed before adjustments to allow space to fill
// as required.
const newMaxLength = this._getCorrectBufferLength(newRows);
const shouldDelayColResize = this._isReflowEnabled && newCols < oldCols;
const shouldDelayRowResize = newRows < this._rows;
const canDelayResize = newMaxLength <= oldMaxLength;
const shouldDelayResize = canDelayResize && (shouldDelayColResize || shouldDelayRowResize);
const initialCols = shouldDelayColResize ? oldCols : newCols;
const initialRows = shouldDelayRowResize ? this._rows : newRows;
const initialMaxLength = shouldDelayResize ? oldMaxLength : newMaxLength;
this._ghosttyBuffer.resize(initialCols, initialRows, initialMaxLength);
if (newMaxLength > this.lines.maxLength) {
this.lines.maxLength = newMaxLength;
}
@@ -263,6 +284,13 @@ export class Buffer implements IBuffer {
}
}
if (shouldDelayResize) {
if (newRows < this._rows || newMaxLength < oldMaxLength) {
this.lines.normalize();
}
this._ghosttyBuffer.resize(newCols, newRows, newMaxLength);
}
this._cols = newCols;
this._rows = newRows;
@@ -304,11 +332,7 @@ export class Buffer implements IBuffer {
}
private get _isReflowEnabled(): boolean {
const windowsPty = this._optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber) {
return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376;
}
return this._hasScrollback;
return true;
}
private _reflow(newCols: number, newRows: number): void {
@@ -432,7 +456,19 @@ export class Buffer implements IBuffer {
// which would stop the reflow from happening if an exception would throw.
break;
}
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true);
const destStart = destCol - cellsToCopy;
const srcStart = srcCol - cellsToCopy;
const sameLine = wrappedLines[destLineIndex] === wrappedLines[srcLineIndex];
const overlaps = sameLine && destStart < srcStart + cellsToCopy && srcStart < destStart + cellsToCopy;
if (overlaps && destStart > srcStart) {
for (let cell = cellsToCopy - 1; cell >= 0; cell--) {
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
}
} else {
for (let cell = 0; cell < cellsToCopy; cell++) {
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
}
}
destCol -= cellsToCopy;
if (destCol === 0) {
destLineIndex--;
@@ -488,7 +524,10 @@ export class Buffer implements IBuffer {
// Record original lines so they don't get overridden when we rearrange the list
const originalLines: BufferLine[] = [];
for (let i = 0; i < this.lines.length; i++) {
originalLines.push(this.lines.get(i) as BufferLine);
const line = this.lines.get(i);
if (line) {
originalLines.push(line.clone() as BufferLine);
}
}
const originalLinesLength = this.lines.length;
+28 -15
View File
@@ -3,9 +3,8 @@
* @license MIT
*/
import { BufferLine } from 'common/buffer/BufferLine';
import { CircularList } from 'common/CircularList';
import { IBufferLine, ICellData } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { ICircularList, IBufferLine, ICellData } from 'common/Types';
export interface INewLayoutResult {
layout: number[];
@@ -22,7 +21,7 @@ export interface INewLayoutResult {
* @param nullCell The cell data to use when filling in empty cells.
* @param reflowCursorLine Whether to reflow the line containing the cursor.
*/
export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
export function reflowLargerGetLinesToRemove(lines: ICircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
// Gather all BufferLines that need to be removed from the Buffer here so that they can be
// batched up and only committed once
const toRemove: number[] = [];
@@ -30,16 +29,16 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
for (let y = 0; y < lines.length - 1; y++) {
// Check if this row is wrapped
let i = y;
let nextLine = lines.get(++i) as BufferLine;
let nextLine = lines.get(++i) as IBufferLine;
if (!nextLine.isWrapped) {
continue;
}
// Check how many lines it's wrapped for
const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine];
const wrappedLines: IBufferLine[] = [lines.get(y) as IBufferLine];
while (i < lines.length && nextLine.isWrapped) {
wrappedLines.push(nextLine);
nextLine = lines.get(++i) as BufferLine;
nextLine = lines.get(++i) as IBufferLine;
}
if (!reflowCursorLine) {
@@ -62,7 +61,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
const destRemainingCells = newCols - destCol;
const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells);
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false);
copyCells(wrappedLines[srcLineIndex], wrappedLines[destLineIndex], srcCol, destCol, cellsToCopy);
destCol += cellsToCopy;
if (destCol === newCols) {
@@ -78,7 +77,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
// Make sure the last cell isn't wide, if it is copy it to the current dest
if (destCol === 0 && destLineIndex !== 0) {
if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) {
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false);
copyCells(wrappedLines[destLineIndex - 1], wrappedLines[destLineIndex], newCols - 1, destCol++, 1);
// Null out the end of the last row
wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell);
}
@@ -113,7 +112,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
* @param lines The buffer lines.
* @param toRemove The indexes to remove.
*/
export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
export function reflowLargerCreateNewLayout(lines: ICircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
const layout: number[] = [];
// First iterate through the list and get the actual indexes to use for rows
let nextToRemoveIndex = 0;
@@ -148,11 +147,11 @@ export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, to
* @param lines The buffer lines.
* @param newLayout The new layout to apply.
*/
export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, newLayout: number[]): void {
export function reflowLargerApplyNewLayout(lines: ICircularList<IBufferLine>, newLayout: number[]): void {
// Record original lines so they don't get overridden when we rearrange the list
const newLayoutLines: BufferLine[] = [];
const newLayoutLines: IBufferLine[] = [];
for (let i = 0; i < newLayout.length; i++) {
newLayoutLines.push(lines.get(newLayout[i]) as BufferLine);
newLayoutLines.push((lines.get(newLayout[i]) as IBufferLine).clone());
}
// Rearrange the list
@@ -176,7 +175,7 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
* @param oldCols The columns before resize.
* @param newCols The columns after resize.
*/
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
export function reflowSmallerGetNewLineLengths(wrappedLines: IBufferLine[], oldCols: number, newCols: number): number[] {
const newLineLengths: number[] = [];
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
@@ -209,7 +208,7 @@ export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCo
return newLineLengths;
}
export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number {
export function getWrappedLineTrimmedLength(lines: IBufferLine[], i: number, cols: number): number {
// If this is the last row in the wrapped line, get the actual trimmed length
if (i === lines.length - 1) {
return lines[i].getTrimmedLength();
@@ -224,3 +223,17 @@ export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols
}
return cols;
}
const workCell = new CellData();
function copyCells(src: IBufferLine, dest: IBufferLine, srcCol: number, destCol: number, length: number): void {
if (src === dest && destCol > srcCol) {
for (let i = length - 1; i >= 0; i--) {
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
}
return;
}
for (let i = 0; i < length; i++) {
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
}
}
@@ -0,0 +1,44 @@
import { assert } from 'chai';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
const WASM_PATH = 'vendor/ghostty-wasm/ghostty-vt.wasm';
describe('GhosttyBufferLine', () => {
before(() => {
(globalThis as any).XTERM_GHOSTTY_WASM_URL = WASM_PATH;
});
it('writes and reads simple cells', () => {
const buffer = new GhosttyWasmBuffer(5, 2, 5);
const line = new GhosttyBufferLine(buffer, 0);
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
assert.equal(line.translateToString(true), 'AB');
assert.equal(line.getCodePoint(0), 'A'.charCodeAt(0));
assert.equal(line.getCodePoint(1), 'B'.charCodeAt(0));
buffer.dispose();
});
it('supports insert and delete', () => {
const buffer = new GhosttyWasmBuffer(5, 1, 5);
const line = new GhosttyBufferLine(buffer, 0);
const fill = new CellData();
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
line.insertCells(1, 1, fill);
assert.equal(line.translateToString(true), 'A B');
line.deleteCells(1, 1, fill);
assert.equal(line.translateToString(true), 'AB');
buffer.dispose();
});
});
+352
View File
@@ -0,0 +1,352 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { BufferLine } from 'common/buffer/BufferLine';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
// Work variables to avoid garbage collection
const workCell = new CellData();
export class GhosttyBufferLine implements IBufferLine {
public get length(): number {
return this._buffer.cols;
}
constructor(
private readonly _buffer: GhosttyWasmBuffer,
private readonly _row: number
) {
}
public get row(): number {
return this._row;
}
public get _extendedAttrs(): { [key: number]: IExtendedAttrs | undefined } {
const rowMap = this._buffer.getExtendedRow(this._row);
if (!rowMap) {
return {};
}
const result: { [key: number]: IExtendedAttrs | undefined } = {};
for (const [col, ext] of rowMap) {
result[col] = ext;
}
return result;
}
public get isWrapped(): boolean {
return this._buffer.getRowWrap(this._row);
}
public set isWrapped(value: boolean) {
this._buffer.setRowWrap(this._row, value);
}
public get(index: number): CharData {
const [content, fg] = this._buffer.getCell(this._row, index);
const cp = content & Content.CODEPOINT_MASK;
const combined = (content & Content.IS_COMBINED_MASK)
? this._buffer.getCombined(this._row, index) ?? ''
: '';
return [
fg,
(content & Content.IS_COMBINED_MASK)
? combined
: (cp) ? stringFromCodePoint(cp) : '',
content >> Content.WIDTH_SHIFT,
(content & Content.IS_COMBINED_MASK)
? (combined.length ? combined.charCodeAt(combined.length - 1) : 0)
: cp
];
}
public set(index: number, value: CharData): void {
const fg = value[CHAR_DATA_ATTR_INDEX];
const chars = value[CHAR_DATA_CHAR_INDEX];
if (chars.length > 1) {
this._buffer.setCombined(this._row, index, chars);
this._buffer.setCell(this._row, index, index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
} else {
this._buffer.clearCombined(this._row, index);
this._buffer.setCell(this._row, index, chars.charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
}
}
public loadCell(index: number, cell: ICellData): ICellData {
const [content, fg, bg] = this._buffer.getCell(this._row, index);
cell.content = content;
cell.fg = fg;
cell.bg = bg;
if (cell.content & Content.IS_COMBINED_MASK) {
cell.combinedData = this._buffer.getCombined(this._row, index) ?? '';
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._buffer.getExtended(this._row, index)!;
}
return cell;
}
public setCell(index: number, cell: ICellData): void {
if (cell.content & Content.IS_COMBINED_MASK) {
this._buffer.setCombined(this._row, index, cell.combinedData);
} else {
this._buffer.clearCombined(this._row, index);
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(this._row, index, cell.extended);
} else {
this._buffer.clearExtended(this._row, index);
}
this._buffer.setCell(this._row, index, cell.content, cell.fg, cell.bg);
}
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(this._row, index, attrs.extended);
} else {
this._buffer.clearExtended(this._row, index);
}
this._buffer.clearCombined(this._row, index);
this._buffer.setCell(this._row, index, codePoint | (width << Content.WIDTH_SHIFT), attrs.fg, attrs.bg);
}
public addCodepointToCell(index: number, codePoint: number, width: number): void {
const [content, fg, bg] = this._buffer.getCell(this._row, index);
let newContent = content;
if (content & Content.IS_COMBINED_MASK) {
const existing = this._buffer.getCombined(this._row, index) ?? '';
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
} else {
if (content & Content.CODEPOINT_MASK) {
const existing = stringFromCodePoint(content & Content.CODEPOINT_MASK);
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
newContent &= ~Content.CODEPOINT_MASK;
newContent |= Content.IS_COMBINED_MASK;
} else {
newContent = codePoint | (1 << Content.WIDTH_SHIFT);
}
}
if (width) {
newContent &= ~Content.WIDTH_MASK;
newContent |= width << Content.WIDTH_SHIFT;
}
this._buffer.setCell(this._row, index, newContent, fg, bg);
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
pos %= this.length;
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
}
if (n < this.length - pos) {
for (let i = this.length - pos - n - 1; i >= 0; --i) {
this.setCell(pos + n + i, this.loadCell(pos + i, workCell));
}
for (let i = 0; i < n; ++i) {
this.setCell(pos + i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
if (this.getWidth(this.length - 1) === 2) {
this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData);
}
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
this.setCell(pos + i, this.loadCell(pos + n + i, workCell));
}
for (let i = this.length - n; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
}
if (this.getWidth(pos) === 0 && !this.hasContent(pos)) {
this.setCellFromCodepoint(pos, 0, 1, fillCellData);
}
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
}
if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) {
this.setCellFromCodepoint(end, 0, 1, fillCellData);
}
while (start < end && start < this.length) {
if (!this.isProtected(start)) {
this.setCell(start, fillCellData);
}
start++;
}
return;
}
if (start && this.getWidth(start - 1) === 2) {
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
}
if (end < this.length && this.getWidth(end - 1) === 2) {
this.setCellFromCodepoint(end, 0, 1, fillCellData);
}
while (start < end && start < this.length) {
this.setCell(start++, fillCellData);
}
}
public resize(_cols: number, _fillCellData: ICellData): boolean {
return false;
}
public cleanupMemory(): number {
return 0;
}
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
if (!this.isProtected(i)) {
this.setCell(i, fillCellData);
}
}
return;
}
for (let i = 0; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
public copyFrom(line: IBufferLine): void {
for (let i = 0; i < this.length; ++i) {
this.setCell(i, line.loadCell(i, workCell));
}
this.isWrapped = line.isWrapped;
}
public clone(): IBufferLine {
const clone = new BufferLine(this.length);
for (let i = 0; i < this.length; ++i) {
clone.setCell(i, this.loadCell(i, workCell));
}
clone.isWrapped = this.isWrapped;
return clone;
}
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._buffer.getCell(this._row, i)[0] & Content.HAS_CONTENT_MASK)) {
return i + (this._buffer.getCell(this._row, i)[0] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
const [content, , bg] = this._buffer.getCell(this._row, i);
if ((content & Content.HAS_CONTENT_MASK) || (bg & Attributes.CM_MASK)) {
return i + (content >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
startCol = startCol ?? 0;
endCol = endCol ?? this.length;
if (trimRight) {
endCol = Math.min(endCol, this.getTrimmedLength());
}
if (outColumns) {
outColumns.length = 0;
}
let result = '';
while (startCol < endCol) {
const content = this._buffer.getCell(this._row, startCol)[0];
const cp = content & Content.CODEPOINT_MASK;
const chars = (content & Content.IS_COMBINED_MASK)
? (this._buffer.getCombined(this._row, startCol) ?? '')
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
result += chars;
if (outColumns) {
for (let i = 0; i < chars.length; ++i) {
outColumns.push(startCol);
}
}
startCol += (content >> Content.WIDTH_SHIFT) || 1;
}
if (outColumns) {
outColumns.push(startCol);
}
return result;
}
public getWidth(index: number): number {
return this._buffer.getCell(this._row, index)[0] >> Content.WIDTH_SHIFT;
}
public hasWidth(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.WIDTH_MASK;
}
public getFg(index: number): number {
return this._buffer.getCell(this._row, index)[1];
}
public getBg(index: number): number {
return this._buffer.getCell(this._row, index)[2];
}
public hasContent(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.HAS_CONTENT_MASK;
}
public getCodePoint(index: number): number {
const content = this._buffer.getCell(this._row, index)[0];
if (content & Content.IS_COMBINED_MASK) {
const combined = this._buffer.getCombined(this._row, index) ?? '';
return combined.charCodeAt(combined.length - 1);
}
return content & Content.CODEPOINT_MASK;
}
public isCombined(index: number): number {
return this._buffer.getCell(this._row, index)[0] & Content.IS_COMBINED_MASK;
}
public getString(index: number): string {
const content = this._buffer.getCell(this._row, index)[0];
if (content & Content.IS_COMBINED_MASK) {
return this._buffer.getCombined(this._row, index) ?? '';
}
if (content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
}
return '';
}
public isProtected(index: number): number {
return this._buffer.getCell(this._row, index)[2] & BgFlags.PROTECTED;
}
}
+270
View File
@@ -0,0 +1,270 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICircularList, IBufferLine } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
import { CellData } from 'common/buffer/CellData';
import { BgFlags, Content } from 'common/buffer/Constants';
export class GhosttyCircularList extends Disposable implements ICircularList<IBufferLine> {
private _startIndex: number = 0;
private _length: number = 0;
private _lines: GhosttyBufferLine[];
public readonly onDeleteEmitter = this._register(new Emitter<IDeleteEvent>());
public readonly onDelete = this.onDeleteEmitter.event;
public readonly onInsertEmitter = this._register(new Emitter<IInsertEvent>());
public readonly onInsert = this.onInsertEmitter.event;
public readonly onTrimEmitter = this._register(new Emitter<number>());
public readonly onTrim = this.onTrimEmitter.event;
private _workCell = new CellData();
constructor(
private _buffer: GhosttyWasmBuffer,
private _maxLength: number
) {
super();
this._lines = new Array<GhosttyBufferLine>(_maxLength);
for (let i = 0; i < _maxLength; i++) {
this._lines[i] = new GhosttyBufferLine(this._buffer, i);
}
}
public get maxLength(): number {
return this._maxLength;
}
public set maxLength(newMaxLength: number) {
if (this._maxLength === newMaxLength) {
return;
}
const nextLength = Math.min(newMaxLength, this.length);
const oldLines = this._getLogicalLines(nextLength);
const newLines = new Array<GhosttyBufferLine>(newMaxLength);
for (let i = 0; i < newMaxLength; i++) {
newLines[i] = new GhosttyBufferLine(this._buffer, i);
}
for (let i = 0; i < nextLength; i++) {
this._buffer.copyRow(oldLines[i].row, newLines[i].row);
}
this._lines = newLines;
this._maxLength = newMaxLength;
this._startIndex = 0;
this._length = nextLength;
}
public get length(): number {
return this._length;
}
public set length(newLength: number) {
this._length = newLength;
}
public get isFull(): boolean {
return this._length === this._maxLength;
}
public get(index: number): IBufferLine | undefined {
if (index < 0 || index >= this._length) {
return undefined;
}
return this._lines[this._getCyclicIndex(index)];
}
public set(index: number, value: IBufferLine): void {
if (index < 0) {
return;
}
if (index >= this._length) {
this._length = Math.min(index + 1, this._maxLength);
}
this._setIndex(index, value);
}
public push(value: IBufferLine): void {
if (this._length === this._maxLength) {
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
} else {
this._length++;
}
this.set(this._length - 1, value);
}
public recycle(): GhosttyBufferLine {
if (!this.isFull) {
throw new Error('recycle is only supported when buffer is full');
}
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
return this._lines[this._getCyclicIndex(this._length - 1)];
}
public pop(): IBufferLine | undefined {
if (this._length === 0) {
return undefined;
}
const value = this._lines[this._getCyclicIndex(this._length - 1)];
this._length--;
return value;
}
public splice(start: number, deleteCount: number, ...items: IBufferLine[]): void {
if (deleteCount === 0 && items.length === 0) {
return;
}
const before = Math.max(0, Math.min(start, this._length));
const actualDeleteCount = Math.min(deleteCount, this._length - before);
if (actualDeleteCount) {
for (let i = before; i < this._length - actualDeleteCount; i++) {
const src = this.get(i + actualDeleteCount);
if (src) {
this._setIndex(i, src);
}
}
this._length -= actualDeleteCount;
this.onDeleteEmitter.fire({ index: before, amount: actualDeleteCount });
}
if (items.length) {
for (let i = this._length - 1; i >= before; i--) {
const src = this.get(i);
if (src) {
this._setIndex(i + items.length, src);
}
}
for (let i = 0; i < items.length; i++) {
this._setIndex(before + i, items[i]);
}
this.onInsertEmitter.fire({ index: before, amount: items.length });
if (this._length + items.length > this._maxLength) {
const countToTrim = this._length + items.length - this._maxLength;
this._startIndex = (this._startIndex + countToTrim) % this._maxLength;
this._length = this._maxLength;
this.onTrimEmitter.fire(countToTrim);
} else {
this._length += items.length;
}
}
}
public trimStart(count: number): void {
if (count <= 0) {
return;
}
count = Math.min(count, this._length);
if (count > 0) {
this._startIndex = (this._startIndex + count) % this._maxLength;
this._length -= count;
this.onTrimEmitter.fire(count);
}
}
public shiftElements(start: number, count: number, offset: number): void {
if (count <= 0) {
return;
}
if (start < 0 || start >= this._length) {
throw new Error('start argument out of range');
}
if (start + offset < 0) {
throw new Error('Cannot shift elements in list beyond index 0');
}
if (offset > 0) {
for (let i = count - 1; i >= 0; i--) {
const srcLine = this.get(start + i);
if (srcLine) {
this._setIndex(start + i + offset, srcLine);
}
}
const expandListBy = (start + count + offset) - this._length;
if (expandListBy > 0) {
this._length += expandListBy;
while (this._length > this._maxLength) {
this._length--;
this._startIndex = (this._startIndex + 1) % this._maxLength;
this.onTrimEmitter.fire(1);
}
}
} else if (offset < 0) {
for (let i = 0; i < count; i++) {
const srcLine = this.get(start + i);
if (srcLine) {
this._setIndex(start + i + offset, srcLine);
}
}
}
}
public normalize(): void {
if (this._startIndex === 0) {
return;
}
const snapshot = this._getLogicalLines(this._length).map(line => line.clone());
for (let i = 0; i < snapshot.length; i++) {
this._copyInto(this._lines[i].row, snapshot[i]);
}
this._startIndex = 0;
}
private _setIndex(index: number, value: IBufferLine): void {
const line = this._lines[this._getCyclicIndex(index)];
this._copyInto(line.row, value);
}
private _getCyclicIndex(index: number): number {
return (this._startIndex + index) % this._maxLength;
}
private _copyInto(rowIndex: number, value: IBufferLine): void {
const nullContent = 1 << Content.WIDTH_SHIFT;
if (value instanceof GhosttyBufferLine) {
this._buffer.copyRow(value.row, rowIndex);
return;
}
const colsToCopy = Math.min(this._buffer.cols, value.length);
for (let col = 0; col < colsToCopy; col++) {
const cell = value.loadCell(col, this._workCell);
this._buffer.setCell(rowIndex, col, cell.content, cell.fg, cell.bg);
if (cell.content & Content.IS_COMBINED_MASK) {
this._buffer.setCombined(rowIndex, col, cell.combinedData);
} else {
this._buffer.clearCombined(rowIndex, col);
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._buffer.setExtended(rowIndex, col, cell.extended);
} else {
this._buffer.clearExtended(rowIndex, col);
}
}
for (let col = colsToCopy; col < this._buffer.cols; col++) {
this._buffer.setCell(rowIndex, col, nullContent, 0, 0);
this._buffer.clearCombined(rowIndex, col);
this._buffer.clearExtended(rowIndex, col);
}
this._buffer.setRowWrap(rowIndex, value.isWrapped);
}
private _getLogicalLines(count: number): GhosttyBufferLine[] {
const result: GhosttyBufferLine[] = [];
for (let i = 0; i < count; i++) {
result.push(this._lines[this._getCyclicIndex(i)]);
}
return result;
}
}
+198
View File
@@ -0,0 +1,198 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
import { IExtendedAttrs } from 'common/Types';
const CELL_STRIDE = 3;
export class GhosttyWasmBuffer {
private readonly _wasm: IGhosttyWasmExports;
private _handle: number;
private _cols: number;
private _rows: number;
private _maxRows: number;
private _combined = new Map<number, Map<number, string>>();
private _extended = new Map<number, Map<number, IExtendedAttrs>>();
private _cellScratchPtr: number;
constructor(cols: number, rows: number, maxRows: number) {
this._wasm = GhosttyWasmRuntime.getInstanceSync();
this._cols = cols;
this._rows = rows;
this._maxRows = maxRows;
this._handle = this._wasm.ghostty_xterm_buffer_new(cols, rows, maxRows);
if (!this._handle) {
throw new Error('Failed to allocate Ghostty xterm buffer');
}
this._cellScratchPtr = this._wasm.ghostty_wasm_alloc_u8_array(CELL_STRIDE * 4);
if (!this._cellScratchPtr) {
this._wasm.ghostty_xterm_buffer_free(this._handle);
this._handle = 0;
throw new Error('Failed to allocate Ghostty cell scratch buffer');
}
}
public dispose(): void {
if (this._handle) {
this._wasm.ghostty_xterm_buffer_free(this._handle);
this._handle = 0;
}
if (this._cellScratchPtr) {
this._wasm.ghostty_wasm_free_u8_array(this._cellScratchPtr, CELL_STRIDE * 4);
this._cellScratchPtr = 0;
}
}
public get cols(): number {
return this._cols;
}
public get rows(): number {
return this._rows;
}
public get maxRows(): number {
return this._maxRows;
}
public resize(cols: number, rows: number, maxRows: number): void {
const oldCols = this._cols;
if (!this._wasm.ghostty_xterm_buffer_resize(this._handle, cols, rows, maxRows)) {
throw new Error('Failed to resize Ghostty xterm buffer');
}
this._cols = cols;
this._rows = rows;
this._maxRows = maxRows;
if (cols < oldCols) {
for (const [row, rowMap] of this._combined) {
for (const col of rowMap.keys()) {
if (col >= cols) {
rowMap.delete(col);
}
}
if (rowMap.size === 0) {
this._combined.delete(row);
}
}
for (const [row, rowMap] of this._extended) {
for (const col of rowMap.keys()) {
if (col >= cols) {
rowMap.delete(col);
}
}
if (rowMap.size === 0) {
this._extended.delete(row);
}
}
}
}
public clear(): void {
this._wasm.ghostty_xterm_buffer_clear(this._handle);
this._combined.clear();
this._extended.clear();
}
public getCell(row: number, col: number): [number, number, number] {
this._wasm.ghostty_xterm_buffer_get_cell(this._handle, row, col, this._cellScratchPtr);
const view = new DataView(this._wasm.memory.buffer, this._cellScratchPtr, CELL_STRIDE * 4);
const content = view.getUint32(0, true);
const fg = view.getUint32(4, true);
const bg = view.getUint32(8, true);
return [content, fg, bg];
}
public setCell(row: number, col: number, content: number, fg: number, bg: number): void {
this._wasm.ghostty_xterm_buffer_set_cell(this._handle, row, col, content, fg, bg);
}
public clearRow(row: number, content: number, fg: number, bg: number): void {
this._wasm.ghostty_xterm_buffer_clear_row(this._handle, row, content, fg, bg);
this._combined.delete(row);
this._extended.delete(row);
}
public copyRow(srcRow: number, dstRow: number): void {
this._wasm.ghostty_xterm_buffer_copy_row(this._handle, srcRow, dstRow);
const combined = this._combined.get(srcRow);
if (combined) {
this._combined.set(dstRow, new Map(combined));
} else {
this._combined.delete(dstRow);
}
const extended = this._extended.get(srcRow);
if (extended) {
this._extended.set(dstRow, new Map(extended));
} else {
this._extended.delete(dstRow);
}
}
public getRowWrap(row: number): boolean {
return this._wasm.ghostty_xterm_buffer_get_row_wrap(this._handle, row) !== 0;
}
public setRowWrap(row: number, wrap: boolean): void {
this._wasm.ghostty_xterm_buffer_set_row_wrap(this._handle, row, wrap ? 1 : 0);
}
public getCombined(row: number, col: number): string | undefined {
return this._combined.get(row)?.get(col);
}
public setCombined(row: number, col: number, data: string): void {
let rowMap = this._combined.get(row);
if (!rowMap) {
rowMap = new Map();
this._combined.set(row, rowMap);
}
rowMap.set(col, data);
}
public clearCombined(row: number, col: number): void {
const rowMap = this._combined.get(row);
if (!rowMap) {
return;
}
rowMap.delete(col);
if (rowMap.size === 0) {
this._combined.delete(row);
}
}
public getExtended(row: number, col: number): IExtendedAttrs | undefined {
return this._extended.get(row)?.get(col);
}
public getExtendedRow(row: number): Map<number, IExtendedAttrs> | undefined {
return this._extended.get(row);
}
public setExtended(row: number, col: number, data: IExtendedAttrs): void {
let rowMap = this._extended.get(row);
if (!rowMap) {
rowMap = new Map();
this._extended.set(row, rowMap);
}
rowMap.set(col, data);
}
public clearExtended(row: number, col: number): void {
const rowMap = this._extended.get(row);
if (!rowMap) {
return;
}
rowMap.delete(col);
if (rowMap.size === 0) {
this._extended.delete(row);
}
}
}
+831
View File
@@ -0,0 +1,831 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, IApcHandler, IApcParser, ApcFallbackHandlerType } from 'common/parser/Types';
import { OscParser } from 'common/parser/OscParser';
import { DcsParser } from 'common/parser/DcsParser';
import { ApcParser } from 'common/parser/ApcParser';
import { Params } from 'common/parser/Params';
import { StringToUtf32 } from 'common/input/TextDecoder';
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
const enum RawEventTag {
PRINT = 0,
EXECUTE = 1,
ESC = 2,
CSI = 3,
OSC = 4,
DCS_HOOK = 5,
DCS_PUT = 6,
DCS_UNHOOK = 7,
APC_START = 8,
APC_PUT = 9,
APC_END = 10
}
export class GhosttyWasmParser extends Disposable implements IEscapeSequenceParser {
public precedingJoinState = 0;
private _printHandler: PrintHandlerType;
private _executeHandlers: { [flag: number]: ExecuteHandlerType };
private _csiHandlers: IHandlerCollection<CsiHandlerType>;
private _escHandlers: IHandlerCollection<EscHandlerType>;
private readonly _oscParser: OscParser;
private readonly _dcsParser: IDcsParser;
private readonly _apcParser: IApcParser;
private _errorHandler: (state: IParsingState) => IParsingState;
private _printHandlerFb: PrintFallbackHandlerType;
private _executeHandlerFb: ExecuteFallbackHandlerType;
private _csiHandlerFb: CsiFallbackHandlerType;
private _escHandlerFb: EscFallbackHandlerType;
private _errorHandlerFb: (state: IParsingState) => IParsingState;
private _parseStack: IParserStackState = {
state: ParserStackType.NONE,
handlers: [],
handlerPos: 0,
transition: 0,
chunkPos: 0
};
private _wasm?: IGhosttyWasmExports;
private _parserHandle = 0;
private _wasmReady?: Promise<boolean>;
private _eventSize = 0;
private _eventMaxParams = 0;
private _eventMaxIntermediates = 0;
private readonly _ptrSize = 4;
private _eventIndex = 0;
private _pendingEvents: { ptr: number, len: number } | undefined;
private _resumeParams: IParams | undefined;
private _textEncoder = new TextEncoder();
private _textDecoder = new TextDecoder('utf-8');
private _utf32Scratch = new Uint32Array(4096);
private _oscBuffer = new StringToUtf32();
private _dcsPutBuffer = new Uint32Array(1);
private _apcPutBuffer = new Uint32Array(1);
constructor() {
super();
this._printHandlerFb = () => { };
this._executeHandlerFb = () => { };
this._csiHandlerFb = () => { };
this._escHandlerFb = () => { };
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
this._printHandler = this._printHandlerFb;
this._executeHandlers = Object.create(null);
this._csiHandlers = Object.create(null);
this._escHandlers = Object.create(null);
this._oscParser = this._register(new OscParser());
this._dcsParser = this._register(new DcsParser());
this._apcParser = this._register(new ApcParser());
this._errorHandler = this._errorHandlerFb;
this._register(toDisposable(() => {
this._csiHandlers = Object.create(null);
this._executeHandlers = Object.create(null);
this._escHandlers = Object.create(null);
}));
this._register(toDisposable(() => {
if (this._wasm && this._parserHandle) {
this._wasm.ghostty_raw_parser_free(this._parserHandle);
}
this._parserHandle = 0;
}));
this.registerEscHandler({ final: '\\' }, () => true);
}
public identToString(ident: number): string {
const res: string[] = [];
while (ident) {
res.push(String.fromCharCode(ident & 0xFF));
ident >>= 8;
}
return res.reverse().join('');
}
public setPrintHandler(handler: PrintHandlerType): void {
this._printHandler = handler;
}
public clearPrintHandler(): void {
this._printHandler = this._printHandlerFb;
}
public registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable {
const ident = this._identifier(id, [0x30, 0x7e]);
this._escHandlers[ident] ??= [];
const handlerList = this._escHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearEscHandler(id: IFunctionIdentifier): void {
if (this._escHandlers[this._identifier(id, [0x30, 0x7e])]) delete this._escHandlers[this._identifier(id, [0x30, 0x7e])];
}
public setEscHandlerFallback(handler: EscFallbackHandlerType): void {
this._escHandlerFb = handler;
}
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
this._executeHandlers[flag.charCodeAt(0)] = handler;
}
public clearExecuteHandler(flag: string): void {
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
}
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
this._executeHandlerFb = handler;
}
public registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable {
const ident = this._identifier(id);
this._csiHandlers[ident] ??= [];
const handlerList = this._csiHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearCsiHandler(id: IFunctionIdentifier): void {
if (this._csiHandlers[this._identifier(id)]) delete this._csiHandlers[this._identifier(id)];
}
public setCsiHandlerFallback(callback: CsiFallbackHandlerType): void {
this._csiHandlerFb = callback;
}
public registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable {
return this._dcsParser.registerHandler(this._identifier(id), handler);
}
public clearDcsHandler(id: IFunctionIdentifier): void {
this._dcsParser.clearHandler(this._identifier(id));
}
public setDcsHandlerFallback(handler: DcsFallbackHandlerType): void {
this._dcsParser.setHandlerFallback(handler);
}
public registerOscHandler(ident: number, handler: IOscHandler): IDisposable {
return this._oscParser.registerHandler(ident, handler);
}
public clearOscHandler(ident: number): void {
this._oscParser.clearHandler(ident);
}
public setOscHandlerFallback(handler: OscFallbackHandlerType): void {
this._oscParser.setHandlerFallback(handler);
}
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
return this._apcParser.registerHandler(ident, handler);
}
public clearApcHandler(ident: number): void {
this._apcParser.clearHandler(ident);
}
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
this._apcParser.setHandlerFallback(handler);
}
public setErrorHandler(handler: (state: IParsingState) => IParsingState): void {
this._errorHandler = handler;
}
public clearErrorHandler(): void {
this._errorHandler = this._errorHandlerFb;
}
public reset(): void {
this._oscParser.reset();
this._dcsParser.reset();
this._apcParser.reset();
this.precedingJoinState = 0;
if (this._wasm && this._parserHandle) {
this._wasm.ghostty_raw_parser_reset(this._parserHandle);
}
if (this._parseStack.state !== ParserStackType.NONE) {
this._parseStack.state = ParserStackType.RESET;
this._parseStack.handlers = [];
}
}
public parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise<boolean> {
if (!this._wasm) {
this._wasmReady ??= this._initWasm();
return this._wasmReady;
}
if (this._parseStack.state !== ParserStackType.NONE) {
return this._resume(promiseResult);
}
const bytes = this._encodeUtf32(data, length);
const result = this._writeToWasm(bytes);
if (result) {
return result;
}
return this._drainEvents();
}
private _resume(promiseResult?: boolean): void | Promise<boolean> {
if (promiseResult === undefined || this._parseStack.state === ParserStackType.FAIL) {
this._parseStack.state = ParserStackType.FAIL;
throw new Error('improper continuation due to previous async handler, giving up parsing');
}
switch (this._parseStack.state) {
case ParserStackType.CSI:
case ParserStackType.ESC:
return this._resumeHandlerLoop(promiseResult);
case ParserStackType.OSC:
return this._resumeOsc(promiseResult);
case ParserStackType.DCS:
return this._resumeDcs(promiseResult);
case ParserStackType.APC:
return this._resumeApc(promiseResult);
case ParserStackType.RESET:
this._parseStack.state = ParserStackType.NONE;
return;
default:
this._parseStack.state = ParserStackType.NONE;
return;
}
}
private _resumeHandlerLoop(promiseResult: boolean): void | Promise<boolean> {
const handlers = this._parseStack.handlers as CsiHandlerType[] | EscHandlerType[];
let handlerPos = this._parseStack.handlerPos - 1;
if (promiseResult === false && handlerPos > -1) {
if (this._parseStack.state === ParserStackType.CSI) {
const params = this._resumeParams ?? new Params();
for (; handlerPos >= 0; handlerPos--) {
const handlerResult = (handlers as CsiHandlerType[])[handlerPos](params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
} else {
for (; handlerPos >= 0; handlerPos--) {
const handlerResult = (handlers as EscHandlerType[])[handlerPos]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
}
}
this._resumeParams = undefined;
this._parseStack.handlers = [];
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeOsc(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._oscParser.end(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeDcs(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._dcsParser.unhook(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _resumeApc(promiseResult: boolean): void | Promise<boolean> {
const handlerResult = this._apcParser.end(true, promiseResult);
if (handlerResult) {
return handlerResult as Promise<boolean>;
}
this._parseStack.state = ParserStackType.NONE;
return this._continueAfterAsync();
}
private _identifier(id: IFunctionIdentifier, finalRange: number[] = [0x40, 0x7e]): number {
let res = 0;
if (id.prefix) {
if (id.prefix.length > 1) {
throw new Error('only one byte as prefix supported');
}
res = id.prefix.charCodeAt(0);
if (res && 0x3c > res || res > 0x3f) {
throw new Error('prefix must be in range 0x3c .. 0x3f');
}
}
if (id.intermediates) {
if (id.intermediates.length > 2) {
throw new Error('only two bytes as intermediates are supported');
}
for (let i = 0; i < id.intermediates.length; ++i) {
const intermediate = id.intermediates.charCodeAt(i);
if (0x20 > intermediate || intermediate > 0x2f) {
throw new Error('intermediate must be in range 0x20 .. 0x2f');
}
res <<= 8;
res |= intermediate;
}
}
if (id.final.length !== 1) {
throw new Error('final must be a single byte');
}
const finalCode = id.final.charCodeAt(0);
if (finalRange[0] > finalCode || finalCode > finalRange[1]) {
throw new Error(`final must be in range ${finalRange[0]} .. ${finalRange[1]}`);
}
res <<= 8;
res |= finalCode;
return res;
}
private async _initWasm(): Promise<boolean> {
this._wasm = await GhosttyWasmRuntime.getInstance();
this._parserHandle = this._wasm.ghostty_raw_parser_new(0);
this._eventSize = this._wasm.ghostty_raw_parser_event_size();
this._eventMaxParams = this._wasm.ghostty_raw_parser_max_params();
this._eventMaxIntermediates = this._wasm.ghostty_raw_parser_max_intermediates();
return true;
}
private _encodeUtf32(data: Uint32Array, length: number): Uint8Array {
if (length === 0) {
return new Uint8Array(0);
}
const bytes: number[] = [];
for (let i = 0; i < length; i++) {
const codePoint = data[i];
if (codePoint === 0x9c) {
bytes.push(0x1b, 0x5c);
continue;
}
if (codePoint <= 0x7f || (codePoint >= 0x80 && codePoint <= 0x9f)) {
bytes.push(codePoint);
} else if (codePoint <= 0x7ff) {
bytes.push(
0xc0 | (codePoint >> 6),
0x80 | (codePoint & 0x3f)
);
} else if (codePoint <= 0xffff) {
bytes.push(
0xe0 | (codePoint >> 12),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
);
} else {
bytes.push(
0xf0 | (codePoint >> 18),
0x80 | ((codePoint >> 12) & 0x3f),
0x80 | ((codePoint >> 6) & 0x3f),
0x80 | (codePoint & 0x3f)
);
}
}
return new Uint8Array(bytes);
}
private _writeToWasm(bytes: Uint8Array): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle) {
return;
}
if (!bytes.length) {
return;
}
const dataPtr = this._wasm.ghostty_wasm_alloc_u8_array(bytes.length);
if (!dataPtr) {
return;
}
const mem = new Uint8Array(this._wasm.memory.buffer, dataPtr, bytes.length);
mem.set(bytes);
this._wasm.ghostty_raw_parser_write(this._parserHandle, dataPtr, bytes.length);
this._wasm.ghostty_wasm_free_u8_array(dataPtr, bytes.length);
}
private _drainEvents(): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle) {
return;
}
const eventsPtr = this._wasm.ghostty_raw_parser_events_ptr(this._parserHandle);
const eventsLen = this._wasm.ghostty_raw_parser_events_len(this._parserHandle);
if (!eventsPtr || !eventsLen) {
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
return;
}
this._pendingEvents = { ptr: eventsPtr, len: eventsLen };
this._eventIndex = 0;
const result = this._processEvents();
if (result) {
return result;
}
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
this._pendingEvents = undefined;
}
private _continueAfterAsync(): void | Promise<boolean> {
if (!this._wasm || !this._parserHandle || !this._pendingEvents) {
return;
}
this._eventIndex++;
const result = this._processEvents();
if (result) {
return result;
}
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
this._pendingEvents = undefined;
}
private _processEvents(): void | Promise<boolean> {
if (!this._pendingEvents || !this._wasm) {
return;
}
const { ptr, len } = this._pendingEvents;
const mem = this._wasm.memory.buffer;
const view = new DataView(mem);
let textStart = 0;
let textLen = 0;
for (; this._eventIndex < len; this._eventIndex++) {
const base = ptr + this._eventIndex * this._eventSize;
const tag = view.getInt32(base, true);
if (tag !== RawEventTag.PRINT && textLen > 0) {
this._flushText(view, ptr, textStart, textLen);
textLen = 0;
}
switch (tag) {
case RawEventTag.PRINT: {
if (textLen === 0) {
textStart = this._eventIndex;
}
textLen++;
break;
}
case RawEventTag.EXECUTE: {
const code = view.getUint32(base + 4, true);
this._handleExecute(code);
break;
}
case RawEventTag.ESC: {
const event = this._readEvent(view, base);
const ident = this._buildIdent(event.intermediates, event.final, false, [0x30, 0x7e]);
const handlers = this._escHandlers[ident];
const handlerResult = this._callEscHandlers(handlers, ident);
if (handlerResult) {
return handlerResult;
}
break;
}
case RawEventTag.CSI: {
const event = this._readEvent(view, base);
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
const ident = this._buildIdent(event.intermediates, event.final, true);
const handlers = this._csiHandlers[ident];
const handlerResult = this._callCsiHandlers(handlers, ident, params);
if (handlerResult) {
return handlerResult;
}
break;
}
case RawEventTag.OSC: {
const event = this._readEvent(view, base);
const oscResult = this._handleOsc(event.code, event.dataPtr, event.dataLen);
if (oscResult) {
return oscResult;
}
break;
}
case RawEventTag.DCS_HOOK: {
const event = this._readEvent(view, base);
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
const ident = this._buildIdent(event.intermediates, event.final, true);
this._dcsParser.hook(ident, params);
this.precedingJoinState = 0;
break;
}
case RawEventTag.DCS_PUT: {
const code = view.getUint32(base + 4, true);
this._dcsPutBuffer[0] = code;
this._dcsParser.put(this._dcsPutBuffer, 0, 1);
break;
}
case RawEventTag.DCS_UNHOOK: {
const dcsResult = this._dcsParser.unhook(true);
if (dcsResult) {
this._parseStack.state = ParserStackType.DCS;
this.precedingJoinState = 0;
return dcsResult as Promise<boolean>;
}
this.precedingJoinState = 0;
break;
}
case RawEventTag.APC_START: {
this._apcParser.start();
this.precedingJoinState = 0;
break;
}
case RawEventTag.APC_PUT: {
const code = view.getUint32(base + 4, true);
this._apcPutBuffer[0] = code;
this._apcParser.put(this._apcPutBuffer, 0, 1);
break;
}
case RawEventTag.APC_END: {
const apcResult = this._apcParser.end(true);
if (apcResult) {
this._parseStack.state = ParserStackType.APC;
this.precedingJoinState = 0;
return apcResult as Promise<boolean>;
}
this.precedingJoinState = 0;
break;
}
}
}
if (textLen > 0) {
this._flushText(view, ptr, textStart, textLen);
}
return;
}
private _flushText(view: DataView, ptr: number, start: number, count: number): void {
let remaining = count;
let offset = 0;
while (remaining > 0) {
const chunk = Math.min(remaining, this._utf32Scratch.length);
for (let i = 0; i < chunk; i++) {
const base = ptr + (start + offset + i) * this._eventSize;
const code = view.getUint32(base + 4, true);
this._utf32Scratch[i] = code;
}
this._printHandler(this._utf32Scratch, 0, chunk);
remaining -= chunk;
offset += chunk;
}
}
private _handleExecute(code: number): void {
if (this._executeHandlers[code]) {
this._executeHandlers[code]();
} else {
this._executeHandlerFb(code);
}
this.precedingJoinState = 0;
}
private _callEscHandlers(handlers: EscHandlerType[] | undefined, ident: number): void | Promise<boolean> {
if (!handlers) {
this._escHandlerFb(ident);
this.precedingJoinState = 0;
return;
}
let j = handlers.length - 1;
for (; j >= 0; j--) {
const handlerResult = handlers[j]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.state = ParserStackType.ESC;
this._parseStack.handlers = handlers;
this._parseStack.handlerPos = j;
this.precedingJoinState = 0;
return handlerResult;
}
}
if (j < 0) {
this._escHandlerFb(ident);
}
this.precedingJoinState = 0;
}
private _callCsiHandlers(handlers: CsiHandlerType[] | undefined, ident: number, params: IParams): void | Promise<boolean> {
if (!handlers) {
this._csiHandlerFb(ident, params);
this.precedingJoinState = 0;
return;
}
let j = handlers.length - 1;
for (; j >= 0; j--) {
const handlerResult = handlers[j](params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.state = ParserStackType.CSI;
this._resumeParams = params;
this._parseStack.handlers = handlers;
this._parseStack.handlerPos = j;
this.precedingJoinState = 0;
return handlerResult;
}
}
if (j < 0) {
this._csiHandlerFb(ident, params);
}
this.precedingJoinState = 0;
}
private _handleOsc(oscId: number, dataPtr: number, dataLen: number): void | Promise<boolean> {
console.log('handle osc');
if (!this._wasm) {
return;
}
let payload = '';
if (dataLen > 0) {
if (!dataPtr || dataPtr + dataLen > this._wasm.memory.buffer.byteLength) {
return;
}
const bytes = new Uint8Array(this._wasm.memory.buffer, dataPtr, dataLen);
payload = this._textDecoder.decode(bytes);
}
if (payload.indexOf('\u0000') !== -1) {
if (oscId === 8) {
const parts = payload.split('\u0000').filter((part, index, all) => {
if (part.length > 0) {
return true;
}
return index < all.length - 1;
});
if (parts.length > 0) {
const uri = parts[parts.length - 1] ?? '';
const params = parts.slice(0, -1).join(':');
payload = `${params};${uri}`;
} else {
payload = '';
}
} else {
payload = payload.replace(/\u0000+/g, '');
}
}
const oscPrefix = `${oscId};`;
const rawPayload = payload.startsWith(oscPrefix)
? payload.slice(oscPrefix.length)
: payload;
let oscPayload = rawPayload;
if (oscId === 8 && oscPayload && !oscPayload.startsWith(';')) {
const sepIndex = oscPayload.indexOf(';');
const paramsPart = sepIndex === -1 ? oscPayload : oscPayload.slice(0, sepIndex);
if (!paramsPart.includes('=')) {
oscPayload = `;${oscPayload}`;
}
}
const oscString = `${oscPrefix}${oscPayload}`;
this._oscParser.start();
const length = this._decodeToScratch(oscString);
this._oscParser.put(this._utf32Scratch, 0, length);
const handlerResult = this._oscParser.end(true);
if (handlerResult) {
this._parseStack.state = ParserStackType.OSC;
this.precedingJoinState = 0;
return handlerResult as Promise<boolean>;
}
this.precedingJoinState = 0;
}
private _buildParams(params: Uint16Array, length: number, sepBits: number): Params {
const result = new Params(this._eventMaxParams, 256);
if (length === 0) {
result.addParam(0);
return result;
}
let i = 0;
while (i < length) {
result.addParam(params[i]);
if (sepBits & (1 << i)) {
i++;
while (i < length) {
result.addSubParam(params[i]);
if ((sepBits & (1 << i)) === 0) {
break;
}
i++;
}
}
i++;
}
return result;
}
private _buildIdent(intermediates: string, final: string, allowPrefix: boolean, finalRange: number[] = [0x40, 0x7e]): number {
let prefix: string | undefined;
let inters = intermediates;
if (allowPrefix && intermediates.length) {
const first = intermediates.charCodeAt(0);
if (first >= 0x3c && first <= 0x3f) {
prefix = intermediates.charAt(0);
inters = intermediates.substring(1);
}
}
return this._identifier({ prefix, intermediates: inters || undefined, final }, finalRange);
}
private _readEvent(view: DataView, base: number): {
final: string;
intermediates: string;
params: Uint16Array;
paramsLen: number;
paramsSep: number;
code: number;
dataPtr: number;
dataLen: number;
} {
const code = view.getUint32(base + 4, true);
const final = String.fromCharCode(view.getUint8(base + 8));
const intermediatesLen = Math.min(view.getUint8(base + 9), this._eventMaxIntermediates);
const paramsLen = Math.min(view.getUint8(base + 10), this._eventMaxParams);
const paramsSep = view.getUint32(base + 12, true);
let intermediates = '';
const interOffset = base + 16;
for (let i = 0; i < intermediatesLen; i++) {
intermediates += String.fromCharCode(view.getUint8(interOffset + i));
}
const paramsOffset = base + 16 + this._eventMaxIntermediates;
const params = new Uint16Array(paramsLen);
for (let i = 0; i < paramsLen; i++) {
params[i] = view.getUint16(paramsOffset + i * 2, true);
}
const rawDataPtrOffset = paramsOffset + this._eventMaxParams * 2;
const dataPtrOffset = (rawDataPtrOffset + (this._ptrSize - 1)) & ~(this._ptrSize - 1);
const dataPtr = view.getUint32(dataPtrOffset, true);
const dataLen = view.getUint32(dataPtrOffset + this._ptrSize, true);
return {
final,
intermediates,
params,
paramsLen,
paramsSep,
code,
dataPtr,
dataLen
};
}
private _decodeToScratch(text: string): number {
if (this._utf32Scratch.length < text.length * 2) {
this._utf32Scratch = new Uint32Array(text.length * 2);
}
return this._oscBuffer.decode(text, this._utf32Scratch);
}
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
/* eslint-disable @typescript-eslint/naming-convention */
export interface IGhosttyWasmExports {
memory: WebAssembly.Memory;
ghostty_raw_parser_new(allocPtr: number): number;
ghostty_raw_parser_free(parser: number): void;
ghostty_raw_parser_reset(parser: number): void;
ghostty_raw_parser_write(parser: number, dataPtr: number, len: number): void;
ghostty_raw_parser_events_ptr(parser: number): number;
ghostty_raw_parser_events_len(parser: number): number;
ghostty_raw_parser_events_clear(parser: number): void;
ghostty_raw_parser_event_size(): number;
ghostty_raw_parser_max_params(): number;
ghostty_raw_parser_max_intermediates(): number;
ghostty_wasm_alloc_u8_array(len: number): number;
ghostty_wasm_free_u8_array(ptr: number, len: number): void;
ghostty_xterm_buffer_new(cols: number, rows: number, maxRows: number): number;
ghostty_xterm_buffer_free(buffer: number): void;
ghostty_xterm_buffer_resize(buffer: number, cols: number, rows: number, maxRows: number): number;
ghostty_xterm_buffer_clear(buffer: number): void;
ghostty_xterm_buffer_get_cols(buffer: number): number;
ghostty_xterm_buffer_get_rows(buffer: number): number;
ghostty_xterm_buffer_get_max_rows(buffer: number): number;
ghostty_xterm_buffer_get_cell(buffer: number, row: number, col: number, outPtr: number): void;
ghostty_xterm_buffer_set_cell(buffer: number, row: number, col: number, content: number, fg: number, bg: number): void;
ghostty_xterm_buffer_clear_row(buffer: number, row: number, content: number, fg: number, bg: number): void;
ghostty_xterm_buffer_copy_row(buffer: number, srcRow: number, dstRow: number): void;
ghostty_xterm_buffer_get_row_wrap(buffer: number, row: number): number;
ghostty_xterm_buffer_set_row_wrap(buffer: number, row: number, wrap: number): void;
}
/* eslint-enable @typescript-eslint/naming-convention */
export class GhosttyWasmRuntime {
private static _instancePromise: Promise<IGhosttyWasmExports> | undefined;
private static _instanceSync: IGhosttyWasmExports | undefined;
public static getInstance(): Promise<IGhosttyWasmExports> {
this._instancePromise ??= this._loadAsync();
return this._instancePromise;
}
public static getInstanceSync(): IGhosttyWasmExports {
this._instanceSync ??= this._loadSync();
return this._instanceSync;
}
private static async _loadAsync(): Promise<IGhosttyWasmExports> {
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
if (isNode) {
const requireFn = (globalThis as any).require
?? (globalThis as any).process?.mainModule?.require
?? (globalThis as any).__xtermRequire;
const fs = requireFn?.('fs') as { readFile(path: string, cb: (err: Error | null, data?: Uint8Array) => void): void } | undefined;
if (!fs) {
throw new Error('Node.js async WASM loading requires require("fs")');
}
const wasmBytes = await new Promise<Uint8Array>((resolve, reject) => {
fs.readFile(wasmUrl, (err, data) => {
if (err || !data) {
reject(err ?? new Error(`Failed to load Ghostty WASM from ${wasmUrl}`));
return;
}
resolve(data);
});
});
const env = { log: () => {} };
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
const response = await fetch(wasmUrl);
if (!response.ok) {
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
}
const wasmBytes = await response.arrayBuffer();
const env = { log: () => {} };
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
private static _loadSync(): IGhosttyWasmExports {
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
if (!isNode && typeof document !== 'undefined') {
const request = new XMLHttpRequest();
request.open('GET', wasmUrl, false);
request.overrideMimeType('text/plain; charset=x-user-defined');
request.send(null);
if (request.status !== 200 || !request.responseText) {
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
}
const binary = request.responseText;
const wasmBytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
wasmBytes[i] = binary.charCodeAt(i) & 0xFF;
}
const module = new WebAssembly.Module(wasmBytes);
const env = { log: () => {} };
const instance = new WebAssembly.Instance(module, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
// Node.js sync load for tests and headless usage.
const requireFn = (globalThis as any).require
?? (globalThis as any).process?.mainModule?.require
?? (globalThis as any).__xtermRequire;
const fs = requireFn?.('fs') as { readFileSync(path: string): Uint8Array } | undefined;
if (!fs) {
throw new Error('Node.js sync WASM loading requires require("fs")');
}
const wasmBytes = fs.readFileSync(wasmUrl);
const module = new WebAssembly.Module(wasmBytes);
const env = { log: () => {} };
const instance = new WebAssembly.Instance(module, { env });
return instance.exports as unknown as IGhosttyWasmExports;
}
private static _getWasmUrl(): string {
const override = (globalThis as any).XTERM_GHOSTTY_WASM_URL as string | undefined;
if (override) {
return override;
}
if (typeof document !== 'undefined') {
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
}
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
}
}
Vendored Submodule
+1
Submodule vendor/ghostty added at 64f78b7063
Binary file not shown.