blob: 9f499e00b0c142f6ab6553d069ae906919f7057c [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
Sean McCullough86b56862025-04-18 13:04:03 -070027 constructor() {
28 super();
29 this._resizeHandler = this._resizeHandler.bind(this);
30 }
31
32 connectedCallback() {
33 super.connectedCallback();
banksean4844be22025-07-03 00:32:55 +000034 this.loadXtermCSS();
Sean McCullough86b56862025-04-18 13:04:03 -070035 // Setup resize handler
36 window.addEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000037 // Listen for view mode changes to detect when terminal becomes visible
38 window.addEventListener(
39 "view-mode-select",
40 this._handleViewModeSelect.bind(this),
41 );
Sean McCullough86b56862025-04-18 13:04:03 -070042 }
43
44 disconnectedCallback() {
45 super.disconnectedCallback();
46
47 window.removeEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000048 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Sean McCullough86b56862025-04-18 13:04:03 -070049
50 this.closeTerminalConnections();
51
52 if (this.terminal) {
53 this.terminal.dispose();
54 this.terminal = null;
55 }
56 this.fitAddon = null;
57 }
58
59 firstUpdated() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000060 // Do nothing - we'll initialize the terminal when it becomes visible
Sean McCullough86b56862025-04-18 13:04:03 -070061 }
62
63 _resizeHandler() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000064 // Only handle resize if terminal has been initialized
65 if (this.fitAddon && this.isInitialized) {
Sean McCullough86b56862025-04-18 13:04:03 -070066 this.fitAddon.fit();
67 // Send resize information to server
68 this.sendTerminalResize();
69 }
70 }
71
Philip Zeyliger37aaf082025-05-06 03:15:55 +000072 /**
73 * Handle view mode selection event to detect when terminal becomes visible
74 */
75 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +000076 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Philip Zeyliger37aaf082025-05-06 03:15:55 +000077 if (mode === "terminal") {
78 // Terminal tab is now visible
79 if (!this.isInitialized) {
80 // First time the terminal is shown - initialize it
81 this.isInitialized = true;
82 setTimeout(() => this.initializeTerminal(), 10);
83 } else if (this.fitAddon) {
84 // Terminal already initialized - just resize it
85 setTimeout(() => {
86 this.fitAddon?.fit();
87 this.sendTerminalResize();
88 this.terminal?.focus();
89 }, 10);
90 }
91 }
92 }
93
banksean4844be22025-07-03 00:32:55 +000094 // Load xterm CSS globally since we're using light DOM
95 private async loadXtermCSS() {
Sean McCullough86b56862025-04-18 13:04:03 -070096 try {
banksean4844be22025-07-03 00:32:55 +000097 // Check if xterm styles are already loaded globally
Sean McCullough71941bd2025-04-18 13:31:48 -070098 const styleId = "xterm-styles";
banksean4844be22025-07-03 00:32:55 +000099 if (document.getElementById(styleId)) {
Sean McCullough86b56862025-04-18 13:04:03 -0700100 return; // Already loaded
101 }
102
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000103 // Fetch the xterm CSS
Philip Zeyliger5681b7c2025-07-18 10:42:47 -0700104 const response = await fetch("./static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -0700105
106 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700107 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700108 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700109 );
Sean McCullough86b56862025-04-18 13:04:03 -0700110 return;
111 }
112
113 const cssText = await response.text();
114
banksean4844be22025-07-03 00:32:55 +0000115 // Create a style element and append to document head
Sean McCullough71941bd2025-04-18 13:31:48 -0700116 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700117 style.id = styleId;
118 style.textContent = cssText;
banksean4844be22025-07-03 00:32:55 +0000119 document.head.appendChild(style);
Sean McCullough86b56862025-04-18 13:04:03 -0700120
banksean4844be22025-07-03 00:32:55 +0000121 console.log("xterm CSS loaded globally");
Sean McCullough86b56862025-04-18 13:04:03 -0700122 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700123 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700124 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700125 }
126
127 /**
128 * Initialize the terminal component
129 * @param terminalContainer The DOM element to contain the terminal
130 */
131 public async initializeTerminal(): Promise<void> {
banksean4844be22025-07-03 00:32:55 +0000132 const terminalContainer = this.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700133 "#terminalContainer",
Sean McCullough71941bd2025-04-18 13:31:48 -0700134 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700135
136 if (!terminalContainer) {
137 console.error("Terminal container not found");
138 return;
139 }
140
141 // If terminal is already initialized, just focus it
142 if (this.terminal) {
143 this.terminal.focus();
144 if (this.fitAddon) {
145 this.fitAddon.fit();
146 }
147 return;
148 }
149
150 // Clear the terminal container
151 terminalContainer.innerHTML = "";
152
153 // Create new terminal instance
154 this.terminal = new Terminal({
155 cursorBlink: true,
156 theme: {
157 background: "#f5f5f5",
158 foreground: "#333333",
159 cursor: "#0078d7",
160 selectionBackground: "rgba(0, 120, 215, 0.4)",
161 },
162 });
163
164 // Add fit addon to handle terminal resizing
165 this.fitAddon = new FitAddon();
166 this.terminal.loadAddon(this.fitAddon);
167
168 // Open the terminal in the container
169 this.terminal.open(terminalContainer);
170
171 // Connect to WebSocket
172 await this.connectTerminal();
173
174 // Fit the terminal to the container
175 this.fitAddon.fit();
176
Earl Lee2e463fb2025-04-17 11:22:22 -0700177 // Focus the terminal
178 this.terminal.focus();
179 }
180
181 /**
182 * Connect to terminal events stream
183 */
184 private async connectTerminal(): Promise<void> {
185 if (!this.terminal) {
186 return;
187 }
188
189 // Close existing connections if any
190 this.closeTerminalConnections();
191
192 try {
193 // Connect directly to the SSE endpoint for terminal 1
194 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700195 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700196 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
197 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700198
Earl Lee2e463fb2025-04-17 11:22:22 -0700199 // Handle SSE events
200 this.terminalEventSource.onopen = () => {
201 console.log("Terminal SSE connection opened");
202 this.sendTerminalResize();
203 };
Sean McCullough86b56862025-04-18 13:04:03 -0700204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 this.terminalEventSource.onmessage = (event) => {
206 if (this.terminal) {
207 // Decode base64 data before writing to terminal
208 try {
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700209 // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
210 const decoded = base64ToUint8Array(event.data);
Earl Lee2e463fb2025-04-17 11:22:22 -0700211 this.terminal.write(decoded);
212 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700213 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 }
215 }
216 };
Sean McCullough86b56862025-04-18 13:04:03 -0700217
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 this.terminalEventSource.onerror = (error) => {
219 console.error("Terminal SSE error:", error);
220 if (this.terminal) {
221 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
222 }
223 // Attempt to reconnect if the connection was lost
224 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
225 this.closeTerminalConnections();
226 }
227 };
Sean McCullough86b56862025-04-18 13:04:03 -0700228
Earl Lee2e463fb2025-04-17 11:22:22 -0700229 // Send key inputs to the server via POST requests
230 if (this.terminal) {
231 this.terminal.onData((data) => {
232 this.sendTerminalInput(data);
233 });
234 }
235 } catch (error) {
236 console.error("Failed to connect to terminal:", error);
237 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700238 this.terminal.write(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700239 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700240 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700241 }
242 }
243 }
244
245 /**
246 * Close any active terminal connections
247 */
248 private closeTerminalConnections(): void {
249 if (this.terminalEventSource) {
250 this.terminalEventSource.close();
251 this.terminalEventSource = null;
252 }
253 }
254
255 /**
256 * Send input to the terminal
257 * @param data The input data to send
258 */
259 private async sendTerminalInput(data: string): Promise<void> {
260 // Add the data to the queue
261 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700262
Earl Lee2e463fb2025-04-17 11:22:22 -0700263 // If we're not already processing inputs, start processing
264 if (!this.processingTerminalInput) {
265 await this.processTerminalInputQueue();
266 }
267 }
268
269 /**
270 * Process the terminal input queue in order
271 */
272 private async processTerminalInputQueue(): Promise<void> {
273 if (this.terminalInputQueue.length === 0) {
274 this.processingTerminalInput = false;
275 return;
276 }
Sean McCullough86b56862025-04-18 13:04:03 -0700277
Earl Lee2e463fb2025-04-17 11:22:22 -0700278 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700279
Earl Lee2e463fb2025-04-17 11:22:22 -0700280 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700281 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700282
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 // Take all currently available items from the queue
284 while (this.terminalInputQueue.length > 0) {
285 combinedData += this.terminalInputQueue.shift()!;
286 }
Sean McCullough86b56862025-04-18 13:04:03 -0700287
Earl Lee2e463fb2025-04-17 11:22:22 -0700288 try {
289 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700290 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
291 const response = await fetch(
292 `${baseUrl}/terminal/input/${this.terminalId}`,
293 {
294 method: "POST",
295 body: combinedData,
296 headers: {
297 "Content-Type": "text/plain",
298 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700299 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700300 );
Sean McCullough86b56862025-04-18 13:04:03 -0700301
Earl Lee2e463fb2025-04-17 11:22:22 -0700302 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700303 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700304 `Failed to send terminal input: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700305 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700306 }
307 } catch (error) {
308 console.error("Error sending terminal input:", error);
309 }
Sean McCullough86b56862025-04-18 13:04:03 -0700310
Earl Lee2e463fb2025-04-17 11:22:22 -0700311 // Continue processing the queue (for any new items that may have been added)
312 await this.processTerminalInputQueue();
313 }
314
315 /**
316 * Send terminal resize information to the server
317 */
318 private async sendTerminalResize(): Promise<void> {
319 if (!this.terminal || !this.fitAddon) {
320 return;
321 }
322
323 // Get terminal dimensions
324 try {
325 // Send resize message in a format the server can understand
326 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700327 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
328 const response = await fetch(
329 `${baseUrl}/terminal/input/${this.terminalId}`,
330 {
331 method: "POST",
332 body: JSON.stringify({
333 type: "resize",
334 cols: this.terminal.cols || 80, // Default to 80 if undefined
335 rows: this.terminal.rows || 24, // Default to 24 if undefined
336 }),
337 headers: {
338 "Content-Type": "application/json",
339 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700340 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700341 );
Sean McCullough86b56862025-04-18 13:04:03 -0700342
Earl Lee2e463fb2025-04-17 11:22:22 -0700343 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700344 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700345 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700346 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700347 }
348 } catch (error) {
349 console.error("Error sending terminal resize:", error);
350 }
351 }
352
Sean McCullough86b56862025-04-18 13:04:03 -0700353 render() {
354 return html`
Autoformatter32969cd2025-07-03 03:25:25 +0000355 <div
356 id="terminalView"
357 class="w-full bg-gray-100 rounded-lg overflow-hidden mb-5 shadow-md p-4"
358 style="height: 70vh;"
359 >
banksean4844be22025-07-03 00:32:55 +0000360 <div id="terminalContainer" class="w-full h-full overflow-hidden"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700361 </div>
362 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700363 }
364}
Sean McCullough86b56862025-04-18 13:04:03 -0700365
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700366function base64ToUint8Array(base64String) {
367 // This isn't yet available in Chrome, but Safari has it!
368 // @ts-ignore
369 if (Uint8Array.fromBase64) {
370 // @ts-ignore
371 return Uint8Array.fromBase64(base64String);
372 }
373
374 const binaryString = atob(base64String);
375 return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
376}
377
Sean McCullough86b56862025-04-18 13:04:03 -0700378declare global {
379 interface HTMLElementTagNameMap {
380 "sketch-terminal": SketchTerminal;
381 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700382}