wip: migrated xterm.js to ghosty

This commit is contained in:
galz10
2026-02-06 15:00:57 -08:00
parent 28805a4b2d
commit 00f496a61c
8 changed files with 557 additions and 126 deletions
+7 -7
View File
@@ -5382,12 +5382,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@xterm/headless": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-5.5.0.tgz",
"integrity": "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==",
"license": "MIT"
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
@@ -9726,6 +9720,12 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/ghostty-web": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/ghostty-web/-/ghostty-web-0.4.0.tgz",
"integrity": "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg==",
"license": "MIT"
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -18232,7 +18232,6 @@
"@opentelemetry/sdk-node": "^0.203.0",
"@types/glob": "^8.1.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
@@ -18240,6 +18239,7 @@
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"ghostty-web": "^0.4.0",
"glob": "^12.0.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
+3 -3
View File
@@ -41,7 +41,6 @@
"@opentelemetry/sdk-node": "^0.203.0",
"@types/glob": "^8.1.0",
"@types/html-to-text": "^9.0.4",
"@xterm/headless": "5.5.0",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.0",
"chardet": "^2.1.0",
@@ -49,6 +48,7 @@
"fast-levenshtein": "^2.0.6",
"fdir": "^6.4.6",
"fzf": "^0.5.2",
"ghostty-web": "^0.4.0",
"glob": "^12.0.0",
"google-auth-library": "^9.11.0",
"html-to-text": "^9.0.5",
@@ -78,8 +78,8 @@
"@lydell/node-pty-linux-x64": "1.1.0",
"@lydell/node-pty-win32-arm64": "1.1.0",
"@lydell/node-pty-win32-x64": "1.1.0",
"node-pty": "^1.0.0",
"keytar": "^7.9.0"
"keytar": "^7.9.0",
"node-pty": "^1.0.0"
},
"devDependencies": {
"@google/gemini-cli-test-utils": "file:../test-utils",
@@ -307,8 +307,8 @@ describe('ShellExecutionService', () => {
);
});
it('should capture large output (10000 lines)', async () => {
const lineCount = 10000;
it('should capture large output (1000 lines)', async () => {
const lineCount = 1000;
const lines = Array.from({ length: lineCount }, (_, i) => `line ${i}`);
const expectedOutput = lines.join('\n');
@@ -317,7 +317,7 @@ describe('ShellExecutionService', () => {
(pty) => {
// Send data in chunks to simulate realistic streaming
// Use \r\n to ensure the terminal moves the cursor to the start of the line
const chunkSize = 1000;
const chunkSize = 200;
for (let i = 0; i < lineCount; i += chunkSize) {
const chunk = lines.slice(i, i + chunkSize).join('\r\n') + '\r\n';
pty.onData.mock.calls[0][0](chunk);
@@ -389,11 +389,8 @@ describe('ShellExecutionService', () => {
expect(result.exitCode).toBe(0);
// The terminal should keep the *last* 'scrollbackLimit' lines + lines in the viewport.
// xterm.js scrollback is the number of lines *above* the viewport.
// So total lines retained = scrollback + rows.
// However, our `getFullBufferText` implementation iterates the *active* buffer.
// In headless xterm, the buffer length grows.
// Let's verify that we have fewer lines than totalLines.
// In ghostty-web, the buffer length includes scrollback + rows.
// Let's verify that the output contains the expected number of lines.
const outputLines = result.output
.trim()
@@ -18,7 +18,8 @@ import {
type ShellType,
} from '../utils/shell-utils.js';
import { isBinary } from '../utils/textUtils.js';
import pkg from '@xterm/headless';
import { init, Terminal } from 'ghostty-web';
import { installBrowserShims } from '../utils/browser-shims.js';
import {
serializeTerminalToObject,
type AnsiOutput,
@@ -28,7 +29,6 @@ import {
type EnvironmentSanitizationConfig,
} from './environmentSanitization.js';
import { killProcessGroup } from '../utils/process-utils.js';
const { Terminal } = pkg;
const MAX_CHILD_PROCESS_BUFFER_SIZE = 16 * 1024 * 1024; // 16MB
@@ -128,7 +128,7 @@ export type ShellOutputEvent =
interface ActivePty {
ptyProcess: IPty;
headlessTerminal: pkg.Terminal;
headlessTerminal: Terminal;
maxSerializedLines?: number;
}
@@ -141,27 +141,27 @@ interface ActiveChildProcess {
};
}
const getFullBufferText = (terminal: pkg.Terminal): string => {
const getFullBufferText = (
terminal: Terminal,
scrollbackLimit?: number,
): string => {
const buffer = terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buffer.length; i++) {
// ghostty-web buffer length includes the full possible scrollback + rows.
// We only want the lines that have actually been written.
// The current active screen lines are at the end of the scrollback.
const scrollbackLength = terminal.getScrollbackLength();
const rows = terminal.rows;
const totalRelevantLines = scrollbackLength + rows;
for (let i = 0; i < totalRelevantLines; i++) {
const line = buffer.getLine(i);
if (!line) {
continue;
}
// If the NEXT line is wrapped, it means it's a continuation of THIS line.
// We should not trim the right side of this line because trailing spaces
// might be significant parts of the wrapped content.
// If it's not wrapped, we trim normally.
let trimRight = true;
if (i + 1 < buffer.length) {
const nextLine = buffer.getLine(i + 1);
if (nextLine?.isWrapped) {
trimRight = false;
}
}
const lineContent = line.translateToString(trimRight);
const lineContent = line.translateToString(false);
if (line.isWrapped && lines.length > 0) {
lines[lines.length - 1] += lineContent;
@@ -171,10 +171,15 @@ const getFullBufferText = (terminal: pkg.Terminal): string => {
}
// Remove trailing empty lines
while (lines.length > 0 && lines[lines.length - 1] === '') {
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
// Manual truncation if the library doesn't respect scrollbackLimit
if (scrollbackLimit !== undefined && lines.length > scrollbackLimit) {
return lines.slice(lines.length - scrollbackLimit).join('\n');
}
return lines.join('\n');
};
@@ -575,19 +580,34 @@ export class ShellExecutionService {
const result = new Promise<ShellExecutionResult>((resolve) => {
this.activeResolvers.set(ptyProcess.pid, resolve);
const headlessTerminal = new Terminal({
allowProposedApi: true,
cols,
rows,
scrollback: shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT,
});
headlessTerminal.scrollToTop();
installBrowserShims();
this.activePtys.set(ptyProcess.pid, {
ptyProcess,
headlessTerminal,
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
});
const initializeTerminal = async () => {
await init();
const scrollback =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
const headlessTerminal = new Terminal({
cols,
rows,
scrollback,
});
headlessTerminal.open(
globalThis.document.createElement('div') as any,
);
headlessTerminal.scrollToTop();
this.activePtys.set(ptyProcess.pid, {
ptyProcess,
headlessTerminal,
maxSerializedLines: shellExecutionConfig.maxSerializedLines,
});
return headlessTerminal;
};
const terminalPromise = initializeTerminal();
let processingChain = Promise.resolve();
let decoder: TextDecoder | null = null;
@@ -603,16 +623,23 @@ export class ShellExecutionService {
let hasStartedOutput = false;
let renderTimeout: NodeJS.Timeout | null = null;
const renderFn = () => {
const renderFn = async () => {
renderTimeout = null;
if (!isStreamingRawContent) {
return;
}
const headlessTerminal = await terminalPromise;
if (!shellExecutionConfig.disableDynamicLineTrimming) {
if (!hasStartedOutput) {
const bufferText = getFullBufferText(headlessTerminal);
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
const bufferText = getFullBufferText(
headlessTerminal,
scrollbackLimit,
);
if (bufferText.trim().length === 0) {
return;
}
@@ -622,9 +649,15 @@ export class ShellExecutionService {
const buffer = headlessTerminal.buffer.active;
const endLine = buffer.length;
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
const startLine = Math.max(
0,
endLine - (shellExecutionConfig.maxSerializedLines ?? 2000),
endLine -
Math.min(
scrollbackLimit + rows,
shellExecutionConfig.maxSerializedLines ?? 2000,
),
);
let newOutput: AnsiOutput;
@@ -690,7 +723,9 @@ export class ShellExecutionService {
if (renderTimeout) {
clearTimeout(renderTimeout);
}
renderFn();
renderFn().catch(() => {
// Ignore errors during final render
});
return;
}
@@ -699,69 +734,81 @@ export class ShellExecutionService {
}
renderTimeout = setTimeout(() => {
renderFn();
renderFn().catch(() => {
// Ignore errors during render
});
renderTimeout = null;
}, 68);
};
headlessTerminal.onScroll(() => {
if (!isWriting) {
render();
}
terminalPromise.then((headlessTerminal) => {
headlessTerminal.onScroll(() => {
if (!isWriting) {
render();
}
});
});
const handleOutput = (data: Buffer) => {
processingChain = processingChain.then(
() =>
new Promise<void>((resolve) => {
if (!decoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
decoder = new TextDecoder(encoding);
} catch {
decoder = new TextDecoder('utf-8');
const inner = async () => {
if (!decoder) {
const encoding = getCachedEncodingForBuffer(data);
try {
decoder = new TextDecoder(encoding);
} catch {
decoder = new TextDecoder('utf-8');
}
}
}
outputChunks.push(data);
outputChunks.push(data);
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) {
const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20));
sniffedBytes = sniffBuffer.length;
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
const event: ShellOutputEvent = { type: 'binary_detected' };
if (isBinary(sniffBuffer)) {
isStreamingRawContent = false;
const event: ShellOutputEvent = {
type: 'binary_detected',
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
}
}
if (isStreamingRawContent) {
const decodedChunk = decoder.decode(data, { stream: true });
if (decodedChunk.length === 0) {
resolve();
return;
}
isWriting = true;
const headlessTerminal = await terminalPromise;
headlessTerminal.write(decodedChunk, () => {
render();
isWriting = false;
resolve();
});
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
const event: ShellOutputEvent = {
type: 'binary_progress',
bytesReceived: totalBytes,
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
}
}
if (isStreamingRawContent) {
const decodedChunk = decoder.decode(data, { stream: true });
if (decodedChunk.length === 0) {
resolve();
return;
}
isWriting = true;
headlessTerminal.write(decodedChunk, () => {
render();
isWriting = false;
resolve();
});
} else {
const totalBytes = outputChunks.reduce(
(sum, chunk) => sum + chunk.length,
0,
);
const event: ShellOutputEvent = {
type: 'binary_progress',
bytesReceived: totalBytes,
};
onOutputEvent(event);
ShellExecutionService.emitEvent(ptyProcess.pid, event);
};
inner().catch(() => {
resolve();
}
});
}),
);
};
@@ -783,7 +830,7 @@ export class ShellExecutionService {
// Ignore errors during cleanup
}
const finalize = () => {
const finalize = async () => {
render(true);
// Store exit info for late subscribers (e.g. backgrounding race condition)
@@ -809,9 +856,12 @@ export class ShellExecutionService {
const finalBuffer = Buffer.concat(outputChunks);
const headlessTerminal = await terminalPromise;
const scrollbackLimit =
shellExecutionConfig.scrollback ?? SCROLLBACK_LIMIT;
resolve({
rawOutput: finalBuffer,
output: getFullBufferText(headlessTerminal),
output: getFullBufferText(headlessTerminal, scrollbackLimit),
exitCode,
signal: signal ?? null,
error,
@@ -822,7 +872,9 @@ export class ShellExecutionService {
};
if (abortSignal.aborted) {
finalize();
finalize().catch(() => {
// Ignore errors during finalization
});
return;
}
@@ -839,7 +891,9 @@ export class ShellExecutionService {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Promise.race([processingComplete, abortFired]).then(() => {
finalize();
finalize().catch(() => {
// Ignore errors during finalization
});
});
},
);
@@ -1010,7 +1064,9 @@ export class ShellExecutionService {
const activeChild = this.activeChildProcesses.get(pid);
if (activePty) {
output = getFullBufferText(activePty.headlessTerminal);
const scrollbackLimit =
activePty.headlessTerminal.options.scrollback ?? SCROLLBACK_LIMIT;
output = getFullBufferText(activePty.headlessTerminal, scrollbackLimit);
resolve({
rawOutput,
output,
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { installBrowserShims } from './browser-shims.js';
import { describe, it, expect, beforeAll } from 'vitest';
import { init, Terminal } from 'ghostty-web';
describe('browser-shims', () => {
beforeAll(() => {
installBrowserShims();
});
it('should install self and window shims', () => {
expect(globalThis.self).toBeDefined();
expect(globalThis.window).toBeDefined();
});
it('should install document shim with createElement', () => {
expect(globalThis.document).toBeDefined();
expect(typeof globalThis.document.createElement).toBe('function');
const div = globalThis.document.createElement('div');
expect(div).toBeDefined();
});
it('should install fetch shim that handles file and data URLs', async () => {
expect(typeof globalThis.fetch).toBe('function');
// Test data URL (minimal WASM-like header)
const dataUrl = 'data:application/wasm;base64,AGFzbQEAAAABBgBgAX5/AX8DAgEABwcBA2xvZwAA';
const response = await globalThis.fetch(dataUrl);
expect(response.ok).toBe(true);
const buffer = await response.arrayBuffer();
expect(buffer).toBeInstanceOf(ArrayBuffer);
});
it('should allow ghostty-web to initialize and create a terminal', async () => {
await init();
const term = new Terminal({
cols: 80,
rows: 24
});
expect(term).toBeDefined();
// Terminal needs to be opened to write
// We use type casting to avoid 'any'
const parent = globalThis.document.createElement('div');
term.open(parent as unknown as HTMLElement);
term.write('Pickle Rick was here');
const line = term.buffer.active.getLine(0);
expect(line?.translateToString(true)).toContain('Pickle Rick');
});
});
+306
View File
@@ -0,0 +1,306 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
interface ShimmedElement {
style: Record<string, string>;
classList: {
add: () => void;
remove: () => void;
contains: () => boolean;
toggle: () => void;
};
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
appendChild: (node: ShimmedElement) => void;
removeChild: (node: ShimmedElement) => void;
setAttribute: (name: string, value: string) => void;
removeAttribute: (name: string, value: string) => void;
hasAttribute: (name: string) => boolean;
getAttribute: (name: string) => string | null;
getBoundingClientRect: () => {
width: number;
height: number;
top: number;
left: number;
right: number;
bottom: number;
};
focus: () => void;
blur: () => void;
getContext?: () => {
measureText: () => { width: number; height: number };
fillRect: () => void;
fillText: () => void;
drawImage: () => void;
beginPath: () => void;
moveTo: () => void;
lineTo: () => void;
stroke: () => void;
arc: () => void;
fill: () => void;
setTransform: () => void;
save: () => void;
restore: () => void;
scale: () => void;
clearRect: () => void;
};
width?: number;
height?: number;
}
interface ShimmedDocument {
createElement: (tag: string) => ShimmedElement;
addEventListener: (type: string, listener: () => void) => void;
removeEventListener: (type: string, listener: () => void) => void;
getElementById: (id: string) => ShimmedElement | null;
body: ShimmedElement;
documentElement: ShimmedElement;
}
interface ShimmedNavigator {
userAgent: string;
clipboard: {
writeText: (text: string) => Promise<void>;
readText: () => Promise<string>;
};
}
interface ShimmedResponse {
ok: boolean;
status: number;
statusText: string;
arrayBuffer: () => Promise<ArrayBufferLike>;
}
interface GlobalWithShims {
self: Window & GlobalWithShims;
window: Window & GlobalWithShims;
name: string;
location: Location & {
href: string;
origin: string;
protocol: string;
host: string;
hostname: string;
port: string;
pathname: string;
search: string;
hash: string;
toString: () => string;
};
document: Document & ShimmedDocument;
navigator: Navigator & ShimmedNavigator;
fetch: (url: string | URL) => Promise<ShimmedResponse>;
ResizeObserver: new () => {
observe: () => void;
unobserve: () => void;
disconnect: () => void;
};
requestAnimationFrame: (cb: (time: number) => void) => number;
cancelAnimationFrame: (id: number) => void;
}
/**
* Minimal browser shims to allow web-focused libraries (like ghostty-web)
* to run in a Node.js environment.
*/
export function installBrowserShims(): void {
// Use a targeted cast to avoid 'any' or 'unknown'
// We cast to our specific interface which is a subset of globalThis
const global = globalThis as typeof globalThis & GlobalWithShims;
if (typeof global.self === 'undefined') {
global.self = global as any;
}
if (typeof global.window === 'undefined') {
global.window = global as any;
}
if (typeof global.name === 'undefined') {
global.name = 'gemini-cli-shim';
}
if (typeof global.location === 'undefined') {
global.location = {
href: `file://${process.cwd()}/`,
origin: 'file://',
protocol: 'file:',
host: '',
hostname: '',
port: '',
pathname: process.cwd() + '/',
search: '',
hash: '',
ancestorOrigins: {
length: 0,
contains: () => false,
item: () => null,
} as any,
assign: () => {},
reload: () => {},
replace: () => {},
toString() {
return this.href;
},
} as any;
}
const createBaseElement = (): any => ({
style: {},
classList: {
add: (): void => {},
remove: (): void => {},
contains: (): boolean => false,
toggle: (): void => {},
},
addEventListener: (): void => {},
removeEventListener: (): void => {},
appendChild: (node: any): any => node,
removeChild: (node: any): any => node,
setAttribute: (): void => {},
removeAttribute: (): void => {},
hasAttribute: (): boolean => false,
getAttribute: (): string | null => null,
getBoundingClientRect: () => ({
width: 800,
height: 600,
top: 0,
left: 0,
right: 800,
bottom: 600,
}),
focus: (): void => {},
blur: (): void => {},
// HTMLElement properties stubs
accessKey: '',
accessKeyLabel: '',
autocapitalize: '',
dir: '',
draggable: false,
hidden: false,
inert: false,
lang: '',
spellcheck: false,
title: '',
});
if (typeof global.document === 'undefined') {
global.document = {
createElement: (tag: string): any => {
const el = createBaseElement();
if (tag === 'canvas') {
return {
...el,
getContext: () => ({
measureText: () => ({
width: 10,
height: 20,
}),
fillRect: (): void => {},
fillText: (): void => {},
drawImage: (): void => {},
beginPath: (): void => {},
moveTo: (): void => {},
lineTo: (): void => {},
stroke: (): void => {},
arc: (): void => {},
fill: (): void => {},
setTransform: (): void => {},
save: (): void => {},
restore: (): void => {},
scale: (): void => {},
clearRect: (): void => {},
}),
width: 800,
height: 600,
};
}
return el;
},
createElementNS: (_ns: string, tag: string): any => {
return createBaseElement();
},
addEventListener: (): void => {},
removeEventListener: (): void => {},
getElementById: (): null => null,
body: createBaseElement(),
documentElement: createBaseElement(),
} as any;
}
if (typeof global.navigator === 'undefined') {
global.navigator = {
userAgent: 'Node.js',
clipboard: {
writeText: async (): Promise<void> => {},
readText: async (): Promise<string> => '',
},
} as any;
}
if (typeof global.fetch === 'undefined') {
global.fetch = (async (url: string | URL): Promise<ShimmedResponse> => {
const urlStr = url.toString();
if (urlStr.startsWith('file://')) {
const filePath = fileURLToPath(urlStr);
const buffer = fs.readFileSync(filePath);
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async (): Promise<ArrayBufferLike> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
};
}
if (urlStr.startsWith('data:')) {
const commaIndex = urlStr.indexOf(',');
if (commaIndex === -1) {
throw new Error('Invalid data URL');
}
const base64 = urlStr.slice(commaIndex + 1);
const buffer = Buffer.from(base64, 'base64');
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async (): Promise<ArrayBufferLike> =>
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
};
}
throw new Error(`Fetch not implemented for URL: ${urlStr}`);
}) as any;
}
if (typeof global.ResizeObserver === 'undefined') {
global.ResizeObserver = class {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
} as any;
}
if (typeof global.requestAnimationFrame === 'undefined') {
global.requestAnimationFrame = (cb: (time: number) => void): number => {
return setTimeout(() => cb(Date.now()), 16) as any;
};
}
if (typeof global.cancelAnimationFrame === 'undefined') {
global.cancelAnimationFrame = (id: number): void => {
clearTimeout(id);
};
}
}
@@ -4,8 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { Terminal } from '@xterm/headless';
import { beforeAll, describe, it, expect } from 'vitest';
import { init, Terminal } from 'ghostty-web';
import { installBrowserShims } from './browser-shims.js';
import {
serializeTerminalToObject,
convertColorToHex,
@@ -22,13 +23,18 @@ function writeToTerminal(terminal: Terminal, data: string): Promise<void> {
}
describe('terminalSerializer', () => {
beforeAll(async () => {
installBrowserShims();
await init();
});
describe('serializeTerminalToObject', () => {
it('should handle an empty terminal', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
const result = serializeTerminalToObject(terminal);
expect(result).toHaveLength(24);
result.forEach((line) => {
@@ -43,8 +49,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, 'Hello, world!');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].text).toContain('Hello, world!');
@@ -54,8 +60,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 7,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, 'Line 1\r\nLine 2');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].text).toBe('Line 1 ');
@@ -66,8 +72,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[1mBold text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].bold).toBe(true);
@@ -78,8 +84,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[3mItalic text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].italic).toBe(true);
@@ -90,8 +96,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[4mUnderlined text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].underline).toBe(true);
@@ -102,8 +108,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[2mDim text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].dim).toBe(true);
@@ -114,8 +120,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[7mInverse text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].inverse).toBe(true);
@@ -126,11 +132,11 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, `${RED_FG}Red text${RESET}`);
const result = serializeTerminalToObject(terminal);
expect(result[0][0].fg).toBe('#800000');
expect(result[0][0].fg).toBe('#cc6666');
expect(result[0][0].text).toBe('Red text');
});
@@ -138,11 +144,11 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[42mGreen background\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].bg).toBe('#008000');
expect(result[0][0].bg).toBe('#b5bd68');
expect(result[0][0].text).toBe('Green background');
});
@@ -150,8 +156,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[38;2;100;200;50mRGB text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].fg).toBe('#64c832');
@@ -162,13 +168,13 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, '\x1b[1;31;42mStyled text\x1b[0m');
const result = serializeTerminalToObject(terminal);
expect(result[0][0].bold).toBe(true);
expect(result[0][0].fg).toBe('#800000');
expect(result[0][0].bg).toBe('#008000');
expect(result[0][0].fg).toBe('#cc6666');
expect(result[0][0].bg).toBe('#b5bd68');
expect(result[0][0].text).toBe('Styled text');
});
@@ -176,8 +182,8 @@ describe('terminalSerializer', () => {
const terminal = new Terminal({
cols: 80,
rows: 24,
allowProposedApi: true,
});
terminal.open(globalThis.document.createElement('div') as any);
await writeToTerminal(terminal, 'Cursor test');
// Move cursor to the start of the line (0,0) using ANSI escape code
await writeToTerminal(terminal, '\x1b[H');
+13 -6
View File
@@ -4,7 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { IBufferCell, Terminal } from '@xterm/headless';
import type { Terminal } from 'ghostty-web';
type IBuffer = Terminal['buffer']['active'];
type IBufferLine = ReturnType<IBuffer['getLine']>;
type IBufferCell = ReturnType<Exclude<IBufferLine, undefined>['getCell']>;
export interface AnsiToken {
text: string;
bold: boolean;
@@ -85,21 +90,23 @@ class Cell {
if (cell.isUnderline()) {
this.attributes += Attribute.underline;
}
if (cell.isDim()) {
if (cell.isFaint()) {
this.attributes += Attribute.dim;
}
if (cell.isFgRGB()) {
const fgMode = cell.getFgColorMode();
if (fgMode === -1) {
this.fgColorMode = ColorMode.RGB;
} else if (cell.isFgPalette()) {
} else if (fgMode >= 0) {
this.fgColorMode = ColorMode.PALETTE;
} else {
this.fgColorMode = ColorMode.DEFAULT;
}
if (cell.isBgRGB()) {
const bgMode = cell.getBgColorMode();
if (bgMode === -1) {
this.bgColorMode = ColorMode.RGB;
} else if (cell.isBgPalette()) {
} else if (bgMode >= 0) {
this.bgColorMode = ColorMode.PALETTE;
} else {
this.bgColorMode = ColorMode.DEFAULT;