Fix theme support and improve tooling

- Fix ITheme property: selection -> selectionBackground (ghostty-web compat)
- Add dynamic body background color matching theme
- Add THEME_BACKGROUNDS mapping in local_server.py
- Add tsconfig.json for TypeScript type checking
- Update Makefile to use bun run for all frontend commands
- Add typecheck script to package.json (make build now typechecks)
- Add detailed console tracing for theme debugging
- Store fontFamily/fontSize in WebTerminal class for cell measurement

v0.6.5
This commit is contained in:
GitHub Copilot
2026-01-28 09:26:49 +00:00
parent 6e0b66a3ad
commit 8463b37e9e
7 changed files with 156 additions and 42 deletions
+22 -16
View File
@@ -1,4 +1,4 @@
.PHONY: help install install-dev lint format test coverage check clean clean-all build build-all bundle bundle-watch bundle-clean bump-patch .PHONY: help install install-dev lint format test coverage check clean clean-all build build-all bundle bundle-watch bundle-clean typecheck bump-patch
PYTHON ?= python3 PYTHON ?= python3
PIP ?= $(PYTHON) -m pip PIP ?= $(PYTHON) -m pip
@@ -12,9 +12,11 @@ GHOSTTY_WASM = $(STATIC_JS_DIR)/ghostty-vt.wasm
help: help:
@echo "Build targets:" @echo "Build targets:"
@echo " build-all - Full reproducible build (clean + deps + bundle + install)" @echo " build-all - Full reproducible build (clean + deps + bundle + install)"
@echo " build - Build frontend only (bundle)" @echo " build - Build frontend (typecheck + bundle)"
@echo " bundle - Build terminal.js and copy WASM" @echo " build-fast - Build frontend without typecheck"
@echo " bundle - Alias for build"
@echo " bundle-watch - Watch mode for development" @echo " bundle-watch - Watch mode for development"
@echo " typecheck - Run TypeScript type checking"
@echo "" @echo ""
@echo "Python targets:" @echo "Python targets:"
@echo " install - Install package in editable mode" @echo " install - Install package in editable mode"
@@ -34,7 +36,7 @@ help:
# Full reproducible build # Full reproducible build
# ============================================================================= # =============================================================================
build-all: clean-all node_modules bundle install-dev check build-all: clean-all node_modules build install-dev check
@echo "Build complete!" @echo "Build complete!"
# ============================================================================= # =============================================================================
@@ -64,6 +66,7 @@ check: lint coverage
# ============================================================================= # =============================================================================
# Frontend build targets (requires Bun: https://bun.sh) # Frontend build targets (requires Bun: https://bun.sh)
# All frontend commands MUST go through bun run to ensure consistency
# ============================================================================= # =============================================================================
# Install node dependencies (creates bun.lock if missing) # Install node dependencies (creates bun.lock if missing)
@@ -71,23 +74,26 @@ node_modules: package.json
bun install bun install
@touch node_modules @touch node_modules
# Build terminal.js from TypeScript # TypeScript type checking
$(TERMINAL_JS): $(TERMINAL_TS) node_modules typecheck: node_modules
bun build $(TERMINAL_TS) --outfile=$(TERMINAL_JS) --minify --target=browser bun run typecheck
# Copy WASM file from node_modules # Main build target - typecheck + bundle + copy WASM
$(GHOSTTY_WASM): node_modules build: node_modules
cp node_modules/ghostty-web/ghostty-vt.wasm $(GHOSTTY_WASM) bun run build
# Main bundle target - builds JS and copies WASM # Fast build without typecheck (for rapid iteration)
bundle: $(TERMINAL_JS) $(GHOSTTY_WASM) build-fast: node_modules
bun run build:fast
@test -f $(GHOSTTY_WASM) || bun run copy-wasm
# Alias for bundle # Alias for build
build: bundle bundle: build
# Watch mode for development # Watch mode for development
bundle-watch: $(GHOSTTY_WASM) bundle-watch: node_modules
bun build $(TERMINAL_TS) --outfile=$(TERMINAL_JS) --watch --target=browser @test -f $(GHOSTTY_WASM) || bun run copy-wasm
bun run watch
# ============================================================================= # =============================================================================
# Clean targets # Clean targets
+3 -1
View File
@@ -9,8 +9,10 @@
"typescript": "^5.7.0" "typescript": "^5.7.0"
}, },
"scripts": { "scripts": {
"build": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/", "build": "bun run typecheck && bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser && cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/",
"build:fast": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --minify --target=browser",
"watch": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --target=browser", "watch": "bun build src/textual_webterm/static/js/terminal.ts --outfile=src/textual_webterm/static/js/terminal.js --watch --target=browser",
"typecheck": "bun x tsc --noEmit -p tsconfig.json",
"copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/" "copy-wasm": "cp node_modules/ghostty-web/ghostty-vt.wasm src/textual_webterm/static/js/"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "textual-webterm" name = "textual-webterm"
version = "0.6.4" version = "0.6.5"
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"
+19 -1
View File
@@ -38,6 +38,21 @@ CLEAR_AND_REDRAW_SEQ = "\x0c" # Ctrl+L: clear and redraw
WEBTERM_STATIC_PATH = Path(__file__).parent / "static" WEBTERM_STATIC_PATH = Path(__file__).parent / "static"
# Theme background colors - must match terminal.ts THEMES
THEME_BACKGROUNDS: dict[str, str] = {
"xterm": "#000000",
"monokai": "#2d2a2e",
"ristretto": "#2d2525",
"dark": "#1e1e1e",
"light": "#ffffff",
"dracula": "#282a36",
"catppuccin": "#1e1e2e",
"nord": "#2e3440",
"solarized-dark": "#002b36",
"solarized-light": "#fdf6e3",
"tokyo-night": "#1a1b26",
}
class LocalClientConnector(SessionConnector): class LocalClientConnector(SessionConnector):
"""Local connector that handles communication between sessions and local server.""" """Local connector that handles communication between sessions and local server."""
@@ -855,6 +870,9 @@ class LocalServer:
escaped_font = self.font_family.replace('"', "&quot;") escaped_font = self.font_family.replace('"', "&quot;")
data_attrs += f' data-font-family="{escaped_font}"' data_attrs += f' data-font-family="{escaped_font}"'
# Get theme background color (fallback to black if unknown theme)
theme_bg = THEME_BACKGROUNDS.get(self.theme.lower(), "#000000")
html_content = f"""<!DOCTYPE html> html_content = f"""<!DOCTYPE html>
<html> <html>
<head> <head>
@@ -862,7 +880,7 @@ class LocalServer:
<link rel=\"stylesheet\" href=\"/static/monospace.css\"> <link rel=\"stylesheet\" href=\"/static/monospace.css\">
<style> <style>
html, body {{ width: 100%; height: 100%; }} html, body {{ width: 100%; height: 100%; }}
body {{ background: #0c181f; margin: 0; padding: 0; overflow: hidden; }} body {{ background: {theme_bg}; margin: 0; padding: 0; overflow: hidden; }}
.textual-terminal {{ width: 100%; height: 100%; display: block; overflow: hidden; }} .textual-terminal {{ width: 100%; height: 100%; display: block; overflow: hidden; }}
</style> </style>
</head> </head>
File diff suppressed because one or more lines are too long
+89 -18
View File
@@ -22,7 +22,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#e5e5e5", foreground: "#e5e5e5",
cursor: "#e5e5e5", cursor: "#e5e5e5",
cursorAccent: "#000000", cursorAccent: "#000000",
selection: "#4d4d4d", selectionBackground: "#4d4d4d",
black: "#000000", black: "#000000",
red: "#cd0000", red: "#cd0000",
green: "#00cd00", green: "#00cd00",
@@ -46,7 +46,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#fcfcfa", foreground: "#fcfcfa",
cursor: "#fcfcfa", cursor: "#fcfcfa",
cursorAccent: "#2d2a2e", cursorAccent: "#2d2a2e",
selection: "#5b595c", selectionBackground: "#5b595c",
black: "#403e41", black: "#403e41",
red: "#ff6188", red: "#ff6188",
green: "#a9dc76", green: "#a9dc76",
@@ -70,7 +70,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#fff1f3", foreground: "#fff1f3",
cursor: "#fff1f3", cursor: "#fff1f3",
cursorAccent: "#2d2525", cursorAccent: "#2d2525",
selection: "#403838", selectionBackground: "#403838",
black: "#2c2525", black: "#2c2525",
red: "#fd6883", red: "#fd6883",
green: "#adda78", green: "#adda78",
@@ -94,7 +94,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#d4d4d4", foreground: "#d4d4d4",
cursor: "#aeafad", cursor: "#aeafad",
cursorAccent: "#1e1e1e", cursorAccent: "#1e1e1e",
selection: "#264f78", selectionBackground: "#264f78",
black: "#000000", black: "#000000",
red: "#cd3131", red: "#cd3131",
green: "#0dbc79", green: "#0dbc79",
@@ -117,7 +117,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#383a42", foreground: "#383a42",
cursor: "#526eff", cursor: "#526eff",
cursorAccent: "#ffffff", cursorAccent: "#ffffff",
selection: "#add6ff", selectionBackground: "#add6ff",
black: "#000000", black: "#000000",
red: "#e45649", red: "#e45649",
green: "#50a14f", green: "#50a14f",
@@ -140,7 +140,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#f8f8f2", foreground: "#f8f8f2",
cursor: "#f8f8f2", cursor: "#f8f8f2",
cursorAccent: "#282a36", cursorAccent: "#282a36",
selection: "#44475a", selectionBackground: "#44475a",
black: "#21222c", black: "#21222c",
red: "#ff5555", red: "#ff5555",
green: "#50fa7b", green: "#50fa7b",
@@ -163,7 +163,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#cdd6f4", foreground: "#cdd6f4",
cursor: "#f5e0dc", cursor: "#f5e0dc",
cursorAccent: "#1e1e2e", cursorAccent: "#1e1e2e",
selection: "#45475a", selectionBackground: "#45475a",
black: "#45475a", black: "#45475a",
red: "#f38ba8", red: "#f38ba8",
green: "#a6e3a1", green: "#a6e3a1",
@@ -186,7 +186,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#d8dee9", foreground: "#d8dee9",
cursor: "#d8dee9", cursor: "#d8dee9",
cursorAccent: "#2e3440", cursorAccent: "#2e3440",
selection: "#434c5e", selectionBackground: "#434c5e",
black: "#3b4252", black: "#3b4252",
red: "#bf616a", red: "#bf616a",
green: "#a3be8c", green: "#a3be8c",
@@ -209,7 +209,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#ebdbb2", foreground: "#ebdbb2",
cursor: "#ebdbb2", cursor: "#ebdbb2",
cursorAccent: "#282828", cursorAccent: "#282828",
selection: "#504945", selectionBackground: "#504945",
black: "#282828", black: "#282828",
red: "#cc241d", red: "#cc241d",
green: "#98971a", green: "#98971a",
@@ -232,7 +232,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#839496", foreground: "#839496",
cursor: "#839496", cursor: "#839496",
cursorAccent: "#002b36", cursorAccent: "#002b36",
selection: "#073642", selectionBackground: "#073642",
black: "#073642", black: "#073642",
red: "#dc322f", red: "#dc322f",
green: "#859900", green: "#859900",
@@ -255,7 +255,7 @@ const THEMES: Record<string, ITheme> = {
foreground: "#a9b1d6", foreground: "#a9b1d6",
cursor: "#c0caf5", cursor: "#c0caf5",
cursorAccent: "#1a1b26", cursorAccent: "#1a1b26",
selection: "#33467c", selectionBackground: "#33467c",
black: "#15161e", black: "#15161e",
red: "#f7768e", red: "#f7768e",
green: "#9ece6a", green: "#9ece6a",
@@ -285,31 +285,45 @@ interface TerminalConfig {
/** Parse configuration from element data attributes */ /** Parse configuration from element data attributes */
function parseConfig(element: HTMLElement): TerminalConfig { function parseConfig(element: HTMLElement): TerminalConfig {
console.log("[webterm:parseConfig] Parsing config from element");
const config: TerminalConfig = {}; const config: TerminalConfig = {};
if (element.dataset.fontFamily) { if (element.dataset.fontFamily) {
config.fontFamily = element.dataset.fontFamily; config.fontFamily = element.dataset.fontFamily;
console.log(`[webterm:parseConfig] fontFamily: "${config.fontFamily}"`);
} }
if (element.dataset.fontSize) { if (element.dataset.fontSize) {
config.fontSize = parseInt(element.dataset.fontSize, 10); config.fontSize = parseInt(element.dataset.fontSize, 10);
console.log(`[webterm:parseConfig] fontSize: ${config.fontSize}`);
} }
if (element.dataset.scrollback) { if (element.dataset.scrollback) {
config.scrollback = parseInt(element.dataset.scrollback, 10); config.scrollback = parseInt(element.dataset.scrollback, 10);
console.log(`[webterm:parseConfig] scrollback: ${config.scrollback}`);
} }
if (element.dataset.theme) { if (element.dataset.theme) {
const themeName = element.dataset.theme.toLowerCase(); const themeName = element.dataset.theme.toLowerCase();
console.log(`[webterm:parseConfig] theme attribute: "${element.dataset.theme}" -> normalized: "${themeName}"`);
console.log(`[webterm:parseConfig] Available themes: ${Object.keys(THEMES).join(", ")}`);
console.log(`[webterm:parseConfig] Theme "${themeName}" in THEMES? ${themeName in THEMES}`);
if (themeName in THEMES) { if (themeName in THEMES) {
config.theme = THEMES[themeName]; config.theme = THEMES[themeName];
console.log(`[webterm:parseConfig] Using built-in theme "${themeName}":`, JSON.stringify(config.theme, null, 2));
} else { } else {
// Try parsing as JSON for custom themes // Try parsing as JSON for custom themes
console.log(`[webterm:parseConfig] Theme not found in THEMES, trying JSON parse...`);
try { try {
config.theme = JSON.parse(element.dataset.theme) as ITheme; config.theme = JSON.parse(element.dataset.theme) as ITheme;
} catch { console.log(`[webterm:parseConfig] Parsed custom JSON theme:`, config.theme);
console.warn(`Unknown theme "${element.dataset.theme}", using default`); } catch (e) {
console.warn(`[webterm:parseConfig] Unknown theme "${element.dataset.theme}", JSON parse failed:`, e);
} }
} }
} else {
console.log(`[webterm:parseConfig] No theme attribute found on element`);
} }
console.log(`[webterm:parseConfig] Final config:`, config);
return config; return config;
} }
@@ -354,17 +368,23 @@ class WebTerminal {
private mobileKeybar: HTMLElement | null = null; private mobileKeybar: HTMLElement | null = null;
private ctrlActive = false; private ctrlActive = false;
private shiftActive = false; private shiftActive = false;
private fontFamily: string;
private fontSize: number;
private constructor( private constructor(
container: HTMLElement, container: HTMLElement,
wsUrl: string, wsUrl: string,
terminal: Terminal, terminal: Terminal,
fitAddon: FitAddon fitAddon: FitAddon,
fontFamily: string,
fontSize: number
) { ) {
this.element = container; this.element = container;
this.wsUrl = wsUrl; this.wsUrl = wsUrl;
this.terminal = terminal; this.terminal = terminal;
this.fitAddon = fitAddon; this.fitAddon = fitAddon;
this.fontFamily = fontFamily;
this.fontSize = fontSize;
} }
/** Create and initialize a WebTerminal instance */ /** Create and initialize a WebTerminal instance */
@@ -373,29 +393,71 @@ class WebTerminal {
wsUrl: string, wsUrl: string,
config: TerminalConfig config: TerminalConfig
): Promise<WebTerminal> { ): Promise<WebTerminal> {
console.log("[webterm:create] WebTerminal.create() called");
console.log("[webterm:create] Container:", container);
console.log("[webterm:create] wsUrl:", wsUrl);
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 - try to find it relative to the script location
const wasmPath = getWasmPath(); const wasmPath = getWasmPath();
console.log("[webterm:create] WASM path:", wasmPath);
// Build terminal options // Build terminal options
const themeToUse = config.theme ?? THEMES.xterm;
console.log("[webterm:create] Theme to use (config.theme ?? THEMES.xterm):", JSON.stringify(themeToUse, null, 2));
const options: ITerminalOptions = { const options: ITerminalOptions = {
fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY, fontFamily: config.fontFamily ?? DEFAULT_FONT_FAMILY,
fontSize: config.fontSize ?? 16, fontSize: config.fontSize ?? 16,
scrollback: config.scrollback ?? 1000, scrollback: config.scrollback ?? 1000,
cursorBlink: true, cursorBlink: true,
cursorStyle: "block", cursorStyle: "block",
theme: config.theme ?? THEMES.xterm, theme: themeToUse,
wasmPath, wasmPath,
}; };
console.log("[webterm:create] Full ITerminalOptions:", JSON.stringify(options, null, 2));
console.log("[webterm:create] Creating ghostty-web Terminal instance...");
const terminal = new Terminal(options); const terminal = new Terminal(options);
console.log("[webterm:create] Terminal created:", terminal);
console.log("[webterm:create] Terminal.options:", (terminal as unknown as { options?: unknown }).options);
console.log("[webterm:create] Creating FitAddon...");
const fitAddon = new FitAddon(); const fitAddon = new FitAddon();
console.log("[webterm:create] Loading FitAddon into terminal...");
terminal.loadAddon(fitAddon); terminal.loadAddon(fitAddon);
// Open terminal (this loads WASM and initializes everything) // Open terminal (this loads WASM and initializes everything)
console.log("[webterm:create] Calling terminal.open(container)...");
await terminal.open(container); await terminal.open(container);
console.log("[webterm:create] terminal.open() completed");
// Check internal state after open
const internalTerminal = terminal as unknown as Record<string, unknown>;
console.log("[webterm:create] Terminal internal keys:", Object.keys(internalTerminal));
if (internalTerminal.renderer) {
console.log("[webterm:create] Renderer exists:", internalTerminal.renderer);
const renderer = internalTerminal.renderer as Record<string, unknown>;
console.log("[webterm:create] Renderer keys:", Object.keys(renderer));
if (renderer.theme) {
console.log("[webterm:create] Renderer.theme:", renderer.theme);
}
if (renderer.palette) {
console.log("[webterm:create] Renderer.palette:", renderer.palette);
}
}
const instance = new WebTerminal(container, wsUrl, terminal, fitAddon); const instance = new WebTerminal(
container,
wsUrl,
terminal,
fitAddon,
options.fontFamily ?? DEFAULT_FONT_FAMILY,
options.fontSize ?? 16
);
console.log("[webterm:create] WebTerminal instance created");
instance.initialize(); instance.initialize();
console.log("[webterm:create] WebTerminal initialized");
return instance; return instance;
} }
@@ -862,8 +924,8 @@ class WebTerminal {
const testElement = document.createElement('span'); const testElement = document.createElement('span');
testElement.style.visibility = 'hidden'; testElement.style.visibility = 'hidden';
testElement.style.position = 'absolute'; testElement.style.position = 'absolute';
testElement.style.fontFamily = this.options.fontFamily || 'monospace'; testElement.style.fontFamily = this.fontFamily;
testElement.style.fontSize = `${this.options.fontSize || 14}px`; testElement.style.fontSize = `${this.fontSize}px`;
testElement.style.lineHeight = 'normal'; testElement.style.lineHeight = 'normal';
testElement.textContent = 'W'; testElement.textContent = 'W';
@@ -1052,9 +1114,14 @@ const instances: Map<HTMLElement, WebTerminal> = new Map();
/** Initialize all terminal containers on page load */ /** Initialize all terminal containers on page load */
async function initTerminals(): Promise<void> { async function initTerminals(): Promise<void> {
console.log("[webterm:init] initTerminals() called");
const containers = document.querySelectorAll<HTMLElement>(".textual-terminal"); const containers = document.querySelectorAll<HTMLElement>(".textual-terminal");
console.log(`[webterm:init] Found ${containers.length} .textual-terminal containers`);
for (const el of containers) { for (const el of containers) {
console.log("[webterm:init] Processing container:", el);
console.log("[webterm:init] Dataset:", JSON.stringify(el.dataset));
const wsUrl = el.dataset.sessionWebsocketUrl; const wsUrl = el.dataset.sessionWebsocketUrl;
if (!wsUrl) { if (!wsUrl) {
console.error("Missing data-session-websocket-url on terminal container"); console.error("Missing data-session-websocket-url on terminal container");
@@ -1062,8 +1129,12 @@ async function initTerminals(): Promise<void> {
} }
const config = parseConfig(el); const config = parseConfig(el);
console.log("[webterm:init] Parsed config:", JSON.stringify(config, null, 2));
try { try {
console.log("[webterm:init] Calling WebTerminal.create()...");
const terminal = await WebTerminal.create(el, wsUrl, config); const terminal = await WebTerminal.create(el, wsUrl, config);
console.log("[webterm:init] WebTerminal created successfully");
instances.set(el, terminal); instances.set(el, terminal);
} catch (e) { } catch (e) {
console.error("Failed to create terminal:", e); console.error("Failed to create terminal:", e);
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/textual_webterm/static/js/**/*.ts"],
"exclude": ["node_modules"]
}