current commit
260426.4 · feat(wasm): Introduce ghostty-wasm as backing WASM terminal (fixes #700) (fixes #670) (fixes #698)
// 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.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

const wasmFilePath = "main.wasm";
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);

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,
  };
};

globalThis.beep = beep;

function beep() {
  beepAudio.currentTime = 0;
  beepAudio.play()?.catch(() => {});
}

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 }
    );
  }
}

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 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;
  }

  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 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);

container.addEventListener("mousedown", (event) => {
  if (!mouseReportingEnabled()) {
    return;
  }
  captureTerminalEvent(event);
  focusTerminal();
  pressedButtons.add(event.button);
  sendMouse(event, event.button, false, true);
}, true);

container.addEventListener("mouseup", (event) => {
  if (!mouseReportingEnabled()) {
    return;
  }
  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);

container.addEventListener("contextmenu", (event) => {
  if (modes.mouseButton) {
    captureTerminalEvent(event);
  }
}, true);

container.addEventListener("auxclick", (event) => {
  if (modes.mouseButton) {
    captureTerminalEvent(event);
  }
}, true);

container.addEventListener("selectstart", (event) => {
  if (modes.mouseButton) {
    captureTerminalEvent(event);
  }
}, true);

container.addEventListener(
  "wheel",
  handleWheel,
  { passive: false }
);

window.addEventListener("wheel", (event) => {
  if (container.contains(event.target)) {
    handleWheel(event);
  }
}, { capture: true, passive: false });

container.addEventListener("focusin", () => {
  if (modes.focus) {
    sendInput("\x1b[I");
  }
});

container.addEventListener("focusout", () => {
  if (modes.focus) {
    sendInput("\x1b[O");
  }
});

window.addEventListener("focus", () => {
  if (modes.focus) {
    sendInput("\x1b[I");
  }
});

window.addEventListener("blur", () => {
  clearModifierState();
  pressedButtons.clear();
  if (modes.focus) {
    sendInput("\x1b[O");
  }
});

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);

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 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.
}

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);
    }
  }
}

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 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;
  }

  // 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,
  ]));
}

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);
}

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 buttonCode(button) {
  switch (button) {
    case 1:
      return 1;
    case 2:
      return 2;
    default:
      return 0;
  }
}

function firstPressedButton() {
  return pressedButtons.size === 0 ? 0 : pressedButtons.values().next().value;
}

function mouseReportingEnabled() {
  return modes.mouseButton || modes.mouseDrag || modes.mouseMotion;
}

function sendInput(data) {
  globalThis.tcellRead?.(data);
}

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_`);
}

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;
}

function unicodeChar(event) {
  if (event.key.length === 1) {
    return event.key.codePointAt(0);
  }
  return 0;
}

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;
}

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;
  }
}

function clearModifierState() {
  for (const key of Object.keys(modifierState)) {
    modifierState[key] = 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;
  }
  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();
const result = await WebAssembly.instantiateStreaming(
  fetch(wasmFilePath),
  go.importObject
);
go.run(result.instance);