apps/web/src/terminal/ghostty/core.ts

import {
  type GhosttyKeyboardLayoutMap,
  ghosttyConsumedMods,
  ghosttyKeyForCode,
  ghosttyUnshiftedCodepoint,
  loadGhosttyKeyboardLayoutMap,
} from "./keyCodes";
import { GhosttyRuntime, loadGhosttyRuntime } from "./runtime";

const GHOSTTY_SUCCESS = 0;
const GHOSTTY_OUT_OF_SPACE = -3;
const MAX_SCROLLBACK_ROWS = 10_000;
// wasm32 C ABI layout for GhosttyTerminalSelectionFormatOptions at the
// libghostty-vt revision pinned alongside this module.
const SELECTION_FORMAT_OPTIONS_SIZE = 16;

const RENDER_DATA = {
  cols: 1,
  rows: 2,
  dirty: 3,
  rowIterator: 4,
  background: 5,
  foreground: 6,
  cursor: 7,
  cursorHasValue: 8,
  cursorStyle: 10,
  cursorVisible: 11,
  cursorBlinking: 12,
  cursorInViewport: 14,
  cursorX: 15,
  cursorY: 16,
} as const;

const ROW_DATA = {
  dirty: 1,
  raw: 2,
  cells: 3,
} as const;

const CELL_DATA = {
  raw: 1,
  style: 2,
  graphemesLength: 3,
  graphemes: 4,
  background: 5,
  foreground: 6,
  selected: 7,
} as const;

const RAW_CELL_DATA = {
  wide: 3,
} as const;

export const GHOSTTY_CELL_WIDE = {
  narrow: 0,
  wide: 1,
  spacerTail: 2,
  spacerHead: 3,
} as const;

export interface GhosttyColor {
  readonly r: number;
  readonly g: number;
  readonly b: number;
}

export interface GhosttyTheme {
  readonly foreground: GhosttyColor;
  readonly background: GhosttyColor;
  readonly cursor: GhosttyColor;
  /** CSS color the renderer overlays on selected cells; not sent to Ghostty. */
  readonly selectionBackground?: string;
}

export interface GhosttyCell {
  readonly text: string;
  readonly wide: number;
  readonly foreground: GhosttyColor;
  readonly background: GhosttyColor;
  readonly bold: boolean;
  readonly italic: boolean;
  readonly invisible: boolean;
  readonly strikethrough: boolean;
  readonly overline: boolean;
  readonly underline: boolean;
  readonly selected: boolean;
}

export interface GhosttyRow {
  readonly cells: readonly GhosttyCell[];
  readonly text: string;
  readonly isWrapContinuation: boolean;
  /** Whether this row soft-wraps onto the next row. */
  readonly wrapsToNext: boolean;
}

export interface GhosttySnapshot {
  readonly cols: number;
  readonly rows: number;
  readonly foreground: GhosttyColor;
  readonly background: GhosttyColor;
  readonly cursor: GhosttyColor;
  readonly cursorX: number;
  readonly cursorY: number;
  readonly cursorVisible: boolean;
  readonly cursorBlinking: boolean;
  readonly cursorStyle: number;
  readonly dirtyRows: ReadonlySet<number>;
  readonly rowData: readonly GhosttyRow[];
}

export interface GhosttySelectionRange {
  readonly viewport: {
    readonly start: { readonly x: number; readonly y: number };
    readonly end: { readonly x: number; readonly y: number };
  };
  readonly screen: {
    readonly start: { readonly x: number; readonly y: number };
    readonly end: { readonly x: number; readonly y: number };
  };
}

export interface GhosttyScrollbar {
  readonly total: number;
  readonly offset: number;
  readonly len: number;
}

/** Grid position tagged with its Ghostty coordinate space: 1 viewport, 2 screen. */
export interface GhosttyPointInput {
  readonly x: number;
  readonly y: number;
  readonly tag?: 1 | 2;
}

export interface GhosttyMouseInput {
  readonly action: "press" | "release" | "motion";
  readonly button: number | null;
  readonly mods: number;
  readonly x: number;
  readonly y: number;
  readonly screenWidth: number;
  readonly screenHeight: number;
  readonly cellWidth: number;
  readonly cellHeight: number;
  readonly paddingLeft: number;
  readonly paddingRight: number;
  readonly paddingTop: number;
  readonly paddingBottom: number;
  readonly anyButtonPressed: boolean;
}

const decoder = new TextDecoder();
const encoder = new TextEncoder();

function blend(foreground: GhosttyColor, background: GhosttyColor): GhosttyColor {
  const channel = (front: number, back: number) => Math.floor((front * 155 + back * 100) / 255);
  return {
    r: channel(foreground.r, background.r),
    g: channel(foreground.g, background.g),
    b: channel(foreground.b, background.b),
  };
}

function sameColor(left: GhosttyColor, right: GhosttyColor): boolean {
  return left.r === right.r && left.g === right.g && left.b === right.b;
}

/**
 * A terminal program can print one base character followed by a huge run of
 * combining marks, packing hundreds of thousands of codepoints into a single
 * cell that still fits the scrollback buffer. Engines cap spread-call
 * arguments far below that, so convert in bounded chunks instead of spreading
 * every codepoint into String.fromCodePoint at once.
 */
export function ghosttyCellText(codepointView: DataView, graphemeLength: number): string {
  if (graphemeLength === 1) return String.fromCodePoint(codepointView.getUint32(0, true));
  const CHUNK_SIZE = 4_096;
  let text = "";
  for (let start = 0; start < graphemeLength; start += CHUNK_SIZE) {
    const count = Math.min(CHUNK_SIZE, graphemeLength - start);
    const codes = new Array<number>(count);
    for (let index = 0; index < count; index += 1) {
      codes[index] = codepointView.getUint32((start + index) * 4, true);
    }
    text += String.fromCodePoint(...codes);
  }
  return text;
}

