packages feed

CPL-0.2.0: web/cpl-terminal.js

import { Terminal } from 'https://cdn.jsdelivr.net/npm/xterm@5.3.0/+esm';
import { FitAddon } from 'https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/+esm';
import { WASI, OpenFile, File, ConsoleStdout } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.3.0/+esm';
import { sampleFiles } from './samples.js';

/**
 * CPL Terminal Manager
 * Manages xterm.js terminal and provides interface for WASM CPL interpreter
 */
class CPLTerminal {
  constructor() {
    this.term = new Terminal({
      cursorBlink: true,
      fontSize: 14,
      fontFamily: 'Menlo, Monaco, "Courier New", monospace',
      theme: {
        background: '#1e1e1e',
        foreground: '#d4d4d4',
        cursor: '#00ff00',
        cursorAccent: '#1e1e1e',
        selection: 'rgba(255, 255, 255, 0.3)',
        black: '#000000',
        red: '#cd3131',
        green: '#0dbc79',
        yellow: '#e5e510',
        blue: '#2472c8',
        magenta: '#bc3fbc',
        cyan: '#11a8cd',
        white: '#e5e5e5',
        brightBlack: '#666666',
        brightRed: '#f14c4c',
        brightGreen: '#23d18b',
        brightYellow: '#f5f543',
        brightBlue: '#3b8eea',
        brightMagenta: '#d670d6',
        brightCyan: '#29b8db',
        brightWhite: '#e5e5e5'
      },
      allowProposedApi: true
    });

    this.fitAddon = new FitAddon();
    this.term.loadAddon(this.fitAddon);

    this.inputBuffer = '';
    this.resolveInput = null;
    this.isInitialized = false;
  }

  /**
   * Initialize terminal and attach event handlers
   */
  initialize() {
    if (this.isInitialized) return;

    const terminalElement = document.getElementById('terminal');
    if (!terminalElement) {
      throw new Error('Terminal element not found');
    }

    this.term.open(terminalElement);
    this.fitAddon.fit();

    // Handle window resize
    window.addEventListener('resize', () => {
      this.fitAddon.fit();
    });

    // Handle keyboard input
    this.term.onData(data => {
      this.handleInput(data);
    });

    this.isInitialized = true;
  }

  /**
   * Handle keyboard input
   * @param {string} data - Input character(s)
   */
  handleInput(data) {
    const code = data.charCodeAt(0);

    // Enter key
    if (code === 13) {
      if (this.resolveInput) {
        const input = this.inputBuffer;
        this.inputBuffer = '';
        this.term.write('\r\n');
        this.resolveInput(input);
        this.resolveInput = null;
      }
      return;
    }

    // Backspace (127) or Ctrl+H (8)
    if (code === 127 || code === 8) {
      if (this.inputBuffer.length > 0) {
        this.inputBuffer = this.inputBuffer.slice(0, -1);
        // Move cursor back, write space, move cursor back again
        this.term.write('\b \b');
      }
      return;
    }

    // Ctrl+C
    if (code === 3) {
      this.term.write('^C\r\n');
      if (this.resolveInput) {
        this.inputBuffer = '';
        this.resolveInput('');
        this.resolveInput = null;
      }
      return;
    }

    // Ctrl+D (EOF)
    if (code === 4) {
      this.term.write('^D\r\n');
      if (this.resolveInput && this.inputBuffer.length === 0) {
        this.resolveInput('quit');
        this.resolveInput = null;
      }
      return;
    }

    // Printable characters (including space)
    if (code >= 32 && code < 127) {
      this.inputBuffer += data;
      this.term.write(data);
      return;
    }

    // Tab key
    if (code === 9) {
      // For now, just insert spaces (future: implement completion)
      const spaces = '  ';
      this.inputBuffer += spaces;
      this.term.write(spaces);
      return;
    }
  }

  /**
   * Print a line to the terminal
   * @param {string} text - Text to print
   */
  printLine(text) {
    // Convert JavaScript string to lines and print each
    const lines = text.split('\n');
    for (let i = 0; i < lines.length; i++) {
      if (i > 0) this.term.write('\r\n');
      this.term.write(lines[i]);
    }
    this.term.write('\r\n');
  }

  /**
   * Read a line from the terminal (async)
   * @param {string} prompt - Prompt to display
   * @returns {Promise<string>} The input line
   */
  async readLine(prompt) {
    return new Promise(resolve => {
      this.term.write(prompt);
      this.resolveInput = resolve;
    });
  }

