@ 261105.1

260426.4 · 5d0b84b1b465a0abd6a27e893d37d5f68f8e1836

feat(wasm): Introduce ghostty-wasm as backing WASM terminal (fixes #700) (fixes #670) (fixes #698)

This gets us all the goodness (or most of it) of ghostty, and fixes a few outstanding problems. This works like xterm.js, just better.

reading declarations

Changes to webfiles/tcell.js. All files

webfiles/tcell.js

@@ -1,4 +1,4 @@-// Copyright 2024 The TCell Authors+// Copyright 2026 The TCell Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.@@ -13,271 +13,627 @@ // limitations under the License.  const wasmFilePath = "main.wasm";-const term = document.getElementById("terminal");-var width = 80;-var height = 24;+const container = document.getElementById("terminal"); const beepAudio = new Audio("beep.wav");+const textDecoder = new TextDecoder();+const ghosttyWebURL = new URL("./ghostty-web/ghostty-web.js", import.meta.url);+const ghosttyWasmURL = new URL("./ghostty-web/ghostty-vt.wasm", import.meta.url); -var cx = -1;-var cy = -1;-var cursorClass = "cursor-blinking-block";-var cursorColor = "";+window.addEventListener("error", (event) => {+  showFatal(event.error ?? event.message);+});+window.addEventListener("unhandledrejection", (event) => {+  showFatal(event.reason);+});++const { FitAddon, Ghostty, Terminal } = await importGhosttyWeb();+const modes = {+  mouseButton: false,+  mouseDrag: false,+  mouseMotion: false,+  mouseSgr: false,+  focus: false,+  paste: false,+  resize: false,+  win32Input: false,+};+const pressedButtons = new Set();+const modifierState = {+  ShiftLeft: false,+  ShiftRight: false,+  ControlLeft: false,+  ControlRight: false,+  AltLeft: false,+  AltRight: false,+  MetaLeft: false,+  MetaRight: false,+};++const initialSize = configuredSize();+const ghostty = await Ghostty.load(ghosttyWasmURL.href);++const term = new Terminal({+  ghostty,+  cols: initialSize.cols,+  rows: initialSize.rows,+  cursorBlink: true,+  fontFamily: '"Menlo", "Andale Mono", "Courier New", monospace',+  fontSize: 14,+  theme: {+    background: "#000000",+    foreground: "#e5e5e5",+  },+});+const fit = new FitAddon();+term.loadAddon(fit);+term.open(container);+fitTerminal();++term.onData((data) => {+  if (modes.win32Input) {+    return;+  }+  globalThis.tcellRead?.(data);+});+term.onResize(() => {+  globalThis.tcellResize?.();+});+term.onBell(() => {+  beep();+});++globalThis.tcellWrite = (data) => {+  const text = data instanceof Uint8Array ? textDecoder.decode(data, { stream: true }) : data;+  handleHostOutput(text);+  term.write(text);+};++globalThis.tcellWindowSize = () => {+  const metrics = term.renderer?.getMetrics?.();+  return {+    cols: term.cols,+    rows: term.rows,+    pixelWidth: metrics ? Math.floor(metrics.width * term.cols) : 0,+    pixelHeight: metrics ? Math.floor(metrics.height * term.rows) : 0,+  };+}; -var content; // {data: row[height], dirty: bool}-// row = {data: element[width], previous: span}-// dirty/[previous being null] indicates if previous (or entire terminal) needs to be recalculated.-// dirty is true/null if terminal/previous need to be re-calculated/shown+globalThis.beep = beep; -function initialize() {-  resize(width, height); // initialize content-  show(); // then show the screen+function beep() {+  beepAudio.currentTime = 0;+  beepAudio.play()?.catch(() => {}); } -function resize(w, h) {-  width = w;-  height = h;-  content = { data: new Array(height), dirty: true };-  for (let i = 0; i < height; i++) {-    content.data[i] = { data: new Array(width), previous: null };+async function importGhosttyWeb() {+  try {+    return await import(/* @vite-ignore */ ghosttyWebURL.href);+  } catch (error) {+    throw new Error(+      "Failed to load ./ghostty-web/ghostty-web.js. Copy webfiles/ghostty-web next to tcell.js.",+      { cause: error }+    );   }+} -  clearScreen();+function showFatal(error) {+  const message = error instanceof Error ? error.message : String(error);+  const cause = error instanceof Error && error.cause instanceof Error ? `\n${error.cause.message}` : "";+  container.textContent = `Tcell WASM startup failed:\n${message}${cause}`;+  container.style.whiteSpace = "pre-wrap";+  container.style.color = "#ff6b6b";+  container.style.backgroundColor = "#111";+  container.style.padding = "1rem";+  console.error(error); } -function clearScreen(fg, bg) {-  if (fg) {-    term.style.color = intToHex(fg);-  }-  if (bg) {-    term.style.backgroundColor = intToHex(bg);+function configuredSize() {+  return {+    cols: positiveInt(container.dataset.cols) ?? 80,+    rows: positiveInt(container.dataset.rows) ?? 24,+  };+}++function explicitSize() {+  return {+    cols: positiveInt(container.dataset.cols),+    rows: positiveInt(container.dataset.rows),+  };+}++function positiveInt(value) {+  const n = Number.parseInt(value ?? "", 10);+  return Number.isFinite(n) && n > 0 ? n : undefined;+}++function fitTerminal() {+  const size = explicitSize();+  if (size.cols != null && size.rows != null) {+    if (term.cols !== size.cols || term.rows !== size.rows) {+      term.resize(size.cols, size.rows);+      reportResize();+    }+    return;   } -  content.dirty = true;-  for (let i = 0; i < height; i++) {-    content.data[i].previous = null; // we set the row to be recalculated later-    for (let j = 0; j < width; j++) {-      content.data[i].data[j] = document.createTextNode(" "); // set the entire row to spaces.+  fit.fit();+  if (size.cols != null || size.rows != null) {+    const cols = size.cols ?? term.cols;+    const rows = size.rows ?? term.rows;+    if (term.cols !== cols || term.rows !== rows) {+      term.resize(cols, rows);     }   }+  reportResize(); } -function drawCell(x, y, s, fg, bg, attrs, us, uc) {-  var span = document.createElement("span");-  var use = false;+function reportResize() {+  globalThis.tcellResize?.();+  if (modes.resize) {+    sendInput(`\x1b[4;${term.rows};${term.cols}t`);+  }+}++const resizeObserver = new ResizeObserver(fitTerminal);+resizeObserver.observe(container);++window.addEventListener("resize", fitTerminal); -  if ((attrs & (1 << 2)) != 0) {-    // reverse video-    var temp = bg;-    bg = fg;-    fg = temp;-    use = true;+container.addEventListener("mousedown", (event) => {+  if (!mouseReportingEnabled()) {+    return;   }-  if (fg != -1) {-    span.style.color = intToHex(fg);-    use = true;+  captureTerminalEvent(event);+  focusTerminal();+  pressedButtons.add(event.button);+  sendMouse(event, event.button, false, true);+}, true);++container.addEventListener("mouseup", (event) => {+  if (!mouseReportingEnabled()) {+    return;   }-  if (bg != -1) {-    span.style.backgroundColor = intToHex(bg);-    use = true;+  captureTerminalEvent(event);+  sendMouse(event, event.button, false, false);+  pressedButtons.delete(event.button);+}, true);++container.addEventListener("mousemove", (event) => {+  if (!mouseReportingEnabled()) {+    return;   }+  if (!modes.mouseMotion && !(modes.mouseDrag && pressedButtons.size > 0)) {+    return;+  }+  captureTerminalEvent(event);+  sendMouse(event, firstPressedButton(), true, true);+}, true); -  // NB: these has to be updated if Attrs.go changes-  if (attrs != 0) {-    use = true;-    if ((attrs & 1) != 0) {-      span.classList.add("bold");-    }-    if ((attrs & (1 << 4)) != 0) {-      span.classList.add("dim");-    }-    if ((attrs & (1 << 5)) != 0) {-      span.classList.add("italic");-    }-    if ((attrs & (1 << 6)) != 0) {-      span.classList.add("strikethrough");-    }+container.addEventListener("contextmenu", (event) => {+  if (modes.mouseButton) {+    captureTerminalEvent(event);   }-  if (us != 0) {-    use = true;-    if (us == 1) {-      span.classList.add("underline");-    } else if (us == 2) {-      span.classList.add("double_underline");-    } else if (us == 3) {-      span.classList.add("curly_underline");-    } else if (us == 4) {-      span.classList.add("dotted_underline");-    } else if (us == 5) {-      span.classList.add("dashed_underline");-    }-    if (uc != -1) {-      span.style.textDecorationColor = intToHex(uc);-    }+}, true);++container.addEventListener("auxclick", (event) => {+  if (modes.mouseButton) {+    captureTerminalEvent(event);   }+}, true); -  if ((attrs & (1 << 1)) != 0) {-    var blink = document.createElement("span");-    blink.classList.add("blink");-    var textnode = document.createTextNode(s);-    blink.appendChild(textnode);-    span.appendChild(blink);-  } else {-    var textnode = document.createTextNode(s);-    span.appendChild(textnode);+container.addEventListener("selectstart", (event) => {+  if (modes.mouseButton) {+    captureTerminalEvent(event);   }+}, true); -  content.dirty = true; // invalidate terminal- new cell-  content.data[y].previous = null; // invalidate row- new row-  content.data[y].data[x] = use ? span : textnode;-}+container.addEventListener(+  "wheel",+  handleWheel,+  { passive: false }+); -function show() {-  if (!content.dirty) {-    return; // no new draws; no need to update+window.addEventListener("wheel", (event) => {+  if (container.contains(event.target)) {+    handleWheel(event);   }+}, { capture: true, passive: false }); -  displayCursor();+container.addEventListener("focusin", () => {+  if (modes.focus) {+    sendInput("\x1b[I");+  }+}); -  term.innerHTML = "";-  content.data.forEach((row) => {-    if (row.previous == null) {-      row.previous = document.createElement("span");-      row.data.forEach((c) => {-        row.previous.appendChild(c);-      });-      row.previous.appendChild(document.createTextNode("\n"));-    }-    term.appendChild(row.previous);-  });+container.addEventListener("focusout", () => {+  if (modes.focus) {+    sendInput("\x1b[O");+  }+}); -  content.dirty = false;-}+window.addEventListener("focus", () => {+  if (modes.focus) {+    sendInput("\x1b[I");+  }+}); -function showCursor(x, y) {-  content.dirty = true;+window.addEventListener("blur", () => {+  clearModifierState();+  pressedButtons.clear();+  if (modes.focus) {+    sendInput("\x1b[O");+  }+}); -  if (!(cx < 0 || cy < 0)) {-    // if original position is a valid cursor position-    content.data[cy].previous = null;-    if (content.data[cy].data[cx].classList) {-      content.data[cy].data[cx].classList.remove(cursorClass);+container.addEventListener(+  "paste",+  (event) => {+    if (!modes.paste) {+      return;     }+    event.preventDefault();+    event.stopPropagation();+    const text = event.clipboardData?.getData("text/plain") ?? "";+    sendInput(`\x1b[200~${text}\x1b[201~`);+  },+  true+);++window.addEventListener("keydown", (event) => {+  if (!modes.win32Input) {+    return;   }+  captureTerminalEvent(event);+  updateModifierState(event, true);+  sendWinKey(event, true);+}, true); -  cx = x;-  cy = y;+window.addEventListener("keyup", (event) => {+  if (!modes.win32Input) {+    return;+  }+  captureTerminalEvent(event);+  updateModifierState(event, false);+  sendWinKey(event, false);+}, true);++function handleHostOutput(text) {+  replyToQueries(text);+  updateModes(text); } -function displayCursor() {-  content.dirty = true;--  if (!(cx < 0 || cy < 0)) {-    // if new position is a valid cursor position-    content.data[cy].previous = null;--    if (!content.data[cy].data[cx].classList) {-      var span = document.createElement("span");-      span.appendChild(content.data[cy].data[cx]);-      content.data[cy].data[cx] = span;+function replyToQueries(text) {+  if (text.includes("\x1b[c")) {+    // VT420-ish primary DA with color and clipboard capability.+    sendInput("\x1b[?64;22;52c");+  }+  if (text.includes("\x1b[>q")) {+    sendInput("\x1bP>|ghostty-web tcell\x1b\\");+  }+  const modeStates = {+    1000: modes.mouseButton,+    1002: modes.mouseDrag,+    1003: modes.mouseMotion,+    1004: modes.focus,+    1006: modes.mouseSgr,+    2004: modes.paste,+    2048: modes.resize,+    9001: modes.win32Input,+  };+  for (const [mode, enabled] of Object.entries(modeStates)) {+    if (text.includes(`\x1b[?${mode}$p`)) {+      sendInput(`\x1b[?${mode};${enabled ? 1 : 2}$y`);     }+  }+  // Deliberately do not advertise Kitty/XTerm keyboard modes; the bridge uses+  // tcell's existing Win32 input parser for browser advanced-key reporting.+} -    if (cursorColor != "") {-      term.style.setProperty("--cursor-color", cursorColor);-    } else {-      term.style.setProperty("--cursor-color", "lightgrey");+function updateModes(text) {+  const re = /\x1b\[\?([0-9;]+)([hl])/g;+  let match;+  while ((match = re.exec(text)) !== null) {+    const enabled = match[2] === "h";+    for (const rawMode of match[1].split(";")) {+      setMode(Number(rawMode), enabled);     }+  }+} -    content.data[cy].data[cx].classList.add(cursorClass);+function setMode(mode, enabled) {+  switch (mode) {+    case 1000:+      modes.mouseButton = enabled;+      break;+    case 1002:+      modes.mouseDrag = enabled;+      break;+    case 1003:+      modes.mouseMotion = enabled;+      break;+    case 1006:+      modes.mouseSgr = enabled;+      break;+    case 1004:+      modes.focus = enabled;+      if (enabled) {+        sendInput(document.hasFocus() ? "\x1b[I" : "\x1b[O");+      }+      break;+    case 2004:+      modes.paste = enabled;+      break;+    case 2048:+      modes.resize = enabled;+      break;+    case 9001:+      modes.win32Input = enabled;+      break;   } } -function setCursorStyle(newClass, newColor) {-  if (newClass == cursorClass && newColor == cursorColor) {+function sendMouse(event, button, motion, down, wheel = false) {+  const pos = mouseCell(event);+  if (pos == null) {+    return;+  }+  let cb;+  if (wheel) {+    cb = 64 + button;+  } else {+    cb = buttonCode(button);+    if (motion) {+      cb += 32;+    }+  }+  if (event.shiftKey) {+    cb += 4;+  }+  if (event.altKey || event.metaKey) {+    cb += 8;+  }+  if (event.ctrlKey) {+    cb += 16;+  }+  if (modes.mouseSgr) {+    sendInput(`\x1b[<${cb};${pos.x};${pos.y}${down ? "M" : "m"}`);     return;   } -  if (!(cx < 0 || cy < 0)) {-    // mark cursor row as dirty; new class has been applied to (cx, cy)-    content.dirty = true;-    content.data[cy].previous = null;--    if (content.data[cy].data[cx].classList) {-      content.data[cy].data[cx].classList.remove(cursorClass);+  // Legacy mouse reports are byte-oriented, not UTF-8 text. Use Uint8Array so+  // coordinates above 95 columns do not get corrupted by string encoding.+  if (!down) {+    cb = 3;+    if (event.shiftKey) {+      cb += 4;+    }+    if (event.altKey || event.metaKey) {+      cb += 8;     }+    if (event.ctrlKey) {+      cb += 16;+    }+  }+  sendInput(new Uint8Array([+    0x1b,+    0x5b,+    0x4d,+    cb + 32,+    Math.min(pos.x, 223) + 32,+    Math.min(pos.y, 223) + 32,+  ]));+} -    // adding the new class will be dealt with when displayCursor() is called+function handleWheel(event) {+  if (!mouseReportingEnabled()) {+    return;   }+  captureTerminalEvent(event);+  const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY);+  const button = horizontal+    ? event.deltaX < 0+      ? 2+      : 3+    : event.deltaY < 0+      ? 0+      : 1;+  sendMouse(event, button, false, true, true);+} -  cursorClass = newClass;-  cursorColor = newColor;+function mouseCell(event) {+  const metrics = term.renderer?.getMetrics?.();+  const rect = container.getBoundingClientRect();+  const cellWidth = metrics?.width || rect.width / term.cols;+  const cellHeight = metrics?.height || rect.height / term.rows;+  if (!cellWidth || !cellHeight) {+    return null;+  }+  return {+    x: Math.max(1, Math.min(term.cols, Math.floor((event.clientX - rect.left) / cellWidth) + 1)),+    y: Math.max(1, Math.min(term.rows, Math.floor((event.clientY - rect.top) / cellHeight) + 1)),+  }; } -function beep() {-  beepAudio.currentTime = 0;-  beepAudio.play();+function buttonCode(button) {+  switch (button) {+    case 1:+      return 1;+    case 2:+      return 2;+    default:+      return 0;+  } } -function setTitle(title) {-  document.title = title;+function firstPressedButton() {+  return pressedButtons.size === 0 ? 0 : pressedButtons.values().next().value; } -function intToHex(n) {-  return "#" + n.toString(16).padStart(6, "0");+function mouseReportingEnabled() {+  return modes.mouseButton || modes.mouseDrag || modes.mouseMotion; } -initialize();+function sendInput(data) {+  globalThis.tcellRead?.(data);+} -let fontwidth = term.clientWidth / width;-let fontheight = term.clientHeight / height;+function sendWinKey(event, down) {+  const vk = virtualKey(event);+  const uc = unicodeChar(event);+  const cs = controlKeyState(event);+  sendInput(`\x1b[${vk};0;${uc};${down ? 1 : 0};${cs};1_`);+} -document.addEventListener("keydown", (e) => {-  onKeyEvent(e.key, e.shiftKey, e.altKey, e.ctrlKey, e.metaKey);-});+function virtualKey(event) {+  const modifierVK = modifierVirtualKey(event);+  if (modifierVK != null) {+    return modifierVK;+  }+  if (event.keyCode) {+    return event.keyCode;+  }+  if (/^Key[A-Z]$/.test(event.code)) {+    return event.code.charCodeAt(3);+  }+  if (/^Digit[0-9]$/.test(event.code)) {+    return event.code.charCodeAt(5);+  }+  if (/^F([1-9]|1[0-9]|2[0-4])$/.test(event.key)) {+    return 0x70 + Number(event.key.slice(1)) - 1;+  }+  return keyToVK[event.key] ?? 0;+} -term.addEventListener("click", (e) => {-  onMouseClick(-    Math.min((e.offsetX / fontwidth) | 0, width - 1),-    Math.min((e.offsetY / fontheight) | 0, height - 1),-    e.which,-    e.shiftKey,-    e.altKey,-    e.ctrlKey-  );-});+function unicodeChar(event) {+  if (event.key.length === 1) {+    return event.key.codePointAt(0);+  }+  return 0;+} -term.addEventListener("mousemove", (e) => {-  onMouseMove(-    Math.min((e.offsetX / fontwidth) | 0, width - 1),-    Math.min((e.offsetY / fontheight) | 0, height - 1),-    e.which,-    e.shiftKey,-    e.altKey,-    e.ctrlKey-  );-});+function controlKeyState(event) {+  let state = 0;+  if (modifierState.ShiftLeft || modifierState.ShiftRight || event.shiftKey) {+    state |= 0x10;+  }+  if (modifierState.ControlLeft) {+    state |= 0x08;+  }+  if (modifierState.ControlRight) {+    state |= 0x04;+  }+  if (event.ctrlKey && !modifierState.ControlLeft && !modifierState.ControlRight) {+    state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x04 : 0x08;+  }+  if (modifierState.AltLeft) {+    state |= 0x02;+  }+  if (modifierState.AltRight) {+    state |= 0x01;+  }+  if (event.altKey && !modifierState.AltLeft && !modifierState.AltRight) {+    state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x01 : 0x02;+  }+  if (modifierState.MetaLeft) {+    state |= 0x200;+  }+  if (modifierState.MetaRight) {+    state |= 0x400;+  }+  if (event.metaKey && !modifierState.MetaLeft && !modifierState.MetaRight) {+    state |= event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? 0x400 : 0x200;+  }+  return state;+} -term.addEventListener("focus", (e) => {-  onFocus(true);-});+function updateModifierState(event, down) {+  if (event.code in modifierState) {+    modifierState[event.code] = down;+    return;+  }+  switch (event.key) {+    case "Shift":+      modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "ShiftRight" : "ShiftLeft"] = down;+      break;+    case "Control":+      modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "ControlRight" : "ControlLeft"] = down;+      break;+    case "Alt":+      modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "AltRight" : "AltLeft"] = down;+      break;+    case "Meta":+      modifierState[event.location === KeyboardEvent.DOM_KEY_LOCATION_RIGHT ? "MetaRight" : "MetaLeft"] = down;+      break;+  }+} -term.addEventListener("blur", (e) => {-  onFocus(false);-});-term.tabIndex = 0;+function clearModifierState() {+  for (const key of Object.keys(modifierState)) {+    modifierState[key] = false;+  }+} -document.addEventListener("paste", (e) => {-  e.preventDefault();-  var text = (e.originalEvent || e).clipboardData.getData("text/plain");-  onPaste(true);-  for (let i = 0; i < text.length; i++) {-    onKeyEvent(text.charAt(i), false, false, false, false);+function modifierVirtualKey(event) {+  switch (event.code) {+    case "ShiftLeft":+      return 0xa0;+    case "ShiftRight":+      return 0xa1;+    case "ControlLeft":+      return 0xa2;+    case "ControlRight":+      return 0xa3;+    case "AltLeft":+      return 0xa4;+    case "AltRight":+      return 0xa5;+    case "MetaLeft":+      return 0x5b;+    case "MetaRight":+      return 0x5c;   }-  onPaste(false);-});+  return null;+}++function captureTerminalEvent(event) {+  event.preventDefault();+  event.stopPropagation();+  event.stopImmediatePropagation();+}++function focusTerminal() {+  term.focus?.();+  container.focus?.();+}++const keyToVK = {+  Backspace: 0x08,+  Tab: 0x09,+  Enter: 0x0d,+  Pause: 0x13,+  Escape: 0x1b,+  PageUp: 0x21,+  PageDown: 0x22,+  End: 0x23,+  Home: 0x24,+  ArrowLeft: 0x25,+  ArrowUp: 0x26,+  ArrowRight: 0x27,+  ArrowDown: 0x28,+  PrintScreen: 0x2c,+  Insert: 0x2d,+  Delete: 0x2e,+  Help: 0x2f,+  Shift: 0x10,+  Control: 0x11,+  Alt: 0x12,+  Meta: 0x5b,+  CapsLock: 0x14,+};  const go = new Go();-WebAssembly.instantiateStreaming(fetch(wasmFilePath), go.importObject).then(-  (result) => {-    go.run(result.instance);-  }+const result = await WebAssembly.instantiateStreaming(+  fetch(wasmFilePath),+  go.importObject );+go.run(result.instance);