export class GhosttyTerminalCore {
  private readonly runtime: GhosttyRuntime;
  private terminalSlot = 0;
  private terminal = 0;
  private renderStateSlot = 0;
  private renderState = 0;
  private rowIteratorSlot = 0;
  private rowCellsSlot = 0;
  private keyEncoderSlot = 0;
  private keyEncoder = 0;
  private keyEventSlot = 0;
  private keyEvent = 0;
  private mouseEncoderSlot = 0;
  private mouseEncoder = 0;
  private mouseEventSlot = 0;
  private mouseEvent = 0;
  private ptyWriterId = 0;
  private ptyWriter: ((data: string) => void) | null = null;
  private scratch = 0;
  private graphemes = 0;
  private graphemeCapacity = 0;
  private style = 0;
  private scrollbar = 0;
  private rows: GhosttyRow[] = [];
  private disposed = false;
  private keyboardLayoutMap: GhosttyKeyboardLayoutMap | undefined;

  private constructor(runtime: GhosttyRuntime) {
    this.runtime = runtime;
    void loadGhosttyKeyboardLayoutMap().then((layoutMap) => {
      if (!this.disposed) this.keyboardLayoutMap = layoutMap;
    });
  }

  static async create(
    cols: number,
    rows: number,
    cellWidth: number,
    cellHeight: number,
    theme: GhosttyTheme,
    onPtyData: (data: string) => void,
  ): Promise<GhosttyTerminalCore> {
    const core = new GhosttyTerminalCore(await loadGhosttyRuntime());
    try {
      core.initialize(cols, rows, cellWidth, cellHeight, theme, onPtyData);
      return core;
    } catch (error) {
      core.dispose();
      throw error;
    }
  }

  private initialize(
    cols: number,
    rows: number,
    cellWidth: number,
    cellHeight: number,
    theme: GhosttyTheme,
    onPtyData: (data: string) => void,
  ): void {
    const optionsSize = this.runtime.layout("GhosttyTerminalOptions").size;
    const options = this.runtime.alloc(optionsSize);
    this.runtime.setField(options, "GhosttyTerminalOptions", "cols", cols);
    this.runtime.setField(options, "GhosttyTerminalOptions", "rows", rows);
    this.runtime.setField(options, "GhosttyTerminalOptions", "max_scrollback", MAX_SCROLLBACK_ROWS);
    this.terminalSlot = this.runtime.allocOpaque();
    const terminalResult = this.runtime.call("ghostty_terminal_new", 0, this.terminalSlot, options);
    this.runtime.free(options, optionsSize);
    this.assertSuccess("ghostty_terminal_new", terminalResult);
    this.terminal = this.runtime.readPointer(this.terminalSlot);
    this.applyDefaultCursorBlink();
    this.ptyWriter = onPtyData;
    this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, onPtyData);

