blob: 38e742553189272a553d7f3d9667c961668cedc9 [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 */
Sean McCullough71941bd2025-04-18 13:31:48 -07005import { css, html, LitElement } from "lit";
Sean McCulloughd9f13372025-04-21 15:08:49 -07006import { customElement } from "lit/decorators.js";
Sean McCullough71941bd2025-04-18 13:31:48 -07007import "./sketch-container-status";
Sean McCullough86b56862025-04-18 13:04:03 -07008
Sean McCullough71941bd2025-04-18 13:31:48 -07009@customElement("sketch-terminal")
Sean McCullough86b56862025-04-18 13:04:03 -070010export class SketchTerminal extends LitElement {
Earl Lee2e463fb2025-04-17 11:22:22 -070011 // Terminal instance
12 private terminal: Terminal | null = null;
13 // Terminal fit addon for handling resize
14 private fitAddon: FitAddon | null = null;
Philip Zeyliger37aaf082025-05-06 03:15:55 +000015 // Flag to track if terminal has been fully initialized
16 private isInitialized: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070017 // Terminal EventSource for SSE
18 private terminalEventSource: EventSource | null = null;
19 // Terminal ID (always 1 for now, will support 1-9 later)
20 private terminalId: string = "1";
21 // Queue for serializing terminal inputs
22 private terminalInputQueue: string[] = [];
23 // Flag to track if we're currently processing a terminal input
24 private processingTerminalInput: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070025
Sean McCullough86b56862025-04-18 13:04:03 -070026 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070027 /* Terminal View Styles */
28 .terminal-view {
29 width: 100%;
30 background-color: #f5f5f5;
31 border-radius: 8px;
32 overflow: hidden;
33 margin-bottom: 20px;
34 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
35 padding: 15px;
36 height: 70vh;
37 }
Earl Lee2e463fb2025-04-17 11:22:22 -070038
Sean McCullough71941bd2025-04-18 13:31:48 -070039 .terminal-container {
40 width: 100%;
41 height: 100%;
42 overflow: hidden;
43 }
44 `;
Sean McCullough86b56862025-04-18 13:04:03 -070045
46 constructor() {
47 super();
48 this._resizeHandler = this._resizeHandler.bind(this);
49 }
50
51 connectedCallback() {
52 super.connectedCallback();
53 this.loadXtermlCSS();
54 // Setup resize handler
55 window.addEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000056 // Listen for view mode changes to detect when terminal becomes visible
57 window.addEventListener(
58 "view-mode-select",
59 this._handleViewModeSelect.bind(this),
60 );
Sean McCullough86b56862025-04-18 13:04:03 -070061 }
62
63 disconnectedCallback() {
64 super.disconnectedCallback();
65
66 window.removeEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000067 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Sean McCullough86b56862025-04-18 13:04:03 -070068
69 this.closeTerminalConnections();
70
71 if (this.terminal) {
72 this.terminal.dispose();
73 this.terminal = null;
74 }
75 this.fitAddon = null;
76 }
77
78 firstUpdated() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000079 // Do nothing - we'll initialize the terminal when it becomes visible
Sean McCullough86b56862025-04-18 13:04:03 -070080 }
81
82 _resizeHandler() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000083 // Only handle resize if terminal has been initialized
84 if (this.fitAddon && this.isInitialized) {
Sean McCullough86b56862025-04-18 13:04:03 -070085 this.fitAddon.fit();
86 // Send resize information to server
87 this.sendTerminalResize();
88 }
89 }
90
Philip Zeyliger37aaf082025-05-06 03:15:55 +000091 /**
92 * Handle view mode selection event to detect when terminal becomes visible
93 */
94 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +000095 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Philip Zeyliger37aaf082025-05-06 03:15:55 +000096 if (mode === "terminal") {
97 // Terminal tab is now visible
98 if (!this.isInitialized) {
99 // First time the terminal is shown - initialize it
100 this.isInitialized = true;
101 setTimeout(() => this.initializeTerminal(), 10);
102 } else if (this.fitAddon) {
103 // Terminal already initialized - just resize it
104 setTimeout(() => {
105 this.fitAddon?.fit();
106 this.sendTerminalResize();
107 this.terminal?.focus();
108 }, 10);
109 }
110 }
111 }
112
Sean McCullough86b56862025-04-18 13:04:03 -0700113 // Load xterm CSS into the shadow DOM
114 private async loadXtermlCSS() {
115 try {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000116 // Check if xterm styles are already loaded
Sean McCullough71941bd2025-04-18 13:31:48 -0700117 const styleId = "xterm-styles";
Sean McCullough86b56862025-04-18 13:04:03 -0700118 if (this.shadowRoot?.getElementById(styleId)) {
119 return; // Already loaded
120 }
121
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000122 // Fetch the xterm CSS
Sean McCullough71941bd2025-04-18 13:31:48 -0700123 const response = await fetch("static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -0700124
125 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700126 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700127 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700128 );
Sean McCullough86b56862025-04-18 13:04:03 -0700129 return;
130 }
131
132 const cssText = await response.text();
133
134 // Create a style element and append to shadow DOM
Sean McCullough71941bd2025-04-18 13:31:48 -0700135 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700136 style.id = styleId;
137 style.textContent = cssText;
138 this.renderRoot?.appendChild(style);
139
Sean McCullough71941bd2025-04-18 13:31:48 -0700140 console.log("xterm CSS loaded into shadow DOM");
Sean McCullough86b56862025-04-18 13:04:03 -0700141 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700142 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700143 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700144 }
145
146 /**
147 * Initialize the terminal component
148 * @param terminalContainer The DOM element to contain the terminal
149 */
150 public async initializeTerminal(): Promise<void> {
Sean McCullough71941bd2025-04-18 13:31:48 -0700151 const terminalContainer = this.renderRoot.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700152 "#terminalContainer",
Sean McCullough71941bd2025-04-18 13:31:48 -0700153 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700154
155 if (!terminalContainer) {
156 console.error("Terminal container not found");
157 return;
158 }
159
160 // If terminal is already initialized, just focus it
161 if (this.terminal) {
162 this.terminal.focus();
163 if (this.fitAddon) {
164 this.fitAddon.fit();
165 }
166 return;
167 }
168
169 // Clear the terminal container
170 terminalContainer.innerHTML = "";
171
172 // Create new terminal instance
173 this.terminal = new Terminal({
174 cursorBlink: true,
175 theme: {
176 background: "#f5f5f5",
177 foreground: "#333333",
178 cursor: "#0078d7",
179 selectionBackground: "rgba(0, 120, 215, 0.4)",
180 },
181 });
182
183 // Add fit addon to handle terminal resizing
184 this.fitAddon = new FitAddon();
185 this.terminal.loadAddon(this.fitAddon);
186
187 // Open the terminal in the container
188 this.terminal.open(terminalContainer);
189
190 // Connect to WebSocket
191 await this.connectTerminal();
192
193 // Fit the terminal to the container
194 this.fitAddon.fit();
195
Earl Lee2e463fb2025-04-17 11:22:22 -0700196 // Focus the terminal
197 this.terminal.focus();
198 }
199
200 /**
201 * Connect to terminal events stream
202 */
203 private async connectTerminal(): Promise<void> {
204 if (!this.terminal) {
205 return;
206 }
207
208 // Close existing connections if any
209 this.closeTerminalConnections();
210
211 try {
212 // Connect directly to the SSE endpoint for terminal 1
213 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700214 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
216 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700217
Earl Lee2e463fb2025-04-17 11:22:22 -0700218 // Handle SSE events
219 this.terminalEventSource.onopen = () => {
220 console.log("Terminal SSE connection opened");
221 this.sendTerminalResize();
222 };
Sean McCullough86b56862025-04-18 13:04:03 -0700223
Earl Lee2e463fb2025-04-17 11:22:22 -0700224 this.terminalEventSource.onmessage = (event) => {
225 if (this.terminal) {
226 // Decode base64 data before writing to terminal
227 try {
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700228 // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
229 const decoded = base64ToUint8Array(event.data);
Earl Lee2e463fb2025-04-17 11:22:22 -0700230 this.terminal.write(decoded);
231 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700232 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700233 }
234 }
235 };
Sean McCullough86b56862025-04-18 13:04:03 -0700236
Earl Lee2e463fb2025-04-17 11:22:22 -0700237 this.terminalEventSource.onerror = (error) => {
238 console.error("Terminal SSE error:", error);
239 if (this.terminal) {
240 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
241 }
242 // Attempt to reconnect if the connection was lost
243 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
244 this.closeTerminalConnections();
245 }
246 };
Sean McCullough86b56862025-04-18 13:04:03 -0700247
Earl Lee2e463fb2025-04-17 11:22:22 -0700248 // Send key inputs to the server via POST requests
249 if (this.terminal) {
250 this.terminal.onData((data) => {
251 this.sendTerminalInput(data);
252 });
253 }
254 } catch (error) {
255 console.error("Failed to connect to terminal:", error);
256 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700257 this.terminal.write(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700258 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700259 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700260 }
261 }
262 }
263
264 /**
265 * Close any active terminal connections
266 */
267 private closeTerminalConnections(): void {
268 if (this.terminalEventSource) {
269 this.terminalEventSource.close();
270 this.terminalEventSource = null;
271 }
272 }
273
274 /**
275 * Send input to the terminal
276 * @param data The input data to send
277 */
278 private async sendTerminalInput(data: string): Promise<void> {
279 // Add the data to the queue
280 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700281
Earl Lee2e463fb2025-04-17 11:22:22 -0700282 // If we're not already processing inputs, start processing
283 if (!this.processingTerminalInput) {
284 await this.processTerminalInputQueue();
285 }
286 }
287
288 /**
289 * Process the terminal input queue in order
290 */
291 private async processTerminalInputQueue(): Promise<void> {
292 if (this.terminalInputQueue.length === 0) {
293 this.processingTerminalInput = false;
294 return;
295 }
Sean McCullough86b56862025-04-18 13:04:03 -0700296
Earl Lee2e463fb2025-04-17 11:22:22 -0700297 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700298
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700300 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700301
Earl Lee2e463fb2025-04-17 11:22:22 -0700302 // Take all currently available items from the queue
303 while (this.terminalInputQueue.length > 0) {
304 combinedData += this.terminalInputQueue.shift()!;
305 }
Sean McCullough86b56862025-04-18 13:04:03 -0700306
Earl Lee2e463fb2025-04-17 11:22:22 -0700307 try {
308 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700309 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
310 const response = await fetch(
311 `${baseUrl}/terminal/input/${this.terminalId}`,
312 {
313 method: "POST",
314 body: combinedData,
315 headers: {
316 "Content-Type": "text/plain",
317 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700318 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700319 );
Sean McCullough86b56862025-04-18 13:04:03 -0700320
Earl Lee2e463fb2025-04-17 11:22:22 -0700321 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700322 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700323 `Failed to send terminal input: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700324 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700325 }
326 } catch (error) {
327 console.error("Error sending terminal input:", error);
328 }
Sean McCullough86b56862025-04-18 13:04:03 -0700329
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 // Continue processing the queue (for any new items that may have been added)
331 await this.processTerminalInputQueue();
332 }
333
334 /**
335 * Send terminal resize information to the server
336 */
337 private async sendTerminalResize(): Promise<void> {
338 if (!this.terminal || !this.fitAddon) {
339 return;
340 }
341
342 // Get terminal dimensions
343 try {
344 // Send resize message in a format the server can understand
345 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700346 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
347 const response = await fetch(
348 `${baseUrl}/terminal/input/${this.terminalId}`,
349 {
350 method: "POST",
351 body: JSON.stringify({
352 type: "resize",
353 cols: this.terminal.cols || 80, // Default to 80 if undefined
354 rows: this.terminal.rows || 24, // Default to 24 if undefined
355 }),
356 headers: {
357 "Content-Type": "application/json",
358 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700359 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700360 );
Sean McCullough86b56862025-04-18 13:04:03 -0700361
Earl Lee2e463fb2025-04-17 11:22:22 -0700362 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700363 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700364 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700365 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700366 }
367 } catch (error) {
368 console.error("Error sending terminal resize:", error);
369 }
370 }
371
Sean McCullough86b56862025-04-18 13:04:03 -0700372 render() {
373 return html`
374 <div id="terminalView" class="terminal-view">
Sean McCullough71941bd2025-04-18 13:31:48 -0700375 <div id="terminalContainer" class="terminal-container"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700376 </div>
377 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700378 }
379}
Sean McCullough86b56862025-04-18 13:04:03 -0700380
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700381function base64ToUint8Array(base64String) {
382 // This isn't yet available in Chrome, but Safari has it!
383 // @ts-ignore
384 if (Uint8Array.fromBase64) {
385 // @ts-ignore
386 return Uint8Array.fromBase64(base64String);
387 }
388
389 const binaryString = atob(base64String);
390 return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
391}
392
Sean McCullough86b56862025-04-18 13:04:03 -0700393declare global {
394 interface HTMLElementTagNameMap {
395 "sketch-terminal": SketchTerminal;
396 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700397}