From 96da9d4e25270608c60f138c95a77f758e43bb3e Mon Sep 17 00:00:00 2001 From: Kyle Carberry Date: Mon, 1 Dec 2025 20:59:54 -0500 Subject: [PATCH 1/2] Migrate to use RenderState From Mitchell's comment on HN: https://news.ycombinator.com/item?id=46111944This should improve performance dramatically.This also adds a basic set of benchmarks which have not yet been optimized. --- bench/versus.ts | 124 +++ bun.lock | 9 + demo/bin/demo.js | 31 +- flake.lock | 147 +++ flake.nix | 48 + ghostty | 2 +- lib/ghostty.ts | 881 ++++++++---------- lib/renderer.ts | 27 +- lib/scrolling.test.ts | 27 +- lib/terminal.test.ts | 686 +++++++++++++- lib/terminal.ts | 100 +- lib/types.ts | 109 ++- package.json | 5 +- patches/ghostty-wasm-api.patch | 1569 ++++++++++++-------------------- 14 files changed, 2091 insertions(+), 1674 deletions(-) create mode 100644 bench/versus.ts create mode 100644 flake.lock create mode 100644 flake.nix diff --git a/bench/versus.ts b/bench/versus.ts new file mode 100644 index 0000000..945e4b6 --- /dev/null +++ b/bench/versus.ts @@ -0,0 +1,124 @@ +import { Terminal as XTerm } from '@xterm/xterm'; +import { bench, group, run } from 'mitata'; +import { Ghostty, Terminal as GhosttyTerminal } from '../lib'; +import '../happydom'; + +function generateColorText(lines: number): string { + const colors = [31, 32, 33, 34, 35, 36]; + let output = ''; + for (let i = 0; i < lines; i++) { + const color = colors[i % colors.length]; + output += `\x1b[${color}mLine ${i}: This is some colored text with ANSI escape sequences\x1b[0m\r\n`; + } + return output; +} + +function generateComplexVT(lines: number): string { + let output = ''; + for (let i = 0; i < lines; i++) { + output += `\x1b[1;4;38;2;255;128;0mBold underline RGB\x1b[0m `; + output += `\x1b[48;5;236mBG 256\x1b[0m `; + output += `\x1b[7mInverse\x1b[0m\r\n`; + } + return output; +} + +function generateRawBytes(size: number): Uint8Array { + const data = new Uint8Array(size); + for (let i = 0; i < size; i++) { + const mod = i % 85; + if (mod < 80) { + data[i] = 32 + (i % 95); // Printable ASCII + } else if (mod === 80) { + data[i] = 13; // \r + } else { + data[i] = 10; // \n + } + } + return data; +} + +function generateCursorMovement(ops: number): string { + let output = ''; + for (let i = 0; i < ops; i++) { + output += `\x1b[${(i % 24) + 1};${(i % 80) + 1}H`; // Cursor position + output += `\x1b[K`; // Clear to end of line + output += `Text at position ${i}`; + output += `\x1b[A\x1b[B\x1b[C\x1b[D`; // Up, Down, Right, Left + } + return output; +} + +const withTerminals = async (fn: (term: GhosttyTerminal | XTerm) => Promise) => { + const ghostty = await Ghostty.load(); + bench('ghostty-web', async () => { + const container = document.createElement('div'); + document.body.appendChild(container); + const term = new GhosttyTerminal({ ghostty }); + await term.open(container); + await fn(term); + await term.dispose(); + }); + bench('xterm.js', async () => { + const xterm = new XTerm(); + const container = document.createElement('div'); + document.body.appendChild(container); + await xterm.open(container); + await fn(xterm); + await xterm.dispose(); + }); +}; + +const throughput = async (prefix: string, data: Record) => { + await Promise.all( + Object.entries(data).map(async ([name, data]) => { + await group(`${prefix}: ${name}`, async () => { + await withTerminals(async (term) => { + await new Promise((resolve) => { + term.write(data, resolve); + }); + }); + }); + }) + ); +}; + +await throughput('raw bytes', { + '1KB': generateRawBytes(1024), + '10KB': generateRawBytes(10 * 1024), + '100KB': generateRawBytes(100 * 1024), + '1MB': generateRawBytes(1024 * 1024), +}); + +await throughput('color text', { + '100 lines': generateColorText(100), + '1000 lines': generateColorText(1000), + '10000 lines': generateColorText(10000), +}); + +await throughput('complex VT', { + '100 lines': generateComplexVT(100), + '1000 lines': generateComplexVT(1000), + '10000 lines': generateComplexVT(10000), +}); + +await throughput('cursor movement', { + '1000 operations': generateCursorMovement(1000), + '10000 operations': generateCursorMovement(10000), + '100000 operations': generateCursorMovement(100000), +}); + +await group('read full viewport', async () => { + await withTerminals(async (term) => { + const lines = term.rows; + for (let i = 0; i < lines; i++) { + const line = term.buffer.active.getLine(i); + if (!line) { + continue; + } + line.translateToString(); + } + }); +}); + +await run(); diff --git a/bun.lock b/bun.lock index 7835f71..557c739 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,9 @@ "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", "@types/bun": "^1.3.2", + "@xterm/headless": "^5.5.0", + "@xterm/xterm": "^5.5.0", + "mitata": "^1.0.34", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^4.5.0", @@ -140,6 +143,10 @@ "@vue/shared": ["@vue/shared@3.5.24", "", {}, "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A=="], + "@xterm/headless": ["@xterm/headless@5.5.0", "", {}, "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g=="], + + "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], "ajv": ["ajv@8.12.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2", "uri-js": "^4.2.2" } }, "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA=="], @@ -218,6 +225,8 @@ "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "mitata": ["mitata@1.0.34", "", {}, "sha512-Mc3zrtNBKIMeHSCQ0XqRLo1vbdIx1wvFV9c8NJAiyho6AjNfMY8bVhbS12bwciUdd1t4rj8099CH3N3NFahaUA=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], diff --git a/demo/bin/demo.js b/demo/bin/demo.js index d652e68..b139a25 100644 --- a/demo/bin/demo.js +++ b/demo/bin/demo.js @@ -446,23 +446,20 @@ wss.on('connection', (ws, req) => { }); // Send welcome message - setTimeout(() => { - if (ws.readyState !== ws.OPEN) return; - const C = '\x1b[1;36m'; // Cyan - const G = '\x1b[1;32m'; // Green - const Y = '\x1b[1;33m'; // Yellow - const R = '\x1b[0m'; // Reset - ws.send(`${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`); - ws.send( - `${C}║${R} ${G}Welcome to ghostty-web!${R} ${C}║${R}\r\n` - ); - ws.send(`${C}║${R} ${C}║${R}\r\n`); - ws.send(`${C}║${R} You have a real shell session with full PTY support. ${C}║${R}\r\n`); - ws.send( - `${C}║${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}║${R}\r\n` - ); - ws.send(`${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`); - }, 100); + const C = '\x1b[1;36m'; // Cyan + const G = '\x1b[1;32m'; // Green + const Y = '\x1b[1;33m'; // Yellow + const R = '\x1b[0m'; // Reset + ws.send(`${C}╔══════════════════════════════════════════════════════════════╗${R}\r\n`); + ws.send( + `${C}║${R} ${G}Welcome to ghostty-web!${R} ${C}║${R}\r\n` + ); + ws.send(`${C}║${R} ${C}║${R}\r\n`); + ws.send(`${C}║${R} You have a real shell session with full PTY support. ${C}║${R}\r\n`); + ws.send( + `${C}║${R} Try: ${Y}ls${R}, ${Y}cd${R}, ${Y}top${R}, ${Y}vim${R}, or any command! ${C}║${R}\r\n` + ); + ws.send(`${C}╚══════════════════════════════════════════════════════════════╝${R}\r\n\r\n`); }); // ============================================================================ diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..0687803 --- /dev/null +++ b/flake.lock @@ -0,0 +1,147 @@ +{ + "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1705309234, + "narHash": "sha256-uNRRNRKmJyCRC/8y1RqBkqWBLM034y4qN7EprSdmgyA=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "1ef2e671c3b0c19053962c07dbda38332dcebf26", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764517877, + "narHash": "sha256-pp3uT4hHijIC8JUK5MEqeAWmParJrgBVzHLNfJDZxg4=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2d293cbfa5a793b4c50d17c05ef9e385b90edf6c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1708161998, + "narHash": "sha256-6KnemmUorCvlcAvGziFosAVkrlWZGIc6UNT9GUYr0jQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "84d981bae8b5e783b3b548de505b22880559515f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "zig-overlay": "zig-overlay" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig-overlay": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1764203689, + "narHash": "sha256-ivb0SqCptyIxx5g8ryRrUL0xrJhLrJPlvZbZjxVaui0=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "8f7347545dea59b75e40247cc1ed55a42f64dbbf", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..eef8e68 --- /dev/null +++ b/flake.nix @@ -0,0 +1,48 @@ +{ + description = "ghostty-web - Web terminal using Ghostty's VT100 parser via WASM"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + zig-overlay.url = "github:mitchellh/zig-overlay"; + }; + + outputs = { self, nixpkgs, flake-utils, zig-overlay }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ zig-overlay.overlays.default ]; + }; + zig = pkgs.zigpkgs."0.15.2"; + in { + devShells.default = pkgs.mkShell { + buildInputs = [ + pkgs.bun + pkgs.nodejs_22 + zig + ]; + }; + + packages.default = pkgs.stdenv.mkDerivation { + pname = "ghostty-web"; + version = "0.3.0"; + + src = ./.; + + nativeBuildInputs = [ pkgs.bun pkgs.nodejs_22 ]; + + buildPhase = '' + export HOME=$TMPDIR + bun install --frozen-lockfile + bun run build + ''; + + installPhase = '' + mkdir -p $out + cp -r dist/* $out/ + ''; + }; + } + ); +} diff --git a/ghostty b/ghostty index 0f64b9a..5714ed0 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 0f64b9a8e86e10a76fb78a595531b04e9b62995c +Subproject commit 5714ed07a1012573261b7b7e3ed2add9c1504496 diff --git a/lib/ghostty.ts b/lib/ghostty.ts index b8fb45a..02a53a1 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -1,26 +1,37 @@ /** * TypeScript wrapper for libghostty-vt WASM API * - * This provides a high-level, ergonomic API around the low-level C ABI - * exports from libghostty-vt.wasm + * High-performance terminal emulation using Ghostty's battle-tested VT100 parser. + * The key optimization is the RenderState API which provides a pre-computed + * snapshot of all render data in a single update call. */ import { CellFlags, type Cursor, - GHOSTTY_CONFIG_SIZE, + DirtyState, type GhosttyCell, - type GhosttyTerminalConfig, type GhosttyWasmExports, KeyEncoderOption, type KeyEvent, type KittyKeyFlags, type RGB, + type RenderStateColors, + type RenderStateCursor, type TerminalHandle, } from './types'; // Re-export types for convenience -export { type GhosttyCell, type Cursor, type RGB, CellFlags, KeyEncoderOption }; +export { + CellFlags, + type Cursor, + DirtyState, + type GhosttyCell, + KeyEncoderOption, + type RGB, + type RenderStateColors, + type RenderStateCursor, +}; /** * Main Ghostty WASM wrapper class @@ -34,102 +45,111 @@ export class Ghostty { this.memory = this.exports.memory; } - /** - * Get current memory buffer (may change when memory grows) - */ - private getBuffer(): ArrayBuffer { - return this.memory.buffer; - } - - /** - * Create a key encoder instance - */ createKeyEncoder(): KeyEncoder { return new KeyEncoder(this.exports); } - /** - * Create a terminal emulator instance - */ - createTerminal( - cols: number = 80, - rows: number = 24, - config?: GhosttyTerminalConfig - ): GhosttyTerminal { - return new GhosttyTerminal(this.exports, this.memory, cols, rows, config); + createTerminal(cols: number = 80, rows: number = 24): GhosttyTerminal { + return new GhosttyTerminal(this.exports, this.memory, cols, rows); } - /** - * Load Ghostty WASM from URL or file path - * If no path is provided, attempts to load from common default locations - */ static async load(wasmPath?: string): Promise { - // Default WASM paths to try (in order) - const defaultPaths = [ - // When running in Node/Bun (resolve to file path) - new URL('../ghostty-vt.wasm', import.meta.url).href.replace('file://', ''), - // When published as npm package (browser) - new URL('../ghostty-vt.wasm', import.meta.url).href, - // When used from CDN or local dev - './ghostty-vt.wasm', - '/ghostty-vt.wasm', - ]; - - const pathsToTry = wasmPath ? [wasmPath] : defaultPaths; + // If explicit path provided, use it + if (wasmPath) { + return Ghostty.loadFromPath(wasmPath); + } + + // Resolve path relative to this module + const moduleUrl = new URL('../ghostty-vt.wasm', import.meta.url); + + // Build paths to try, prioritizing file system paths for Node/Bun + const defaultPaths: string[] = []; + + // For Node/Bun: try absolute file path first (strip file:// protocol) + if (moduleUrl.protocol === 'file:') { + let filePath = moduleUrl.pathname; + // Remove leading slash on Windows paths (e.g., /C:/ -> C:/) + if (filePath.match(/^\/[A-Za-z]:\//)) { + filePath = filePath.slice(1); + } + defaultPaths.push(filePath); + } + + // Also try other common paths + defaultPaths.push(moduleUrl.href, './ghostty-vt.wasm', '/ghostty-vt.wasm'); + let lastError: Error | null = null; + for (const path of defaultPaths) { + try { + return await Ghostty.loadFromPath(path); + } catch (e) { + lastError = e instanceof Error ? e : new Error(String(e)); + } + } + throw lastError || new Error('Failed to load Ghostty WASM'); + } - for (const path of pathsToTry) { + private static async loadFromPath(path: string): Promise { + let wasmBytes: ArrayBuffer | undefined; + + // Try Bun.file first (for Bun environments) + if (typeof Bun !== 'undefined' && typeof Bun.file === 'function') { try { - let wasmBytes: ArrayBuffer; - - // Try loading as file first (for Node/Bun environments) - try { - const fs = await import('fs/promises'); - const buffer = await fs.readFile(path); - wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); - } catch (e) { - // Fall back to fetch (for browser environments) - const response = await fetch(path); - if (!response.ok) { - throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); - } - wasmBytes = await response.arrayBuffer(); - if (wasmBytes.byteLength === 0) { - throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); - } + const file = Bun.file(path); + if (await file.exists()) { + wasmBytes = await file.arrayBuffer(); } + } catch { + // Bun.file failed, try next method + } + } - // Successfully loaded, instantiate and return - const wasmModule = await WebAssembly.instantiate(wasmBytes, { - env: { - log: (ptr: number, len: number) => { - const instance = (wasmModule as any).instance; - const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len); - const text = new TextDecoder().decode(bytes); - console.log('[ghostty-wasm]', text); - }, - }, - }); + // Try Node.js fs module if Bun.file didn't work + if (!wasmBytes) { + try { + const fs = await import('fs/promises'); + const buffer = await fs.readFile(path); + wasmBytes = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + } catch { + // fs failed, try fetch + } + } - return new Ghostty(wasmModule.instance); - } catch (e) { - lastError = e instanceof Error ? e : new Error(String(e)); - // Try next path + // Fall back to fetch (for browser environments) + if (!wasmBytes) { + const response = await fetch(path); + if (!response.ok) { + throw new Error(`Failed to fetch WASM: ${response.status} ${response.statusText}`); + } + wasmBytes = await response.arrayBuffer(); + if (wasmBytes.byteLength === 0) { + throw new Error(`WASM file is empty (0 bytes). Check path: ${path}`); } } - // All paths failed - throw new Error( - `Failed to load ghostty-vt.wasm. Tried paths: ${pathsToTry.join(', ')}. ` + - `Last error: ${lastError?.message}. ` + - `You can specify a custom path with: new Terminal({ wasmPath: './path/to/ghostty-vt.wasm' })` - ); + if (!wasmBytes) { + throw new Error(`Could not load WASM from path: ${path}`); + } + + const wasmModule = await WebAssembly.compile(wasmBytes); + const wasmInstance = await WebAssembly.instantiate(wasmModule, { + env: { + log: (ptr: number, len: number) => { + const bytes = new Uint8Array( + (wasmInstance.exports as GhosttyWasmExports).memory.buffer, + ptr, + len + ); + console.log('[ghostty-vt]', new TextDecoder().decode(bytes)); + }, + }, + }); + return new Ghostty(wasmInstance); } } /** - * Key Encoder - * Converts keyboard events into terminal escape sequences + * Key Encoder - converts keyboard events into terminal escape sequences */ export class KeyEncoder { private exports: GhosttyWasmExports; @@ -137,66 +157,35 @@ export class KeyEncoder { constructor(exports: GhosttyWasmExports) { this.exports = exports; - - // Allocate encoder const encoderPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); const result = this.exports.ghostty_key_encoder_new(0, encoderPtrPtr); - if (result !== 0) { - throw new Error(`Failed to create key encoder: ${result}`); - } - - // Read the encoder pointer + if (result !== 0) throw new Error(`Failed to create key encoder: ${result}`); const view = new DataView(this.exports.memory.buffer); this.encoder = view.getUint32(encoderPtrPtr, true); this.exports.ghostty_wasm_free_opaque(encoderPtrPtr); } - /** - * Set an encoder option - */ setOption(option: KeyEncoderOption, value: boolean | number): void { const valuePtr = this.exports.ghostty_wasm_alloc_u8(); const view = new DataView(this.exports.memory.buffer); - - if (typeof value === 'boolean') { - view.setUint8(valuePtr, value ? 1 : 0); - } else { - view.setUint8(valuePtr, value); - } - - const result = this.exports.ghostty_key_encoder_setopt(this.encoder, option, valuePtr); - + view.setUint8(valuePtr, typeof value === 'boolean' ? (value ? 1 : 0) : value); + this.exports.ghostty_key_encoder_setopt(this.encoder, option, valuePtr); this.exports.ghostty_wasm_free_u8(valuePtr); - - // Check result if it's defined (some WASM functions may return void) - if (result !== undefined && result !== 0) { - throw new Error(`Failed to set encoder option: ${result}`); - } } - /** - * Enable Kitty keyboard protocol with specified flags - */ setKittyFlags(flags: KittyKeyFlags): void { this.setOption(KeyEncoderOption.KITTY_KEYBOARD_FLAGS, flags); } - /** - * Encode a key event to escape sequence - */ encode(event: KeyEvent): Uint8Array { - // Create key event structure const eventPtrPtr = this.exports.ghostty_wasm_alloc_opaque(); const createResult = this.exports.ghostty_key_event_new(0, eventPtrPtr); - if (createResult !== 0) { - throw new Error(`Failed to create key event: ${createResult}`); - } + if (createResult !== 0) throw new Error(`Failed to create key event: ${createResult}`); const view = new DataView(this.exports.memory.buffer); const eventPtr = view.getUint32(eventPtrPtr, true); this.exports.ghostty_wasm_free_opaque(eventPtrPtr); - // Set event properties this.exports.ghostty_key_event_set_action(eventPtr, event.action); this.exports.ghostty_key_event_set_key(eventPtr, event.key); this.exports.ghostty_key_event_set_mods(eventPtr, event.mods); @@ -210,12 +199,10 @@ export class KeyEncoder { this.exports.ghostty_wasm_free_u8_array(utf8Ptr, utf8Bytes.length); } - // Allocate output buffer const bufferSize = 32; const bufPtr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); const writtenPtr = this.exports.ghostty_wasm_alloc_usize(); - // Encode const encodeResult = this.exports.ghostty_key_encoder_encode( this.encoder, eventPtr, @@ -231,11 +218,9 @@ export class KeyEncoder { throw new Error(`Failed to encode key: ${encodeResult}`); } - // Read result const bytesWritten = view.getUint32(writtenPtr, true); - const encoded = new Uint8Array(this.exports.memory.buffer, bufPtr, bytesWritten).slice(); // Copy the data + const encoded = new Uint8Array(this.exports.memory.buffer, bufPtr, bytesWritten).slice(); - // Cleanup this.exports.ghostty_wasm_free_u8_array(bufPtr, bufferSize); this.exports.ghostty_wasm_free_usize(writtenPtr); this.exports.ghostty_key_event_free(eventPtr); @@ -243,9 +228,6 @@ export class KeyEncoder { return encoded; } - /** - * Free encoder resources - */ dispose(): void { if (this.encoder) { this.exports.ghostty_key_encoder_free(this.encoder); @@ -255,22 +237,12 @@ export class KeyEncoder { } /** - * GhosttyTerminal - Wraps the WASM terminal emulator - * - * Provides a TypeScript-friendly interface to Ghostty's complete - * terminal implementation via WASM. + * GhosttyTerminal - High-performance terminal emulator * - * @example - * ```typescript - * const ghostty = await Ghostty.load('./ghostty-vt.wasm'); - * const term = ghostty.createTerminal(80, 24); - * - * term.write('Hello\x1b[31m Red\x1b[0m\n'); - * const cursor = term.getCursor(); - * const cells = term.getLine(0); - * - * term.free(); - * ``` + * Uses Ghostty's native RenderState for optimal performance: + * - ONE call to update all state (renderStateUpdate) + * - ONE call to get all cells (getViewport) + * - No per-row WASM boundary crossings! */ export class GhosttyTerminal { private exports: GhosttyWasmExports; @@ -279,449 +251,368 @@ export class GhosttyTerminal { private _cols: number; private _rows: number; - /** - * Size of ghostty_cell_t in bytes (16 bytes in WASM) - * Structure: codepoint(u32) + fg_rgb(3xu8) + bg_rgb(3xu8) + flags(u8) + width(u8) + hyperlink_id(u16) + padding(u32) - */ + /** Size of GhosttyCell in WASM (16 bytes) */ private static readonly CELL_SIZE = 16; - /** - * Create a new terminal. - * - * @param exports WASM exports - * @param memory WASM memory - * @param cols Number of columns (default: 80) - * @param rows Number of rows (default: 24) - * @param config Optional terminal configuration (colors, scrollback) - * @throws Error if allocation fails - */ - constructor( - exports: GhosttyWasmExports, - memory: WebAssembly.Memory, - cols: number = 80, - rows: number = 24, - config?: GhosttyTerminalConfig - ) { + /** Reusable buffer for viewport operations */ + private viewportBufferPtr: number = 0; + private viewportBufferSize: number = 0; + + /** Cell pool for zero-allocation rendering */ + private cellPool: GhosttyCell[] = []; + + constructor(exports: GhosttyWasmExports, memory: WebAssembly.Memory, cols: number, rows: number) { this.exports = exports; this.memory = memory; this._cols = cols; this._rows = rows; - let handle: TerminalHandle; - - if (config) { - // Allocate config struct in WASM memory - const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); - if (configPtr === 0) { - throw new Error('Failed to allocate config (out of memory)'); - } - - try { - // Write config to WASM memory - const view = new DataView(this.memory.buffer); - let offset = configPtr; - - // scrollback_limit (u32) - view.setUint32(offset, config.scrollbackLimit ?? 10000, true); - offset += 4; - - // fg_color (u32) - view.setUint32(offset, config.fgColor ?? 0, true); - offset += 4; - - // bg_color (u32) - view.setUint32(offset, config.bgColor ?? 0, true); - offset += 4; - - // cursor_color (u32) - view.setUint32(offset, config.cursorColor ?? 0, true); - offset += 4; - - // palette[16] (u32 * 16) - for (let i = 0; i < 16; i++) { - const color = config.palette?.[i] ?? 0; - view.setUint32(offset, color, true); - offset += 4; - } + this.handle = this.exports.ghostty_terminal_new(cols, rows); + if (!this.handle) throw new Error('Failed to create terminal'); - handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); - } finally { - // Free config memory - this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); - } - } else { - handle = this.exports.ghostty_terminal_new(cols, rows); - } - - if (handle === 0) { - throw new Error('Failed to allocate terminal (out of memory)'); - } - - this.handle = handle; + this.initCellPool(); } - /** - * Free the terminal. Must be called to prevent memory leaks. - */ - free(): void { - if (this.handle !== 0) { - this.exports.ghostty_terminal_free(this.handle); - this.handle = 0; - } + get cols(): number { + return this._cols; + } + get rows(): number { + return this._rows; } - /** - * Write data to terminal (parses VT sequences and updates screen). - * - * @param data UTF-8 string or Uint8Array - * - * @example - * ```typescript - * term.write('Hello, World!\n'); - * term.write('\x1b[1;31mBold Red\x1b[0m\n'); - * term.write(new Uint8Array([0x1b, 0x5b, 0x41])); // Up arrow - * ``` - */ + // ========================================================================== + // Lifecycle + // ========================================================================== + write(data: string | Uint8Array): void { const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data; - - if (bytes.length === 0) return; - - // Allocate in WASM memory const ptr = this.exports.ghostty_wasm_alloc_u8_array(bytes.length); - const mem = new Uint8Array(this.memory.buffer); - mem.set(bytes, ptr); - - try { - this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); - } finally { - this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); - } + new Uint8Array(this.memory.buffer).set(bytes, ptr); + this.exports.ghostty_terminal_write(this.handle, ptr, bytes.length); + this.exports.ghostty_wasm_free_u8_array(ptr, bytes.length); } - /** - * Resize the terminal. - * - * @param cols New column count - * @param rows New row count - */ resize(cols: number, rows: number): void { - this.exports.ghostty_terminal_resize(this.handle, cols, rows); + if (cols === this._cols && rows === this._rows) return; this._cols = cols; this._rows = rows; + this.exports.ghostty_terminal_resize(this.handle, cols, rows); + this.invalidateBuffers(); + this.initCellPool(); } - /** - * Get terminal dimensions. - */ - get cols(): number { - return this._cols; + free(): void { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + this.viewportBufferPtr = 0; + } + this.exports.ghostty_terminal_free(this.handle); } - get rows(): number { - return this._rows; - } + // ========================================================================== + // RenderState API - The key performance optimization + // ========================================================================== /** - * Get terminal dimensions (for IRenderable compatibility) + * Update render state from terminal. + * + * This syncs the RenderState with the current Terminal state. + * The dirty state (full/partial/none) is stored in the WASM RenderState + * and can be queried via isRowDirty(). When dirty==full, isRowDirty() + * returns true for ALL rows. + * + * The WASM layer automatically detects screen switches (normal <-> alternate) + * and returns FULL dirty state when switching screens (e.g., vim exit). + * + * Safe to call multiple times - dirty state persists until markClean(). */ - getDimensions(): { cols: number; rows: number } { - return { cols: this._cols, rows: this._rows }; + update(): DirtyState { + return this.exports.ghostty_render_state_update(this.handle) as DirtyState; } /** - * Get cursor position and visibility. + * Get cursor state from render state. + * Ensures render state is fresh by calling update(). */ - getCursor(): Cursor { + getCursor(): RenderStateCursor { + // Call update() to ensure render state is fresh. + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); return { - x: this.exports.ghostty_terminal_get_cursor_x(this.handle), - y: this.exports.ghostty_terminal_get_cursor_y(this.handle), - visible: this.exports.ghostty_terminal_get_cursor_visible(this.handle), + x: this.exports.ghostty_render_state_get_cursor_x(this.handle), + y: this.exports.ghostty_render_state_get_cursor_y(this.handle), + viewportX: this.exports.ghostty_render_state_get_cursor_x(this.handle), + viewportY: this.exports.ghostty_render_state_get_cursor_y(this.handle), + visible: this.exports.ghostty_render_state_get_cursor_visible(this.handle), + blinking: false, // TODO: Add blinking support + style: 'block', // TODO: Add style support }; } /** - * Get scrollback length (number of lines in history). + * Get default colors from render state */ - getScrollbackLength(): number { - return this.exports.ghostty_terminal_get_scrollback_length(this.handle); + getColors(): RenderStateColors { + const bg = this.exports.ghostty_render_state_get_bg_color(this.handle); + const fg = this.exports.ghostty_render_state_get_fg_color(this.handle); + return { + background: { + r: (bg >> 16) & 0xff, + g: (bg >> 8) & 0xff, + b: bg & 0xff, + }, + foreground: { + r: (fg >> 16) & 0xff, + g: (fg >> 8) & 0xff, + b: fg & 0xff, + }, + cursor: null, // TODO: Add cursor color support + }; } /** - * Check if terminal is in alternate screen buffer mode. - * - * The alternate screen is used by vim, less, htop, etc. - * When active, normal buffer is preserved and restored when the app exits. - * - * @returns true if in alternate screen, false if in normal screen - * - * @example - * ```typescript - * // Detect if vim is running - * if (term.isAlternateScreen()) { - * console.log('Full-screen app is active'); - * } - * ``` + * Check if a specific row is dirty */ - isAlternateScreen(): boolean { - return Boolean(this.exports.ghostty_terminal_is_alternate_screen(this.handle)); + isRowDirty(y: number): boolean { + return this.exports.ghostty_render_state_is_row_dirty(this.handle, y); } /** - * Check if a row is wrapped from the previous row. - * - * Wrapped rows are continuations of long lines that exceeded terminal width. - * Used for text selection to treat wrapped lines as single logical lines. - * - * @param row Row index (0 = top visible line) - * @returns true if row continues from previous line, false otherwise - * - * @example - * ```typescript - * // Get full logical line including wraps - * let text = ''; - * for (let row = 0; row < term.rows; row++) { - * const line = term.getLine(row); - * text += lineToString(line); - * - * // Only add newline if NOT wrapped - * if (!term.isRowWrapped(row + 1)) { - * text += '\n'; - * } - * } - * ``` + * Mark render state as clean (call after rendering) */ - isRowWrapped(row: number): boolean { - if (row < 0 || row >= this._rows) return false; - return Boolean(this.exports.ghostty_terminal_is_row_wrapped(this.handle, row)); + markClean(): void { + this.exports.ghostty_render_state_mark_clean(this.handle); } /** - * Get a line of cells from the visible screen. - * - * @param y Line number (0 = top visible line) - * @returns Array of cells, or null if y is out of bounds - * - * @example - * ```typescript - * const cells = term.getLine(0); - * if (cells) { - * for (const cell of cells) { - * const char = String.fromCodePoint(cell.codepoint); - * const isBold = (cell.flags & CellFlags.BOLD) !== 0; - * console.log(`"${char}" ${isBold ? 'bold' : 'normal'}`); - * } - * } - * ``` + * Get ALL viewport cells in ONE WASM call - the key performance optimization! + * Returns a reusable cell array (zero allocation after warmup). */ - getLine(y: number): GhosttyCell[] | null { - if (y < 0 || y >= this._rows) return null; - - const bufferSize = this._cols * GhosttyTerminal.CELL_SIZE; - - // Allocate buffer - const ptr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); - - try { - // Get line from WASM - const count = this.exports.ghostty_terminal_get_line(this.handle, y, ptr, this._cols); - - if (count < 0) return null; - - // Parse cells - const cells: GhosttyCell[] = []; - const view = new DataView(this.memory.buffer, ptr, bufferSize); - - for (let i = 0; i < count; i++) { - const offset = i * GhosttyTerminal.CELL_SIZE; - cells.push({ - codepoint: view.getUint32(offset, true), - fg_r: view.getUint8(offset + 4), - fg_g: view.getUint8(offset + 5), - fg_b: view.getUint8(offset + 6), - bg_r: view.getUint8(offset + 7), - bg_g: view.getUint8(offset + 8), - bg_b: view.getUint8(offset + 9), - flags: view.getUint8(offset + 10), - width: view.getUint8(offset + 11), - hyperlink_id: view.getUint16(offset + 12, true), - }); + getViewport(): GhosttyCell[] { + const totalCells = this._cols * this._rows; + const neededSize = totalCells * GhosttyTerminal.CELL_SIZE; + + // Ensure buffer is allocated + if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); } - - return cells; - } finally { - this.exports.ghostty_wasm_free_u8_array(ptr, bufferSize); + this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); + this.viewportBufferSize = neededSize; } + + // Get all cells in one call + const count = this.exports.ghostty_render_state_get_viewport( + this.handle, + this.viewportBufferPtr, + totalCells + ); + + if (count < 0) return this.cellPool; + + // Parse cells into pool (reuses existing objects) + this.parseCellsIntoPool(this.viewportBufferPtr, totalCells); + return this.cellPool; } + // ========================================================================== + // Compatibility methods (delegate to render state) + // ========================================================================== + /** - * Get a line from scrollback history. - * - * @param offset Line offset from top of scrollback (0 = oldest line) - * @returns Array of cells, or null if not available + * Get line - for compatibility, extracts from viewport. + * Ensures render state is fresh by calling update(). + * Returns a COPY of the cells to avoid pool reference issues. */ - getScrollbackLine(offset: number): GhosttyCell[] | null { - const scrollbackLen = this.getScrollbackLength(); + getLine(y: number): GhosttyCell[] | null { + if (y < 0 || y >= this._rows) return null; + // Call update() to ensure render state is fresh. + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); + const viewport = this.getViewport(); + const start = y * this._cols; + // Return deep copies to avoid cell pool reference issues + return viewport.slice(start, start + this._cols).map((cell) => ({ ...cell })); + } - if (offset < 0 || offset >= scrollbackLen) { - return null; - } + /** For compatibility with old API */ + isDirty(): boolean { + return this.update() !== DirtyState.NONE; + } - const bufferSize = this._cols * GhosttyTerminal.CELL_SIZE; - const ptr = this.exports.ghostty_wasm_alloc_u8_array(bufferSize); + /** + * Check if a full redraw is needed (screen change, resize, etc.) + * Note: This calls update() to ensure fresh state. Safe to call multiple times. + */ + needsFullRedraw(): boolean { + return this.update() === DirtyState.FULL; + } - try { - const count = this.exports.ghostty_terminal_get_scrollback_line( - this.handle, - offset, - ptr, - this._cols - ); + /** Mark render state as clean after rendering */ + clearDirty(): void { + this.markClean(); + } - if (count < 0) { - return null; - } + // ========================================================================== + // Terminal modes + // ========================================================================== - // Parse cells (same logic as getLine) - const cells: GhosttyCell[] = []; - const view = new DataView(this.memory.buffer, ptr, bufferSize); - - for (let i = 0; i < count; i++) { - const offset = i * GhosttyTerminal.CELL_SIZE; - cells.push({ - codepoint: view.getUint32(offset, true), - fg_r: view.getUint8(offset + 4), - fg_g: view.getUint8(offset + 5), - fg_b: view.getUint8(offset + 6), - bg_r: view.getUint8(offset + 7), - bg_g: view.getUint8(offset + 8), - bg_b: view.getUint8(offset + 9), - flags: view.getUint8(offset + 10), - width: view.getUint8(offset + 11), - hyperlink_id: view.getUint16(offset + 12, true), - }); - } + isAlternateScreen(): boolean { + return !!this.exports.ghostty_terminal_is_alternate_screen(this.handle); + } - return cells; - } finally { - this.exports.ghostty_wasm_free_u8_array(ptr, bufferSize); - } + hasBracketedPaste(): boolean { + // Mode 2004 = bracketed paste (DEC mode) + return this.getMode(2004, false); } - /** - * Check if any part of the screen is dirty. - */ - isDirty(): boolean { - return this.exports.ghostty_terminal_is_dirty(this.handle); + hasFocusEvents(): boolean { + // Mode 1004 = focus events (DEC mode) + return this.getMode(1004, false); } - /** - * Check if a specific row is dirty. - */ - isRowDirty(y: number): boolean { - if (y < 0 || y >= this._rows) return false; - return this.exports.ghostty_terminal_is_row_dirty(this.handle, y); + hasMouseTracking(): boolean { + return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; } - /** - * Clear all dirty flags (call after rendering). - */ - clearDirty(): void { - this.exports.ghostty_terminal_clear_dirty(this.handle); + // ========================================================================== + // Extended API (scrollback, modes, etc.) + // ========================================================================== + + /** Get dimensions - for compatibility */ + getDimensions(): { cols: number; rows: number } { + return { cols: this._cols, rows: this._rows }; } - /** - * Get all visible lines at once (convenience method). - * - * @returns Array of line arrays, or empty array on error - */ - getAllLines(): GhosttyCell[][] { - const lines: GhosttyCell[][] = []; - for (let y = 0; y < this._rows; y++) { - const line = this.getLine(y); - if (line) { - lines.push(line); - } - } - return lines; + /** Get number of scrollback lines (history, not including active screen) */ + getScrollbackLength(): number { + return this.exports.ghostty_terminal_get_scrollback_length(this.handle); } /** - * Get only the dirty lines (for optimized rendering). - * - * @returns Map of row number to cell array + * Get a line from the scrollback buffer. + * Ensures render state is fresh by calling update(). + * @param offset 0 = oldest line, (length-1) = most recent scrollback line */ - getDirtyLines(): Map { - const dirtyLines = new Map(); - for (let y = 0; y < this._rows; y++) { - if (this.isRowDirty(y)) { - const line = this.getLine(y); - if (line) { - dirtyLines.set(y, line); - } + getScrollbackLine(offset: number): GhosttyCell[] | null { + const neededSize = this._cols * GhosttyTerminal.CELL_SIZE; + + // Ensure buffer is allocated + if (!this.viewportBufferPtr || this.viewportBufferSize < neededSize) { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); } + this.viewportBufferPtr = this.exports.ghostty_wasm_alloc_u8_array(neededSize); + this.viewportBufferSize = neededSize; } - return dirtyLines; - } - /** - * Get hyperlink URI by ID - * - * @param hyperlinkId Hyperlink ID from a GhosttyCell (0 = no link) - * @returns URI string or null if ID is invalid/not found - */ - getHyperlinkUri(hyperlinkId: number): string | null { - if (hyperlinkId === 0) return null; - - const maxUriLen = 2048; // Reasonable limit for URIs - const bufferPtr = this.exports.ghostty_wasm_alloc_u8_array(maxUriLen); - - try { - const bytesWritten = this.exports.ghostty_terminal_get_hyperlink_uri( - this.handle, - hyperlinkId, - bufferPtr, - maxUriLen - ); - - if (bytesWritten === 0) return null; - - const buffer = new Uint8Array(this.memory.buffer, bufferPtr, bytesWritten); - return new TextDecoder().decode(buffer); - } finally { - this.exports.ghostty_wasm_free_u8_array(bufferPtr, maxUriLen); + // Call update() to ensure render state is fresh (needed for colors). + // This is safe to call multiple times - dirty state persists until markClean(). + this.update(); + + const count = this.exports.ghostty_terminal_get_scrollback_line( + this.handle, + offset, + this.viewportBufferPtr, + this._cols + ); + + if (count < 0) return null; + + // Parse cells + const cells: GhosttyCell[] = []; + const buffer = this.memory.buffer; + const u8 = new Uint8Array(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); + const view = new DataView(buffer, this.viewportBufferPtr, count * GhosttyTerminal.CELL_SIZE); + + for (let i = 0; i < count; i++) { + const cellOffset = i * GhosttyTerminal.CELL_SIZE; + cells.push({ + codepoint: view.getUint32(cellOffset, true), + fg_r: u8[cellOffset + 4], + fg_g: u8[cellOffset + 5], + fg_b: u8[cellOffset + 6], + bg_r: u8[cellOffset + 7], + bg_g: u8[cellOffset + 8], + bg_b: u8[cellOffset + 9], + flags: u8[cellOffset + 10], + width: u8[cellOffset + 11], + hyperlink_id: view.getUint16(cellOffset + 12, true), + }); } + + return cells; } - // ============================================================================ - // Terminal Modes - // ============================================================================ + /** Check if a row in the active screen is wrapped (soft-wrapped to next line) */ + isRowWrapped(row: number): boolean { + return this.exports.ghostty_terminal_is_row_wrapped(this.handle, row) !== 0; + } - /** - * Query terminal mode state - */ - getMode(mode: number, isAnsi: boolean = false): boolean { - return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi ? 1 : 0) !== 0; + /** Hyperlink URI not yet exposed in simplified API */ + getHyperlinkUri(_id: number): string | null { + return null; // TODO: Add hyperlink support } /** - * Check if bracketed paste mode is enabled + * Query arbitrary terminal mode by number + * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) + * @param isAnsi True for ANSI modes, false for DEC modes (default: false) */ - hasBracketedPaste(): boolean { - return this.exports.ghostty_terminal_has_bracketed_paste(this.handle) !== 0; + getMode(mode: number, isAnsi: boolean = false): boolean { + return this.exports.ghostty_terminal_get_mode(this.handle, mode, isAnsi) !== 0; + } + + // ========================================================================== + // Private helpers + // ========================================================================== + + private initCellPool(): void { + const total = this._cols * this._rows; + if (this.cellPool.length < total) { + for (let i = this.cellPool.length; i < total; i++) { + this.cellPool.push({ + codepoint: 0, + fg_r: 204, + fg_g: 204, + fg_b: 204, + bg_r: 0, + bg_g: 0, + bg_b: 0, + flags: 0, + width: 1, + hyperlink_id: 0, + }); + } + } } - /** - * Check if focus event reporting is enabled - */ - hasFocusEvents(): boolean { - return this.exports.ghostty_terminal_has_focus_events(this.handle) !== 0; + private parseCellsIntoPool(ptr: number, count: number): void { + const buffer = this.memory.buffer; + const u8 = new Uint8Array(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); + const view = new DataView(buffer, ptr, count * GhosttyTerminal.CELL_SIZE); + + for (let i = 0; i < count; i++) { + const offset = i * GhosttyTerminal.CELL_SIZE; + const cell = this.cellPool[i]; + cell.codepoint = view.getUint32(offset, true); + cell.fg_r = u8[offset + 4]; + cell.fg_g = u8[offset + 5]; + cell.fg_b = u8[offset + 6]; + cell.bg_r = u8[offset + 7]; + cell.bg_g = u8[offset + 8]; + cell.bg_b = u8[offset + 9]; + cell.flags = u8[offset + 10]; + cell.width = u8[offset + 11]; + cell.hyperlink_id = view.getUint16(offset + 12, true); + } } - /** - * Check if mouse tracking is enabled - */ - hasMouseTracking(): boolean { - return this.exports.ghostty_terminal_has_mouse_tracking(this.handle) !== 0; + private invalidateBuffers(): void { + if (this.viewportBufferPtr) { + this.exports.ghostty_wasm_free_u8_array(this.viewportBufferPtr, this.viewportBufferSize); + this.viewportBufferPtr = 0; + this.viewportBufferSize = 0; + } } } diff --git a/lib/renderer.ts b/lib/renderer.ts index bf04f94..046665e 100644 --- a/lib/renderer.ts +++ b/lib/renderer.ts @@ -21,6 +21,8 @@ export interface IRenderable { getCursor(): { x: number; y: number; visible: boolean }; getDimensions(): { cols: number; rows: number }; isRowDirty(y: number): boolean; + /** Returns true if a full redraw is needed (e.g., screen change) */ + needsFullRedraw?(): boolean; clearDirty(): void; } @@ -251,10 +253,17 @@ export class CanvasRenderer { scrollbackProvider?: IScrollbackProvider, scrollbarOpacity: number = 1 ): void { + // getCursor() calls update() internally to ensure fresh state. + // Multiple update() calls are safe - dirty state persists until clearDirty(). const cursor = buffer.getCursor(); const dims = buffer.getDimensions(); const scrollbackLength = scrollbackProvider ? scrollbackProvider.getScrollbackLength() : 0; + // Check if buffer needs full redraw (e.g., screen change between normal/alternate) + if (buffer.needsFullRedraw?.()) { + forceAll = true; + } + // Resize canvas if dimensions changed const needsResize = this.canvas.width !== dims.cols * this.metrics.width * this.devicePixelRatio || @@ -454,10 +463,10 @@ export class CanvasRenderer { // Update last cursor position this.lastCursorPosition = { x: cursor.x, y: cursor.y }; - // Clear dirty flags after rendering - if (!forceAll) { - buffer.clearDirty(); - } + // ALWAYS clear dirty flags after rendering, regardless of forceAll. + // This is critical - if we don't clear after a full redraw, the dirty + // state persists and the next frame might not detect new changes properly. + buffer.clearDirty(); } /** @@ -503,9 +512,13 @@ export class CanvasRenderer { [fg_r, fg_g, fg_b, bg_r, bg_g, bg_b] = [bg_r, bg_g, bg_b, fg_r, fg_g, fg_b]; } - // Always draw background to clear previous character - this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); - this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + // Only draw cell background if it's different from the default (black) + // This lets the theme background (drawn in renderLine) show through for default cells + const isDefaultBg = bg_r === 0 && bg_g === 0 && bg_b === 0; + if (!isDefaultBg) { + this.ctx.fillStyle = this.rgbToCSS(bg_r, bg_g, bg_b); + this.ctx.fillRect(cellX, cellY, cellWidth, this.metrics.height); + } // Skip rendering if invisible if (cell.flags & CellFlags.INVISIBLE) { diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 1a653c9..5af1b5c 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -335,11 +335,12 @@ describe('Terminal Scrolling', () => { }); /** + * * Tests for scrolling methods and events (Phase 2) */ describe('Scrolling Methods', () => { - let term: Terminal | null = null; - let container: HTMLDivElement | null = null; + let term: Terminal; + let container: HTMLDivElement; beforeEach(async () => { container = document.createElement('div'); @@ -351,8 +352,8 @@ describe('Scrolling Methods', () => { afterEach(() => { term.dispose(); document.body.removeChild(container); - term = null; - container = null; + term = null!; + container = null!; }); test('scrollLines() should scroll viewport up', async () => { @@ -492,8 +493,8 @@ describe('Scrolling Methods', () => { }); describe('Scroll Events', () => { - let term: Terminal | null = null; - let container: HTMLDivElement | null = null; + let term: Terminal; + let container: HTMLDivElement; beforeEach(async () => { container = document.createElement('div'); @@ -503,10 +504,10 @@ describe('Scroll Events', () => { }); afterEach(() => { - term!.dispose(); + term.dispose(); document.body.removeChild(container!); - term = null; - container = null; + term = null!; + container = null!; }); test('onScroll should fire when scrolling', async () => { @@ -589,8 +590,8 @@ describe('Scroll Events', () => { }); describe('Custom Wheel Event Handler', () => { - let term: Terminal | null = null; - let container: HTMLDivElement | null = null; + let term: Terminal; + let container: HTMLDivElement; beforeEach(async () => { container = document.createElement('div'); @@ -602,8 +603,8 @@ describe('Custom Wheel Event Handler', () => { afterEach(() => { term!.dispose(); document.body.removeChild(container!); - term = null; - container = null; + term = null!; + container = null!; }); test('attachCustomWheelEventHandler() should set handler', async () => { diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 0373aac..d21279f 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -47,7 +47,7 @@ function setSelectionViewportRelative( } describe('Terminal', () => { - let container: HTMLElement | null = null; + let container: HTMLElement; beforeEach(async () => { // Create a container element if document is available @@ -61,7 +61,7 @@ describe('Terminal', () => { // Clean up container if (container && container.parentNode) { container.parentNode.removeChild(container); - container = null; + container = null!; } }); @@ -184,8 +184,8 @@ describe('Terminal', () => { term.resize(100, 30); expect(resizeEvent).not.toBeNull(); - expect(resizeEvent?.cols).toBe(100); - expect(resizeEvent?.rows).toBe(30); + expect(resizeEvent!.cols).toBe(100); + expect(resizeEvent!.rows).toBe(30); term.dispose(); }); @@ -1142,6 +1142,216 @@ describe('Buffer Access API', () => { expect(term.wasmTerm?.isAlternateScreen()).toBe(false); }); + test('alternate screen exit triggers full redraw (vim exit fix)', async () => { + if (!container) throw new Error('DOM environment not available - check happydom setup'); + + term.open(container!); + + // Write content to main screen + term.write('Main screen content line 1\r\n'); + term.write('Main screen content line 2\r\n'); + + // Clear dirty state after initial write + term.wasmTerm?.clearDirty(); + + // Verify we can read the main screen content + const mainLine0 = term.wasmTerm?.getLine(0); + expect(mainLine0).not.toBeNull(); + const mainContent = mainLine0! + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trim(); + expect(mainContent).toBe('Main screen content line 1'); + + // Enter alternate screen (like vim does) + term.write('\x1b[?1049h'); + expect(term.wasmTerm?.isAlternateScreen()).toBe(true); + + // Write different content on alternate screen + term.write('Alternate screen - vim content'); + + // Clear dirty state + term.wasmTerm?.clearDirty(); + + // Exit alternate screen (like vim :q) + term.write('\x1b[?1049l'); + expect(term.wasmTerm?.isAlternateScreen()).toBe(false); + + // The key fix: needsFullRedraw should return true after screen switch + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + + // After the switch, update() should still return FULL (for subsequent calls before clearDirty) + const dirtyState = term.wasmTerm?.update(); + expect(dirtyState).toBe(2); // DirtyState.FULL = 2 + + // The main screen content should be restored + const restoredLine0 = term.wasmTerm?.getLine(0); + expect(restoredLine0).not.toBeNull(); + const restoredContent = restoredLine0! + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trim(); + expect(restoredContent).toBe('Main screen content line 1'); + }); + + test('dirty state is cleared after markClean() following screen switch', async () => { + if (!container) throw new Error('DOM environment not available - check happydom setup'); + + term.open(container!); + + // Enter and exit alternate screen + term.write('\x1b[?1049h'); + term.write('\x1b[?1049l'); + + // First call should indicate full redraw needed + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + + // Clear the dirty state (simulating render completion) + term.wasmTerm?.clearDirty(); + + // Now needsFullRedraw should return false (no changes since last render) + expect(term.wasmTerm?.needsFullRedraw()).toBe(false); + }); + + test('multiple screen switches are handled correctly', async () => { + if (!container) throw new Error('DOM environment not available - check happydom setup'); + + term.open(container!); + term.write('Initial content\r\n'); + term.wasmTerm?.clearDirty(); + + // Enter alternate screen + term.write('\x1b[?1049h'); + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + term.wasmTerm?.clearDirty(); + expect(term.wasmTerm?.needsFullRedraw()).toBe(false); + + // Exit alternate screen + term.write('\x1b[?1049l'); + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + term.wasmTerm?.clearDirty(); + expect(term.wasmTerm?.needsFullRedraw()).toBe(false); + + // Enter again + term.write('\x1b[?1049h'); + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + term.wasmTerm?.clearDirty(); + + // Exit again + term.write('\x1b[?1049l'); + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + }); + + test('viewport content is correct after alternate screen exit', async () => { + if (!container) throw new Error('DOM environment not available - check happydom setup'); + + term.open(container!); + + // Write distinct content to main screen + term.write('MAIN_LINE_1\r\n'); + term.write('MAIN_LINE_2\r\n'); + term.write('MAIN_LINE_3\r\n'); + + // Use getLine which calls update() first + const mainLine0 = term.wasmTerm?.getLine(0); + expect(mainLine0).not.toBeNull(); + + const mainLine1 = mainLine0! + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trim(); + expect(mainLine1).toBe('MAIN_LINE_1'); + + // Clear dirty state to simulate render completion + term.wasmTerm?.clearDirty(); + + // Enter alternate screen + term.write('\x1b[?1049h'); + expect(term.wasmTerm?.isAlternateScreen()).toBe(true); + + // Write different content to alternate screen + term.write('ALT_LINE_1\r\n'); + term.write('ALT_LINE_2\r\n'); + + // Skip checking alternate screen content - focus on the key issue: + // Does main screen content get restored after exit? + + // Clear dirty state + term.wasmTerm?.clearDirty(); + + // Exit alternate screen (like vim :q) + term.write('\x1b[?1049l'); + expect(term.wasmTerm?.isAlternateScreen()).toBe(false); + + // CRITICAL: needsFullRedraw must be true + expect(term.wasmTerm?.needsFullRedraw()).toBe(true); + + // CRITICAL: getLine must return MAIN screen content, not alternate + const restoredLine0 = term.wasmTerm?.getLine(0); + expect(restoredLine0).not.toBeNull(); + + const restoredLine1Content = restoredLine0! + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trim(); + + // This is the key assertion - content must be from main screen + expect(restoredLine1Content).toBe('MAIN_LINE_1'); + + // Also check second line to be thorough + const restoredLine1 = term.wasmTerm?.getLine(1); + const restoredLine2Content = restoredLine1! + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trim(); + expect(restoredLine2Content).toBe('MAIN_LINE_2'); + }); + + test('background colors are correctly restored after alternate screen exit', async () => { + if (!container) throw new Error('DOM environment not available - check happydom setup'); + + term.open(container!); + + // Write to main screen (default background = black) + term.write('MAIN\r\n'); + term.wasmTerm?.update(); + term.wasmTerm?.markClean(); + + // Enter alternate screen and fill with colored background (like vim does) + term.write('\x1b[?1049h'); // Enter alt screen + term.write('\x1b[H'); // Home + term.write('\x1b[44m'); // Blue background (palette color 4) + + // Fill screen with spaces that have blue background + for (let y = 0; y < term.rows; y++) { + term.write(' '.repeat(term.cols)); + if (y < term.rows - 1) term.write('\r\n'); + } + + term.wasmTerm?.update(); + term.wasmTerm?.markClean(); + + // Verify alternate screen has non-default background + const altViewport = term.wasmTerm?.getViewport(); + expect(altViewport![0].bg_r).not.toBe(0); // Should be blue-ish + + // Exit alternate screen + term.write('\x1b[?1049l'); + term.wasmTerm?.update(); + + // CRITICAL: Background colors must be restored to main screen values (black) + const restoredViewport = term.wasmTerm?.getViewport(); + const firstCell = restoredViewport![0]; + + // Main screen cells should have default background (0, 0, 0 = black) + expect(firstCell.bg_r).toBe(0); + expect(firstCell.bg_g).toBe(0); + expect(firstCell.bg_b).toBe(0); + + // Verify text is also restored + expect(String.fromCodePoint(firstCell.codepoint)).toBe('M'); + }); + test('isRowWrapped() returns false for normal line breaks', async () => { if (!container) throw new Error('DOM environment not available - check happydom setup'); @@ -1316,6 +1526,466 @@ describe('Terminal Modes', () => { }); }); +describe('Alternate Screen Rendering', () => { + /** + * Helper to get line content as a string + */ + function getLineContent(term: Terminal, y: number): string { + const line = term.wasmTerm?.getLine(y); + if (!line) return ''; + return line + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trimEnd(); + } + + /** + * Helper to check if a line is empty (all spaces/null codepoints) + */ + function isLineEmpty(term: Terminal, y: number): boolean { + const line = term.wasmTerm?.getLine(y); + if (!line) return true; + return line.every((c) => c.codepoint === 0 || c.codepoint === 32); + } + + /** + * Helper to get line content directly from viewport + */ + function getViewportLineContent(term: Terminal, y: number): string { + const viewport = term.wasmTerm?.getViewport(); + if (!viewport) return ''; + const cols = term.cols; + const start = y * cols; + return viewport + .slice(start, start + cols) + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trimEnd(); + } + + test('BUG REPRO: getLine and getViewport should return same data after partial updates', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + // Enter alternate screen + term.write('\x1b[?1049h'); + + // Draw content in middle (like vim welcome) + term.write('\x1b[12;30HWelcome to Vim!'); + term.write('\x1b[13;30HPress i to insert'); + + // First render cycle + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + + // Verify initial state with BOTH methods + const line11_initial_getLine = getLineContent(term, 11); + const line11_initial_viewport = getViewportLineContent(term, 11); + expect(line11_initial_getLine).toContain('Welcome to Vim!'); + expect(line11_initial_viewport).toContain('Welcome to Vim!'); + expect(line11_initial_getLine).toBe(line11_initial_viewport); + + // Now simulate typing at top (vim insert mode) + // Just write to row 0, don't clear middle + term.write('\x1b[1;1H'); // cursor to top + term.write('typing here'); + + // Render cycle + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + + // Check row 0 - should have new content + const line0_getLine = getLineContent(term, 0); + const line0_viewport = getViewportLineContent(term, 0); + expect(line0_getLine).toBe('typing here'); + expect(line0_viewport).toBe('typing here'); + expect(line0_getLine).toBe(line0_viewport); + + // CRITICAL: Check row 11 - should STILL have welcome content + // Both methods MUST return the same data + const line11_getLine = getLineContent(term, 11); + const line11_viewport = getViewportLineContent(term, 11); + + console.log('After typing at top:'); + console.log(' line11 via getLine:', JSON.stringify(line11_getLine)); + console.log(' line11 via viewport:', JSON.stringify(line11_viewport)); + + expect(line11_getLine).toContain('Welcome to Vim!'); + expect(line11_viewport).toContain('Welcome to Vim!'); + expect(line11_getLine).toBe(line11_viewport); + + term.dispose(); + }); + + test('BUG REPRO: cells should have correct codepoints after clearing', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + + // Draw content in middle + term.write('\x1b[12;30HWelcome!'); + + // Verify it's there + const line11 = term.wasmTerm?.getLine(11); + const welcomeCell = line11?.[29]; // 0-indexed, so col 30 is index 29 + console.log('Welcome cell:', welcomeCell); + expect(welcomeCell?.codepoint).toBe('W'.charCodeAt(0)); + + // Clear dirty and "render" + term.wasmTerm?.clearDirty(); + + // Now clear the line using EL (Erase in Line) + term.write('\x1b[12;1H\x1b[K'); // Move to row 12, clear entire line + + // Check the cell again - should be empty (codepoint 0 or 32) + const line11After = term.wasmTerm?.getLine(11); + const clearedCell = line11After?.[29]; + console.log('Cleared cell:', clearedCell); + expect(clearedCell?.codepoint === 0 || clearedCell?.codepoint === 32).toBe(true); + + term.dispose(); + }); + + test('BUG REPRO: multiple render cycles should not lose data', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + + // Draw content across multiple rows + term.write('\x1b[1;1HROW_0'); + term.write('\x1b[6;1HROW_5'); + term.write('\x1b[11;1HROW_10'); + term.write('\x1b[16;1HROW_15'); + term.write('\x1b[21;1HROW_20'); + + // Simulate 10 render cycles with typing at top + for (let i = 0; i < 10; i++) { + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + + // Type at top + term.write(`\x1b[1;1H\x1b[KIteration ${i}`); + } + + // Final render + term.wasmTerm?.update(); + + // Check ALL rows - each should have expected content + const results: Record = {}; + for (const row of [0, 5, 10, 15, 20]) { + results[row] = { + getLine: getLineContent(term, row), + viewport: getViewportLineContent(term, row), + }; + console.log(`Row ${row}:`, results[row]); + } + + // Row 0 should have latest iteration + expect(results[0].getLine).toBe('Iteration 9'); + expect(results[0].viewport).toBe('Iteration 9'); + + // Other rows should be unchanged + expect(results[5].getLine).toBe('ROW_5'); + expect(results[5].viewport).toBe('ROW_5'); + expect(results[10].getLine).toBe('ROW_10'); + expect(results[10].viewport).toBe('ROW_10'); + expect(results[15].getLine).toBe('ROW_15'); + expect(results[15].viewport).toBe('ROW_15'); + expect(results[20].getLine).toBe('ROW_20'); + expect(results[20].viewport).toBe('ROW_20'); + + term.dispose(); + }); + + test('can enter alternate screen and write content', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + expect(term.wasmTerm?.isAlternateScreen()).toBe(true); + + term.write('Hello World'); + expect(getLineContent(term, 0)).toBe('Hello World'); + + term.dispose(); + }); + + test('writing to line 0 should not affect content on line 10', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[11;1HMIDDLE_CONTENT'); + expect(getLineContent(term, 10)).toBe('MIDDLE_CONTENT'); + expect(isLineEmpty(term, 0)).toBe(true); + + term.wasmTerm?.clearDirty(); + + term.write('\x1b[1;1HTOP_CONTENT'); + expect(getLineContent(term, 0)).toBe('TOP_CONTENT'); + expect(getLineContent(term, 10)).toBe('MIDDLE_CONTENT'); + + term.dispose(); + }); + + test('erasing display should clear all content including middle', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[11;1HMIDDLE_CONTENT'); + expect(getLineContent(term, 10)).toBe('MIDDLE_CONTENT'); + + term.wasmTerm?.clearDirty(); + term.write('\x1b[2J'); + expect(isLineEmpty(term, 10)).toBe(true); + + term.dispose(); + }); + + test('simulating vim-like behavior: welcome screen then typing', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[2J'); + term.write('\x1b[12;30HWelcome to Vim!'); + term.write('\x1b[13;30HPress i to insert'); + + expect(getLineContent(term, 11)).toContain('Welcome to Vim!'); + expect(getLineContent(term, 12)).toContain('Press i to insert'); + + term.wasmTerm?.clearDirty(); + term.write('\x1b[1;1H\x1b[J'); + + expect(isLineEmpty(term, 0)).toBe(true); + expect(isLineEmpty(term, 11)).toBe(true); + expect(isLineEmpty(term, 12)).toBe(true); + + term.dispose(); + }); + + test('REGRESSION: middle content persists incorrectly after partial updates', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[11;1HMIDDLE_LINE'); + + term.wasmTerm?.update(); + expect(getLineContent(term, 10)).toBe('MIDDLE_LINE'); + term.wasmTerm?.clearDirty(); + + for (let i = 0; i < 5; i++) { + term.write('\x1b[1;1H\x1b[K'); + term.write(`Typing iteration ${i}`); + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + } + + expect(getLineContent(term, 0)).toBe('Typing iteration 4'); + expect(getLineContent(term, 10)).toBe('MIDDLE_LINE'); + + term.dispose(); + }); + + test('getLine returns fresh data after each update', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('INITIAL'); + expect(getLineContent(term, 0)).toBe('INITIAL'); + + term.write('\x1b[1;1HCHANGED'); + expect(getLineContent(term, 0)).toBe('CHANGED'); + + term.dispose(); + }); + + test('full viewport retrieval reflects actual terminal state', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[2J'); + term.write('\x1b[1;1HLINE_0'); + term.write('\x1b[6;1HLINE_5'); + term.write('\x1b[11;1HLINE_10'); + term.write('\x1b[16;1HLINE_15'); + term.write('\x1b[21;1HLINE_20'); + + expect(getLineContent(term, 0)).toBe('LINE_0'); + expect(getLineContent(term, 5)).toBe('LINE_5'); + expect(getLineContent(term, 10)).toBe('LINE_10'); + expect(getLineContent(term, 15)).toBe('LINE_15'); + expect(getLineContent(term, 20)).toBe('LINE_20'); + + expect(isLineEmpty(term, 1)).toBe(true); + expect(isLineEmpty(term, 7)).toBe(true); + expect(isLineEmpty(term, 12)).toBe(true); + + term.dispose(); + }); + + test('ED (Erase Display) sequences work correctly in alternate screen', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + for (let i = 0; i < 24; i++) { + term.write(`\x1b[${i + 1};1HRow ${i.toString().padStart(2, '0')}`); + } + + expect(getLineContent(term, 0)).toBe('Row 00'); + expect(getLineContent(term, 10)).toBe('Row 10'); + expect(getLineContent(term, 23)).toBe('Row 23'); + + term.wasmTerm?.clearDirty(); + term.write('\x1b[2J'); + + for (let i = 0; i < 24; i++) { + expect(isLineEmpty(term, i)).toBe(true); + } + + term.dispose(); + }); + + test('ED 0 (erase from cursor to end) works correctly', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + for (let i = 0; i < 24; i++) { + term.write(`\x1b[${i + 1};1HRow ${i.toString().padStart(2, '0')}`); + } + + term.wasmTerm?.clearDirty(); + term.write('\x1b[11;1H\x1b[J'); + + expect(getLineContent(term, 0)).toBe('Row 00'); + expect(getLineContent(term, 9)).toBe('Row 09'); + + for (let i = 10; i < 24; i++) { + expect(isLineEmpty(term, i)).toBe(true); + } + + term.dispose(); + }); + + test('multiple update/clearDirty cycles maintain correct state', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + + term.write('\x1b[11;1HMIDDLE'); + term.wasmTerm?.update(); + expect(getLineContent(term, 10)).toBe('MIDDLE'); + term.wasmTerm?.clearDirty(); + + term.write('\x1b[1;1HTOP'); + term.wasmTerm?.update(); + expect(getLineContent(term, 0)).toBe('TOP'); + expect(getLineContent(term, 10)).toBe('MIDDLE'); + term.wasmTerm?.clearDirty(); + + term.write('\x1b[11;1H\x1b[K'); + term.wasmTerm?.update(); + expect(getLineContent(term, 0)).toBe('TOP'); + expect(isLineEmpty(term, 10)).toBe(true); + term.wasmTerm?.clearDirty(); + + term.wasmTerm?.update(); + expect(getLineContent(term, 0)).toBe('TOP'); + expect(isLineEmpty(term, 10)).toBe(true); + + term.dispose(); + }); + + test('clearing a line marks it dirty', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[11;1HMIDDLE'); + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + + term.wasmTerm?.update(); + expect(term.wasmTerm?.isRowDirty(10)).toBeFalsy(); + + term.write('\x1b[11;1H\x1b[K'); + term.wasmTerm?.update(); + expect(term.wasmTerm?.isRowDirty(10)).toBeTruthy(); + + term.dispose(); + }); + + test('ED sequence marks all affected rows dirty', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + for (let i = 0; i < 24; i++) { + term.write(`\x1b[${i + 1};1HRow${i}`); + } + term.wasmTerm?.update(); + term.wasmTerm?.clearDirty(); + + term.write('\x1b[6;1H\x1b[J'); + term.wasmTerm?.update(); + + for (let i = 0; i < 5; i++) { + expect(term.wasmTerm?.isRowDirty(i)).toBeFalsy(); + } + for (let i = 5; i < 24; i++) { + expect(term.wasmTerm?.isRowDirty(i)).toBeTruthy(); + } + + term.dispose(); + }); + + test('getViewport and getLine return consistent data', async () => { + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + term.write('\x1b[?1049h'); + term.write('\x1b[5;1HVIEWPORT_TEST'); + + const lineContent = getLineContent(term, 4); + const viewport = term.wasmTerm?.getViewport(); + const viewportLineContent = viewport + ?.slice(4 * 80, 5 * 80) + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trimEnd(); + + expect(lineContent).toBe('VIEWPORT_TEST'); + expect(viewportLineContent).toBe('VIEWPORT_TEST'); + + term.dispose(); + }); +}); + describe('Selection with Scrollback', () => { let container: HTMLElement | null = null; @@ -1351,7 +2021,7 @@ describe('Selection with Scrollback', () => { // Scroll up 50 lines to view older content term.scrollLines(-50); - expect(term.viewportY).toBe(50); + expect(term.getViewportY()).toBe(50); // The viewport now shows: // - Lines 0-23 of viewport = Lines 27-50 of the original output @@ -1399,7 +2069,7 @@ describe('Selection with Scrollback', () => { // Scroll up 10 lines (less than screen height) term.scrollLines(-10); - expect(term.viewportY).toBe(10); + expect(term.getViewportY()).toBe(10); // Now viewport shows: // - Top 10 rows: scrollback content (lines 67-76) @@ -1438,7 +2108,7 @@ describe('Selection with Scrollback', () => { } // Don't scroll - should be at bottom (viewportY = 0) - expect(term.viewportY).toBe(0); + expect(term.getViewportY()).toBe(0); // Select from screen buffer (last visible lines) // Using helper to convert viewport rows to absolute coordinates @@ -1512,7 +2182,7 @@ describe('Selection with Scrollback', () => { // Scroll to top to view oldest content term.scrollToTop(); - const viewportY = term.viewportY; + const viewportY = term.getViewportY(); // Should be scrolled up significantly expect(viewportY).toBeGreaterThan(0); diff --git a/lib/terminal.ts b/lib/terminal.ts index 22f8498..ab07721 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -36,7 +36,6 @@ import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; -import type { GhosttyTerminalConfig } from './types'; import type { ILink, ILinkProvider } from './types'; // ============================================================================ @@ -113,7 +112,7 @@ export class Terminal implements ITerminalCore { private currentTitle: string = ''; // Phase 2: Viewport and scrolling state - private viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) + public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) private targetViewportY: number = 0; // Target viewport position for smooth scrolling private scrollAnimationStartTime?: number; private scrollAnimationStartY?: number; @@ -175,82 +174,6 @@ export class Terminal implements ITerminalCore { this.buffer = new BufferNamespace(this); } - // ========================================================================== - // Theme to WASM Config Conversion - // ========================================================================== - - /** - * Parse a CSS color string to 0xRRGGBB format. - * Returns 0 if the color is undefined or invalid. - */ - private parseColorToHex(color?: string): number { - if (!color) return 0; - - // Handle hex colors (#RGB, #RRGGBB) - if (color.startsWith('#')) { - let hex = color.slice(1); - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? 0 : value; - } - - // Handle rgb(r, g, b) format - const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (match) { - const r = Number.parseInt(match[1], 10); - const g = Number.parseInt(match[2], 10); - const b = Number.parseInt(match[3], 10); - return (r << 16) | (g << 8) | b; - } - - return 0; - } - - /** - * Convert terminal options to WASM terminal config. - */ - private buildWasmConfig(): GhosttyTerminalConfig | undefined { - const theme = this.options.theme; - const scrollback = this.options.scrollback; - - // If no theme and default scrollback, use defaults - if (!theme && scrollback === 1000) { - return undefined; - } - - // Build palette array from theme colors - // Order: black, red, green, yellow, blue, magenta, cyan, white, - // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite - const palette: number[] = [ - this.parseColorToHex(theme?.black), - this.parseColorToHex(theme?.red), - this.parseColorToHex(theme?.green), - this.parseColorToHex(theme?.yellow), - this.parseColorToHex(theme?.blue), - this.parseColorToHex(theme?.magenta), - this.parseColorToHex(theme?.cyan), - this.parseColorToHex(theme?.white), - this.parseColorToHex(theme?.brightBlack), - this.parseColorToHex(theme?.brightRed), - this.parseColorToHex(theme?.brightGreen), - this.parseColorToHex(theme?.brightYellow), - this.parseColorToHex(theme?.brightBlue), - this.parseColorToHex(theme?.brightMagenta), - this.parseColorToHex(theme?.brightCyan), - this.parseColorToHex(theme?.brightWhite), - ]; - - return { - scrollbackLimit: scrollback, - fgColor: this.parseColorToHex(theme?.foreground), - bgColor: this.parseColorToHex(theme?.background), - cursorColor: this.parseColorToHex(theme?.cursor), - palette, - }; - } - // ========================================================================== // Option Change Handling (for mutable options) // ========================================================================== @@ -336,9 +259,8 @@ export class Terminal implements ITerminalCore { parent.setAttribute('aria-label', 'Terminal input'); parent.setAttribute('aria-multiline', 'true'); - // Create WASM terminal with current dimensions and theme config - const wasmConfig = this.buildWasmConfig(); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, wasmConfig); + // Create WASM terminal with current dimensions + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows);; // Create canvas element this.canvas = document.createElement('canvas'); @@ -453,7 +375,7 @@ export class Terminal implements ITerminalCore { // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); - // Render initial blank screen + // Render initial blank screen (force full redraw) this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); // Start render loop @@ -638,8 +560,7 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm) { this.wasmTerm.free(); } - const wasmConfig = this.buildWasmConfig(); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, wasmConfig); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows); // Clear renderer this.renderer!.clear(); @@ -1045,16 +966,21 @@ export class Terminal implements ITerminalCore { private startRenderLoop(): void { const loop = () => { if (!this.isDisposed && this.isOpen) { + // Render using WASM's native dirty tracking + // The render() method: + // 1. Calls update() once to sync state and check dirty flags + // 2. Only redraws dirty rows when forceAll=false + // 3. Always calls clearDirty() at the end + this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); + // Check for cursor movement (Phase 2: onCursorMove event) + // Note: getCursor() reads from already-updated render state (from render() above) const cursor = this.wasmTerm!.getCursor(); if (cursor.y !== this.lastCursorY) { this.lastCursorY = cursor.y; this.cursorMoveEmitter.fire(); } - // Render only dirty lines for 60 FPS performance (with scrollbar opacity) - this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); - // Note: onRender event is intentionally not fired in the render loop // to avoid performance issues. For now, consumers can use requestAnimationFrame // if they need frame-by-frame updates. diff --git a/lib/types.ts b/lib/types.ts index 7cf6b49..76a1fe1 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -342,45 +342,43 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { ghostty_key_event_set_mods(event: number, mods: number): void; ghostty_key_event_set_utf8(event: number, ptr: number, len: number): void; - // Terminal + // Terminal lifecycle ghostty_terminal_new(cols: number, rows: number): TerminalHandle; - ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; ghostty_terminal_free(terminal: TerminalHandle): void; - ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; - ghostty_terminal_get_cols(terminal: TerminalHandle): number; - ghostty_terminal_get_rows(terminal: TerminalHandle): number; - ghostty_terminal_get_cursor_x(terminal: TerminalHandle): number; - ghostty_terminal_get_cursor_y(terminal: TerminalHandle): number; - ghostty_terminal_get_cursor_visible(terminal: TerminalHandle): boolean; - ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; - ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): boolean; - ghostty_terminal_is_dirty(terminal: TerminalHandle): boolean; - ghostty_terminal_is_row_dirty(terminal: TerminalHandle, row: number): boolean; - ghostty_terminal_clear_dirty(terminal: TerminalHandle): void; - ghostty_terminal_get_hyperlink_uri( - terminal: TerminalHandle, - hyperlinkId: number, - bufPtr: number, - bufLen: number - ): number; // returns bytes written - ghostty_terminal_get_line( + ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; + + // RenderState API - high-performance rendering (ONE call gets ALL data) + ghostty_render_state_update(terminal: TerminalHandle): number; // 0=none, 1=partial, 2=full + ghostty_render_state_get_cols(terminal: TerminalHandle): number; + ghostty_render_state_get_rows(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_x(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_y(terminal: TerminalHandle): number; + ghostty_render_state_get_cursor_visible(terminal: TerminalHandle): boolean; + ghostty_render_state_get_bg_color(terminal: TerminalHandle): number; // 0xRRGGBB + ghostty_render_state_get_fg_color(terminal: TerminalHandle): number; // 0xRRGGBB + ghostty_render_state_is_row_dirty(terminal: TerminalHandle, row: number): boolean; + ghostty_render_state_mark_clean(terminal: TerminalHandle): void; + ghostty_render_state_get_viewport( terminal: TerminalHandle, - row: number, bufPtr: number, bufLen: number - ): number; + ): number; // Returns total cells written or -1 on error + + // Terminal modes + ghostty_terminal_is_alternate_screen(terminal: TerminalHandle): boolean; + ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; + ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: boolean): number; + + // Scrollback API + ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; ghostty_terminal_get_scrollback_line( terminal: TerminalHandle, offset: number, bufPtr: number, bufLen: number - ): number; - ghostty_terminal_get_scrollback_length(terminal: TerminalHandle): number; - ghostty_terminal_get_mode(terminal: TerminalHandle, mode: number, isAnsi: number): number; - ghostty_terminal_has_bracketed_paste(terminal: TerminalHandle): number; - ghostty_terminal_has_focus_events(terminal: TerminalHandle): number; - ghostty_terminal_has_mouse_tracking(terminal: TerminalHandle): number; + ): number; // Returns cells written or -1 on error + ghostty_terminal_is_row_wrapped(terminal: TerminalHandle, row: number): number; } // ============================================================================ @@ -388,22 +386,55 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // ============================================================================ /** - * Terminal configuration for WASM. - * All colors use 0xRRGGBB format. A value of 0 means "use default". + * Dirty state from RenderState */ -export interface GhosttyTerminalConfig { - scrollbackLimit?: number; - fgColor?: number; // 0xRRGGBB - bgColor?: number; // 0xRRGGBB - cursorColor?: number; // 0xRRGGBB - palette?: number[]; // 16 colors, 0xRRGGBB format +export enum DirtyState { + NONE = 0, + PARTIAL = 1, + FULL = 2, } /** - * Size of GhosttyTerminalConfig struct in WASM memory (bytes). - * Layout: scrollback_limit(u32) + fg_color(u32) + bg_color(u32) + cursor_color(u32) + palette[16](u32*16) - * Total: 4 + 4 + 4 + 4 + 64 = 80 bytes + * Cursor state from RenderState (8 bytes packed) + * Layout: x(u16) + y(u16) + viewport_x(i16) + viewport_y(i16) + visible(bool) + blinking(bool) + style(u8) + _pad(u8) */ +export interface RenderStateCursor { + x: number; + y: number; + viewportX: number; // -1 if not in viewport + viewportY: number; + visible: boolean; + blinking: boolean; + style: 'block' | 'underline' | 'bar'; +} + +/** + * Colors from RenderState (12 bytes packed) + */ +export interface RenderStateColors { + background: RGB; + foreground: RGB; + cursor: RGB | null; +} + +/** + * Size of cursor struct in WASM (8 bytes) + */ +export const CURSOR_STRUCT_SIZE = 8; + +/** + * Size of colors struct in WASM (12 bytes) + */ +export const COLORS_STRUCT_SIZE = 12; + +// Legacy - kept for compatibility but not used with new API +export interface GhosttyTerminalConfig { + scrollbackLimit?: number; + fgColor?: number; + bgColor?: number; + cursorColor?: number; + palette?: number[]; +} export const GHOSTTY_CONFIG_SIZE = 80; /** diff --git a/package.json b/package.json index 6af0ce4..f9a27c7 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ "test": "bun test", "typecheck": "tsc --noEmit", "fmt": "prettier --check .", - "fmt:fix": "prettier --write .", + "fmt:fix": "prettier --write --cache .", "lint": "biome check .", "lint:fix": "biome check --write .", "prepublishOnly": "bun run build" @@ -64,6 +64,9 @@ "@biomejs/biome": "^1.9.4", "@happy-dom/global-registrator": "^15.11.0", "@types/bun": "^1.3.2", + "@xterm/headless": "^5.5.0", + "@xterm/xterm": "^5.5.0", + "mitata": "^1.0.34", "prettier": "^3.6.2", "typescript": "^5.9.3", "vite": "^4.5.0", diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index 266652e..d6f2042 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -27,71 +27,154 @@ index 4f8fef88e..ca9fb1d4d 100644 #include #include #include +diff --git a/src/lib_vt.zig b/src/lib_vt.zig +index 03a883e20..22e4fe909 100644 +--- a/src/lib_vt.zig ++++ b/src/lib_vt.zig +@@ -140,6 +140,34 @@ comptime { + @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); + @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); + @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); ++ // Terminal lifecycle ++ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); ++ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); ++ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); ++ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); ++ ++ // RenderState API - high-performance rendering ++ @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); ++ @export(&c.render_state_get_cols, .{ .name = "ghostty_render_state_get_cols" }); ++ @export(&c.render_state_get_rows, .{ .name = "ghostty_render_state_get_rows" }); ++ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" }); ++ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" }); ++ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" }); ++ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" }); ++ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" }); ++ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); ++ @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); ++ @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); ++ ++ // Terminal modes ++ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); ++ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); ++ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); ++ ++ // Scrollback API ++ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); ++ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); ++ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); + + // On Wasm we need to export our allocator convenience functions. + if (builtin.target.cpu.arch.isWasm()) { +diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig +index bc92597f5..cb5ccb3a1 100644 +--- a/src/terminal/c/main.zig ++++ b/src/terminal/c/main.zig +@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); + pub const key_encode = @import("key_encode.zig"); + pub const paste = @import("paste.zig"); + pub const sgr = @import("sgr.zig"); ++pub const terminal = @import("terminal.zig"); + + // The full C API, unexported. + pub const osc_new = osc.new; +@@ -52,6 +53,35 @@ pub const key_encoder_encode = key_encode.encode; + + pub const paste_is_safe = paste.is_safe; + ++// Terminal lifecycle ++pub const terminal_new = terminal.new; ++pub const terminal_free = terminal.free; ++pub const terminal_resize = terminal.resize; ++pub const terminal_write = terminal.write; ++ ++// RenderState API - high-performance rendering ++pub const render_state_update = terminal.renderStateUpdate; ++pub const render_state_get_cols = terminal.renderStateGetCols; ++pub const render_state_get_rows = terminal.renderStateGetRows; ++pub const render_state_get_cursor_x = terminal.renderStateGetCursorX; ++pub const render_state_get_cursor_y = terminal.renderStateGetCursorY; ++pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible; ++pub const render_state_get_bg_color = terminal.renderStateGetBgColor; ++pub const render_state_get_fg_color = terminal.renderStateGetFgColor; ++pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; ++pub const render_state_mark_clean = terminal.renderStateMarkClean; ++pub const render_state_get_viewport = terminal.renderStateGetViewport; ++ ++// Terminal modes ++pub const terminal_is_alternate_screen = terminal.isAlternateScreen; ++pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; ++pub const terminal_get_mode = terminal.getMode; ++ ++// Scrollback API ++pub const terminal_get_scrollback_length = terminal.getScrollbackLength; ++pub const terminal_get_scrollback_line = terminal.getScrollbackLine; ++pub const terminal_is_row_wrapped = terminal.isRowWrapped; ++ + test { + _ = color; + _ = osc; +@@ -59,6 +89,7 @@ test { + _ = key_encode; + _ = paste; + _ = sgr; ++ _ = terminal; + + // We want to make sure we run the tests for the C allocator interface. + _ = @import("../../lib/allocator.zig"); +diff --git a/src/terminal/render.zig b/src/terminal/render.zig +index b6430ea34..10e0ef79d 100644 +--- a/src/terminal/render.zig ++++ b/src/terminal/render.zig +@@ -322,13 +322,14 @@ pub const RenderState = struct { + // Colors. + self.colors.cursor = t.colors.cursor.get(); + self.colors.palette = t.colors.palette.current; +- bg_fg: { ++ { + // Background/foreground can be unset initially which would +- // depend on "default" background/foreground. The expected use +- // case of Terminal is that the caller set their own configured +- // defaults on load so this doesn't happen. +- const bg = t.colors.background.get() orelse break :bg_fg; +- const fg = t.colors.foreground.get() orelse break :bg_fg; ++ // depend on "default" background/foreground. Use sensible defaults ++ // (black background, light gray foreground) when not explicitly set. ++ const default_bg: color.RGB = .{ .r = 0, .g = 0, .b = 0 }; ++ const default_fg: color.RGB = .{ .r = 204, .g = 204, .b = 204 }; ++ const bg = t.colors.background.get() orelse default_bg; ++ const fg = t.colors.foreground.get() orelse default_fg; + if (t.modes.get(.reverse_colors)) { + self.colors.background = fg; + self.colors.foreground = bg; diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 -index 000000000..078a0b872 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,499 @@ +@@ -0,0 +1,162 @@ +/** + * @file terminal.h + * -+ * Complete terminal emulator API for WASM integration. ++ * Minimal, high-performance terminal emulator API for WASM. ++ * ++ * The key optimization is the RenderState API which provides a pre-computed ++ * snapshot of all render data in a single update call, avoiding multiple ++ * WASM boundary crossings. ++ * ++ * Basic usage: ++ * 1. Create terminal: ghostty_terminal_new(80, 24) ++ * 2. Write data: ghostty_terminal_write(term, data, len) ++ * 3. Each frame: ++ * - ghostty_render_state_update(term) ++ * - ghostty_render_state_get_viewport(term, buffer, size) ++ * - Render the buffer ++ * - ghostty_render_state_mark_clean(term) ++ * 4. Free: ghostty_terminal_free(term) + */ + +#ifndef GHOSTTY_VT_TERMINAL_H +#define GHOSTTY_VT_TERMINAL_H + -+/** @defgroup terminal Terminal Emulator -+ * -+ * Complete terminal emulator with VT100/ANSI parsing and screen buffer management. -+ * -+ * This API exports Ghostty's production-tested terminal emulator for use in -+ * WASM environments. It handles all VT sequence parsing, screen buffer management, -+ * scrollback, cursor positioning, and text styling. -+ * -+ * ## Basic Usage -+ * -+ * 1. Create a terminal with ghostty_terminal_new() -+ * 2. Write data with ghostty_terminal_write() (parses VT sequences) -+ * 3. Read screen content with ghostty_terminal_get_line() -+ * 4. Query cursor position with ghostty_terminal_get_cursor_x/y() -+ * 5. Free with ghostty_terminal_free() when done -+ * -+ * ## Example -+ * -+ * @code{.c} -+ * #include -+ * #include -+ * -+ * int main() { -+ * // Create 80x24 terminal -+ * GhosttyTerminal term = ghostty_terminal_new(80, 24); -+ * if (!term) return 1; -+ * -+ * // Write some text with color -+ * const char* data = "Hello \x1b[31mRed\x1b[0m World!"; -+ * ghostty_terminal_write(term, (const uint8_t*)data, strlen(data)); -+ * -+ * // Read first line -+ * GhosttyCell cells[80]; -+ * int count = ghostty_terminal_get_line(term, 0, cells, 80); -+ * -+ * // Check cursor position -+ * int x = ghostty_terminal_get_cursor_x(term); -+ * int y = ghostty_terminal_get_cursor_y(term); -+ * -+ * // Cleanup -+ * ghostty_terminal_free(term); -+ * return 0; -+ * } -+ * @endcode -+ * -+ * @{ -+ */ -+ -+#include -+#include +#include +#include +#include @@ -100,1076 +183,613 @@ index 000000000..078a0b872 +extern "C" { +#endif + -+/** -+ * Opaque terminal handle. -+ * -+ * Represents a terminal emulator instance. Create with ghostty_terminal_new() -+ * and free with ghostty_terminal_free(). -+ */ ++/** Opaque terminal handle */ +typedef void* GhosttyTerminal; + -+/** -+ * Terminal configuration options. -+ * -+ * Used when creating a new terminal to specify behavior and limits. -+ * All colors use 0xRRGGBB format. A value of 0 means "use default". -+ */ -+typedef struct { -+ /** -+ * Maximum scrollback lines (0 = unlimited, default = 10000). -+ * -+ * Limits memory usage by restricting how many lines of history are kept. -+ * For WASM environments, a reasonable limit is recommended. -+ */ -+ uint32_t scrollback_limit; -+ -+ /** -+ * Initial foreground color (RGB, 0xRRGGBB format, 0 = use default). -+ */ -+ uint32_t fg_color; -+ -+ /** -+ * Initial background color (RGB, 0xRRGGBB format, 0 = use default). -+ */ -+ uint32_t bg_color; -+ -+ /** -+ * Cursor color (RGB, 0xRRGGBB format, 0 = use default). -+ */ -+ uint32_t cursor_color; -+ -+ /** -+ * ANSI color palette (16 colors, 0xRRGGBB format, 0 = use default). -+ * Index 0-7: Normal colors (black, red, green, yellow, blue, magenta, cyan, white) -+ * Index 8-15: Bright colors (same order) -+ */ -+ uint32_t palette[16]; -+} GhosttyTerminalConfig; -+ -+/** -+ * Cell structure - represents a single character position. -+ * -+ * This is designed to be simple and C-compatible. Colors are always -+ * exported as RGB (terminal color palette is resolved internally). -+ * -+ * Size: 16 bytes (efficient for bulk transfers across WASM boundary) -+ */ ++/** Cell structure - 16 bytes, pre-resolved colors */ +typedef struct { -+ /** Unicode codepoint (0 = empty cell) */ + uint32_t codepoint; -+ -+ /** Foreground color - Red component (0-255) */ -+ uint8_t fg_r; -+ /** Foreground color - Green component (0-255) */ -+ uint8_t fg_g; -+ /** Foreground color - Blue component (0-255) */ -+ uint8_t fg_b; -+ -+ /** Background color - Red component (0-255) */ -+ uint8_t bg_r; -+ /** Background color - Green component (0-255) */ -+ uint8_t bg_g; -+ /** Background color - Blue component (0-255) */ -+ uint8_t bg_b; -+ -+ /** Style flags (see GHOSTTY_CELL_* constants) */ ++ uint8_t fg_r, fg_g, fg_b; ++ uint8_t bg_r, bg_g, bg_b; + uint8_t flags; -+ -+ /** Character width: 0=combining, 1=normal, 2=wide (CJK) */ + uint8_t width; -+ -+ /** Hyperlink ID (0 = no link, >0 = lookup in hyperlink set) */ + uint16_t hyperlink_id; -+ -+ /** Padding for alignment (keeps struct at 16 bytes) */ -+ uint32_t _padding; ++ uint16_t _pad; +} GhosttyCell; + -+/** Cell flag: Bold text */ ++/** Cell flags */ +#define GHOSTTY_CELL_BOLD (1 << 0) -+/** Cell flag: Italic text */ +#define GHOSTTY_CELL_ITALIC (1 << 1) -+/** Cell flag: Underlined text */ +#define GHOSTTY_CELL_UNDERLINE (1 << 2) -+/** Cell flag: Strikethrough text */ +#define GHOSTTY_CELL_STRIKETHROUGH (1 << 3) -+/** Cell flag: Inverse video (swap fg/bg) */ +#define GHOSTTY_CELL_INVERSE (1 << 4) -+/** Cell flag: Invisible text */ +#define GHOSTTY_CELL_INVISIBLE (1 << 5) -+/** Cell flag: Blinking text */ +#define GHOSTTY_CELL_BLINK (1 << 6) -+/** Cell flag: Faint/dim text */ +#define GHOSTTY_CELL_FAINT (1 << 7) + ++/** Dirty state */ ++typedef enum { ++ GHOSTTY_DIRTY_NONE = 0, ++ GHOSTTY_DIRTY_PARTIAL = 1, ++ GHOSTTY_DIRTY_FULL = 2 ++} GhosttyDirty; ++ +/* ============================================================================ -+ * Lifecycle Management ++ * Lifecycle + * ========================================================================= */ + -+/** -+ * Create a new terminal instance with default configuration. -+ * -+ * Creates an 80x24 terminal with default settings (10,000 line scrollback, -+ * standard color palette, autowrap enabled). -+ * -+ * @param cols Number of columns (typically 80, minimum 1) -+ * @param rows Number of rows (typically 24, minimum 1) -+ * @return Terminal handle, or NULL on allocation failure -+ * -+ * @see ghostty_terminal_new_with_config() for custom configuration -+ * @see ghostty_terminal_free() -+ */ ++/** Create a new terminal */ +GhosttyTerminal ghostty_terminal_new(int cols, int rows); + -+/** -+ * Create a new terminal instance with custom configuration. -+ * -+ * @param cols Number of columns (typically 80, minimum 1) -+ * @param rows Number of rows (typically 24, minimum 1) -+ * @param config Configuration options (NULL = use defaults) -+ * @return Terminal handle, or NULL on allocation failure -+ * -+ * @see ghostty_terminal_new() -+ * @see ghostty_terminal_free() -+ */ -+GhosttyTerminal ghostty_terminal_new_with_config( -+ int cols, -+ int rows, -+ const GhosttyTerminalConfig* config -+); -+ -+/** -+ * Free a terminal instance. -+ * -+ * Releases all memory associated with the terminal. The handle becomes -+ * invalid after this call. -+ * -+ * @param term Terminal to free (NULL is safe) -+ */ ++/** Free a terminal */ +void ghostty_terminal_free(GhosttyTerminal term); + -+/** -+ * Resize the terminal. -+ * -+ * Changes the terminal dimensions. Content is preserved where possible, -+ * with appropriate reflowing or truncation. -+ * -+ * @param term Terminal instance -+ * @param cols New column count (minimum 1) -+ * @param rows New row count (minimum 1) -+ */ ++/** Resize terminal */ +void ghostty_terminal_resize(GhosttyTerminal term, int cols, int rows); + -+/* ============================================================================ -+ * Input/Output -+ * ========================================================================= */ -+ -+/** -+ * Write data to terminal (parses VT sequences and updates screen). -+ * -+ * This is the main entry point - all terminal output goes through here. -+ * The data is parsed as VT100/ANSI escape sequences and the screen -+ * buffer is updated accordingly. -+ * -+ * Supports: -+ * - Text output (UTF-8) -+ * - CSI sequences (colors, cursor movement, etc.) -+ * - OSC sequences (title, colors, etc.) -+ * - All standard VT100/xterm sequences -+ * -+ * @param term Terminal instance -+ * @param data UTF-8 encoded data (may contain VT sequences) -+ * @param len Length of data in bytes -+ * -+ * @note This function marks affected rows as dirty for rendering optimization -+ */ ++/** Write data to terminal (parses VT sequences) */ +void ghostty_terminal_write(GhosttyTerminal term, const uint8_t* data, size_t len); + +/* ============================================================================ -+ * Screen Queries ++ * RenderState API - High-performance rendering + * ========================================================================= */ + -+/** -+ * Get terminal width in columns. -+ * -+ * @param term Terminal instance -+ * @return Number of columns, or 0 if term is NULL -+ */ -+int ghostty_terminal_get_cols(GhosttyTerminal term); -+ -+/** -+ * Get terminal height in rows. -+ * -+ * @param term Terminal instance -+ * @return Number of rows, or 0 if term is NULL -+ */ -+int ghostty_terminal_get_rows(GhosttyTerminal term); -+ -+/** -+ * Get cursor X position (column). -+ * -+ * @param term Terminal instance -+ * @return Column position (0-indexed), or 0 if term is NULL -+ */ -+int ghostty_terminal_get_cursor_x(GhosttyTerminal term); -+ -+/** -+ * Get cursor Y position (row). -+ * -+ * @param term Terminal instance -+ * @return Row position (0-indexed), or 0 if term is NULL -+ */ -+int ghostty_terminal_get_cursor_y(GhosttyTerminal term); -+ -+/** -+ * Get cursor visibility state. -+ * -+ * @param term Terminal instance -+ * @return true if cursor is visible, false otherwise -+ */ -+bool ghostty_terminal_get_cursor_visible(GhosttyTerminal term); ++/** Update render state from terminal. Call once per frame. */ ++GhosttyDirty ghostty_render_state_update(GhosttyTerminal term); + -+/** -+ * Check if terminal is in alternate screen buffer mode. -+ */ -+bool ghostty_terminal_is_alternate_screen(GhosttyTerminal term); ++/** Get dimensions */ ++int ghostty_render_state_get_cols(GhosttyTerminal term); ++int ghostty_render_state_get_rows(GhosttyTerminal term); + -+/** -+ * Check if a row is wrapped from the previous row. -+ */ -+bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int row); ++/** Get cursor state (individual getters for WASM efficiency) */ ++int ghostty_render_state_get_cursor_x(GhosttyTerminal term); ++int ghostty_render_state_get_cursor_y(GhosttyTerminal term); ++bool ghostty_render_state_get_cursor_visible(GhosttyTerminal term); + -+/** -+ * Get scrollback length (number of lines in history). -+ * -+ * @param term Terminal instance -+ * @return Number of scrollback lines, or 0 if none/NULL -+ */ -+int ghostty_terminal_get_scrollback_length(GhosttyTerminal term); ++/** Get default colors as 0xRRGGBB */ ++uint32_t ghostty_render_state_get_bg_color(GhosttyTerminal term); ++uint32_t ghostty_render_state_get_fg_color(GhosttyTerminal term); + -+/* ============================================================================ -+ * Cell Data Access -+ * ========================================================================= */ ++/** Check if a row is dirty */ ++bool ghostty_render_state_is_row_dirty(GhosttyTerminal term, int y); + -+/** -+ * Get a line of cells from the visible screen. -+ * -+ * Retrieves an entire row of cells at once for efficient rendering. -+ * Colors are returned as RGB values (palette indices are resolved). -+ * -+ * @param term Terminal instance -+ * @param y Line number (0-indexed, 0 = top visible line) -+ * @param out_buffer Output buffer (must have space for at least 'cols' cells) -+ * @param buffer_size Size of output buffer in cells (should be >= cols) -+ * @return Number of cells written (equals cols on success), or -1 on error -+ * -+ * @note Always writes exactly 'cols' cells, padding with empty cells if needed -+ */ -+int ghostty_terminal_get_line( -+ GhosttyTerminal term, -+ int y, -+ GhosttyCell* out_buffer, -+ size_t buffer_size -+); ++/** Mark render state as clean (call after rendering) */ ++void ghostty_render_state_mark_clean(GhosttyTerminal term); + +/** -+ * Get a line from scrollback history. -+ * -+ * @param term Terminal instance -+ * @param y Line number (0 = oldest scrollback line) -+ * @param out_buffer Output buffer -+ * @param buffer_size Size of output buffer in cells -+ * @return Number of cells written, or -1 on error/not implemented -+ * -+ * @note Currently not implemented - returns -1 ++ * Get ALL viewport cells in one call - the key performance optimization! ++ * Buffer must be at least (rows * cols) cells. ++ * Returns total cells written, or -1 on error. + */ -+int ghostty_terminal_get_scrollback_line( ++int ghostty_render_state_get_viewport( + GhosttyTerminal term, -+ int y, + GhosttyCell* out_buffer, + size_t buffer_size +); + +/* ============================================================================ -+ * Dirty Tracking (for efficient rendering) ++ * Terminal Modes + * ========================================================================= */ + -+/** -+ * Check if any part of the screen is dirty. -+ * -+ * Dirty tracking helps optimize rendering by identifying what changed. -+ * After writing to the terminal, check which rows are dirty and only -+ * re-render those. -+ * -+ * @param term Terminal instance -+ * @return true if any row needs redrawing, false otherwise -+ * -+ * @see ghostty_terminal_is_row_dirty() -+ * @see ghostty_terminal_clear_dirty() -+ */ -+bool ghostty_terminal_is_dirty(GhosttyTerminal term); ++/** Check if alternate screen is active */ ++bool ghostty_terminal_is_alternate_screen(GhosttyTerminal term); + -+/** -+ * Check if a specific row is dirty. -+ * -+ * @param term Terminal instance -+ * @param y Row number (0-indexed) -+ * @return true if row needs redrawing, false otherwise -+ */ -+bool ghostty_terminal_is_row_dirty(GhosttyTerminal term, int y); ++/** Check if any mouse tracking mode is enabled */ ++bool ghostty_terminal_has_mouse_tracking(GhosttyTerminal term); + +/** -+ * Clear all dirty flags (call after rendering). -+ * -+ * After reading dirty rows and re-rendering them, call this to mark -+ * the screen as clean. -+ * -+ * @param term Terminal instance ++ * Query arbitrary terminal mode by number. ++ * @param mode Mode number (e.g., 25 for cursor visibility, 2004 for bracketed paste) ++ * @param is_ansi true for ANSI modes, false for DEC modes ++ * @return true if mode is enabled + */ -+void ghostty_terminal_clear_dirty(GhosttyTerminal term); ++bool ghostty_terminal_get_mode(GhosttyTerminal term, int mode, bool is_ansi); + +/* ============================================================================ -+ * Hyperlink Support ++ * Scrollback API + * ========================================================================= */ + ++/** Get number of scrollback lines (history, not including active screen) */ ++int ghostty_terminal_get_scrollback_length(GhosttyTerminal term); ++ +/** -+ * Get hyperlink URI by ID. -+ * -+ * Retrieves the URI string for a hyperlink ID obtained from a GhosttyCell. -+ * The URI is written to the provided buffer. -+ * -+ * @param term Terminal instance -+ * @param hyperlink_id Hyperlink ID from GhosttyCell (must be > 0) -+ * @param out_buffer Buffer to write URI string (UTF-8) -+ * @param buffer_size Size of output buffer in bytes -+ * @return Number of bytes written (not including null terminator), or 0 if: -+ * - hyperlink_id is 0 or invalid -+ * - URI doesn't exist -+ * - buffer is too small (URI is truncated) -+ * -+ * @note The returned string is NOT null-terminated. Use the return value -+ * to determine the actual length. ++ * Get a line from the scrollback buffer. ++ * @param offset 0 = oldest line, (length-1) = most recent scrollback line ++ * @param out_buffer Buffer to write cells to ++ * @param buffer_size Size of buffer in cells (must be >= cols) ++ * @return Number of cells written, or -1 on error + */ -+int ghostty_terminal_get_hyperlink_uri( ++int ghostty_terminal_get_scrollback_line( + GhosttyTerminal term, -+ uint16_t hyperlink_id, -+ uint8_t* out_buffer, ++ int offset, ++ GhosttyCell* out_buffer, + size_t buffer_size +); + -+ -+/* ============================================================================ -+ * Terminal Modes -+ * ========================================================================= */ -+ -+/** -+ * Query terminal mode state. -+ * -+ * This function queries whether a specific terminal mode is enabled or disabled. -+ * Modes can be either ANSI modes or DEC private modes (indicated by is_ansi parameter). -+ * -+ * Common DEC modes (is_ansi = false): -+ * - 25 = Cursor visible (DECTCEM) -+ * - 1000 = Mouse tracking (normal) -+ * - 1002 = Mouse tracking (button events) -+ * - 1003 = Mouse tracking (any events) -+ * - 1004 = Focus event reporting -+ * - 1047 = Alternate screen buffer -+ * - 1049 = Alternate screen buffer with cursor save -+ * - 2004 = Bracketed paste mode -+ * -+ * Common ANSI modes (is_ansi = true): -+ * - 4 = Insert/replace mode (IRM) -+ * -+ * @param term Terminal instance -+ * @param mode_number Mode number to query -+ * @param is_ansi true for ANSI modes, false for DEC private modes -+ * @return true if mode is enabled, false if disabled or mode is invalid -+ */ -+bool ghostty_terminal_get_mode(GhosttyTerminal term, int mode_number, bool is_ansi); -+ -+/** -+ * Check if bracketed paste mode is enabled (DEC mode 2004). -+ * -+ * Bracketed paste wraps pasted text with escape sequences to distinguish -+ * it from typed text: ESC[200~ ... ESC[201~ -+ * -+ * @param term Terminal instance -+ * @return true if bracketed paste is enabled -+ */ -+bool ghostty_terminal_has_bracketed_paste(GhosttyTerminal term); -+ -+/** -+ * Check if focus event reporting is enabled (DEC mode 1004). -+ * -+ * When enabled, the terminal reports focus in/out events: -+ * - Focus in: ESC[I -+ * - Focus out: ESC[O -+ * -+ * @param term Terminal instance -+ * @return true if focus events are enabled -+ */ -+bool ghostty_terminal_has_focus_events(GhosttyTerminal term); -+ -+/** -+ * Check if any mouse tracking mode is enabled. -+ * -+ * Returns true if any of these DEC modes are enabled: -+ * - 1000: Normal mouse tracking -+ * - 1002: Button event tracking -+ * - 1003: Any event tracking -+ * -+ * @param term Terminal instance -+ * @return true if mouse tracking is enabled -+ */ -+bool ghostty_terminal_has_mouse_tracking(GhosttyTerminal term); -+ ++/** Check if a row is a continuation from previous row (soft-wrapped) */ ++bool ghostty_terminal_is_row_wrapped(GhosttyTerminal term, int y); + +#ifdef __cplusplus +} +#endif + -+/** @} */ -+ +#endif /* GHOSTTY_VT_TERMINAL_H */ -diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index e95eee5f4..687ccc6a3 100644 ---- a/src/lib_vt.zig -+++ b/src/lib_vt.zig -@@ -137,6 +137,29 @@ comptime { - @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); - @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); - @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); -+ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); -+ @export(&c.terminal_new_with_config, .{ .name = "ghostty_terminal_new_with_config" }); -+ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); -+ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); -+ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); -+ @export(&c.terminal_get_cols, .{ .name = "ghostty_terminal_get_cols" }); -+ @export(&c.terminal_get_rows, .{ .name = "ghostty_terminal_get_rows" }); -+ @export(&c.terminal_get_cursor_x, .{ .name = "ghostty_terminal_get_cursor_x" }); -+ @export(&c.terminal_get_cursor_y, .{ .name = "ghostty_terminal_get_cursor_y" }); -+ @export(&c.terminal_get_cursor_visible, .{ .name = "ghostty_terminal_get_cursor_visible" }); -+ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); -+ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); -+ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); -+ @export(&c.terminal_get_line, .{ .name = "ghostty_terminal_get_line" }); -+ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); -+ @export(&c.terminal_is_dirty, .{ .name = "ghostty_terminal_is_dirty" }); -+ @export(&c.terminal_is_row_dirty, .{ .name = "ghostty_terminal_is_row_dirty" }); -+ @export(&c.terminal_clear_dirty, .{ .name = "ghostty_terminal_clear_dirty" }); -+ @export(&c.terminal_get_hyperlink_uri, .{ .name = "ghostty_terminal_get_hyperlink_uri" }); -+ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); -+ @export(&c.terminal_has_bracketed_paste, .{ .name = "ghostty_terminal_has_bracketed_paste" }); -+ @export(&c.terminal_has_focus_events, .{ .name = "ghostty_terminal_has_focus_events" }); -+ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); - - // On Wasm we need to export our allocator convenience functions. - if (builtin.target.cpu.arch.isWasm()) { -diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..d988967f7 100644 ---- a/src/terminal/c/main.zig -+++ b/src/terminal/c/main.zig -@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); - pub const key_encode = @import("key_encode.zig"); - pub const paste = @import("paste.zig"); - pub const sgr = @import("sgr.zig"); -+pub const terminal = @import("terminal.zig"); - - // The full C API, unexported. - pub const osc_new = osc.new; -@@ -52,6 +53,30 @@ pub const key_encoder_encode = key_encode.encode; - - pub const paste_is_safe = paste.is_safe; - -+pub const terminal_new = terminal.new; -+pub const terminal_new_with_config = terminal.newWithConfig; -+pub const terminal_free = terminal.free; -+pub const terminal_resize = terminal.resize; -+pub const terminal_write = terminal.write; -+pub const terminal_get_cols = terminal.getCols; -+pub const terminal_get_rows = terminal.getRows; -+pub const terminal_get_cursor_x = terminal.getCursorX; -+pub const terminal_get_cursor_y = terminal.getCursorY; -+pub const terminal_get_cursor_visible = terminal.getCursorVisible; -+pub const terminal_is_alternate_screen = terminal.isAlternateScreen; -+pub const terminal_is_row_wrapped = terminal.isRowWrapped; -+pub const terminal_get_scrollback_length = terminal.getScrollbackLength; -+pub const terminal_get_line = terminal.getLine; -+pub const terminal_get_scrollback_line = terminal.getScrollbackLine; -+pub const terminal_is_dirty = terminal.isDirty; -+pub const terminal_is_row_dirty = terminal.isRowDirty; -+pub const terminal_clear_dirty = terminal.clearDirty; -+pub const terminal_get_hyperlink_uri = terminal.getHyperlinkUri; -+pub const terminal_get_mode = terminal.getMode; -+pub const terminal_has_bracketed_paste = terminal.hasBracketedPaste; -+pub const terminal_has_focus_events = terminal.hasFocusEvents; -+pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; -+ - test { - _ = color; - _ = osc; -@@ -59,6 +84,7 @@ test { - _ = key_encode; - _ = paste; - _ = sgr; -+ _ = terminal; - - // We want to make sure we run the tests for the C allocator interface. - _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 -index 000000000..e79702488 --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,642 @@ +@@ -0,0 +1,503 @@ +//! C API wrapper for Terminal +//! -+//! This provides a C-compatible interface to Ghostty's Terminal for WASM export. ++//! This provides a minimal, high-performance interface to Ghostty's Terminal ++//! for WASM export. The key optimization is using RenderState which provides ++//! a pre-computed snapshot of all render data in a single update call. ++//! ++//! API Design: ++//! - Lifecycle: new, free, resize, write ++//! - Rendering: render_state_update, render_state_get_viewport, etc. ++//! ++//! The RenderState approach means: ++//! - ONE call to update all state (render_state_update) ++//! - ONE call to get all cells (render_state_get_viewport) ++//! - No per-row or per-cell WASM boundary crossings! + +const std = @import("std"); +const Allocator = std.mem.Allocator; -+const assert = std.debug.assert; +const builtin = @import("builtin"); + +const Terminal = @import("../Terminal.zig"); +const ReadonlyStream = @import("../stream_readonly.zig").Stream; -+const size = @import("../size.zig"); -+const pagepkg = @import("../page.zig"); -+const Cell = pagepkg.Cell; -+const PageList = @import("../PageList.zig"); ++const render = @import("../render.zig"); ++const RenderState = render.RenderState; +const color = @import("../color.zig"); -+const point = @import("../point.zig"); -+const style = @import("../style.zig"); +const modespkg = @import("../modes.zig"); ++const point = @import("../point.zig"); ++const Style = @import("../style.zig").Style; + +const log = std.log.scoped(.terminal_c); + -+/// Wrapper struct that owns both the Terminal and its allocator. -+/// This is what we return as an opaque pointer to C. ++/// Wrapper struct that owns the Terminal, stream, and RenderState. +const TerminalWrapper = struct { -+ /// The allocator that owns all terminal memory + alloc: Allocator, -+ -+ /// The terminal instance + terminal: Terminal, -+ -+ /// Persistent VT stream for parsing (preserves state across writes) -+ /// This is critical for handling escape sequences split across WebSocket messages. + stream: ReadonlyStream, -+ -+ /// Dirty tracking - which rows have changed since last clear -+ dirty_rows: []bool, -+ -+ /// Configuration used for terminal -+ config: Config, -+ -+ const Config = struct { -+ scrollback_limit: u32, -+ fg_color: u32, -+ bg_color: u32, -+ cursor_color: u32, -+ palette: [16]u32, -+ }; ++ render_state: RenderState, ++ /// Track alternate screen state to detect screen switches ++ last_screen_is_alternate: bool = false, +}; + -+/// C-compatible cell structure (14 bytes actual, padded to 16 by compiler) ++/// C-compatible cell structure (16 bytes) +pub const GhosttyCell = extern struct { -+ codepoint: u32, // 0-3 -+ fg_r: u8, // 4 -+ fg_g: u8, // 5 -+ fg_b: u8, // 6 -+ bg_r: u8, // 7 -+ bg_g: u8, // 8 -+ bg_b: u8, // 9 -+ flags: u8, // 10 -+ width: u8, // 11 -+ hyperlink_id: u16, // 12-13 (0 = no link, >0 = hyperlink ID in set) -+ // Compiler adds 2 bytes padding here to align to 4 bytes -+ // Total size: 16 bytes with padding ++ codepoint: u32, ++ fg_r: u8, ++ fg_g: u8, ++ fg_b: u8, ++ bg_r: u8, ++ bg_g: u8, ++ bg_b: u8, ++ flags: u8, ++ width: u8, ++ hyperlink_id: u16, ++ _pad: u16 = 0, +}; + -+/// C-compatible terminal configuration -+pub const GhosttyTerminalConfig = extern struct { -+ scrollback_limit: u32, -+ fg_color: u32, -+ bg_color: u32, -+ cursor_color: u32, -+ palette: [16]u32, ++/// Dirty state ++pub const GhosttyDirty = enum(u8) { ++ none = 0, ++ partial = 1, ++ full = 2, +}; + +// ============================================================================ -+// Lifecycle Management ++// Lifecycle +// ============================================================================ + +pub fn new(cols: c_int, rows: c_int) callconv(.c) ?*anyopaque { -+ return newWithConfig(cols, rows, null); -+} -+ -+pub fn newWithConfig( -+ cols: c_int, -+ rows: c_int, -+ config_: ?*const GhosttyTerminalConfig, -+) callconv(.c) ?*anyopaque { -+ // Use WASM allocator for WASM builds, GPA otherwise + const alloc = if (builtin.target.cpu.arch.isWasm()) + std.heap.wasm_allocator + else + std.heap.c_allocator; + -+ // Parse configuration -+ const config: TerminalWrapper.Config = if (config_) |cfg| .{ -+ .scrollback_limit = cfg.scrollback_limit, -+ .fg_color = cfg.fg_color, -+ .bg_color = cfg.bg_color, -+ .cursor_color = cfg.cursor_color, -+ .palette = cfg.palette, -+ } else .{ -+ .scrollback_limit = 10_000, -+ .fg_color = 0, -+ .bg_color = 0, -+ .cursor_color = 0, -+ .palette = [_]u32{0} ** 16, -+ }; -+ -+ // Allocate wrapper -+ const wrapper = alloc.create(TerminalWrapper) catch |err| { -+ log.err("Failed to allocate TerminalWrapper: {}", .{err}); -+ return null; -+ }; ++ const wrapper = alloc.create(TerminalWrapper) catch return null; + -+ // Setup terminal colors -+ var colors = Terminal.Colors.default; -+ if (config.fg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((config.fg_color >> 16) & 0xFF), -+ .g = @truncate((config.fg_color >> 8) & 0xFF), -+ .b = @truncate(config.fg_color & 0xFF), -+ }; -+ colors.foreground = color.DynamicRGB.init(rgb); -+ } -+ if (config.bg_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((config.bg_color >> 16) & 0xFF), -+ .g = @truncate((config.bg_color >> 8) & 0xFF), -+ .b = @truncate(config.bg_color & 0xFF), -+ }; -+ colors.background = color.DynamicRGB.init(rgb); -+ } -+ if (config.cursor_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((config.cursor_color >> 16) & 0xFF), -+ .g = @truncate((config.cursor_color >> 8) & 0xFF), -+ .b = @truncate(config.cursor_color & 0xFF), -+ }; -+ colors.cursor = color.DynamicRGB.init(rgb); -+ } -+ // Apply palette colors (0 = use default, non-zero = override) -+ for (config.palette, 0..) |palette_color, i| { -+ if (palette_color != 0) { -+ const rgb = color.RGB{ -+ .r = @truncate((palette_color >> 16) & 0xFF), -+ .g = @truncate((palette_color >> 8) & 0xFF), -+ .b = @truncate(palette_color & 0xFF), -+ }; -+ colors.palette.set(@intCast(i), rgb); -+ } -+ } -+ -+ // Create terminal -+ const terminal = Terminal.init( -+ alloc, -+ .{ -+ .cols = @intCast(cols), -+ .rows = @intCast(rows), -+ .max_scrollback = if (config.scrollback_limit == 0) -+ std.math.maxInt(usize) -+ else -+ config.scrollback_limit, -+ .colors = colors, -+ }, -+ ) catch |err| { -+ log.err("Failed to initialize Terminal: {}", .{err}); ++ wrapper.terminal = Terminal.init(alloc, .{ ++ .cols = @intCast(cols), ++ .rows = @intCast(rows), ++ .max_scrollback = 10_000, ++ }) catch { + alloc.destroy(wrapper); + return null; + }; + -+ // Allocate dirty tracking -+ const rows_usize: usize = @intCast(rows); -+ const dirty_rows = alloc.alloc(bool, rows_usize) catch |err| { -+ log.err("Failed to allocate dirty tracking: {}", .{err}); -+ // Note: terminal.deinit() requires the allocator be passed -+ var term_mut = terminal; -+ term_mut.deinit(alloc); -+ alloc.destroy(wrapper); -+ return null; -+ }; -+ @memset(dirty_rows, true); // Initially all dirty -+ + wrapper.* = .{ + .alloc = alloc, -+ .terminal = terminal, -+ .stream = undefined, // Will be initialized below -+ .dirty_rows = dirty_rows, -+ .config = config, ++ .terminal = wrapper.terminal, ++ .stream = wrapper.terminal.vtStream(), ++ .render_state = RenderState.empty, + }; + -+ // Initialize the persistent VT stream (must be done after terminal is set) -+ wrapper.stream = wrapper.terminal.vtStream(); -+ -+ // Enable linefeed mode so \n performs carriage return (moves cursor to column 0) -+ // Without this, \n only moves down without returning to column 0, causing staggered text -+ wrapper.terminal.modes.set(.linefeed, true); -+ ++ // NOTE: linefeed mode must be FALSE to match native terminal behavior ++ // When true, LF does automatic CR which breaks apps like nvim ++ wrapper.terminal.modes.set(.linefeed, false); + return @ptrCast(wrapper); +} + +pub fn free(ptr: ?*anyopaque) callconv(.c) void { + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); + const alloc = wrapper.alloc; -+ + wrapper.stream.deinit(); -+ alloc.free(wrapper.dirty_rows); ++ wrapper.render_state.deinit(alloc); + wrapper.terminal.deinit(alloc); + alloc.destroy(wrapper); +} + +pub fn resize(ptr: ?*anyopaque, cols: c_int, rows: c_int) callconv(.c) void { + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ -+ // Resize terminal -+ wrapper.terminal.resize( -+ wrapper.alloc, -+ @intCast(cols), -+ @intCast(rows), -+ ) catch |err| { -+ log.err("Resize failed: {}", .{err}); -+ return; -+ }; -+ -+ // Reallocate dirty tracking -+ const rows_usize: usize = @intCast(rows); -+ const new_dirty = wrapper.alloc.realloc(wrapper.dirty_rows, rows_usize) catch |err| { -+ log.err("Failed to reallocate dirty tracking: {}", .{err}); -+ return; -+ }; -+ wrapper.dirty_rows = new_dirty; -+ @memset(new_dirty, true); // All dirty after resize ++ wrapper.terminal.resize(wrapper.alloc, @intCast(cols), @intCast(rows)) catch return; +} + -+// ============================================================================ -+// Input/Output -+// ============================================================================ -+ +pub fn write(ptr: ?*anyopaque, data: [*]const u8, len: usize) callconv(.c) void { + const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ -+ // Use persistent stream to preserve parser state across writes -+ // This is critical for handling escape sequences split across WebSocket messages -+ const slice = data[0..len]; -+ wrapper.stream.nextSlice(slice) catch |err| { -+ log.err("Write failed: {}", .{err}); -+ return; -+ }; -+ -+ // Mark all visible rows as dirty (conservative approach) -+ @memset(wrapper.dirty_rows, true); ++ wrapper.stream.nextSlice(data[0..len]) catch return; +} + +// ============================================================================ -+// Screen Queries ++// RenderState API - High-performance rendering +// ============================================================================ + -+pub fn getCols(ptr: ?*anyopaque) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.terminal.cols); ++/// Update render state from terminal. Call once per frame. ++/// Returns dirty state: 0=none, 1=partial, 2=full ++pub fn renderStateUpdate(ptr: ?*anyopaque) callconv(.c) GhosttyDirty { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return .full)); ++ ++ // Detect screen buffer switch (normal <-> alternate) ++ const current_is_alternate = wrapper.terminal.screens.active_key == .alternate; ++ const screen_switched = current_is_alternate != wrapper.last_screen_is_alternate; ++ wrapper.last_screen_is_alternate = current_is_alternate; ++ ++ // When screen switches, we must fully reset the render state to avoid ++ // stale cached cell data from the previous screen buffer. ++ if (screen_switched) { ++ wrapper.render_state.deinit(wrapper.alloc); ++ wrapper.render_state = RenderState.empty; ++ } ++ ++ wrapper.render_state.update(wrapper.alloc, &wrapper.terminal) catch return .full; ++ ++ // If screen switched, always return full dirty to force complete redraw ++ if (screen_switched) { ++ return .full; ++ } ++ ++ return switch (wrapper.render_state.dirty) { ++ .false => .none, ++ .partial => .partial, ++ .full => .full, ++ }; +} + -+pub fn getRows(ptr: ?*anyopaque) callconv(.c) c_int { ++/// Get dimensions from render state ++pub fn renderStateGetCols(ptr: ?*anyopaque) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.terminal.rows); ++ return @intCast(wrapper.render_state.cols); +} + -+pub fn getCursorX(ptr: ?*anyopaque) callconv(.c) c_int { ++pub fn renderStateGetRows(ptr: ?*anyopaque) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.terminal.screen.cursor.x); ++ return @intCast(wrapper.render_state.rows); +} + -+pub fn getCursorY(ptr: ?*anyopaque) callconv(.c) c_int { ++/// Get cursor X position ++pub fn renderStateGetCursorX(ptr: ?*anyopaque) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ return @intCast(wrapper.terminal.screen.cursor.y); -+} -+ -+pub fn getCursorVisible(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ // Check if cursor is visible based on modes -+ return wrapper.terminal.modes.get(.cursor_visible); ++ return @intCast(wrapper.render_state.cursor.active.x); +} + -+pub fn isAlternateScreen(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.active_screen == .alternate; ++/// Get cursor Y position ++pub fn renderStateGetCursorY(ptr: ?*anyopaque) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); ++ return @intCast(wrapper.render_state.cursor.active.y); +} + -+pub fn isRowWrapped(ptr: ?*anyopaque, row: c_int) callconv(.c) bool { ++/// Check if cursor is visible ++pub fn renderStateGetCursorVisible(ptr: ?*anyopaque) callconv(.c) bool { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ if (row < 0 or row >= wrapper.terminal.rows) return false; -+ if (row == 0) return false; -+ const pt = point.Point{ .viewport = .{ .x = 0, .y = @intCast(row) }}; -+ const list_cell = wrapper.terminal.screen.pages.getCell(pt) orelse return false; -+ return list_cell.row.wrap; ++ return wrapper.render_state.cursor.visible; +} + -+pub fn getScrollbackLength(ptr: ?*anyopaque) callconv(.c) c_int { ++/// Get default background color as 0xRRGGBB ++pub fn renderStateGetBgColor(ptr: ?*anyopaque) callconv(.c) u32 { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); -+ // Calculate scrollback length as difference between history top and active top -+ const active_pin = wrapper.terminal.screen.pages.getTopLeft(.active); -+ const screen_pin = wrapper.terminal.screen.pages.getTopLeft(.screen); -+ -+ // Count rows between screen top and active top -+ var count: c_int = 0; -+ var pin = screen_pin; -+ while (pin.node != active_pin.node or pin.y != active_pin.y) { -+ count += 1; -+ pin = pin.down(1) orelse break; -+ if (count > 100000) break; // Safety limit -+ } -+ return count; ++ const bg = wrapper.render_state.colors.background; ++ return (@as(u32, bg.r) << 16) | (@as(u32, bg.g) << 8) | bg.b; +} + -+// ============================================================================ -+// Cell Data Access -+// ============================================================================ -+ -+/// Convert Ghostty's internal Cell to C-compatible GhosttyCell -+/// list_cell_opt can be null if we're using a default empty cell -+fn convertCell(wrapper: *const TerminalWrapper, cell: Cell, list_cell_opt: @TypeOf(wrapper.terminal.screen.pages.getCell(.{.viewport = .{}}))) GhosttyCell { -+ const terminal = &wrapper.terminal; -+ const palette = &terminal.colors.palette.current; -+ -+ // Get codepoint -+ const cp = cell.content.codepoint; -+ -+ // Get the style - either from the page or use default -+ const cell_style: style.Style = if (cell.style_id == style.default_id) -+ .{} -+ else if (list_cell_opt) |list_cell| -+ list_cell.node.data.styles.get(list_cell.node.data.memory, cell.style_id).* -+ else -+ .{}; -+ -+ // Resolve foreground color -+ const fg_rgb: color.RGB = fg: { -+ switch (cell_style.fg_color) { -+ .none => { -+ // Use default foreground -+ if (terminal.colors.foreground.get()) |rgb| { -+ break :fg rgb; -+ } else { -+ // Default to white -+ break :fg .{ .r = 0xEA, .g = 0xEA, .b = 0xEA }; -+ } -+ }, -+ .palette => |idx| break :fg palette[idx], -+ .rgb => |rgb| break :fg rgb, -+ } -+ }; -+ -+ // Resolve background color -+ const bg_rgb: color.RGB = bg: { -+ // Check for cell-level color override -+ if (cell_style.bg(&cell, palette)) |rgb| { -+ break :bg rgb; -+ } -+ -+ // Use default background -+ if (terminal.colors.background.get()) |rgb| { -+ break :bg rgb; -+ } else { -+ // Default to black -+ break :bg .{ .r = 0x1D, .g = 0x1F, .b = 0x21 }; -+ } -+ }; -+ -+ // Build flags bitfield -+ var flags: u8 = 0; -+ if (cell_style.flags.bold) flags |= 1 << 0; -+ if (cell_style.flags.italic) flags |= 1 << 1; -+ if (cell_style.flags.underline != .none) flags |= 1 << 2; -+ if (cell_style.flags.strikethrough) flags |= 1 << 3; -+ if (cell_style.flags.inverse) flags |= 1 << 4; -+ if (cell_style.flags.invisible) flags |= 1 << 5; -+ if (cell_style.flags.blink) flags |= 1 << 6; -+ if (cell_style.flags.faint) flags |= 1 << 7; -+ -+ // Map cell.wide enum to actual character width -+ // narrow = 0 -> width 1, wide = 1 -> width 2, spacer_tail = 2 -> width 0 -+ const width: u8 = switch (cell.wide) { -+ .narrow => 1, -+ .wide => 2, -+ .spacer_tail => 0, -+ .spacer_head => 0, -+ }; -+ -+ // Get hyperlink ID if cell has hyperlink -+ const hyperlink_id: u16 = if (cell.hyperlink) blk: { -+ if (list_cell_opt) |list_cell| { -+ const page = &list_cell.node.data; -+ const cell_offset = size.getOffset(Cell, page.memory, list_cell.cell); -+ const map = page.hyperlink_map.map(page.memory); -+ if (map.get(cell_offset)) |id| { -+ break :blk id; -+ } -+ } -+ break :blk 0; -+ } else 0; -+ -+ return .{ -+ .codepoint = cp, -+ .fg_r = fg_rgb.r, -+ .fg_g = fg_rgb.g, -+ .fg_b = fg_rgb.b, -+ .bg_r = bg_rgb.r, -+ .bg_g = bg_rgb.g, -+ .bg_b = bg_rgb.b, -+ .flags = flags, -+ .width = width, -+ .hyperlink_id = hyperlink_id, -+ }; ++/// Get default foreground color as 0xRRGGBB ++pub fn renderStateGetFgColor(ptr: ?*anyopaque) callconv(.c) u32 { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0xCCCCCC)); ++ const fg = wrapper.render_state.colors.foreground; ++ return (@as(u32, fg.r) << 16) | (@as(u32, fg.g) << 8) | fg.b; +} + -+pub fn getLine( -+ ptr: ?*anyopaque, -+ y: c_int, -+ out_buffer: [*]GhosttyCell, -+ buffer_size: usize, -+) callconv(.c) c_int { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); -+ ++/// Check if row is dirty ++pub fn renderStateIsRowDirty(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return true)); ++ if (wrapper.render_state.dirty == .full) return true; ++ if (wrapper.render_state.dirty == .false) return false; + const y_usize: usize = @intCast(y); -+ if (y_usize >= wrapper.terminal.rows) return -1; -+ -+ const cols = wrapper.terminal.cols; -+ if (buffer_size < cols) return -1; -+ -+ // Get cells from the screen using viewport coordinates -+ var x: usize = 0; -+ while (x < cols) : (x += 1) { -+ const pt = point.Point{ .viewport = .{ -+ .x = @intCast(x), -+ .y = @intCast(y), -+ } }; -+ -+ const list_cell = wrapper.terminal.screen.pages.getCell(pt); -+ const cell = if (list_cell) |lc| lc.cell.* else Cell{}; -+ out_buffer[x] = convertCell(wrapper, cell, list_cell); -+ } ++ if (y_usize >= wrapper.render_state.row_data.len) return false; ++ return wrapper.render_state.row_data.items(.dirty)[y_usize]; ++} + -+ return @intCast(cols); ++/// Mark render state as clean after rendering ++pub fn renderStateMarkClean(ptr: ?*anyopaque) callconv(.c) void { ++ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); ++ wrapper.render_state.dirty = .false; ++ @memset(wrapper.render_state.row_data.items(.dirty), false); +} + -+pub fn getScrollbackLine( ++/// Get ALL viewport cells in one call - reads directly from terminal screen buffer. ++/// This bypasses the RenderState cache to ensure fresh data for all rows. ++/// Returns total cells written (rows * cols), or -1 on error. ++pub fn renderStateGetViewport( + ptr: ?*anyopaque, -+ y: c_int, -+ out_buffer: [*]GhosttyCell, -+ buffer_size: usize, ++ out: [*]GhosttyCell, ++ buf_size: usize, +) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const rs = &wrapper.render_state; ++ const t = &wrapper.terminal; ++ const rows = rs.rows; ++ const cols = rs.cols; ++ const total: usize = @as(usize, rows) * cols; ++ ++ if (buf_size < total) return -1; ++ ++ // Read directly from terminal's active screen, bypassing RenderState cache. ++ // This ensures we always get fresh data for ALL rows, not just dirty ones. ++ const pages = &t.screens.active.pages; ++ ++ var idx: usize = 0; ++ for (0..rows) |y| { ++ // Get the row from the active viewport ++ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse { ++ // Row doesn't exist, fill with defaults ++ for (0..cols) |_| { ++ out[idx] = .{ ++ .codepoint = 0, ++ .fg_r = rs.colors.foreground.r, ++ .fg_g = rs.colors.foreground.g, ++ .fg_b = rs.colors.foreground.b, ++ .bg_r = rs.colors.background.r, ++ .bg_g = rs.colors.background.g, ++ .bg_b = rs.colors.background.b, ++ .flags = 0, ++ .width = 1, ++ .hyperlink_id = 0, ++ }; ++ idx += 1; ++ } ++ continue; ++ }; + -+ const y_usize: usize = @intCast(y); -+ -+ // Get scrollback length to validate bounds -+ const active_pin = wrapper.terminal.screen.pages.getTopLeft(.active); -+ const screen_pin = wrapper.terminal.screen.pages.getTopLeft(.screen); -+ -+ // Count total scrollback rows -+ var scrollback_len: usize = 0; -+ var pin = screen_pin; -+ while (pin.node != active_pin.node or pin.y != active_pin.y) { -+ scrollback_len += 1; -+ pin = pin.down(1) orelse break; -+ if (scrollback_len > 100000) return -1; // Safety limit -+ } -+ -+ // Validate y is within scrollback bounds -+ if (y_usize >= scrollback_len) return -1; ++ const cells = pin.cells(.all); ++ const page = pin.node.data; ++ ++ for (0..cols) |x| { ++ if (x >= cells.len) { ++ // Past end of row, fill with default ++ out[idx] = .{ ++ .codepoint = 0, ++ .fg_r = rs.colors.foreground.r, ++ .fg_g = rs.colors.foreground.g, ++ .fg_b = rs.colors.foreground.b, ++ .bg_r = rs.colors.background.r, ++ .bg_g = rs.colors.background.g, ++ .bg_b = rs.colors.background.b, ++ .flags = 0, ++ .width = 1, ++ .hyperlink_id = 0, ++ }; ++ idx += 1; ++ continue; ++ } + -+ const cols = wrapper.terminal.cols; -+ if (buffer_size < cols) return -1; ++ const cell = &cells[x]; + -+ // Get cells from scrollback using screen coordinates -+ // Screen coordinates: y=0 is the oldest line in scrollback, increasing toward active area -+ var x: usize = 0; -+ while (x < cols) : (x += 1) { -+ const pt = point.Point{ .screen = .{ -+ .x = @intCast(x), -+ .y = @intCast(y_usize), -+ } }; ++ // Get style from page styles (cell has style_id) ++ const sty: Style = if (cell.style_id > 0) ++ page.styles.get(page.memory, cell.style_id).* ++ else ++ .{}; + -+ const list_cell = wrapper.terminal.screen.pages.getCell(pt); -+ const cell = if (list_cell) |lc| lc.cell.* else Cell{}; -+ out_buffer[x] = convertCell(wrapper, cell, list_cell); ++ // Resolve colors ++ const fg: color.RGB = switch (sty.fg_color) { ++ .none => rs.colors.foreground, ++ .palette => |i| rs.colors.palette[i], ++ .rgb => |rgb| rgb, ++ }; ++ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; ++ ++ // Build flags ++ var flags: u8 = 0; ++ if (sty.flags.bold) flags |= 1 << 0; ++ if (sty.flags.italic) flags |= 1 << 1; ++ if (sty.flags.underline != .none) flags |= 1 << 2; ++ if (sty.flags.strikethrough) flags |= 1 << 3; ++ if (sty.flags.inverse) flags |= 1 << 4; ++ if (sty.flags.invisible) flags |= 1 << 5; ++ if (sty.flags.blink) flags |= 1 << 6; ++ if (sty.flags.faint) flags |= 1 << 7; ++ ++ out[idx] = .{ ++ .codepoint = cell.codepoint(), ++ .fg_r = fg.r, ++ .fg_g = fg.g, ++ .fg_b = fg.b, ++ .bg_r = bg.r, ++ .bg_g = bg.g, ++ .bg_b = bg.b, ++ .flags = flags, ++ .width = switch (cell.wide) { ++ .narrow => 1, ++ .wide => 2, ++ .spacer_tail, .spacer_head => 0, ++ }, ++ .hyperlink_id = if (cell.hyperlink) 1 else 0, ++ }; ++ idx += 1; ++ } + } + -+ return @intCast(cols); ++ return @intCast(total); +} + +// ============================================================================ -+// Dirty Tracking ++// Terminal Modes (minimal set for compatibility) +// ============================================================================ + -+pub fn isDirty(ptr: ?*anyopaque) callconv(.c) bool { ++pub fn isAlternateScreen(ptr: ?*anyopaque) callconv(.c) bool { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ -+ for (wrapper.dirty_rows) |dirty| { -+ if (dirty) return true; -+ } -+ return false; ++ return wrapper.terminal.screens.active_key == .alternate; +} + -+pub fn isRowDirty(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { ++pub fn hasMouseTracking(ptr: ?*anyopaque) callconv(.c) bool { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ -+ const y_usize: usize = @intCast(y); -+ if (y_usize >= wrapper.dirty_rows.len) return false; -+ -+ return wrapper.dirty_rows[y_usize]; ++ return wrapper.terminal.modes.get(.mouse_event_normal) or ++ wrapper.terminal.modes.get(.mouse_event_button) or ++ wrapper.terminal.modes.get(.mouse_event_any); +} + -+pub fn clearDirty(ptr: ?*anyopaque) callconv(.c) void { -+ const wrapper: *TerminalWrapper = @ptrCast(@alignCast(ptr orelse return)); -+ @memset(wrapper.dirty_rows, false); ++/// Query arbitrary terminal mode by number ++/// Returns true if mode is set, false otherwise ++pub fn getMode(ptr: ?*anyopaque, mode_num: c_int, is_ansi: bool) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); ++ const mode = modespkg.modeFromInt(@intCast(mode_num), is_ansi) orelse return false; ++ return wrapper.terminal.modes.get(mode); +} + +// ============================================================================ -+// Hyperlink Support ++// Scrollback API +// ============================================================================ + -+pub fn getHyperlinkUri( -+ ptr: ?*anyopaque, -+ hyperlink_id: u16, -+ out_buffer: [*]u8, -+ buffer_size: usize, -+) callconv(.c) c_int { ++/// Get the number of scrollback lines (history, not including active screen) ++pub fn getScrollbackLength(ptr: ?*anyopaque) callconv(.c) c_int { + const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return 0)); ++ const pages = &wrapper.terminal.screens.active.pages; ++ // total_rows includes both scrollback and active area ++ // We subtract rows (active area) to get just scrollback ++ if (pages.total_rows <= pages.rows) return 0; ++ return @intCast(pages.total_rows - pages.rows); ++} + -+ // Hyperlink ID 0 means no link -+ if (hyperlink_id == 0) return 0; -+ -+ // Get the current page -+ const page = &wrapper.terminal.screen.cursor.page_pin.node.data; -+ -+ // Look up hyperlink in the set -+ const hyperlink_entry = page.hyperlink_set.get(page.memory, hyperlink_id); -+ -+ // Get URI string from page memory -+ const uri = hyperlink_entry.uri.offset.ptr(page.memory)[0..hyperlink_entry.uri.len]; -+ -+ // Copy to output buffer (truncate if necessary) -+ const copy_len = @min(uri.len, buffer_size); -+ @memcpy(out_buffer[0..copy_len], uri[0..copy_len]); ++/// Get a line from the scrollback buffer ++/// offset 0 = oldest line in scrollback, offset (length-1) = most recent scrollback line ++/// Returns number of cells written, or -1 on error ++pub fn getScrollbackLine( ++ ptr: ?*anyopaque, ++ offset: c_int, ++ out: [*]GhosttyCell, ++ buf_size: usize, ++) callconv(.c) c_int { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return -1)); ++ const rs = &wrapper.render_state; ++ const cols = rs.cols; ++ ++ if (buf_size < cols) return -1; ++ if (offset < 0) return -1; ++ ++ const scrollback_len = getScrollbackLength(ptr); ++ if (offset >= scrollback_len) return -1; ++ ++ // Get the pin for this scrollback row ++ // history point: y=0 is oldest, y=scrollback_len-1 is newest ++ const pages = &wrapper.terminal.screens.active.pages; ++ const pin = pages.pin(.{ .history = .{ .y = @intCast(offset) } }) orelse return -1; ++ ++ // Get cells for this row ++ const cells = pin.cells(.all); ++ const page = pin.node.data; ++ ++ // Fill output buffer ++ for (0..cols) |x| { ++ if (x >= cells.len) { ++ // Fill with default ++ out[x] = .{ ++ .codepoint = 0, ++ .fg_r = rs.colors.foreground.r, ++ .fg_g = rs.colors.foreground.g, ++ .fg_b = rs.colors.foreground.b, ++ .bg_r = rs.colors.background.r, ++ .bg_g = rs.colors.background.g, ++ .bg_b = rs.colors.background.b, ++ .flags = 0, ++ .width = 1, ++ .hyperlink_id = 0, ++ }; ++ continue; ++ } ++ ++ const cell = &cells[x]; ++ ++ // Get style from page styles (cell has style_id) ++ const sty: Style = if (cell.style_id > 0) ++ page.styles.get(page.memory, cell.style_id).* ++ else ++ .{}; ++ ++ // Resolve colors ++ const fg: color.RGB = switch (sty.fg_color) { ++ .none => rs.colors.foreground, ++ .palette => |i| rs.colors.palette[i], ++ .rgb => |rgb| rgb, ++ }; ++ const bg: color.RGB = if (sty.bg(cell, &rs.colors.palette)) |rgb| rgb else rs.colors.background; ++ ++ // Build flags ++ var flags: u8 = 0; ++ if (sty.flags.bold) flags |= 1 << 0; ++ if (sty.flags.italic) flags |= 1 << 1; ++ if (sty.flags.underline != .none) flags |= 1 << 2; ++ if (sty.flags.strikethrough) flags |= 1 << 3; ++ if (sty.flags.inverse) flags |= 1 << 4; ++ if (sty.flags.invisible) flags |= 1 << 5; ++ if (sty.flags.blink) flags |= 1 << 6; ++ if (sty.flags.faint) flags |= 1 << 7; ++ ++ out[x] = .{ ++ .codepoint = cell.codepoint(), ++ .fg_r = fg.r, ++ .fg_g = fg.g, ++ .fg_b = fg.b, ++ .bg_r = bg.r, ++ .bg_g = bg.g, ++ .bg_b = bg.b, ++ .flags = flags, ++ .width = switch (cell.wide) { ++ .narrow => 1, ++ .wide => 2, ++ .spacer_tail, .spacer_head => 0, ++ }, ++ .hyperlink_id = if (cell.hyperlink) 1 else 0, ++ }; ++ } ++ return @intCast(cols); ++} + -+ return @intCast(copy_len); ++/// Check if a row is a continuation from the previous row (soft-wrapped) ++/// This matches xterm.js semantics where isWrapped indicates the row continues ++/// from the previous row, not that it wraps to the next row. ++pub fn isRowWrapped(ptr: ?*anyopaque, y: c_int) callconv(.c) bool { ++ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); ++ const pages = &wrapper.terminal.screens.active.pages; ++ ++ // Get pin for this row in active area ++ const pin = pages.pin(.{ .active = .{ .y = @intCast(y) } }) orelse return false; ++ const rac = pin.rowAndCell(); ++ ++ // wrap_continuation means this row continues from the previous row ++ return rac.row.wrap_continuation; +} + +// ============================================================================ @@ -1179,89 +799,26 @@ index 000000000..e79702488 +test "terminal lifecycle" { + const term = new(80, 24); + defer free(term); -+ + try std.testing.expect(term != null); -+ try std.testing.expectEqual(@as(c_int, 80), getCols(term)); -+ try std.testing.expectEqual(@as(c_int, 24), getRows(term)); ++ ++ _ = renderStateUpdate(term); ++ try std.testing.expectEqual(@as(c_int, 80), renderStateGetCols(term)); ++ try std.testing.expectEqual(@as(c_int, 24), renderStateGetRows(term)); +} + -+test "terminal write and read" { ++test "terminal write and read via render state" { + const term = new(80, 24); + defer free(term); + -+ // Write "Hello" -+ const data = "Hello"; -+ write(term, data.ptr, data.len); -+ -+ // Read first line -+ var cells: [80]GhosttyCell = undefined; -+ const count = getLine(term, 0, &cells, 80); -+ try std.testing.expectEqual(@as(c_int, 80), count); ++ write(term, "Hello", 5); ++ _ = renderStateUpdate(term); + -+ // Check first few characters ++ var cells: [80 * 24]GhosttyCell = undefined; ++ const count = renderStateGetViewport(term, &cells, 80 * 24); ++ try std.testing.expectEqual(@as(c_int, 80 * 24), count); + try std.testing.expectEqual(@as(u32, 'H'), cells[0].codepoint); + try std.testing.expectEqual(@as(u32, 'e'), cells[1].codepoint); + try std.testing.expectEqual(@as(u32, 'l'), cells[2].codepoint); + try std.testing.expectEqual(@as(u32, 'l'), cells[3].codepoint); + try std.testing.expectEqual(@as(u32, 'o'), cells[4].codepoint); +} -+ -+test "terminal cursor position" { -+ const term = new(80, 24); -+ defer free(term); -+ -+ // Initially at 0, 0 -+ try std.testing.expectEqual(@as(c_int, 0), getCursorX(term)); -+ try std.testing.expectEqual(@as(c_int, 0), getCursorY(term)); -+ -+ // Write "Hello" (5 chars) -+ const data = "Hello"; -+ write(term, data.ptr, data.len); -+ -+ // Cursor should have moved -+ try std.testing.expectEqual(@as(c_int, 5), getCursorX(term)); -+ try std.testing.expectEqual(@as(c_int, 0), getCursorY(term)); -+} -+ -+test "terminal dirty tracking" { -+ const term = new(80, 24); -+ defer free(term); -+ -+ // Initially dirty -+ try std.testing.expect(isDirty(term)); -+ -+ // Clear dirty -+ clearDirty(term); -+ try std.testing.expect(!isDirty(term)); -+ -+ // Write makes it dirty again -+ const data = "X"; -+ write(term, data.ptr, data.len); -+ try std.testing.expect(isDirty(term)); -+ try std.testing.expect(isRowDirty(term, 0)); -+} -+ -+// ============================================================================ -+// Terminal Modes -+// ============================================================================ -+ -+pub fn getMode(ptr: ?*anyopaque, mode_number: c_int, is_ansi: bool) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ const mode = modespkg.modeFromInt(@intCast(mode_number), is_ansi) orelse return false; -+ return wrapper.terminal.modes.get(mode); -+} -+ -+pub fn hasBracketedPaste(ptr: ?*anyopaque) callconv(.c) bool { -+ return getMode(ptr, 2004, false); -+} -+ -+pub fn hasFocusEvents(ptr: ?*anyopaque) callconv(.c) bool { -+ return getMode(ptr, 1004, false); -+} -+ -+pub fn hasMouseTracking(ptr: ?*anyopaque) callconv(.c) bool { -+ const wrapper: *const TerminalWrapper = @ptrCast(@alignCast(ptr orelse return false)); -+ return wrapper.terminal.modes.get(.mouse_event_normal) or -+ wrapper.terminal.modes.get(.mouse_event_button) or -+ wrapper.terminal.modes.get(.mouse_event_any); -+} From 8748b891b3050c9b513604c98fcfee986a02d113 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Wed, 3 Dec 2025 01:14:04 -0600 Subject: [PATCH 2/2] fix: restore terminal config support and fix formatting - Add GhosttyTerminalConfig struct and ghostty_terminal_new_with_config to WASM API - Update GhosttyTerminal constructor to accept optional config parameter - Update Ghostty.createTerminal() to pass config through - Use same parseColorToHex() and buildWasmConfig() pattern as main branch - Fix double semicolon formatting issue in terminal.ts - Add tests for config (scrollback, theme colors, palette colors) Restored config options: - scrollbackLimit: Max scrollback lines (0 = unlimited) - fgColor: Default foreground color (0xRRGGBB) - bgColor: Default background color (0xRRGGBB) - cursorColor: Cursor color (0xRRGGBB) - palette: 16-color ANSI palette --- lib/ghostty.ts | 63 +++++- lib/terminal.test.ts | 111 +++++++++++ lib/terminal.ts | 82 +++++++- lib/types.ts | 12 +- patches/ghostty-wasm-api.patch | 346 +++++++++++++++++++++------------ 5 files changed, 481 insertions(+), 133 deletions(-) diff --git a/lib/ghostty.ts b/lib/ghostty.ts index 02a53a1..69d2426 100644 --- a/lib/ghostty.ts +++ b/lib/ghostty.ts @@ -10,7 +10,9 @@ import { CellFlags, type Cursor, DirtyState, + GHOSTTY_CONFIG_SIZE, type GhosttyCell, + type GhosttyTerminalConfig, type GhosttyWasmExports, KeyEncoderOption, type KeyEvent, @@ -27,6 +29,7 @@ export { type Cursor, DirtyState, type GhosttyCell, + type GhosttyTerminalConfig, KeyEncoderOption, type RGB, type RenderStateColors, @@ -49,8 +52,12 @@ export class Ghostty { return new KeyEncoder(this.exports); } - createTerminal(cols: number = 80, rows: number = 24): GhosttyTerminal { - return new GhosttyTerminal(this.exports, this.memory, cols, rows); + createTerminal( + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ): GhosttyTerminal { + return new GhosttyTerminal(this.exports, this.memory, cols, rows, config); } static async load(wasmPath?: string): Promise { @@ -261,13 +268,61 @@ export class GhosttyTerminal { /** Cell pool for zero-allocation rendering */ private cellPool: GhosttyCell[] = []; - constructor(exports: GhosttyWasmExports, memory: WebAssembly.Memory, cols: number, rows: number) { + constructor( + exports: GhosttyWasmExports, + memory: WebAssembly.Memory, + cols: number = 80, + rows: number = 24, + config?: GhosttyTerminalConfig + ) { this.exports = exports; this.memory = memory; this._cols = cols; this._rows = rows; - this.handle = this.exports.ghostty_terminal_new(cols, rows); + if (config) { + // Allocate config struct in WASM memory + const configPtr = this.exports.ghostty_wasm_alloc_u8_array(GHOSTTY_CONFIG_SIZE); + if (configPtr === 0) { + throw new Error('Failed to allocate config (out of memory)'); + } + + try { + // Write config to WASM memory + const view = new DataView(this.memory.buffer); + let offset = configPtr; + + // scrollback_limit (u32) + view.setUint32(offset, config.scrollbackLimit ?? 10000, true); + offset += 4; + + // fg_color (u32) + view.setUint32(offset, config.fgColor ?? 0, true); + offset += 4; + + // bg_color (u32) + view.setUint32(offset, config.bgColor ?? 0, true); + offset += 4; + + // cursor_color (u32) + view.setUint32(offset, config.cursorColor ?? 0, true); + offset += 4; + + // palette[16] (u32 * 16) + for (let i = 0; i < 16; i++) { + view.setUint32(offset, config.palette?.[i] ?? 0, true); + offset += 4; + } + + this.handle = this.exports.ghostty_terminal_new_with_config(cols, rows, configPtr); + } finally { + // Free the config memory + this.exports.ghostty_wasm_free_u8_array(configPtr, GHOSTTY_CONFIG_SIZE); + } + } else { + this.handle = this.exports.ghostty_terminal_new(cols, rows); + } + if (!this.handle) throw new Error('Failed to create terminal'); this.initCellPool(); diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d21279f..b3adc7a 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -1399,6 +1399,117 @@ describe('Buffer Access API', () => { }); }); +describe('Terminal Config', () => { + test('should pass scrollback option to WASM terminal', async () => { + if (typeof document === 'undefined') return; + + // Create terminal with custom scrollback + const term = await createIsolatedTerminal({ cols: 80, rows: 24, scrollback: 500 }); + const container = document.createElement('div'); + term.open(container); + + try { + // Write enough lines to fill scrollback + for (let i = 0; i < 600; i++) { + term.write(`Line ${i}\r\n`); + } + + // Scrollback should be limited based on the config + const scrollbackLength = term.wasmTerm!.getScrollbackLength(); + // With 500 scrollback limit, we wrote 600 lines so scrollback should be capped + // The actual value depends on ghostty's implementation but should be around 500 + expect(scrollbackLength).toBeLessThan(600); + expect(scrollbackLength).toBeGreaterThan(450); + } finally { + term.dispose(); + } + }); + + test('should pass theme colors to WASM terminal', async () => { + if (typeof document === 'undefined') return; + + // Create terminal with custom theme + const term = await createIsolatedTerminal({ + cols: 80, + rows: 24, + theme: { + foreground: '#00ff00', // Green + background: '#000080', // Navy blue + }, + }); + const container = document.createElement('div'); + term.open(container); + + try { + // Get the default colors from render state + const colors = term.wasmTerm!.getColors(); + + // Verify foreground is green (0x00FF00) + expect(colors.foreground.r).toBe(0); + expect(colors.foreground.g).toBe(255); + expect(colors.foreground.b).toBe(0); + + // Verify background is navy (0x000080) + expect(colors.background.r).toBe(0); + expect(colors.background.g).toBe(0); + expect(colors.background.b).toBe(128); + } finally { + term.dispose(); + } + }); + + test('should pass palette colors to WASM terminal', async () => { + if (typeof document === 'undefined') return; + + // Create terminal with custom red color in palette + const term = await createIsolatedTerminal({ + cols: 80, + rows: 24, + theme: { + red: '#ff0000', // Bright red for ANSI red + }, + }); + const container = document.createElement('div'); + term.open(container); + + try { + // Write red text using ANSI escape code + term.write('\x1b[31mRed text\x1b[0m'); + + // Get first cell - should have red foreground + const line = term.wasmTerm!.getLine(0); + const firstCell = line[0]; + + // The foreground should be red (0xFF0000) + expect(firstCell.fg_r).toBe(255); + expect(firstCell.fg_g).toBe(0); + expect(firstCell.fg_b).toBe(0); + } finally { + term.dispose(); + } + }); + + test('should use default config when no options provided', async () => { + if (typeof document === 'undefined') return; + + // Create terminal with no config + const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); + const container = document.createElement('div'); + term.open(container); + + try { + // Should still work and have reasonable defaults + const colors = term.wasmTerm!.getColors(); + + // Default colors should be set (light gray foreground, black background) + expect(colors.foreground).toBeDefined(); + expect(colors.background).toBeDefined(); + } finally { + term.dispose(); + } + }); +}); + describe('Terminal Modes', () => { test('should detect bracketed paste mode', async () => { if (typeof document === 'undefined') return; diff --git a/lib/terminal.ts b/lib/terminal.ts index ab07721..b37c3bf 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -17,7 +17,7 @@ import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; -import type { Ghostty, GhosttyCell, GhosttyTerminal } from './ghostty'; +import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; import { getGhostty } from './index'; import { InputHandler } from './input-handler'; import type { @@ -220,6 +220,78 @@ export class Terminal implements ITerminalCore { } } + /** + * Parse a CSS color string to 0xRRGGBB format. + * Returns 0 if the color is undefined or invalid. + */ + private parseColorToHex(color?: string): number { + if (!color) return 0; + + // Handle hex colors (#RGB, #RRGGBB) + if (color.startsWith('#')) { + let hex = color.slice(1); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? 0 : value; + } + + // Handle rgb(r, g, b) format + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (match) { + const r = Number.parseInt(match[1], 10); + const g = Number.parseInt(match[2], 10); + const b = Number.parseInt(match[3], 10); + return (r << 16) | (g << 8) | b; + } + + return 0; + } + + /** + * Convert terminal options to WASM terminal config. + */ + private buildWasmConfig(): GhosttyTerminalConfig | undefined { + const theme = this.options.theme; + const scrollback = this.options.scrollback; + + // If no theme and default scrollback, use defaults + if (!theme && scrollback === 1000) { + return undefined; + } + + // Build palette array from theme colors + // Order: black, red, green, yellow, blue, magenta, cyan, white, + // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite + const palette: number[] = [ + this.parseColorToHex(theme?.black), + this.parseColorToHex(theme?.red), + this.parseColorToHex(theme?.green), + this.parseColorToHex(theme?.yellow), + this.parseColorToHex(theme?.blue), + this.parseColorToHex(theme?.magenta), + this.parseColorToHex(theme?.cyan), + this.parseColorToHex(theme?.white), + this.parseColorToHex(theme?.brightBlack), + this.parseColorToHex(theme?.brightRed), + this.parseColorToHex(theme?.brightGreen), + this.parseColorToHex(theme?.brightYellow), + this.parseColorToHex(theme?.brightBlue), + this.parseColorToHex(theme?.brightMagenta), + this.parseColorToHex(theme?.brightCyan), + this.parseColorToHex(theme?.brightWhite), + ]; + + return { + scrollbackLimit: scrollback, + fgColor: this.parseColorToHex(theme?.foreground), + bgColor: this.parseColorToHex(theme?.background), + cursorColor: this.parseColorToHex(theme?.cursor), + palette, + }; + } + // ========================================================================== // Lifecycle Methods // ========================================================================== @@ -259,8 +331,9 @@ export class Terminal implements ITerminalCore { parent.setAttribute('aria-label', 'Terminal input'); parent.setAttribute('aria-multiline', 'true'); - // Create WASM terminal with current dimensions - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows);; + // Create WASM terminal with current dimensions and config + const config = this.buildWasmConfig(); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); // Create canvas element this.canvas = document.createElement('canvas'); @@ -560,7 +633,8 @@ export class Terminal implements ITerminalCore { if (this.wasmTerm) { this.wasmTerm.free(); } - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows); + const config = this.buildWasmConfig(); + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); // Clear renderer this.renderer!.clear(); diff --git a/lib/types.ts b/lib/types.ts index 76a1fe1..c505277 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -344,6 +344,7 @@ export interface GhosttyWasmExports extends WebAssembly.Exports { // Terminal lifecycle ghostty_terminal_new(cols: number, rows: number): TerminalHandle; + ghostty_terminal_new_with_config(cols: number, rows: number, configPtr: number): TerminalHandle; ghostty_terminal_free(terminal: TerminalHandle): void; ghostty_terminal_resize(terminal: TerminalHandle, cols: number, rows: number): void; ghostty_terminal_write(terminal: TerminalHandle, dataPtr: number, dataLen: number): void; @@ -427,7 +428,10 @@ export const CURSOR_STRUCT_SIZE = 8; */ export const COLORS_STRUCT_SIZE = 12; -// Legacy - kept for compatibility but not used with new API +/** + * Terminal configuration (passed to ghostty_terminal_new_with_config) + * All color values use 0xRRGGBB format. A value of 0 means "use default". + */ export interface GhosttyTerminalConfig { scrollbackLimit?: number; fgColor?: number; @@ -435,6 +439,12 @@ export interface GhosttyTerminalConfig { cursorColor?: number; palette?: number[]; } + +/** + * Size of GhosttyTerminalConfig struct in WASM memory (bytes). + * Layout: scrollback_limit(u32) + fg_color(u32) + bg_color(u32) + cursor_color(u32) + palette[16](u32*16) + * Total: 4 + 4 + 4 + 4 + 64 = 80 bytes + */ export const GHOSTTY_CONFIG_SIZE = 80; /** diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index d6f2042..0c02270 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -27,131 +27,12 @@ index 4f8fef88e..ca9fb1d4d 100644 #include #include #include -diff --git a/src/lib_vt.zig b/src/lib_vt.zig -index 03a883e20..22e4fe909 100644 ---- a/src/lib_vt.zig -+++ b/src/lib_vt.zig -@@ -140,6 +140,34 @@ comptime { - @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); - @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); - @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); -+ // Terminal lifecycle -+ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); -+ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); -+ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); -+ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); -+ -+ // RenderState API - high-performance rendering -+ @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); -+ @export(&c.render_state_get_cols, .{ .name = "ghostty_render_state_get_cols" }); -+ @export(&c.render_state_get_rows, .{ .name = "ghostty_render_state_get_rows" }); -+ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" }); -+ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" }); -+ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" }); -+ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" }); -+ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" }); -+ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); -+ @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); -+ @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); -+ -+ // Terminal modes -+ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); -+ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); -+ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); -+ -+ // Scrollback API -+ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); -+ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); -+ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); - - // On Wasm we need to export our allocator convenience functions. - if (builtin.target.cpu.arch.isWasm()) { -diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig -index bc92597f5..cb5ccb3a1 100644 ---- a/src/terminal/c/main.zig -+++ b/src/terminal/c/main.zig -@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); - pub const key_encode = @import("key_encode.zig"); - pub const paste = @import("paste.zig"); - pub const sgr = @import("sgr.zig"); -+pub const terminal = @import("terminal.zig"); - - // The full C API, unexported. - pub const osc_new = osc.new; -@@ -52,6 +53,35 @@ pub const key_encoder_encode = key_encode.encode; - - pub const paste_is_safe = paste.is_safe; - -+// Terminal lifecycle -+pub const terminal_new = terminal.new; -+pub const terminal_free = terminal.free; -+pub const terminal_resize = terminal.resize; -+pub const terminal_write = terminal.write; -+ -+// RenderState API - high-performance rendering -+pub const render_state_update = terminal.renderStateUpdate; -+pub const render_state_get_cols = terminal.renderStateGetCols; -+pub const render_state_get_rows = terminal.renderStateGetRows; -+pub const render_state_get_cursor_x = terminal.renderStateGetCursorX; -+pub const render_state_get_cursor_y = terminal.renderStateGetCursorY; -+pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible; -+pub const render_state_get_bg_color = terminal.renderStateGetBgColor; -+pub const render_state_get_fg_color = terminal.renderStateGetFgColor; -+pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; -+pub const render_state_mark_clean = terminal.renderStateMarkClean; -+pub const render_state_get_viewport = terminal.renderStateGetViewport; -+ -+// Terminal modes -+pub const terminal_is_alternate_screen = terminal.isAlternateScreen; -+pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; -+pub const terminal_get_mode = terminal.getMode; -+ -+// Scrollback API -+pub const terminal_get_scrollback_length = terminal.getScrollbackLength; -+pub const terminal_get_scrollback_line = terminal.getScrollbackLine; -+pub const terminal_is_row_wrapped = terminal.isRowWrapped; -+ - test { - _ = color; - _ = osc; -@@ -59,6 +89,7 @@ test { - _ = key_encode; - _ = paste; - _ = sgr; -+ _ = terminal; - - // We want to make sure we run the tests for the C allocator interface. - _ = @import("../../lib/allocator.zig"); -diff --git a/src/terminal/render.zig b/src/terminal/render.zig -index b6430ea34..10e0ef79d 100644 ---- a/src/terminal/render.zig -+++ b/src/terminal/render.zig -@@ -322,13 +322,14 @@ pub const RenderState = struct { - // Colors. - self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; -- bg_fg: { -+ { - // Background/foreground can be unset initially which would -- // depend on "default" background/foreground. The expected use -- // case of Terminal is that the caller set their own configured -- // defaults on load so this doesn't happen. -- const bg = t.colors.background.get() orelse break :bg_fg; -- const fg = t.colors.foreground.get() orelse break :bg_fg; -+ // depend on "default" background/foreground. Use sensible defaults -+ // (black background, light gray foreground) when not explicitly set. -+ const default_bg: color.RGB = .{ .r = 0, .g = 0, .b = 0 }; -+ const default_fg: color.RGB = .{ .r = 204, .g = 204, .b = 204 }; -+ const bg = t.colors.background.get() orelse default_bg; -+ const fg = t.colors.foreground.get() orelse default_fg; - if (t.modes.get(.reverse_colors)) { - self.colors.background = fg; - self.colors.foreground = bg; diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h new file mode 100644 +index 000000000..e371164b6 --- /dev/null +++ b/include/ghostty/vt/terminal.h -@@ -0,0 +1,162 @@ +@@ -0,0 +1,192 @@ +/** + * @file terminal.h + * @@ -186,6 +67,23 @@ new file mode 100644 +/** Opaque terminal handle */ +typedef void* GhosttyTerminal; + ++/** ++ * Terminal configuration. ++ * All color values use 0xRRGGBB format. A value of 0 means "use default". ++ */ ++typedef struct { ++ /** Maximum scrollback lines (0 = unlimited) */ ++ uint32_t scrollback_limit; ++ /** Default foreground color (0xRRGGBB, 0 = default) */ ++ uint32_t fg_color; ++ /** Default background color (0xRRGGBB, 0 = default) */ ++ uint32_t bg_color; ++ /** Cursor color (0xRRGGBB, 0 = default) */ ++ uint32_t cursor_color; ++ /** ANSI color palette (16 colors, 0xRRGGBB format, 0 = default) */ ++ uint32_t palette[16]; ++} GhosttyTerminalConfig; ++ +/** Cell structure - 16 bytes, pre-resolved colors */ +typedef struct { + uint32_t codepoint; @@ -218,9 +116,22 @@ new file mode 100644 + * Lifecycle + * ========================================================================= */ + -+/** Create a new terminal */ ++/** Create a new terminal with default settings */ +GhosttyTerminal ghostty_terminal_new(int cols, int rows); + ++/** ++ * Create a new terminal with custom configuration. ++ * @param cols Number of columns ++ * @param rows Number of rows ++ * @param config Configuration options (NULL = use defaults) ++ * @return Terminal handle, or NULL on failure ++ */ ++GhosttyTerminal ghostty_terminal_new_with_config( ++ int cols, ++ int rows, ++ const GhosttyTerminalConfig* config ++); ++ +/** Free a terminal */ +void ghostty_terminal_free(GhosttyTerminal term); + @@ -314,11 +225,109 @@ new file mode 100644 +#endif + +#endif /* GHOSTTY_VT_TERMINAL_H */ +diff --git a/src/lib_vt.zig b/src/lib_vt.zig +index 03a883e20..35f6b787f 100644 +--- a/src/lib_vt.zig ++++ b/src/lib_vt.zig +@@ -140,6 +140,35 @@ comptime { + @export(&c.sgr_unknown_partial, .{ .name = "ghostty_sgr_unknown_partial" }); + @export(&c.sgr_attribute_tag, .{ .name = "ghostty_sgr_attribute_tag" }); + @export(&c.sgr_attribute_value, .{ .name = "ghostty_sgr_attribute_value" }); ++ // Terminal lifecycle ++ @export(&c.terminal_new, .{ .name = "ghostty_terminal_new" }); ++ @export(&c.terminal_new_with_config, .{ .name = "ghostty_terminal_new_with_config" }); ++ @export(&c.terminal_free, .{ .name = "ghostty_terminal_free" }); ++ @export(&c.terminal_resize, .{ .name = "ghostty_terminal_resize" }); ++ @export(&c.terminal_write, .{ .name = "ghostty_terminal_write" }); ++ ++ // RenderState API - high-performance rendering ++ @export(&c.render_state_update, .{ .name = "ghostty_render_state_update" }); ++ @export(&c.render_state_get_cols, .{ .name = "ghostty_render_state_get_cols" }); ++ @export(&c.render_state_get_rows, .{ .name = "ghostty_render_state_get_rows" }); ++ @export(&c.render_state_get_cursor_x, .{ .name = "ghostty_render_state_get_cursor_x" }); ++ @export(&c.render_state_get_cursor_y, .{ .name = "ghostty_render_state_get_cursor_y" }); ++ @export(&c.render_state_get_cursor_visible, .{ .name = "ghostty_render_state_get_cursor_visible" }); ++ @export(&c.render_state_get_bg_color, .{ .name = "ghostty_render_state_get_bg_color" }); ++ @export(&c.render_state_get_fg_color, .{ .name = "ghostty_render_state_get_fg_color" }); ++ @export(&c.render_state_is_row_dirty, .{ .name = "ghostty_render_state_is_row_dirty" }); ++ @export(&c.render_state_mark_clean, .{ .name = "ghostty_render_state_mark_clean" }); ++ @export(&c.render_state_get_viewport, .{ .name = "ghostty_render_state_get_viewport" }); ++ ++ // Terminal modes ++ @export(&c.terminal_is_alternate_screen, .{ .name = "ghostty_terminal_is_alternate_screen" }); ++ @export(&c.terminal_has_mouse_tracking, .{ .name = "ghostty_terminal_has_mouse_tracking" }); ++ @export(&c.terminal_get_mode, .{ .name = "ghostty_terminal_get_mode" }); ++ ++ // Scrollback API ++ @export(&c.terminal_get_scrollback_length, .{ .name = "ghostty_terminal_get_scrollback_length" }); ++ @export(&c.terminal_get_scrollback_line, .{ .name = "ghostty_terminal_get_scrollback_line" }); ++ @export(&c.terminal_is_row_wrapped, .{ .name = "ghostty_terminal_is_row_wrapped" }); + + // On Wasm we need to export our allocator convenience functions. + if (builtin.target.cpu.arch.isWasm()) { +diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig +index bc92597f5..6a97183fe 100644 +--- a/src/terminal/c/main.zig ++++ b/src/terminal/c/main.zig +@@ -4,6 +4,7 @@ pub const key_event = @import("key_event.zig"); + pub const key_encode = @import("key_encode.zig"); + pub const paste = @import("paste.zig"); + pub const sgr = @import("sgr.zig"); ++pub const terminal = @import("terminal.zig"); + + // The full C API, unexported. + pub const osc_new = osc.new; +@@ -52,6 +53,36 @@ pub const key_encoder_encode = key_encode.encode; + + pub const paste_is_safe = paste.is_safe; + ++// Terminal lifecycle ++pub const terminal_new = terminal.new; ++pub const terminal_new_with_config = terminal.newWithConfig; ++pub const terminal_free = terminal.free; ++pub const terminal_resize = terminal.resize; ++pub const terminal_write = terminal.write; ++ ++// RenderState API - high-performance rendering ++pub const render_state_update = terminal.renderStateUpdate; ++pub const render_state_get_cols = terminal.renderStateGetCols; ++pub const render_state_get_rows = terminal.renderStateGetRows; ++pub const render_state_get_cursor_x = terminal.renderStateGetCursorX; ++pub const render_state_get_cursor_y = terminal.renderStateGetCursorY; ++pub const render_state_get_cursor_visible = terminal.renderStateGetCursorVisible; ++pub const render_state_get_bg_color = terminal.renderStateGetBgColor; ++pub const render_state_get_fg_color = terminal.renderStateGetFgColor; ++pub const render_state_is_row_dirty = terminal.renderStateIsRowDirty; ++pub const render_state_mark_clean = terminal.renderStateMarkClean; ++pub const render_state_get_viewport = terminal.renderStateGetViewport; ++ ++// Terminal modes ++pub const terminal_is_alternate_screen = terminal.isAlternateScreen; ++pub const terminal_has_mouse_tracking = terminal.hasMouseTracking; ++pub const terminal_get_mode = terminal.getMode; ++ ++// Scrollback API ++pub const terminal_get_scrollback_length = terminal.getScrollbackLength; ++pub const terminal_get_scrollback_line = terminal.getScrollbackLine; ++pub const terminal_is_row_wrapped = terminal.isRowWrapped; ++ + test { + _ = color; + _ = osc; +@@ -59,6 +90,7 @@ test { + _ = key_encode; + _ = paste; + _ = sgr; ++ _ = terminal; + + // We want to make sure we run the tests for the C allocator interface. + _ = @import("../../lib/allocator.zig"); diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig new file mode 100644 +index 000000000..3868eb17b --- /dev/null +++ b/src/terminal/c/terminal.zig -@@ -0,0 +1,503 @@ +@@ -0,0 +1,567 @@ +//! C API wrapper for Terminal +//! +//! This provides a minimal, high-performance interface to Ghostty's Terminal @@ -381,11 +390,28 @@ new file mode 100644 + full = 2, +}; + ++/// C-compatible terminal configuration ++pub const GhosttyTerminalConfig = extern struct { ++ scrollback_limit: u32, ++ fg_color: u32, ++ bg_color: u32, ++ cursor_color: u32, ++ palette: [16]u32, ++}; ++ +// ============================================================================ +// Lifecycle +// ============================================================================ + +pub fn new(cols: c_int, rows: c_int) callconv(.c) ?*anyopaque { ++ return newWithConfig(cols, rows, null); ++} ++ ++pub fn newWithConfig( ++ cols: c_int, ++ rows: c_int, ++ config_: ?*const GhosttyTerminalConfig, ++) callconv(.c) ?*anyopaque { + const alloc = if (builtin.target.cpu.arch.isWasm()) + std.heap.wasm_allocator + else @@ -393,10 +419,57 @@ new file mode 100644 + + const wrapper = alloc.create(TerminalWrapper) catch return null; + ++ // Parse config or use defaults ++ const scrollback_limit: usize = if (config_) |cfg| ++ if (cfg.scrollback_limit == 0) std.math.maxInt(usize) else cfg.scrollback_limit ++ else ++ 10_000; ++ ++ // Setup terminal colors ++ var colors = Terminal.Colors.default; ++ if (config_) |cfg| { ++ if (cfg.fg_color != 0) { ++ const rgb = color.RGB{ ++ .r = @truncate((cfg.fg_color >> 16) & 0xFF), ++ .g = @truncate((cfg.fg_color >> 8) & 0xFF), ++ .b = @truncate(cfg.fg_color & 0xFF), ++ }; ++ colors.foreground = color.DynamicRGB.init(rgb); ++ } ++ if (cfg.bg_color != 0) { ++ const rgb = color.RGB{ ++ .r = @truncate((cfg.bg_color >> 16) & 0xFF), ++ .g = @truncate((cfg.bg_color >> 8) & 0xFF), ++ .b = @truncate(cfg.bg_color & 0xFF), ++ }; ++ colors.background = color.DynamicRGB.init(rgb); ++ } ++ if (cfg.cursor_color != 0) { ++ const rgb = color.RGB{ ++ .r = @truncate((cfg.cursor_color >> 16) & 0xFF), ++ .g = @truncate((cfg.cursor_color >> 8) & 0xFF), ++ .b = @truncate(cfg.cursor_color & 0xFF), ++ }; ++ colors.cursor = color.DynamicRGB.init(rgb); ++ } ++ // Apply palette colors (0 = use default) ++ for (cfg.palette, 0..) |palette_color, i| { ++ if (palette_color != 0) { ++ const rgb = color.RGB{ ++ .r = @truncate((palette_color >> 16) & 0xFF), ++ .g = @truncate((palette_color >> 8) & 0xFF), ++ .b = @truncate(palette_color & 0xFF), ++ }; ++ colors.palette.set(@intCast(i), rgb); ++ } ++ } ++ } ++ + wrapper.terminal = Terminal.init(alloc, .{ + .cols = @intCast(cols), + .rows = @intCast(rows), -+ .max_scrollback = 10_000, ++ .max_scrollback = scrollback_limit, ++ .colors = colors, + }) catch { + alloc.destroy(wrapper); + return null; @@ -822,3 +895,28 @@ new file mode 100644 + try std.testing.expectEqual(@as(u32, 'l'), cells[3].codepoint); + try std.testing.expectEqual(@as(u32, 'o'), cells[4].codepoint); +} +diff --git a/src/terminal/render.zig b/src/terminal/render.zig +index b6430ea34..10e0ef79d 100644 +--- a/src/terminal/render.zig ++++ b/src/terminal/render.zig +@@ -322,13 +322,14 @@ pub const RenderState = struct { + // Colors. + self.colors.cursor = t.colors.cursor.get(); + self.colors.palette = t.colors.palette.current; +- bg_fg: { ++ { + // Background/foreground can be unset initially which would +- // depend on "default" background/foreground. The expected use +- // case of Terminal is that the caller set their own configured +- // defaults on load so this doesn't happen. +- const bg = t.colors.background.get() orelse break :bg_fg; +- const fg = t.colors.foreground.get() orelse break :bg_fg; ++ // depend on "default" background/foreground. Use sensible defaults ++ // (black background, light gray foreground) when not explicitly set. ++ const default_bg: color.RGB = .{ .r = 0, .g = 0, .b = 0 }; ++ const default_fg: color.RGB = .{ .r = 204, .g = 204, .b = 204 }; ++ const bg = t.colors.background.get() orelse default_bg; ++ const fg = t.colors.foreground.get() orelse default_fg; + if (t.modes.get(.reverse_colors)) { + self.colors.background = fg; + self.colors.foreground = bg;