blob: e75d8a71af223a452bb6a1b538f3dd487c2d5297 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { Terminal } from "@xterm/xterm";
2import { FitAddon } from "@xterm/addon-fit";
3
philip.zeyliger26bc6592025-06-30 20:15:30 -07004/* eslint-disable @typescript-eslint/ban-ts-comment */
banksean4844be22025-07-03 00:32:55 +00005import { html } from "lit";
Sean McCulloughd9f13372025-04-21 15:08:49 -07006import { customElement } from "lit/decorators.js";
banksean4844be22025-07-03 00:32:55 +00007import { SketchTailwindElement } from "./sketch-tailwind-element";
Sean McCullough71941bd2025-04-18 13:31:48 -07008import "./sketch-container-status";
Sean McCullough86b56862025-04-18 13:04:03 -07009
Sean McCullough71941bd2025-04-18 13:31:48 -070010@customElement("sketch-terminal")
banksean4844be22025-07-03 00:32:55 +000011export class SketchTerminal extends SketchTailwindElement {
Earl Lee2e463fb2025-04-17 11:22:22 -070012 // Terminal instance
13 private terminal: Terminal | null = null;
14 // Terminal fit addon for handling resize
15 private fitAddon: FitAddon | null = null;
Philip Zeyliger37aaf082025-05-06 03:15:55 +000016 // Flag to track if terminal has been fully initialized
17 private isInitialized: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070018 // Terminal EventSource for SSE
19 private terminalEventSource: EventSource | null = null;
20 // Terminal ID (always 1 for now, will support 1-9 later)
21 private terminalId: string = "1";
22 // Queue for serializing terminal inputs
23 private terminalInputQueue: string[] = [];
24 // Flag to track if we're currently processing a terminal input
25 private processingTerminalInput: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070026
Earl Lee2e463fb2025-04-17 11:22:22 -070027
Sean McCullough86b56862025-04-18 13:04:03 -070028
29 constructor() {
30 super();
31 this._resizeHandler = this._resizeHandler.bind(this);
32 }
33
34 connectedCallback() {
35 super.connectedCallback();
banksean4844be22025-07-03 00:32:55 +000036 this.loadXtermCSS();
Sean McCullough86b56862025-04-18 13:04:03 -070037 // Setup resize handler
38 window.addEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000039 // Listen for view mode changes to detect when terminal becomes visible
40 window.addEventListener(
41 "view-mode-select",
42 this._handleViewModeSelect.bind(this),
43 );
Sean McCullough86b56862025-04-18 13:04:03 -070044 }
45
46 disconnectedCallback() {
47 super.disconnectedCallback();
48
49 window.removeEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000050 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Sean McCullough86b56862025-04-18 13:04:03 -070051
52 this.closeTerminalConnections();
53
54 if (this.terminal) {
55 this.terminal.dispose();
56 this.terminal = null;
57 }
58 this.fitAddon = null;
59 }
60
61 firstUpdated() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000062 // Do nothing - we'll initialize the terminal when it becomes visible
Sean McCullough86b56862025-04-18 13:04:03 -070063 }
64
65 _resizeHandler() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000066 // Only handle resize if terminal has been initialized
67 if (this.fitAddon && this.isInitialized) {
Sean McCullough86b56862025-04-18 13:04:03 -070068 this.fitAddon.fit();
69 // Send resize information to server
70 this.sendTerminalResize();
71 }
72 }
73
Philip Zeyliger37aaf082025-05-06 03:15:55 +000074 /**
75 * Handle view mode selection event to detect when terminal becomes visible
76 */
77 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +000078 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Philip Zeyliger37aaf082025-05-06 03:15:55 +000079 if (mode === "terminal") {
80 // Terminal tab is now visible
81 if (!this.isInitialized) {
82 // First time the terminal is shown - initialize it
83 this.isInitialized = true;
84 setTimeout(() => this.initializeTerminal(), 10);
85 } else if (this.fitAddon) {
86 // Terminal already initialized - just resize it
87 setTimeout(() => {
88 this.fitAddon?.fit();
89 this.sendTerminalResize();
90 this.terminal?.focus();
91 }, 10);
92 }
93 }
94 }
95
banksean4844be22025-07-03 00:32:55 +000096 // Load xterm CSS globally since we're using light DOM
97 private async loadXtermCSS() {
Sean McCullough86b56862025-04-18 13:04:03 -070098 try {
banksean4844be22025-07-03 00:32:55 +000099 // Check if xterm styles are already loaded globally
Sean McCullough71941bd2025-04-18 13:31:48 -0700100 const styleId = "xterm-styles";
banksean4844be22025-07-03 00:32:55 +0000101 if (document.getElementById(styleId)) {
Sean McCullough86b56862025-04-18 13:04:03 -0700102 return; // Already loaded
103 }
104
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000105 // Fetch the xterm CSS
Sean McCullough71941bd2025-04-18 13:31:48 -0700106 const response = await fetch("static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -0700107
108 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700109 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700110 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700111 );
Sean McCullough86b56862025-04-18 13:04:03 -0700112 return;
113 }
114
115 const cssText = await response.text();
116
banksean4844be22025-07-03 00:32:55 +0000117 // Create a style element and append to document head
Sean McCullough71941bd2025-04-18 13:31:48 -0700118 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700119 style.id = styleId;
120 style.textContent = cssText;
banksean4844be22025-07-03 00:32:55 +0000121 document.head.appendChild(style);
Sean McCullough86b56862025-04-18 13:04:03 -0700122
banksean4844be22025-07-03 00:32:55 +0000123 console.log("xterm CSS loaded globally");
Sean McCullough86b56862025-04-18 13:04:03 -0700124 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700125 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700126 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700127 }
128
129 /**
130 * Initialize the terminal component
131 * @param terminalContainer The DOM element to contain the terminal
132 */
133 public async initializeTerminal(): Promise<void> {
banksean4844be22025-07-03 00:32:55 +0000134 const terminalContainer = this.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700135 "#terminalContainer",
Sean McCullough71941bd2025-04-18 13:31:48 -0700136 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700137
138 if (!terminalContainer) {
139 console.error("Terminal container not found");
140 return;
141 }
142
143 // If terminal is already initialized, just focus it
144 if (this.terminal) {
145 this.terminal.focus();
146 if (this.fitAddon) {
147 this.fitAddon.fit();
148 }
149 return;
150 }
151
152 // Clear the terminal container
153 terminalContainer.innerHTML = "";
154
155 // Create new terminal instance
156 this.terminal = new Terminal({
157 cursorBlink: true,
158 theme: {
159 background: "#f5f5f5",
160 foreground: "#333333",
161 cursor: "#0078d7",
162 selectionBackground: "rgba(0, 120, 215, 0.4)",
163 },
164 });
165
166 // Add fit addon to handle terminal resizing
167 this.fitAddon = new FitAddon();
168 this.terminal.loadAddon(this.fitAddon);
169
170 // Open the terminal in the container
171 this.terminal.open(terminalContainer);
172
173 // Connect to WebSocket
174 await this.connectTerminal();
175
176 // Fit the terminal to the container
177 this.fitAddon.fit();
178
Earl Lee2e463fb2025-04-17 11:22:22 -0700179 // Focus the terminal
180 this.terminal.focus();
181 }
182
183 /**
184 * Connect to terminal events stream
185 */
186 private async connectTerminal(): Promise<void> {
187 if (!this.terminal) {
188 return;
189 }
190
191 // Close existing connections if any
192 this.closeTerminalConnections();
193
194 try {
195 // Connect directly to the SSE endpoint for terminal 1
196 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700197 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700198 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
199 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700200
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 // Handle SSE events
202 this.terminalEventSource.onopen = () => {
203 console.log("Terminal SSE connection opened");
204 this.sendTerminalResize();
205 };
Sean McCullough86b56862025-04-18 13:04:03 -0700206
Earl Lee2e463fb2025-04-17 11:22:22 -0700207 this.terminalEventSource.onmessage = (event) => {
208 if (this.terminal) {
209 // Decode base64 data before writing to terminal
210 try {
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700211 // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
212 const decoded = base64ToUint8Array(event.data);
Earl Lee2e463fb2025-04-17 11:22:22 -0700213 this.terminal.write(decoded);
214 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700215 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 }
217 }
218 };
Sean McCullough86b56862025-04-18 13:04:03 -0700219
Earl Lee2e463fb2025-04-17 11:22:22 -0700220 this.terminalEventSource.onerror = (error) => {
221 console.error("Terminal SSE error:", error);
222 if (this.terminal) {
223 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
224 }
225 // Attempt to reconnect if the connection was lost
226 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
227 this.closeTerminalConnections();
228 }
229 };
Sean McCullough86b56862025-04-18 13:04:03 -0700230
Earl Lee2e463fb2025-04-17 11:22:22 -0700231 // Send key inputs to the server via POST requests
232 if (this.terminal) {
233 this.terminal.onData((data) => {
234 this.sendTerminalInput(data);
235 });
236 }
237 } catch (error) {
238 console.error("Failed to connect to terminal:", error);
239 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700240 this.terminal.write(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700241 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700242 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 }
244 }
245 }
246
247 /**
248 * Close any active terminal connections
249 */
250 private closeTerminalConnections(): void {
251 if (this.terminalEventSource) {
252 this.terminalEventSource.close();
253 this.terminalEventSource = null;
254 }
255 }
256
257 /**
258 * Send input to the terminal
259 * @param data The input data to send
260 */
261 private async sendTerminalInput(data: string): Promise<void> {
262 // Add the data to the queue
263 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700264
Earl Lee2e463fb2025-04-17 11:22:22 -0700265 // If we're not already processing inputs, start processing
266 if (!this.processingTerminalInput) {
267 await this.processTerminalInputQueue();
268 }
269 }
270
271 /**
272 * Process the terminal input queue in order
273 */
274 private async processTerminalInputQueue(): Promise<void> {
275 if (this.terminalInputQueue.length === 0) {
276 this.processingTerminalInput = false;
277 return;
278 }
Sean McCullough86b56862025-04-18 13:04:03 -0700279
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700281
Earl Lee2e463fb2025-04-17 11:22:22 -0700282 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700283 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700284
Earl Lee2e463fb2025-04-17 11:22:22 -0700285 // Take all currently available items from the queue
286 while (this.terminalInputQueue.length > 0) {
287 combinedData += this.terminalInputQueue.shift()!;
288 }
Sean McCullough86b56862025-04-18 13:04:03 -0700289
Earl Lee2e463fb2025-04-17 11:22:22 -0700290 try {
291 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700292 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
293 const response = await fetch(
294 `${baseUrl}/terminal/input/${this.terminalId}`,
295 {
296 method: "POST",
297 body: combinedData,
298 headers: {
299 "Content-Type": "text/plain",
300 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700301 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700302 );
Sean McCullough86b56862025-04-18 13:04:03 -0700303
Earl Lee2e463fb2025-04-17 11:22:22 -0700304 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700305 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700306 `Failed to send terminal input: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700307 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700308 }
309 } catch (error) {
310 console.error("Error sending terminal input:", error);
311 }
Sean McCullough86b56862025-04-18 13:04:03 -0700312
Earl Lee2e463fb2025-04-17 11:22:22 -0700313 // Continue processing the queue (for any new items that may have been added)
314 await this.processTerminalInputQueue();
315 }
316
317 /**
318 * Send terminal resize information to the server
319 */
320 private async sendTerminalResize(): Promise<void> {
321 if (!this.terminal || !this.fitAddon) {
322 return;
323 }
324
325 // Get terminal dimensions
326 try {
327 // Send resize message in a format the server can understand
328 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700329 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
330 const response = await fetch(
331 `${baseUrl}/terminal/input/${this.terminalId}`,
332 {
333 method: "POST",
334 body: JSON.stringify({
335 type: "resize",
336 cols: this.terminal.cols || 80, // Default to 80 if undefined
337 rows: this.terminal.rows || 24, // Default to 24 if undefined
338 }),
339 headers: {
340 "Content-Type": "application/json",
341 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700342 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700343 );
Sean McCullough86b56862025-04-18 13:04:03 -0700344
Earl Lee2e463fb2025-04-17 11:22:22 -0700345 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700346 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700347 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700348 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700349 }
350 } catch (error) {
351 console.error("Error sending terminal resize:", error);
352 }
353 }
354
Sean McCullough86b56862025-04-18 13:04:03 -0700355 render() {
356 return html`
banksean4844be22025-07-03 00:32:55 +0000357 <div id="terminalView" class="w-full bg-gray-100 rounded-lg overflow-hidden mb-5 shadow-md p-4" style="height: 70vh;">
358 <div id="terminalContainer" class="w-full h-full overflow-hidden"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700359 </div>
360 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 }
362}
Sean McCullough86b56862025-04-18 13:04:03 -0700363
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700364function base64ToUint8Array(base64String) {
365 // This isn't yet available in Chrome, but Safari has it!
366 // @ts-ignore
367 if (Uint8Array.fromBase64) {
368 // @ts-ignore
369 return Uint8Array.fromBase64(base64String);
370 }
371
372 const binaryString = atob(base64String);
373 return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
374}
375
Sean McCullough86b56862025-04-18 13:04:03 -0700376declare global {
377 interface HTMLElementTagNameMap {
378 "sketch-terminal": SketchTerminal;
379 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700380}