loop/webui: swtich to web components impl (#1)

* loop/webui: swtich to web components impl

This change reorganizes the original vibe-coded
frontend code into a structure that's much
easier for a human to read and reason about,
while retaining the user-visible functionality
of its vibe-coded predecessor. Perhaps most
importantly, this change makes the code testable.

Some other notable details:

This does not use any of the popular large web
frameworks, but instead follows more of an
"a la carte" approach: leverage features
that already exist in modern web browsers,
like custom elements and shadow DOM.

Templating and basic component lifecycle
management are provided by lit.

State management is nothing fancy. It
doesn't use any library or framework, just
a basic "Events up, properties down"
approach.

* fix bad esbuild.go merge

* loop/webui: don't bundle src/web-components/demo

* loop/webui: don't 'npm ci' dev deps in the container

* rebase to main, undo README.md changes, add webuil.Build() call to LaunchContainer()
diff --git a/loop/webui/src/web-components/sketch-terminal.ts b/loop/webui/src/web-components/sketch-terminal.ts
new file mode 100644
index 0000000..788521d
--- /dev/null
+++ b/loop/webui/src/web-components/sketch-terminal.ts
@@ -0,0 +1,341 @@
+import { Terminal } from "@xterm/xterm";
+import { FitAddon } from "@xterm/addon-fit";
+
+import {css, html, LitElement} from 'lit';
+import {customElement, property, state} from 'lit/decorators.js';
+import {DataManager, ConnectionStatus} from '../data';
+import {State, TimelineMessage} from '../types';
+import './sketch-container-status';
+
+@customElement('sketch-terminal')
+export class SketchTerminal extends LitElement {
+  // Terminal instance
+  private terminal: Terminal | null = null;
+  // Terminal fit addon for handling resize
+  private fitAddon: FitAddon | null = null;
+  // Terminal EventSource for SSE
+  private terminalEventSource: EventSource | null = null;
+  // Terminal ID (always 1 for now, will support 1-9 later)
+  private terminalId: string = "1";
+  // Queue for serializing terminal inputs
+  private terminalInputQueue: string[] = [];
+  // Flag to track if we're currently processing a terminal input
+  private processingTerminalInput: boolean = false;
+
+  static styles = css`
+/* Terminal View Styles */
+.terminal-view {
+  width: 100%;
+  background-color: #f5f5f5;
+  border-radius: 8px;
+  overflow: hidden;
+  margin-bottom: 20px;
+  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+  padding: 15px;
+  height: 70vh;
+}
+
+.terminal-container {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+}
+`;
+
+  constructor() {
+    super();
+    this._resizeHandler = this._resizeHandler.bind(this);
+  }
+
+  connectedCallback() {
+    super.connectedCallback();
+    this.loadXtermlCSS();
+    // Setup resize handler
+    window.addEventListener("resize", this._resizeHandler);
+  }
+
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener("resize", this._resizeHandler);
+
+    this.closeTerminalConnections();
+
+    if (this.terminal) {
+      this.terminal.dispose();
+      this.terminal = null;
+    }
+    this.fitAddon = null;
+  }
+
+  firstUpdated() {
+    this.initializeTerminal();
+  }
+
+  _resizeHandler() {
+    if (this.fitAddon) {
+      this.fitAddon.fit();
+      // Send resize information to server
+      this.sendTerminalResize();
+    }
+  }
+
+  // Load xterm CSS into the shadow DOM
+  private async loadXtermlCSS() {
+    try {
+      // Check if diff2html styles are already loaded
+      const styleId = 'xterm-styles';
+      if (this.shadowRoot?.getElementById(styleId)) {
+        return; // Already loaded
+      }
+
+      // Fetch the diff2html CSS
+      const response = await fetch('static/xterm.css');
+
+      if (!response.ok) {
+        console.error(`Failed to load xterm CSS: ${response.status} ${response.statusText}`);
+        return;
+      }
+
+      const cssText = await response.text();
+
+      // Create a style element and append to shadow DOM
+      const style = document.createElement('style');
+      style.id = styleId;
+      style.textContent = cssText;
+      this.renderRoot?.appendChild(style);
+
+      console.log('xterm CSS loaded into shadow DOM');
+    } catch (error) {
+      console.error('Error loading xterm CSS:', error);
+    }
+  }
+
+  /**
+   * Initialize the terminal component
+   * @param terminalContainer The DOM element to contain the terminal
+   */
+  public async initializeTerminal(): Promise<void> {
+    const terminalContainer = this.renderRoot.querySelector("#terminalContainer") as HTMLElement;
+
+    if (!terminalContainer) {
+      console.error("Terminal container not found");
+      return;
+    }
+
+    // If terminal is already initialized, just focus it
+    if (this.terminal) {
+      this.terminal.focus();
+      if (this.fitAddon) {
+        this.fitAddon.fit();
+      }
+      return;
+    }
+
+    // Clear the terminal container
+    terminalContainer.innerHTML = "";
+
+    // Create new terminal instance
+    this.terminal = new Terminal({
+      cursorBlink: true,
+      theme: {
+        background: "#f5f5f5",
+        foreground: "#333333",
+        cursor: "#0078d7",
+        selectionBackground: "rgba(0, 120, 215, 0.4)",
+      },
+    });
+
+    // Add fit addon to handle terminal resizing
+    this.fitAddon = new FitAddon();
+    this.terminal.loadAddon(this.fitAddon);
+
+    // Open the terminal in the container
+    this.terminal.open(terminalContainer);
+
+    // Connect to WebSocket
+    await this.connectTerminal();
+
+    // Fit the terminal to the container
+    this.fitAddon.fit();
+
+    // Focus the terminal
+    this.terminal.focus();
+  }
+
+  /**
+   * Connect to terminal events stream
+   */
+  private async connectTerminal(): Promise<void> {
+    if (!this.terminal) {
+      return;
+    }
+
+    // Close existing connections if any
+    this.closeTerminalConnections();
+
+    try {
+      // Connect directly to the SSE endpoint for terminal 1
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
+      this.terminalEventSource = new EventSource(eventsUrl);
+
+      // Handle SSE events
+      this.terminalEventSource.onopen = () => {
+        console.log("Terminal SSE connection opened");
+        this.sendTerminalResize();
+      };
+
+      this.terminalEventSource.onmessage = (event) => {
+        if (this.terminal) {
+          // Decode base64 data before writing to terminal
+          try {
+            const decoded = atob(event.data);
+            this.terminal.write(decoded);
+          } catch (e) {
+            console.error('Error decoding terminal data:', e);
+            // Fallback to raw data if decoding fails
+            this.terminal.write(event.data);
+          }
+        }
+      };
+
+      this.terminalEventSource.onerror = (error) => {
+        console.error("Terminal SSE error:", error);
+        if (this.terminal) {
+          this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
+        }
+        // Attempt to reconnect if the connection was lost
+        if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
+          this.closeTerminalConnections();
+        }
+      };
+
+      // Send key inputs to the server via POST requests
+      if (this.terminal) {
+        this.terminal.onData((data) => {
+          this.sendTerminalInput(data);
+        });
+      }
+    } catch (error) {
+      console.error("Failed to connect to terminal:", error);
+      if (this.terminal) {
+        this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
+      }
+    }
+  }
+
+  /**
+   * Close any active terminal connections
+   */
+  private closeTerminalConnections(): void {
+    if (this.terminalEventSource) {
+      this.terminalEventSource.close();
+      this.terminalEventSource = null;
+    }
+  }
+
+  /**
+   * Send input to the terminal
+   * @param data The input data to send
+   */
+  private async sendTerminalInput(data: string): Promise<void> {
+    // Add the data to the queue
+    this.terminalInputQueue.push(data);
+
+    // If we're not already processing inputs, start processing
+    if (!this.processingTerminalInput) {
+      await this.processTerminalInputQueue();
+    }
+  }
+
+  /**
+   * Process the terminal input queue in order
+   */
+  private async processTerminalInputQueue(): Promise<void> {
+    if (this.terminalInputQueue.length === 0) {
+      this.processingTerminalInput = false;
+      return;
+    }
+
+    this.processingTerminalInput = true;
+
+    // Concatenate all available inputs from the queue into a single request
+    let combinedData = '';
+
+    // Take all currently available items from the queue
+    while (this.terminalInputQueue.length > 0) {
+      combinedData += this.terminalInputQueue.shift()!;
+    }
+
+    try {
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: combinedData,
+        headers: {
+          'Content-Type': 'text/plain'
+        }
+      });
+
+      if (!response.ok) {
+        console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal input:", error);
+    }
+
+    // Continue processing the queue (for any new items that may have been added)
+    await this.processTerminalInputQueue();
+  }
+
+  /**
+   * Send terminal resize information to the server
+   */
+  private async sendTerminalResize(): Promise<void> {
+    if (!this.terminal || !this.fitAddon) {
+      return;
+    }
+
+    // Get terminal dimensions
+    try {
+      // Send resize message in a format the server can understand
+      // Use relative URL based on current location
+      const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
+      const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
+        method: 'POST',
+        body: JSON.stringify({
+          type: "resize",
+          cols: this.terminal.cols || 80, // Default to 80 if undefined
+          rows: this.terminal.rows || 24, // Default to 24 if undefined
+        }),
+        headers: {
+          'Content-Type': 'application/json'
+        }
+      });
+
+      if (!response.ok) {
+        console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
+      }
+    } catch (error) {
+      console.error("Error sending terminal resize:", error);
+    }
+  }
+
+
+  render() {
+    return html`
+      <div id="terminalView" class="terminal-view">
+         <div id="terminalContainer" class="terminal-container"></div>
+      </div>
+    `;
+  }
+}
+
+declare global {
+  interface HTMLElementTagNameMap {
+    "sketch-terminal": SketchTerminal;
+  }
+}
\ No newline at end of file