  /**
   * Write text without newline
   * @param {string} text - Text to write
   */
  write(text) {
    this.term.write(text);
  }

  /**
   * Clear the terminal
   */
  clear() {
    this.term.clear();
  }
}

// Global terminal instance
const cplTerminal = new CPLTerminal();

// WASM FFI exports - these will be called from Haskell
window.terminal_initialize = () => {
  cplTerminal.initialize();
};

window.terminal_printLine = (jsVal) => {
  // Convert JSVal to string
  const text = String(jsVal);
  cplTerminal.printLine(text);
};

window.terminal_readLine = async (jsVal) => {
  // Convert JSVal to string for prompt
  const prompt = String(jsVal);
  const result = await cplTerminal.readLine(prompt);
  return result;
};

window.terminal_loadFile = async (jsVal) => {
  const filename = String(jsVal).trim();

  // 1. Filename specified: look up built-in sample
  if (filename) {
    if (sampleFiles[filename] !== undefined) {
      return sampleFiles[filename];
    }
    // Not found: show available files
    const available = Object.keys(sampleFiles).join(', ');
    throw new Error(
      `File not found: ${filename}\n` +
      `Available files: ${available}\n` +
      `Type 'load' with no arguments to open a file picker.`
    );
  }

  // 2. No filename: open file picker
  return new Promise((resolve, reject) => {
    const input = document.createElement('input');
    input.type = 'file';
    input.accept = '.cpl,.cdt,.txt';
    input.style.display = 'none';
    document.body.appendChild(input);
    input.addEventListener('change', async () => {
      document.body.removeChild(input);
      const file = input.files[0];
      if (file) {
        resolve(await file.text());
      } else {
        reject(new Error('No file selected'));
      }
    });
    input.addEventListener('cancel', () => {
      document.body.removeChild(input);
      reject(new Error('File selection cancelled'));
    });
    input.click();
  });
};

/**
 * Show error message
 */
function showError(message, details) {
  const loadingEl = document.getElementById('loading');
  loadingEl.className = 'error';
  loadingEl.innerHTML = `
    <div class="error-title">Failed to load CPL interpreter</div>
    <div>${message}</div>
    ${details ? `<pre style="margin-top: 1rem; text-align: left;">${details}</pre>` : ''}
  `;
}

/**
 * Initialize and load WASM module
 */
async function initCPL() {
  const loadingEl = document.getElementById('loading');
  const terminalContainer = document.getElementById('terminal-container');

  try {
    loadingEl.textContent = 'Downloading WASM module...';

    // Fetch WASM binary
    const response = await fetch('cpl.wasm');
    if (!response.ok) {
      throw new Error(`Failed to fetch cpl.wasm: ${response.status} ${response.statusText}`);
    }

    loadingEl.textContent = 'Loading WASM module...';

    const wasmBytes = await response.arrayBuffer();

    loadingEl.textContent = 'Initializing CPL interpreter...';

    // Import JSFFI glue code generated by post-link.mjs
    const jsffiModule = await import('./cpl.js');

    // Knot-tying: create empty exports object, fill after instantiation
    const __exports = {};
    const jsffi = jsffiModule.default(__exports);

    // Set up WASI shim for browser
    const fds = [
      new OpenFile(new File([])),                                       // stdin
      ConsoleStdout.lineBuffered(msg => console.log(`[CPL] ${msg}`)),   // stdout
      ConsoleStdout.lineBuffered(msg => console.warn(`[CPL] ${msg}`)),  // stderr
    ];
    const wasi = new WASI([], [], fds);

    // Instantiate WASM with JSFFI + WASI imports
    const { instance } = await WebAssembly.instantiate(wasmBytes, {
      ghc_wasm_jsffi: jsffi,
      wasi_snapshot_preview1: wasi.wasiImport,
    });

    // Complete knot-tying: fill in exports
    Object.assign(__exports, instance.exports);

    // Initialize WASI reactor (sets wasi.inst and calls _initialize)
    wasi.initialize(instance);

    // Hide loading indicator and show terminal
    loadingEl.classList.add('hidden');
    terminalContainer.style.display = 'block';

    // Initialize Haskell RTS and start REPL
    instance.exports.hs_init();
    instance.exports.hs_start();

  } catch (error) {
    console.error('CPL initialization error:', error);
    showError(
      error.message || 'An unknown error occurred',
      error.stack
    );
  }
}

// Start initialization when DOM is ready
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', initCPL);
} else {
  initCPL();
}