feat: upgrade to ghostty-web 0.4.0-ime-fix from rcarmo/ghostty-web
- Vendor patched version with native theme/palette support at WASM level - Remove color remapping patches (no longer needed) - Pre-load Ghostty WASM before terminal creation - Bundle size reduced from 1.16 MB to 0.67 MB - Includes IME input fixes Bump version to 0.7.0
This commit is contained in:
Generated
+33
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "textual-webterm-frontend",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "textual-webterm-frontend",
|
||||||
|
"dependencies": {
|
||||||
|
"ghostty-web": "github:rcarmo/ghostty-web"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ghostty-web": {
|
||||||
|
"version": "0.4.0-ime-fix",
|
||||||
|
"resolved": "git+ssh://git@github.com/rcarmo/ghostty-web.git#50fc9127151f7d9d20d5c7bfaea8a6dba8b15bf5",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ghostty-web": "^0.1.0"
|
"ghostty-web": "github:rcarmo/ghostty-web"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.0"
|
"typescript": "^5.7.0"
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual-webterm"
|
name = "textual-webterm"
|
||||||
version = "0.6.9"
|
version = "0.7.0"
|
||||||
description = "Serve terminal sessions over the web"
|
description = "Serve terminal sessions over the web"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -6,7 +6,7 @@
|
|||||||
* - Server → Client: ["stdout", data], ["pong", data], or binary frames
|
* - Server → Client: ["stdout", data], ["pong", data], or binary frames
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Terminal, FitAddon, type ITerminalOptions, type ITheme } from "ghostty-web";
|
import { Terminal, FitAddon, Ghostty, type ITerminalOptions, type ITheme } from "ghostty-web";
|
||||||
|
|
||||||
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
/** Default font stack - prefers system monospace, falls back through programming fonts */
|
||||||
const DEFAULT_FONT_FAMILY =
|
const DEFAULT_FONT_FAMILY =
|
||||||
@@ -275,187 +275,6 @@ const THEMES: Record<string, ITheme> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* ghostty-web's internal default palette (Tomorrow Night theme).
|
|
||||||
* This is what the WASM terminal uses to resolve ANSI color codes to RGB.
|
|
||||||
* We need to know these to remap them to our custom theme.
|
|
||||||
*/
|
|
||||||
const GHOSTTY_DEFAULT_PALETTE: ITheme = {
|
|
||||||
foreground: "#c5c8c6",
|
|
||||||
background: "#1d1f21",
|
|
||||||
cursor: "#c5c8c6",
|
|
||||||
cursorAccent: "#1d1f21",
|
|
||||||
selectionBackground: "#373b41",
|
|
||||||
black: "#1d1f21",
|
|
||||||
red: "#cc6666",
|
|
||||||
green: "#b5bd68",
|
|
||||||
yellow: "#f0c674",
|
|
||||||
blue: "#81a2be",
|
|
||||||
magenta: "#b294bb",
|
|
||||||
cyan: "#8abeb7",
|
|
||||||
white: "#c5c8c6",
|
|
||||||
brightBlack: "#969896",
|
|
||||||
brightRed: "#cc6666",
|
|
||||||
brightGreen: "#b5bd68",
|
|
||||||
brightYellow: "#f0c674",
|
|
||||||
brightBlue: "#81a2be",
|
|
||||||
brightMagenta: "#b294bb",
|
|
||||||
brightCyan: "#8abeb7",
|
|
||||||
brightWhite: "#ffffff",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Convert hex color to RGB tuple */
|
|
||||||
function hexToRgb(hex: string): [number, number, number] {
|
|
||||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
|
||||||
if (!result) return [0, 0, 0];
|
|
||||||
return [
|
|
||||||
parseInt(result[1], 16),
|
|
||||||
parseInt(result[2], 16),
|
|
||||||
parseInt(result[3], 16),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build a fast lookup table for color remapping.
|
|
||||||
* Uses a flat Uint8Array indexed by packed RGB (r << 16 | g << 8 | b) % tableSize.
|
|
||||||
* Returns null for unmapped colors (non-palette colors like 256-color/truecolor).
|
|
||||||
*/
|
|
||||||
function buildColorLookup(theme: ITheme): {
|
|
||||||
lookup: Uint32Array;
|
|
||||||
hasMapping: Uint8Array;
|
|
||||||
} {
|
|
||||||
// Use a hash table with separate chaining avoided by using unique keys
|
|
||||||
// Since we only have 18 colors, a small table with direct indexing works
|
|
||||||
const TABLE_SIZE = 65536; // 2^16 - good for sparse lookups
|
|
||||||
const lookup = new Uint32Array(TABLE_SIZE);
|
|
||||||
const hasMapping = new Uint8Array(TABLE_SIZE);
|
|
||||||
|
|
||||||
const colorKeys: (keyof ITheme)[] = [
|
|
||||||
'foreground', 'background',
|
|
||||||
'black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white',
|
|
||||||
'brightBlack', 'brightRed', 'brightGreen', 'brightYellow',
|
|
||||||
'brightBlue', 'brightMagenta', 'brightCyan', 'brightWhite',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const key of colorKeys) {
|
|
||||||
const defaultColor = GHOSTTY_DEFAULT_PALETTE[key];
|
|
||||||
const themeColor = theme[key];
|
|
||||||
if (defaultColor && themeColor) {
|
|
||||||
const [dr, dg, db] = hexToRgb(defaultColor);
|
|
||||||
const [tr, tg, tb] = hexToRgb(themeColor);
|
|
||||||
// Hash: use lower bits of combined RGB
|
|
||||||
const hash = ((dr * 31 + dg) * 31 + db) & (TABLE_SIZE - 1);
|
|
||||||
// Pack theme RGB into uint32: r | g << 8 | b << 16
|
|
||||||
lookup[hash] = tr | (tg << 8) | (tb << 16);
|
|
||||||
hasMapping[hash] = 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { lookup, hasMapping };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Compute hash for RGB color (must match buildColorLookup) */
|
|
||||||
function colorHash(r: number, g: number, b: number): number {
|
|
||||||
return ((r * 31 + g) * 31 + b) & 65535;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Patch the WASM terminal's getLine method to remap colors at the source.
|
|
||||||
* This is more efficient than patching renderCell since it processes
|
|
||||||
* colors once per line fetch rather than once per cell render.
|
|
||||||
*/
|
|
||||||
function patchWasmColors(terminal: Terminal, theme: ITheme): void {
|
|
||||||
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
|
||||||
const wasmTerminal = internalTerminal.terminal as Record<string, unknown> | undefined;
|
|
||||||
|
|
||||||
if (!wasmTerminal) {
|
|
||||||
console.warn("[webterm:patch] No WASM terminal found, cannot patch colors");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { lookup, hasMapping } = buildColorLookup(theme);
|
|
||||||
console.log("[webterm:patch] Built optimized color lookup table");
|
|
||||||
|
|
||||||
// Patch getLine to remap colors in returned cells
|
|
||||||
const originalGetLine = wasmTerminal.getLine as (row: number) => Array<{
|
|
||||||
fg_r: number; fg_g: number; fg_b: number;
|
|
||||||
bg_r: number; bg_g: number; bg_b: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}> | null;
|
|
||||||
|
|
||||||
if (!originalGetLine) {
|
|
||||||
console.warn("[webterm:patch] No getLine method found on WASM terminal");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
wasmTerminal.getLine = function(row: number) {
|
|
||||||
const cells = originalGetLine.call(this, row);
|
|
||||||
if (!cells) return cells;
|
|
||||||
|
|
||||||
// Remap colors in-place for performance
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
|
||||||
const cell = cells[i];
|
|
||||||
|
|
||||||
// Remap foreground
|
|
||||||
const fgHash = colorHash(cell.fg_r, cell.fg_g, cell.fg_b);
|
|
||||||
if (hasMapping[fgHash]) {
|
|
||||||
const packed = lookup[fgHash];
|
|
||||||
cell.fg_r = packed & 0xff;
|
|
||||||
cell.fg_g = (packed >> 8) & 0xff;
|
|
||||||
cell.fg_b = (packed >> 16) & 0xff;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remap background
|
|
||||||
const bgHash = colorHash(cell.bg_r, cell.bg_g, cell.bg_b);
|
|
||||||
if (hasMapping[bgHash]) {
|
|
||||||
const packed = lookup[bgHash];
|
|
||||||
cell.bg_r = packed & 0xff;
|
|
||||||
cell.bg_g = (packed >> 8) & 0xff;
|
|
||||||
cell.bg_b = (packed >> 16) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Also patch getScrollbackLine for scrollback rendering
|
|
||||||
const originalGetScrollbackLine = wasmTerminal.getScrollbackLine as (offset: number) => Array<{
|
|
||||||
fg_r: number; fg_g: number; fg_b: number;
|
|
||||||
bg_r: number; bg_g: number; bg_b: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}> | null;
|
|
||||||
|
|
||||||
if (originalGetScrollbackLine) {
|
|
||||||
wasmTerminal.getScrollbackLine = function(offset: number) {
|
|
||||||
const cells = originalGetScrollbackLine.call(this, offset);
|
|
||||||
if (!cells) return cells;
|
|
||||||
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
|
||||||
const cell = cells[i];
|
|
||||||
|
|
||||||
const fgHash = colorHash(cell.fg_r, cell.fg_g, cell.fg_b);
|
|
||||||
if (hasMapping[fgHash]) {
|
|
||||||
const packed = lookup[fgHash];
|
|
||||||
cell.fg_r = packed & 0xff;
|
|
||||||
cell.fg_g = (packed >> 8) & 0xff;
|
|
||||||
cell.fg_b = (packed >> 16) & 0xff;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bgHash = colorHash(cell.bg_r, cell.bg_g, cell.bg_b);
|
|
||||||
if (hasMapping[bgHash]) {
|
|
||||||
const packed = lookup[bgHash];
|
|
||||||
cell.bg_r = packed & 0xff;
|
|
||||||
cell.bg_g = (packed >> 8) & 0xff;
|
|
||||||
cell.bg_b = (packed >> 16) & 0xff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("[webterm:patch] Successfully patched WASM getLine/getScrollbackLine for theme colors");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Configuration options passed via data attributes or window config */
|
/** Configuration options passed via data attributes or window config */
|
||||||
interface TerminalConfig {
|
interface TerminalConfig {
|
||||||
@@ -580,9 +399,12 @@ class WebTerminal {
|
|||||||
console.log("[webterm:create] wsUrl:", wsUrl);
|
console.log("[webterm:create] wsUrl:", wsUrl);
|
||||||
console.log("[webterm:create] Config received:", JSON.stringify(config, null, 2));
|
console.log("[webterm:create] Config received:", JSON.stringify(config, null, 2));
|
||||||
|
|
||||||
// Determine WASM path - try to find it relative to the script location
|
// Determine WASM path and pre-load Ghostty
|
||||||
const wasmPath = getWasmPath();
|
const wasmPath = getWasmPath();
|
||||||
console.log("[webterm:create] WASM path:", wasmPath);
|
console.log("[webterm:create] WASM path:", wasmPath);
|
||||||
|
console.log("[webterm:create] Loading Ghostty WASM...");
|
||||||
|
const ghostty = await Ghostty.load(wasmPath);
|
||||||
|
console.log("[webterm:create] Ghostty loaded:", ghostty);
|
||||||
|
|
||||||
// Build terminal options
|
// Build terminal options
|
||||||
const themeToUse = config.theme ?? THEMES.xterm;
|
const themeToUse = config.theme ?? THEMES.xterm;
|
||||||
@@ -595,7 +417,7 @@ class WebTerminal {
|
|||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
cursorStyle: "block",
|
cursorStyle: "block",
|
||||||
theme: themeToUse,
|
theme: themeToUse,
|
||||||
wasmPath,
|
ghostty,
|
||||||
};
|
};
|
||||||
console.log("[webterm:create] Full ITerminalOptions:", JSON.stringify(options, null, 2));
|
console.log("[webterm:create] Full ITerminalOptions:", JSON.stringify(options, null, 2));
|
||||||
|
|
||||||
@@ -609,14 +431,11 @@ class WebTerminal {
|
|||||||
console.log("[webterm:create] Loading FitAddon into terminal...");
|
console.log("[webterm:create] Loading FitAddon into terminal...");
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
|
|
||||||
// Open terminal (this loads WASM and initializes everything)
|
// Open terminal (initializes rendering - WASM already loaded)
|
||||||
console.log("[webterm:create] Calling terminal.open(container)...");
|
console.log("[webterm:create] Calling terminal.open(container)...");
|
||||||
await terminal.open(container);
|
terminal.open(container);
|
||||||
console.log("[webterm:create] terminal.open() completed");
|
console.log("[webterm:create] terminal.open() completed");
|
||||||
|
|
||||||
// Patch WASM terminal to remap colors from ghostty-web's default palette to our theme
|
|
||||||
patchWasmColors(terminal, themeToUse);
|
|
||||||
|
|
||||||
// Check internal state after open
|
// Check internal state after open
|
||||||
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
const internalTerminal = terminal as unknown as Record<string, unknown>;
|
||||||
console.log("[webterm:create] Terminal internal keys:", Object.keys(internalTerminal));
|
console.log("[webterm:create] Terminal internal keys:", Object.keys(internalTerminal));
|
||||||
|
|||||||
Reference in New Issue
Block a user