    this.renderStateSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_render_state_new",
      this.runtime.call("ghostty_render_state_new", 0, this.renderStateSlot),
    );
    this.renderState = this.runtime.readPointer(this.renderStateSlot);

    this.rowIteratorSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_render_state_row_iterator_new",
      this.runtime.call("ghostty_render_state_row_iterator_new", 0, this.rowIteratorSlot),
    );
    this.rowCellsSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_render_state_row_cells_new",
      this.runtime.call("ghostty_render_state_row_cells_new", 0, this.rowCellsSlot),
    );

    this.keyEncoderSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_key_encoder_new",
      this.runtime.call("ghostty_key_encoder_new", 0, this.keyEncoderSlot),
    );
    this.keyEncoder = this.runtime.readPointer(this.keyEncoderSlot);
    this.keyEventSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_key_event_new",
      this.runtime.call("ghostty_key_event_new", 0, this.keyEventSlot),
    );
    this.keyEvent = this.runtime.readPointer(this.keyEventSlot);

    this.mouseEncoderSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_mouse_encoder_new",
      this.runtime.call("ghostty_mouse_encoder_new", 0, this.mouseEncoderSlot),
    );
    this.mouseEncoder = this.runtime.readPointer(this.mouseEncoderSlot);
    this.mouseEventSlot = this.runtime.allocOpaque();
    this.assertSuccess(
      "ghostty_mouse_event_new",
      this.runtime.call("ghostty_mouse_event_new", 0, this.mouseEventSlot),
    );
    this.mouseEvent = this.runtime.readPointer(this.mouseEventSlot);

    this.scratch = this.runtime.alloc(16);
    const styleSize = this.runtime.layout("GhosttyStyle").size;
    this.style = this.runtime.alloc(styleSize);
    this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
    this.scrollbar = this.runtime.alloc(this.runtime.layout("GhosttyTerminalScrollbar").size);
    this.setTheme(theme);
    this.resize(cols, rows, cellWidth, cellHeight);
  }

  write(data: string | Uint8Array): void {
    this.ensureActive();
    const bytes = typeof data === "string" ? encoder.encode(data) : data;
    if (bytes.length === 0) return;
    const pointer = this.runtime.alloc(bytes.length);
    this.runtime.bytes(pointer, bytes.length).set(bytes);
    this.runtime.call("ghostty_terminal_vt_write", this.terminal, pointer, bytes.length);
    this.runtime.free(pointer, bytes.length);
  }

  resetAndWrite(data: string): void {
    this.ensureActive();
    this.runtime.call("ghostty_terminal_reset", this.terminal);
    // RIS returns the cursor to Ghostty's built-in steady default, so the
    // embedder default has to be applied again before the replay runs.
    this.applyDefaultCursorBlink();
    this.rows = [];
    if (data.length === 0) return;
    const writer = this.ptyWriter;
    if (this.ptyWriterId !== 0) {
      this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId);
      this.ptyWriterId = 0;
    }
    try {
      this.write(data);
    } finally {
      if (writer !== null && !this.disposed) {
        this.ptyWriterId = this.runtime.attachPtyWriter(this.terminal, writer);
      }
    }
  }

  resize(cols: number, rows: number, cellWidth: number, cellHeight: number): void {
    this.ensureActive();
    this.assertSuccess(
      "ghostty_terminal_resize",
      this.runtime.call(
        "ghostty_terminal_resize",
        this.terminal,
        Math.max(1, Math.min(65_535, cols)),
        Math.max(1, Math.min(65_535, rows)),
        Math.max(1, Math.round(cellWidth)),
        Math.max(1, Math.round(cellHeight)),
      ),
    );
  }

  /**
   * Ghostty's built-in default cursor is steady, while the xterm.js renderer
   * this replaced ran with `cursorBlink: true`. Option 23 is the embedder's
   * default blink, which is the state a session starts in and returns to on
   * DECSCUSR reset (CSI 0 q), so programs that ask for a specific cursor
   * through DECSCUSR or DEC mode 12 still win.
   */
  private applyDefaultCursorBlink(): void {
    const blink = this.runtime.alloc(1);
    this.runtime.bytes(blink, 1)[0] = 1;
    this.runtime.call("ghostty_terminal_set", this.terminal, 23, blink);
    this.runtime.free(blink, 1);
  }

  setTheme(theme: GhosttyTheme): void {
    this.ensureActive();
    const color = this.runtime.alloc(3);
    for (const [option, value] of [
      [11, theme.foreground],
      [12, theme.background],
      [13, theme.cursor],
    ] as const) {
      this.runtime.bytes(color, 3).set([value.r, value.g, value.b]);
      this.runtime.call("ghostty_terminal_set", this.terminal, option, color);
    }
    this.runtime.free(color, 3);
  }

  scroll(deltaRows: number): void {
    this.ensureActive();
    const layout = this.runtime.layout("GhosttyTerminalScrollViewport");
    const scroll = this.runtime.alloc(layout.size);
    this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 2);
    const value = layout.fields.value!;
    this.runtime.view(scroll + value.offset, value.size).setInt32(0, deltaRows, true);
    this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll);
    this.runtime.free(scroll, layout.size);
  }

  scrollToBottom(): void {
    this.ensureActive();
    const layout = this.runtime.layout("GhosttyTerminalScrollViewport");
    const scroll = this.runtime.alloc(layout.size);
    this.runtime.setField(scroll, "GhosttyTerminalScrollViewport", "tag", 1);
    this.runtime.call("ghostty_terminal_scroll_viewport", this.terminal, scroll);
    this.runtime.free(scroll, layout.size);
  }

  isViewportActive(): boolean {
    this.ensureActive();
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_terminal_get", this.terminal, 32, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  scrollbarState(): GhosttyScrollbar | null {
    this.ensureActive();
    const layout = this.runtime.layout("GhosttyTerminalScrollbar");
    this.runtime.bytes(this.scrollbar, layout.size).fill(0);
    if (
      this.runtime.call("ghostty_terminal_get", this.terminal, 9, this.scrollbar) !==
      GHOSTTY_SUCCESS
    ) {
      return null;
    }
    return {
      total: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "total"),
      offset: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "offset"),
      len: this.runtime.readField(this.scrollbar, "GhosttyTerminalScrollbar", "len"),
    };
  }

  isMouseTracking(): boolean {
    this.ensureActive();
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_terminal_get", this.terminal, 11, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  isMouseAnyEventTracking(): boolean {
    this.ensureActive();
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1003, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  isAlternateScreen(): boolean {
    this.ensureActive();
    this.runtime.bytes(this.scratch, 4).fill(0);
    return (
      this.runtime.call("ghostty_terminal_get", this.terminal, 6, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.view(this.scratch, 4).getUint32(0, true) === 1
    );
  }

  isApplicationCursorKeys(): boolean {
    this.ensureActive();
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_terminal_mode_get", this.terminal, 1, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  encodeKey(event: KeyboardEvent, action: "press" | "release" = "press"): string {
    this.ensureActive();
    this.runtime.call("ghostty_key_encoder_setopt_from_terminal", this.keyEncoder, this.terminal);
    this.runtime.call(
      "ghostty_key_event_set_action",
      this.keyEvent,
      action === "release" ? 0 : event.repeat ? 2 : 1,
    );
    this.runtime.call("ghostty_key_event_set_key", this.keyEvent, ghosttyKeyForCode(event.code));
    const mods =
      (event.shiftKey ? 1 : 0) |
      (event.ctrlKey ? 1 << 1 : 0) |
      (event.altKey ? 1 << 2 : 0) |
      (event.metaKey ? 1 << 3 : 0) |
      (event.getModifierState("CapsLock") ? 1 << 4 : 0) |
      (event.getModifierState("NumLock") ? 1 << 5 : 0);
    this.runtime.call("ghostty_key_event_set_mods", this.keyEvent, mods);
    this.runtime.call(
      "ghostty_key_event_set_consumed_mods",
      this.keyEvent,
      ghosttyConsumedMods(event),
    );
    this.runtime.call("ghostty_key_event_set_composing", this.keyEvent, event.isComposing ? 1 : 0);
    this.runtime.call(
      "ghostty_key_event_set_unshifted_codepoint",
      this.keyEvent,
      ghosttyUnshiftedCodepoint(event, this.keyboardLayoutMap),
    );

    const text = event.key.length === 1 ? event.key : "";
    const textBytes = encoder.encode(text);
    const textPointer = textBytes.length === 0 ? 0 : this.runtime.alloc(textBytes.length);
    if (textPointer !== 0) this.runtime.bytes(textPointer, textBytes.length).set(textBytes);
    this.runtime.call("ghostty_key_event_set_utf8", this.keyEvent, textPointer, textBytes.length);

    const written = this.runtime.call("ghostty_wasm_alloc_usize");
    const encoded = this.encodeOutput(written, (output, outputSize) =>
      this.runtime.call(
        "ghostty_key_encoder_encode",
        this.keyEncoder,
        this.keyEvent,
        output,
        outputSize,
        written,
      ),
    );
    this.runtime.call("ghostty_wasm_free_usize", written);
    if (textPointer !== 0) this.runtime.free(textPointer, textBytes.length);
    return encoded;
  }

  encodePaste(data: string): string {
    this.ensureActive();
    const input = encoder.encode(data);
    if (input.length === 0) return "";
    const inputPointer = this.runtime.alloc(input.length);
    this.runtime.bytes(inputPointer, input.length).set(input);
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    const bracketed =
      this.runtime.call("ghostty_terminal_mode_get", this.terminal, 2004, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0;
    const written = this.runtime.call("ghostty_wasm_alloc_usize");
    let encoded = "";
    const sizeResult = this.runtime.call(
      "ghostty_paste_encode",
      inputPointer,
      input.length,
      bracketed ? 1 : 0,
      0,
      0,
      written,
    );
    const outputSize = this.runtime.view(written, 4).getUint32(0, true);
    if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
      const output = this.runtime.alloc(outputSize);
      const result = this.runtime.call(
        "ghostty_paste_encode",
        inputPointer,
        input.length,
        bracketed ? 1 : 0,
        output,
        outputSize,
        written,
      );
      const outputLength = this.runtime.view(written, 4).getUint32(0, true);
      encoded =
        result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : "";
      this.runtime.free(output, outputSize);
    }
    this.runtime.call("ghostty_wasm_free_usize", written);
    this.runtime.free(inputPointer, input.length);
    return encoded;
  }

  encodeMouse(input: GhosttyMouseInput): string {
    this.ensureActive();
    this.runtime.call(
      "ghostty_mouse_encoder_setopt_from_terminal",
      this.mouseEncoder,
      this.terminal,
    );

    const sizeLayout = this.runtime.layout("GhosttyMouseEncoderSize");
    const size = this.runtime.alloc(sizeLayout.size);
    for (const [field, value] of [
      ["size", sizeLayout.size],
      ["screen_width", input.screenWidth],
      ["screen_height", input.screenHeight],
      ["cell_width", input.cellWidth],
      ["cell_height", input.cellHeight],
      ["padding_top", input.paddingTop],
      ["padding_bottom", input.paddingBottom],
      ["padding_right", input.paddingRight],
      ["padding_left", input.paddingLeft],
    ] as const) {
      this.runtime.setField(size, "GhosttyMouseEncoderSize", field, Math.max(0, Math.round(value)));
    }
    this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 2, size);
    this.runtime.free(size, sizeLayout.size);

    this.runtime.bytes(this.scratch, 1)[0] = input.anyButtonPressed ? 1 : 0;
    this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 3, this.scratch);
    this.runtime.bytes(this.scratch, 1)[0] = 1;
    this.runtime.call("ghostty_mouse_encoder_setopt", this.mouseEncoder, 4, this.scratch);

    this.runtime.call(
      "ghostty_mouse_event_set_action",
      this.mouseEvent,
      input.action === "press" ? 0 : input.action === "release" ? 1 : 2,
    );
    if (input.button === null) {
      this.runtime.call("ghostty_mouse_event_clear_button", this.mouseEvent);
    } else {
      this.runtime.call("ghostty_mouse_event_set_button", this.mouseEvent, input.button);
    }
    this.runtime.call("ghostty_mouse_event_set_mods", this.mouseEvent, input.mods);
    const positionLayout = this.runtime.layout("GhosttyMousePosition");
    const position = this.runtime.alloc(positionLayout.size);
    const positionView = this.runtime.view(position, positionLayout.size);
    positionView.setFloat32(positionLayout.fields.x!.offset, input.x, true);
    positionView.setFloat32(positionLayout.fields.y!.offset, input.y, true);
    this.runtime.call("ghostty_mouse_event_set_position", this.mouseEvent, position);
    this.runtime.free(position, positionLayout.size);

    const written = this.runtime.call("ghostty_wasm_alloc_usize");
    const encoded = this.encodeOutput(written, (output, outputSize) =>
      this.runtime.call(
        "ghostty_mouse_encoder_encode",
        this.mouseEncoder,
        this.mouseEvent,
        output,
        outputSize,
        written,
      ),
    );
    this.runtime.call("ghostty_wasm_free_usize", written);
    return encoded;
  }

  setSelection(anchor: GhosttyPointInput, end: GhosttyPointInput): void {
    this.ensureActive();
    const selectionLayout = this.runtime.layout("GhosttySelection");
    const gridRefSize = this.runtime.layout("GhosttyGridRef").size;
    const selection = this.runtime.alloc(selectionLayout.size);
    let start = 0;
    let endRef = 0;
    try {
      this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size);
      start = this.gridRef(anchor.x, anchor.y, anchor.tag ?? 1);
      endRef = this.gridRef(end.x, end.y, end.tag ?? 1);
      const startField = selectionLayout.fields.start!;
      const endField = selectionLayout.fields.end!;
      this.runtime
        .bytes(selection + startField.offset, startField.size)
        .set(this.runtime.bytes(start, startField.size));
      this.runtime
        .bytes(selection + endField.offset, endField.size)
        .set(this.runtime.bytes(endRef, endField.size));
      this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
    } finally {
      this.runtime.free(start, gridRefSize);
      this.runtime.free(endRef, gridRefSize);
      this.runtime.free(selection, selectionLayout.size);
    }
  }

  selectAll(): void {
    this.ensureActive();
    const layout = this.runtime.layout("GhosttySelection");
    const selection = this.runtime.alloc(layout.size);
    this.runtime.setField(selection, "GhosttySelection", "size", layout.size);
    if (
      this.runtime.call("ghostty_terminal_select_all", this.terminal, selection) === GHOSTTY_SUCCESS
    ) {
      this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
    }
    this.runtime.free(selection, layout.size);
  }

  selectWord(col: number, row: number): GhosttySelectionRange | null {
    return this.selectAt(
      "GhosttyTerminalSelectWordOptions",
      "ghostty_terminal_select_word",
      col,
      row,
    );
  }

  selectLine(col: number, row: number): GhosttySelectionRange | null {
    return this.selectAt(
      "GhosttyTerminalSelectLineOptions",
      "ghostty_terminal_select_line",
      col,
      row,
    );
  }

  hyperlinkAt(col: number, row: number): string | null {
    this.ensureActive();
    const ref = this.gridRef(col, row);
    const written = this.runtime.call("ghostty_wasm_alloc_usize");
    const sizeResult = this.runtime.call("ghostty_grid_ref_hyperlink_uri", ref, 0, 0, written);
    const outputSize = this.runtime.view(written, 4).getUint32(0, true);
    let hyperlink: string | null = null;
    if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
      const output = this.runtime.alloc(outputSize);
      const result = this.runtime.call(
        "ghostty_grid_ref_hyperlink_uri",
        ref,
        output,
        outputSize,
        written,
      );
      const outputLength = this.runtime.view(written, 4).getUint32(0, true);
      if (result === GHOSTTY_SUCCESS && outputLength > 0) {
        hyperlink = decoder.decode(this.runtime.bytes(output, outputLength));
      }
      this.runtime.free(output, outputSize);
    }
    this.runtime.call("ghostty_wasm_free_usize", written);
    this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
    return hyperlink;
  }

  clearSelection(): void {
    this.ensureActive();
    this.runtime.call("ghostty_terminal_set", this.terminal, 21, 0);
  }

  snapshot(): GhosttySnapshot {
    this.ensureActive();
    this.assertSuccess(
      "ghostty_render_state_update",
      this.runtime.call("ghostty_render_state_update", this.renderState, this.terminal),
    );
    const cols = this.getU16(RENDER_DATA.cols);
    const rowCount = this.getU16(RENDER_DATA.rows);
    const dirty = this.getU32(RENDER_DATA.dirty);
    const foreground = this.getColor(RENDER_DATA.foreground, { r: 229, g: 231, b: 235 });
    const background = this.getColor(RENDER_DATA.background, { r: 0, g: 0, b: 0 });
    const cursorHasValue = this.getBool(RENDER_DATA.cursorHasValue);
    const cursor = cursorHasValue ? this.getColor(RENDER_DATA.cursor, foreground) : foreground;
    const cursorInViewport = this.getBool(RENDER_DATA.cursorInViewport);
    const cursorVisible = this.getBool(RENDER_DATA.cursorVisible) && cursorInViewport;
    const cursorX = cursorInViewport ? this.getU16(RENDER_DATA.cursorX) : -1;
    const cursorY = cursorInViewport ? this.getU16(RENDER_DATA.cursorY) : -1;

    if (this.rows.length !== rowCount || this.rows.some((row) => row.cells.length !== cols)) {
      this.rows = Array.from({ length: rowCount }, () => ({
        cells: Array.from({ length: cols }, () => this.emptyCell(foreground, background)),
        text: "",
        isWrapContinuation: false,
        wrapsToNext: false,
      }));
    }

    const dirtyRows = new Set<number>();
    if (dirty !== 0) {
      this.assertSuccess(
        "ghostty_render_state_get(row iterator)",
        this.runtime.call(
          "ghostty_render_state_get",
          this.renderState,
          RENDER_DATA.rowIterator,
          this.rowIteratorSlot,
        ),
      );
      const iterator = this.runtime.readPointer(this.rowIteratorSlot);
      let rowIndex = 0;
      while (
        rowIndex < rowCount &&
        this.runtime.call("ghostty_render_state_row_iterator_next", iterator) !== 0
      ) {
        const rowDirty = dirty === 2 || this.getRowBool(iterator, ROW_DATA.dirty);
        if (rowDirty) {
          this.rows[rowIndex] = this.readRow(iterator, cols, foreground, background);
          dirtyRows.add(rowIndex);
          this.runtime.bytes(this.scratch, 1)[0] = 0;
          this.runtime.call("ghostty_render_state_row_set", iterator, 0, this.scratch);
        }
        rowIndex += 1;
      }
      this.runtime.view(this.scratch, 4).setUint32(0, 0, true);
      this.runtime.call("ghostty_render_state_set", this.renderState, 0, this.scratch);
    }

    return {
      cols,
      rows: rowCount,
      foreground,
      background,
      cursor,
      cursorX,
      cursorY,
      cursorVisible,
      cursorBlinking: this.getBool(RENDER_DATA.cursorBlinking),
      cursorStyle: this.getU32(RENDER_DATA.cursorStyle),
      dirtyRows,
      rowData: this.rows,
    };
  }

  selectionText(): string {
    this.ensureActive();
    const options = this.runtime.alloc(SELECTION_FORMAT_OPTIONS_SIZE);
    const optionsView = this.runtime.view(options, SELECTION_FORMAT_OPTIONS_SIZE);
    optionsView.setUint32(0, SELECTION_FORMAT_OPTIONS_SIZE, true);
    optionsView.setUint32(4, 0, true);
    optionsView.setUint8(8, 1);
    optionsView.setUint8(9, 1);
    optionsView.setUint32(12, 0, true);
    const written = this.runtime.call("ghostty_wasm_alloc_usize");
    const sizeResult = this.runtime.call(
      "ghostty_terminal_selection_format_buf",
      this.terminal,
      options,
      0,
      0,
      written,
    );
    const outputSize = this.runtime.view(written, 4).getUint32(0, true);
    let text = "";
    if (sizeResult === GHOSTTY_OUT_OF_SPACE && outputSize > 0) {
      const output = this.runtime.alloc(outputSize);
      const result = this.runtime.call(
        "ghostty_terminal_selection_format_buf",
        this.terminal,
        options,
        output,
        outputSize,
        written,
      );
      const outputLength = this.runtime.view(written, 4).getUint32(0, true);
      if (result === GHOSTTY_SUCCESS) {
        text = decoder.decode(this.runtime.bytes(output, outputLength));
      }
      this.runtime.free(output, outputSize);
    }
    this.runtime.call("ghostty_wasm_free_usize", written);
    this.runtime.free(options, SELECTION_FORMAT_OPTIONS_SIZE);
    return text;
  }

  viewportPointToScreen(col: number, row: number): { x: number; y: number } | null {
    return this.convertPoint(col, row, 1, 2);
  }

  screenPointToViewport(col: number, row: number): { x: number; y: number } | null {
    return this.convertPoint(col, row, 2, 1);
  }

  private convertPoint(
    col: number,
    row: number,
    fromTag: 1 | 2,
    toTag: 1 | 2,
  ): { x: number; y: number } | null {
    this.ensureActive();
    const ref = this.gridRef(col, row, fromTag);
    const point = this.pointFromGridRef(ref, toTag);
    this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
    return point;
  }

  dispose(): void {
    if (this.disposed) return;
    this.disposed = true;
    if (this.mouseEvent) this.runtime.call("ghostty_mouse_event_free", this.mouseEvent);
    if (this.mouseEncoder) this.runtime.call("ghostty_mouse_encoder_free", this.mouseEncoder);
    if (this.keyEvent) this.runtime.call("ghostty_key_event_free", this.keyEvent);
    if (this.keyEncoder) this.runtime.call("ghostty_key_encoder_free", this.keyEncoder);
    if (this.rowCellsSlot) {
      const cells = this.runtime.readPointer(this.rowCellsSlot);
      if (cells) this.runtime.call("ghostty_render_state_row_cells_free", cells);
    }
    if (this.rowIteratorSlot) {
      const iterator = this.runtime.readPointer(this.rowIteratorSlot);
      if (iterator) this.runtime.call("ghostty_render_state_row_iterator_free", iterator);
    }
    if (this.renderState) this.runtime.call("ghostty_render_state_free", this.renderState);
    if (this.terminal) {
      if (this.ptyWriterId) this.runtime.detachPtyWriter(this.terminal, this.ptyWriterId);
      this.runtime.call("ghostty_terminal_free", this.terminal);
    }
    if (this.style) this.runtime.free(this.style, this.runtime.layout("GhosttyStyle").size);
    if (this.scrollbar) {
      this.runtime.free(this.scrollbar, this.runtime.layout("GhosttyTerminalScrollbar").size);
    }
    if (this.scratch) this.runtime.free(this.scratch, 16);
    if (this.graphemes) this.runtime.free(this.graphemes, this.graphemeCapacity);
    for (const slot of [
      this.mouseEventSlot,
      this.mouseEncoderSlot,
      this.keyEventSlot,
      this.keyEncoderSlot,
      this.rowCellsSlot,
      this.rowIteratorSlot,
      this.renderStateSlot,
      this.terminalSlot,
    ]) {
      this.runtime.freeOpaque(slot);
    }
  }

  private encodeOutput(
    written: number,
    encode: (output: number, outputSize: number) => number,
  ): string {
    const sizeResult = encode(0, 0);
    const outputSize = this.runtime.view(written, 4).getUint32(0, true);
    if (sizeResult === GHOSTTY_SUCCESS && outputSize === 0) return "";
    if (sizeResult !== GHOSTTY_OUT_OF_SPACE || outputSize === 0) return "";

    const output = this.runtime.alloc(outputSize);
    const result = encode(output, outputSize);
    const outputLength = this.runtime.view(written, 4).getUint32(0, true);
    const encoded =
      result === GHOSTTY_SUCCESS ? decoder.decode(this.runtime.bytes(output, outputLength)) : "";
    this.runtime.free(output, outputSize);
    return encoded;
  }

  private readRow(
    iterator: number,
    cols: number,
    defaultForeground: GhosttyColor,
    defaultBackground: GhosttyColor,
  ): GhosttyRow {
    this.assertSuccess(
      "ghostty_render_state_row_get(raw)",
      this.runtime.call("ghostty_render_state_row_get", iterator, ROW_DATA.raw, this.scratch),
    );
    const rawRow = this.runtime.view(this.scratch, 8).getBigUint64(0, true);
    this.runtime.bytes(this.scratch + 8, 1)[0] = 0;
    this.assertSuccess(
      "ghostty_row_get(wrap continuation)",
      this.runtime.call("ghostty_row_get", rawRow, 2, this.scratch + 8),
    );
    const isWrapContinuation = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0;
    this.runtime.bytes(this.scratch + 8, 1)[0] = 0;
    this.assertSuccess(
      "ghostty_row_get(wrap)",
      this.runtime.call("ghostty_row_get", rawRow, 1, this.scratch + 8),
    );
    const wrapsToNext = this.runtime.bytes(this.scratch + 8, 1)[0] !== 0;

    this.assertSuccess(
      "ghostty_render_state_row_get(cells)",
      this.runtime.call(
        "ghostty_render_state_row_get",
        iterator,
        ROW_DATA.cells,
        this.rowCellsSlot,
      ),
    );
    const cellsIterator = this.runtime.readPointer(this.rowCellsSlot);
    const { size: styleSize, fields: styleFields } = this.runtime.layout("GhosttyStyle");
    const cells: GhosttyCell[] = [];
    while (
      cells.length < cols &&
      this.runtime.call("ghostty_render_state_row_cells_next", cellsIterator) !== 0
    ) {
      let foreground = this.getCellColor(cellsIterator, CELL_DATA.foreground, defaultForeground);
      let background = this.getCellColor(cellsIterator, CELL_DATA.background, defaultBackground);
      this.runtime.bytes(this.style, styleSize).fill(0);
      this.runtime.setField(this.style, "GhosttyStyle", "size", styleSize);
      this.runtime.call(
        "ghostty_render_state_row_cells_get",
        cellsIterator,
        CELL_DATA.style,
        this.style,
      );
      const graphemeLength = this.getCellU32(cellsIterator, CELL_DATA.graphemesLength);
      let text = "";
      if (graphemeLength > 0) {
        const bufferSize = graphemeLength * 4;
        if (bufferSize > this.graphemeCapacity) {
          const capacity = Math.max(bufferSize, this.graphemeCapacity * 2);
          const buffer = this.runtime.alloc(capacity);
          this.runtime.free(this.graphemes, this.graphemeCapacity);
          this.graphemes = buffer;
          this.graphemeCapacity = capacity;
        }
        if (
          this.runtime.call(
            "ghostty_render_state_row_cells_get",
            cellsIterator,
            CELL_DATA.graphemes,
            this.graphemes,
          ) === GHOSTTY_SUCCESS
        ) {
          // Read through a DataView: the byte-array allocator guarantees no
          // 4-byte alignment, which a Uint32Array view would require.
          const codepointView = this.runtime.view(this.graphemes, bufferSize);
          text = ghosttyCellText(codepointView, graphemeLength);
        }
      }
      let wide = 0;
      if (text.length === 0 && cells.at(-1)?.text.length) {
        this.assertSuccess(
          "ghostty_render_state_row_cells_get(raw)",
          this.runtime.call(
            "ghostty_render_state_row_cells_get",
            cellsIterator,
            CELL_DATA.raw,
            this.scratch,
          ),
        );
        const rawCell = this.runtime.view(this.scratch, 8).getBigUint64(0, true);
        this.runtime.view(this.scratch + 8, 4).setUint32(0, 0, true);
        this.assertSuccess(
          "ghostty_cell_get(wide)",
          this.runtime.call("ghostty_cell_get", rawCell, RAW_CELL_DATA.wide, this.scratch + 8),
        );
        wide = this.runtime.view(this.scratch + 8, 4).getUint32(0, true);
      }
      const selected = this.getCellBool(cellsIterator, CELL_DATA.selected);
      // Read the style after allocation and ABI calls, which can grow WASM memory.
      const styleView = this.runtime.view(this.style, styleSize);
      if (styleView.getUint8(styleFields.inverse!.offset) !== 0) {
        [foreground, background] = [background, foreground];
      }
      if (styleView.getUint8(styleFields.faint!.offset) !== 0) {
        foreground = blend(foreground, background);
      }
      cells.push({
        text,
        wide,
        foreground,
        background,
        bold: styleView.getUint8(styleFields.bold!.offset) !== 0,
        italic: styleView.getUint8(styleFields.italic!.offset) !== 0,
        invisible: styleView.getUint8(styleFields.invisible!.offset) !== 0,
        strikethrough: styleView.getUint8(styleFields.strikethrough!.offset) !== 0,
        overline: styleView.getUint8(styleFields.overline!.offset) !== 0,
        underline: styleView.getInt32(styleFields.underline!.offset, true) !== 0,
        selected,
      });
    }
    while (cells.length < cols) cells.push(this.emptyCell(defaultForeground, defaultBackground));
    return {
      cells,
      text: cells
        .map((cell) => cell.text || " ")
        .join("")
        .trimEnd(),
      isWrapContinuation,
      wrapsToNext,
    };
  }

  private gridRef(col: number, row: number, tag: 1 | 2 = 1): number {
    const pointLayout = this.runtime.layout("GhosttyPoint");
    const point = this.runtime.alloc(pointLayout.size);
    this.runtime.setField(point, "GhosttyPoint", "tag", tag);
    const pointValue = pointLayout.fields.value!;
    const valueOffset = pointValue.offset;
    const view = this.runtime.view(point + valueOffset, pointValue.size);
    view.setUint16(0, Math.max(0, col), true);
    view.setUint32(4, Math.max(0, row), true);
    const gridRefSize = this.runtime.layout("GhosttyGridRef").size;
    const gridRef = this.runtime.alloc(gridRefSize);
    this.runtime.setField(gridRef, "GhosttyGridRef", "size", gridRefSize);
    const result = this.runtime.call("ghostty_terminal_grid_ref", this.terminal, point, gridRef);
    this.runtime.free(point, pointLayout.size);
    if (result !== GHOSTTY_SUCCESS) {
      this.runtime.free(gridRef, gridRefSize);
      this.assertSuccess("ghostty_terminal_grid_ref", result);
    }
    return gridRef;
  }

  private selectAt(
    optionsName: "GhosttyTerminalSelectWordOptions" | "GhosttyTerminalSelectLineOptions",
    operation: "ghostty_terminal_select_word" | "ghostty_terminal_select_line",
    col: number,
    row: number,
  ): GhosttySelectionRange | null {
    this.ensureActive();
    const optionsLayout = this.runtime.layout(optionsName);
    const selectionLayout = this.runtime.layout("GhosttySelection");
    const options = this.runtime.alloc(optionsLayout.size);
    let ref = 0;
    let selection = 0;
    let range: GhosttySelectionRange | null = null;
    try {
      this.runtime.setField(options, optionsName, "size", optionsLayout.size);
      ref = this.gridRef(col, row);
      const refField = optionsLayout.fields.ref!;
      this.runtime
        .bytes(options + refField.offset, refField.size)
        .set(this.runtime.bytes(ref, refField.size));
      selection = this.runtime.alloc(selectionLayout.size);
      this.runtime.setField(selection, "GhosttySelection", "size", selectionLayout.size);
      const result = this.runtime.call(operation, this.terminal, options, selection);
      if (result === GHOSTTY_SUCCESS) {
        const start = selection + selectionLayout.fields.start!.offset;
        const end = selection + selectionLayout.fields.end!.offset;
        const viewportStart = this.pointFromGridRef(start, 1);
        const viewportEnd = this.pointFromGridRef(end, 1);
        const screenStart = this.pointFromGridRef(start, 2);
        const screenEnd = this.pointFromGridRef(end, 2);
        if (viewportStart && viewportEnd && screenStart && screenEnd) {
          range = {
            viewport: { start: viewportStart, end: viewportEnd },
            screen: { start: screenStart, end: screenEnd },
          };
        }
        this.runtime.call("ghostty_terminal_set", this.terminal, 21, selection);
      }
    } finally {
      this.runtime.free(selection, selectionLayout.size);
      this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size);
      this.runtime.free(options, optionsLayout.size);
    }
    return range;
  }

  private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null {
    const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate");
    const coordinate = this.runtime.alloc(coordinateLayout.size);
    const result = this.runtime.call(
      "ghostty_terminal_point_from_grid_ref",
      this.terminal,
      ref,
      tag,
      coordinate,
    );
    const point =
      result === GHOSTTY_SUCCESS
        ? {
            x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"),
            y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"),
          }
        : null;
    this.runtime.free(coordinate, coordinateLayout.size);
    return point;
  }

  private getU16(data: number): number {
    this.runtime.bytes(this.scratch, 2).fill(0);
    this.assertSuccess(
      "ghostty_render_state_get",
      this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
    );
    return this.runtime.view(this.scratch, 2).getUint16(0, true);
  }

  private getU32(data: number): number {
    this.runtime.bytes(this.scratch, 4).fill(0);
    this.assertSuccess(
      "ghostty_render_state_get",
      this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
    );
    return this.runtime.view(this.scratch, 4).getUint32(0, true);
  }

  private getBool(data: number): boolean {
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    this.assertSuccess(
      "ghostty_render_state_get",
      this.runtime.call("ghostty_render_state_get", this.renderState, data, this.scratch),
    );
    return this.runtime.bytes(this.scratch, 1)[0] !== 0;
  }

  private getColor(data: number, fallback: GhosttyColor): GhosttyColor {
    this.runtime.bytes(this.scratch, 3).fill(0);
    const result = this.runtime.call(
      "ghostty_render_state_get",
      this.renderState,
      data,
      this.scratch,
    );
    return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback;
  }

  private getRowBool(iterator: number, data: number): boolean {
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_render_state_row_get", iterator, data, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  private getCellU32(iterator: number, data: number): number {
    this.runtime.bytes(this.scratch, 4).fill(0);
    const result = this.runtime.call(
      "ghostty_render_state_row_cells_get",
      iterator,
      data,
      this.scratch,
    );
    return result === GHOSTTY_SUCCESS ? this.runtime.view(this.scratch, 4).getUint32(0, true) : 0;
  }

  private getCellBool(iterator: number, data: number): boolean {
    this.runtime.bytes(this.scratch, 1)[0] = 0;
    return (
      this.runtime.call("ghostty_render_state_row_cells_get", iterator, data, this.scratch) ===
        GHOSTTY_SUCCESS && this.runtime.bytes(this.scratch, 1)[0] !== 0
    );
  }

  private getCellColor(iterator: number, data: number, fallback: GhosttyColor): GhosttyColor {
    this.runtime.bytes(this.scratch, 3).fill(0);
    const result = this.runtime.call(
      "ghostty_render_state_row_cells_get",
      iterator,
      data,
      this.scratch,
    );
    return result === GHOSTTY_SUCCESS ? this.readColor(this.scratch) : fallback;
  }

  private readColor(pointer: number): GhosttyColor {
    const bytes = this.runtime.bytes(pointer, 3);
    return { r: bytes[0] ?? 0, g: bytes[1] ?? 0, b: bytes[2] ?? 0 };
  }

  private emptyCell(foreground: GhosttyColor, background: GhosttyColor): GhosttyCell {
    return {
      text: "",
      wide: 0,
      foreground,
      background,
      bold: false,
      italic: false,
      invisible: false,
      strikethrough: false,
      overline: false,
      underline: false,
      selected: false,
    };
  }

  private assertSuccess(operation: string, result: number): void {
    if (result !== GHOSTTY_SUCCESS) throw new Error(`${operation} failed with result ${result}`);
  }

  private ensureActive(): void {
    if (this.disposed) throw new Error("libghostty-vt terminal has been disposed");
  }
}

export function ghosttyColorsEqual(left: GhosttyColor, right: GhosttyColor): boolean {
  return sameColor(left, right);
}