blob: 0c3b9a7415d1f9efbdc962642b26ca581b4b2003 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { Terminal } from "@xterm/xterm";
2import { FitAddon } from "@xterm/addon-fit";
3
Sean McCullough71941bd2025-04-18 13:31:48 -07004import { css, html, LitElement } from "lit";
Sean McCulloughd9f13372025-04-21 15:08:49 -07005import { customElement } from "lit/decorators.js";
Sean McCullough71941bd2025-04-18 13:31:48 -07006import "./sketch-container-status";
Sean McCullough86b56862025-04-18 13:04:03 -07007
Sean McCullough71941bd2025-04-18 13:31:48 -07008@customElement("sketch-terminal")
Sean McCullough86b56862025-04-18 13:04:03 -07009export class SketchTerminal extends LitElement {
Earl Lee2e463fb2025-04-17 11:22:22 -070010 // Terminal instance
11 private terminal: Terminal | null = null;
12 // Terminal fit addon for handling resize
13 private fitAddon: FitAddon | null = null;
Philip Zeyliger37aaf082025-05-06 03:15:55 +000014 // Flag to track if terminal has been fully initialized
15 private isInitialized: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070016 // Terminal EventSource for SSE
17 private terminalEventSource: EventSource | null = null;
18 // Terminal ID (always 1 for now, will support 1-9 later)
19 private terminalId: string = "1";
20 // Queue for serializing terminal inputs
21 private terminalInputQueue: string[] = [];
22 // Flag to track if we're currently processing a terminal input
23 private processingTerminalInput: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070024
Sean McCullough86b56862025-04-18 13:04:03 -070025 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070026 /* Terminal View Styles */
27 .terminal-view {
28 width: 100%;
29 background-color: #f5f5f5;
30 border-radius: 8px;
31 overflow: hidden;
32 margin-bottom: 20px;
33 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
34 padding: 15px;
35 height: 70vh;
36 }
Earl Lee2e463fb2025-04-17 11:22:22 -070037
Sean McCullough71941bd2025-04-18 13:31:48 -070038 .terminal-container {
39 width: 100%;
40 height: 100%;
41 overflow: hidden;
42 }
43 `;
Sean McCullough86b56862025-04-18 13:04:03 -070044
45 constructor() {
46 super();
47 this._resizeHandler = this._resizeHandler.bind(this);
48 }
49
50 connectedCallback() {
51 super.connectedCallback();
52 this.loadXtermlCSS();
53 // Setup resize handler
54 window.addEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000055 // Listen for view mode changes to detect when terminal becomes visible
56 window.addEventListener(
57 "view-mode-select",
58 this._handleViewModeSelect.bind(this),
59 );
Sean McCullough86b56862025-04-18 13:04:03 -070060 }
61
62 disconnectedCallback() {
63 super.disconnectedCallback();
64
65 window.removeEventListener("resize", this._resizeHandler);
Philip Zeyliger37aaf082025-05-06 03:15:55 +000066 window.removeEventListener("view-mode-select", this._handleViewModeSelect);
Sean McCullough86b56862025-04-18 13:04:03 -070067
68 this.closeTerminalConnections();
69
70 if (this.terminal) {
71 this.terminal.dispose();
72 this.terminal = null;
73 }
74 this.fitAddon = null;
75 }
76
77 firstUpdated() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000078 // Do nothing - we'll initialize the terminal when it becomes visible
Sean McCullough86b56862025-04-18 13:04:03 -070079 }
80
81 _resizeHandler() {
Philip Zeyliger37aaf082025-05-06 03:15:55 +000082 // Only handle resize if terminal has been initialized
83 if (this.fitAddon && this.isInitialized) {
Sean McCullough86b56862025-04-18 13:04:03 -070084 this.fitAddon.fit();
85 // Send resize information to server
86 this.sendTerminalResize();
87 }
88 }
89
Philip Zeyliger37aaf082025-05-06 03:15:55 +000090 /**
91 * Handle view mode selection event to detect when terminal becomes visible
92 */
93 private _handleViewModeSelect(event: CustomEvent) {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +000094 const mode = event.detail.mode as "chat" | "diff2" | "terminal";
Philip Zeyliger37aaf082025-05-06 03:15:55 +000095 if (mode === "terminal") {
96 // Terminal tab is now visible
97 if (!this.isInitialized) {
98 // First time the terminal is shown - initialize it
99 this.isInitialized = true;
100 setTimeout(() => this.initializeTerminal(), 10);
101 } else if (this.fitAddon) {
102 // Terminal already initialized - just resize it
103 setTimeout(() => {
104 this.fitAddon?.fit();
105 this.sendTerminalResize();
106 this.terminal?.focus();
107 }, 10);
108 }
109 }
110 }
111
Sean McCullough86b56862025-04-18 13:04:03 -0700112 // Load xterm CSS into the shadow DOM
113 private async loadXtermlCSS() {
114 try {
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000115 // Check if xterm styles are already loaded
Sean McCullough71941bd2025-04-18 13:31:48 -0700116 const styleId = "xterm-styles";
Sean McCullough86b56862025-04-18 13:04:03 -0700117 if (this.shadowRoot?.getElementById(styleId)) {
118 return; // Already loaded
119 }
120
Philip Zeyliger00bcaef2025-05-30 04:21:15 +0000121 // Fetch the xterm CSS
Sean McCullough71941bd2025-04-18 13:31:48 -0700122 const response = await fetch("static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -0700123
124 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700125 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700126 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700127 );
Sean McCullough86b56862025-04-18 13:04:03 -0700128 return;
129 }
130
131 const cssText = await response.text();
132
133 // Create a style element and append to shadow DOM
Sean McCullough71941bd2025-04-18 13:31:48 -0700134 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700135 style.id = styleId;
136 style.textContent = cssText;
137 this.renderRoot?.appendChild(style);
138
Sean McCullough71941bd2025-04-18 13:31:48 -0700139 console.log("xterm CSS loaded into shadow DOM");
Sean McCullough86b56862025-04-18 13:04:03 -0700140 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700141 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700142 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700143 }
144
145 /**
146 * Initialize the terminal component
147 * @param terminalContainer The DOM element to contain the terminal
148 */
149 public async initializeTerminal(): Promise<void> {
Sean McCullough71941bd2025-04-18 13:31:48 -0700150 const terminalContainer = this.renderRoot.querySelector(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700151 "#terminalContainer",
Sean McCullough71941bd2025-04-18 13:31:48 -0700152 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700153
154 if (!terminalContainer) {
155 console.error("Terminal container not found");
156 return;
157 }
158
159 // If terminal is already initialized, just focus it
160 if (this.terminal) {
161 this.terminal.focus();
162 if (this.fitAddon) {
163 this.fitAddon.fit();
164 }
165 return;
166 }
167
168 // Clear the terminal container
169 terminalContainer.innerHTML = "";
170
171 // Create new terminal instance
172 this.terminal = new Terminal({
173 cursorBlink: true,
174 theme: {
175 background: "#f5f5f5",
176 foreground: "#333333",
177 cursor: "#0078d7",
178 selectionBackground: "rgba(0, 120, 215, 0.4)",
179 },
180 });
181
182 // Add fit addon to handle terminal resizing
183 this.fitAddon = new FitAddon();
184 this.terminal.loadAddon(this.fitAddon);
185
186 // Open the terminal in the container
187 this.terminal.open(terminalContainer);
188
189 // Connect to WebSocket
190 await this.connectTerminal();
191
192 // Fit the terminal to the container
193 this.fitAddon.fit();
194
Earl Lee2e463fb2025-04-17 11:22:22 -0700195 // Focus the terminal
196 this.terminal.focus();
197 }
198
199 /**
200 * Connect to terminal events stream
201 */
202 private async connectTerminal(): Promise<void> {
203 if (!this.terminal) {
204 return;
205 }
206
207 // Close existing connections if any
208 this.closeTerminalConnections();
209
210 try {
211 // Connect directly to the SSE endpoint for terminal 1
212 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700213 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700214 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
215 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 // Handle SSE events
218 this.terminalEventSource.onopen = () => {
219 console.log("Terminal SSE connection opened");
220 this.sendTerminalResize();
221 };
Sean McCullough86b56862025-04-18 13:04:03 -0700222
Earl Lee2e463fb2025-04-17 11:22:22 -0700223 this.terminalEventSource.onmessage = (event) => {
224 if (this.terminal) {
225 // Decode base64 data before writing to terminal
226 try {
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700227 // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
228 const decoded = base64ToUint8Array(event.data);
Earl Lee2e463fb2025-04-17 11:22:22 -0700229 this.terminal.write(decoded);
230 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700231 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700232 }
233 }
234 };
Sean McCullough86b56862025-04-18 13:04:03 -0700235
Earl Lee2e463fb2025-04-17 11:22:22 -0700236 this.terminalEventSource.onerror = (error) => {
237 console.error("Terminal SSE error:", error);
238 if (this.terminal) {
239 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
240 }
241 // Attempt to reconnect if the connection was lost
242 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
243 this.closeTerminalConnections();
244 }
245 };
Sean McCullough86b56862025-04-18 13:04:03 -0700246
Earl Lee2e463fb2025-04-17 11:22:22 -0700247 // Send key inputs to the server via POST requests
248 if (this.terminal) {
249 this.terminal.onData((data) => {
250 this.sendTerminalInput(data);
251 });
252 }
253 } catch (error) {
254 console.error("Failed to connect to terminal:", error);
255 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700256 this.terminal.write(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700257 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700258 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700259 }
260 }
261 }
262
263 /**
264 * Close any active terminal connections
265 */
266 private closeTerminalConnections(): void {
267 if (this.terminalEventSource) {
268 this.terminalEventSource.close();
269 this.terminalEventSource = null;
270 }
271 }
272
273 /**
274 * Send input to the terminal
275 * @param data The input data to send
276 */
277 private async sendTerminalInput(data: string): Promise<void> {
278 // Add the data to the queue
279 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700280
Earl Lee2e463fb2025-04-17 11:22:22 -0700281 // If we're not already processing inputs, start processing
282 if (!this.processingTerminalInput) {
283 await this.processTerminalInputQueue();
284 }
285 }
286
287 /**
288 * Process the terminal input queue in order
289 */
290 private async processTerminalInputQueue(): Promise<void> {
291 if (this.terminalInputQueue.length === 0) {
292 this.processingTerminalInput = false;
293 return;
294 }
Sean McCullough86b56862025-04-18 13:04:03 -0700295
Earl Lee2e463fb2025-04-17 11:22:22 -0700296 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700297
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700299 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700300
Earl Lee2e463fb2025-04-17 11:22:22 -0700301 // Take all currently available items from the queue
302 while (this.terminalInputQueue.length > 0) {
303 combinedData += this.terminalInputQueue.shift()!;
304 }
Sean McCullough86b56862025-04-18 13:04:03 -0700305
Earl Lee2e463fb2025-04-17 11:22:22 -0700306 try {
307 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700308 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
309 const response = await fetch(
310 `${baseUrl}/terminal/input/${this.terminalId}`,
311 {
312 method: "POST",
313 body: combinedData,
314 headers: {
315 "Content-Type": "text/plain",
316 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700317 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700318 );
Sean McCullough86b56862025-04-18 13:04:03 -0700319
Earl Lee2e463fb2025-04-17 11:22:22 -0700320 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700321 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700322 `Failed to send terminal input: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700323 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700324 }
325 } catch (error) {
326 console.error("Error sending terminal input:", error);
327 }
Sean McCullough86b56862025-04-18 13:04:03 -0700328
Earl Lee2e463fb2025-04-17 11:22:22 -0700329 // Continue processing the queue (for any new items that may have been added)
330 await this.processTerminalInputQueue();
331 }
332
333 /**
334 * Send terminal resize information to the server
335 */
336 private async sendTerminalResize(): Promise<void> {
337 if (!this.terminal || !this.fitAddon) {
338 return;
339 }
340
341 // Get terminal dimensions
342 try {
343 // Send resize message in a format the server can understand
344 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700345 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
346 const response = await fetch(
347 `${baseUrl}/terminal/input/${this.terminalId}`,
348 {
349 method: "POST",
350 body: JSON.stringify({
351 type: "resize",
352 cols: this.terminal.cols || 80, // Default to 80 if undefined
353 rows: this.terminal.rows || 24, // Default to 24 if undefined
354 }),
355 headers: {
356 "Content-Type": "application/json",
357 },
Philip Zeyliger72682df2025-04-23 13:09:46 -0700358 },
Sean McCullough71941bd2025-04-18 13:31:48 -0700359 );
Sean McCullough86b56862025-04-18 13:04:03 -0700360
Earl Lee2e463fb2025-04-17 11:22:22 -0700361 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700362 console.error(
Philip Zeyliger72682df2025-04-23 13:09:46 -0700363 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
Sean McCullough71941bd2025-04-18 13:31:48 -0700364 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700365 }
366 } catch (error) {
367 console.error("Error sending terminal resize:", error);
368 }
369 }
370
Sean McCullough86b56862025-04-18 13:04:03 -0700371 render() {
372 return html`
373 <div id="terminalView" class="terminal-view">
Sean McCullough71941bd2025-04-18 13:31:48 -0700374 <div id="terminalContainer" class="terminal-container"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700375 </div>
376 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700377 }
378}
Sean McCullough86b56862025-04-18 13:04:03 -0700379
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700380function base64ToUint8Array(base64String) {
381 // This isn't yet available in Chrome, but Safari has it!
382 // @ts-ignore
383 if (Uint8Array.fromBase64) {
384 // @ts-ignore
385 return Uint8Array.fromBase64(base64String);
386 }
387
388 const binaryString = atob(base64String);
389 return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
390}
391
Sean McCullough86b56862025-04-18 13:04:03 -0700392declare global {
393 interface HTMLElementTagNameMap {
394 "sketch-terminal": SketchTerminal;
395 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700396}