blob: 78f1c1223e8195e50d8350318b6c006b02aa2dc4 [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;
14 // Terminal EventSource for SSE
15 private terminalEventSource: EventSource | null = null;
16 // Terminal ID (always 1 for now, will support 1-9 later)
17 private terminalId: string = "1";
18 // Queue for serializing terminal inputs
19 private terminalInputQueue: string[] = [];
20 // Flag to track if we're currently processing a terminal input
21 private processingTerminalInput: boolean = false;
Earl Lee2e463fb2025-04-17 11:22:22 -070022
Sean McCullough86b56862025-04-18 13:04:03 -070023 static styles = css`
Sean McCullough71941bd2025-04-18 13:31:48 -070024 /* Terminal View Styles */
25 .terminal-view {
26 width: 100%;
27 background-color: #f5f5f5;
28 border-radius: 8px;
29 overflow: hidden;
30 margin-bottom: 20px;
31 box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
32 padding: 15px;
33 height: 70vh;
34 }
Earl Lee2e463fb2025-04-17 11:22:22 -070035
Sean McCullough71941bd2025-04-18 13:31:48 -070036 .terminal-container {
37 width: 100%;
38 height: 100%;
39 overflow: hidden;
40 }
41 `;
Sean McCullough86b56862025-04-18 13:04:03 -070042
43 constructor() {
44 super();
45 this._resizeHandler = this._resizeHandler.bind(this);
46 }
47
48 connectedCallback() {
49 super.connectedCallback();
50 this.loadXtermlCSS();
51 // Setup resize handler
52 window.addEventListener("resize", this._resizeHandler);
53 }
54
55 disconnectedCallback() {
56 super.disconnectedCallback();
57
58 window.removeEventListener("resize", this._resizeHandler);
59
60 this.closeTerminalConnections();
61
62 if (this.terminal) {
63 this.terminal.dispose();
64 this.terminal = null;
65 }
66 this.fitAddon = null;
67 }
68
69 firstUpdated() {
70 this.initializeTerminal();
71 }
72
73 _resizeHandler() {
74 if (this.fitAddon) {
75 this.fitAddon.fit();
76 // Send resize information to server
77 this.sendTerminalResize();
78 }
79 }
80
81 // Load xterm CSS into the shadow DOM
82 private async loadXtermlCSS() {
83 try {
84 // Check if diff2html styles are already loaded
Sean McCullough71941bd2025-04-18 13:31:48 -070085 const styleId = "xterm-styles";
Sean McCullough86b56862025-04-18 13:04:03 -070086 if (this.shadowRoot?.getElementById(styleId)) {
87 return; // Already loaded
88 }
89
90 // Fetch the diff2html CSS
Sean McCullough71941bd2025-04-18 13:31:48 -070091 const response = await fetch("static/xterm.css");
Sean McCullough86b56862025-04-18 13:04:03 -070092
93 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -070094 console.error(
Philip Zeyliger000c1f72025-04-22 11:57:37 -070095 `Failed to load xterm CSS: ${response.status} ${response.statusText}`
Sean McCullough71941bd2025-04-18 13:31:48 -070096 );
Sean McCullough86b56862025-04-18 13:04:03 -070097 return;
98 }
99
100 const cssText = await response.text();
101
102 // Create a style element and append to shadow DOM
Sean McCullough71941bd2025-04-18 13:31:48 -0700103 const style = document.createElement("style");
Sean McCullough86b56862025-04-18 13:04:03 -0700104 style.id = styleId;
105 style.textContent = cssText;
106 this.renderRoot?.appendChild(style);
107
Sean McCullough71941bd2025-04-18 13:31:48 -0700108 console.log("xterm CSS loaded into shadow DOM");
Sean McCullough86b56862025-04-18 13:04:03 -0700109 } catch (error) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700110 console.error("Error loading xterm CSS:", error);
Sean McCullough86b56862025-04-18 13:04:03 -0700111 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700112 }
113
114 /**
115 * Initialize the terminal component
116 * @param terminalContainer The DOM element to contain the terminal
117 */
118 public async initializeTerminal(): Promise<void> {
Sean McCullough71941bd2025-04-18 13:31:48 -0700119 const terminalContainer = this.renderRoot.querySelector(
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700120 "#terminalContainer"
Sean McCullough71941bd2025-04-18 13:31:48 -0700121 ) as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700122
123 if (!terminalContainer) {
124 console.error("Terminal container not found");
125 return;
126 }
127
128 // If terminal is already initialized, just focus it
129 if (this.terminal) {
130 this.terminal.focus();
131 if (this.fitAddon) {
132 this.fitAddon.fit();
133 }
134 return;
135 }
136
137 // Clear the terminal container
138 terminalContainer.innerHTML = "";
139
140 // Create new terminal instance
141 this.terminal = new Terminal({
142 cursorBlink: true,
143 theme: {
144 background: "#f5f5f5",
145 foreground: "#333333",
146 cursor: "#0078d7",
147 selectionBackground: "rgba(0, 120, 215, 0.4)",
148 },
149 });
150
151 // Add fit addon to handle terminal resizing
152 this.fitAddon = new FitAddon();
153 this.terminal.loadAddon(this.fitAddon);
154
155 // Open the terminal in the container
156 this.terminal.open(terminalContainer);
157
158 // Connect to WebSocket
159 await this.connectTerminal();
160
161 // Fit the terminal to the container
162 this.fitAddon.fit();
163
Earl Lee2e463fb2025-04-17 11:22:22 -0700164 // Focus the terminal
165 this.terminal.focus();
166 }
167
168 /**
169 * Connect to terminal events stream
170 */
171 private async connectTerminal(): Promise<void> {
172 if (!this.terminal) {
173 return;
174 }
175
176 // Close existing connections if any
177 this.closeTerminalConnections();
178
179 try {
180 // Connect directly to the SSE endpoint for terminal 1
181 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700182 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
Earl Lee2e463fb2025-04-17 11:22:22 -0700183 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
184 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700185
Earl Lee2e463fb2025-04-17 11:22:22 -0700186 // Handle SSE events
187 this.terminalEventSource.onopen = () => {
188 console.log("Terminal SSE connection opened");
189 this.sendTerminalResize();
190 };
Sean McCullough86b56862025-04-18 13:04:03 -0700191
Earl Lee2e463fb2025-04-17 11:22:22 -0700192 this.terminalEventSource.onmessage = (event) => {
193 if (this.terminal) {
194 // Decode base64 data before writing to terminal
195 try {
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700196 // @ts-ignore This isn't in the type definitions yet; it's pretty new?!?
197 const decoded = base64ToUint8Array(event.data);
Earl Lee2e463fb2025-04-17 11:22:22 -0700198 this.terminal.write(decoded);
199 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700200 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700201 }
202 }
203 };
Sean McCullough86b56862025-04-18 13:04:03 -0700204
Earl Lee2e463fb2025-04-17 11:22:22 -0700205 this.terminalEventSource.onerror = (error) => {
206 console.error("Terminal SSE error:", error);
207 if (this.terminal) {
208 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
209 }
210 // Attempt to reconnect if the connection was lost
211 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
212 this.closeTerminalConnections();
213 }
214 };
Sean McCullough86b56862025-04-18 13:04:03 -0700215
Earl Lee2e463fb2025-04-17 11:22:22 -0700216 // Send key inputs to the server via POST requests
217 if (this.terminal) {
218 this.terminal.onData((data) => {
219 this.sendTerminalInput(data);
220 });
221 }
222 } catch (error) {
223 console.error("Failed to connect to terminal:", error);
224 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700225 this.terminal.write(
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700226 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`
Sean McCullough71941bd2025-04-18 13:31:48 -0700227 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700228 }
229 }
230 }
231
232 /**
233 * Close any active terminal connections
234 */
235 private closeTerminalConnections(): void {
236 if (this.terminalEventSource) {
237 this.terminalEventSource.close();
238 this.terminalEventSource = null;
239 }
240 }
241
242 /**
243 * Send input to the terminal
244 * @param data The input data to send
245 */
246 private async sendTerminalInput(data: string): Promise<void> {
247 // Add the data to the queue
248 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700249
Earl Lee2e463fb2025-04-17 11:22:22 -0700250 // If we're not already processing inputs, start processing
251 if (!this.processingTerminalInput) {
252 await this.processTerminalInputQueue();
253 }
254 }
255
256 /**
257 * Process the terminal input queue in order
258 */
259 private async processTerminalInputQueue(): Promise<void> {
260 if (this.terminalInputQueue.length === 0) {
261 this.processingTerminalInput = false;
262 return;
263 }
Sean McCullough86b56862025-04-18 13:04:03 -0700264
Earl Lee2e463fb2025-04-17 11:22:22 -0700265 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700266
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700268 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700269
Earl Lee2e463fb2025-04-17 11:22:22 -0700270 // Take all currently available items from the queue
271 while (this.terminalInputQueue.length > 0) {
272 combinedData += this.terminalInputQueue.shift()!;
273 }
Sean McCullough86b56862025-04-18 13:04:03 -0700274
Earl Lee2e463fb2025-04-17 11:22:22 -0700275 try {
276 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700277 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
278 const response = await fetch(
279 `${baseUrl}/terminal/input/${this.terminalId}`,
280 {
281 method: "POST",
282 body: combinedData,
283 headers: {
284 "Content-Type": "text/plain",
285 },
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700286 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700287 );
Sean McCullough86b56862025-04-18 13:04:03 -0700288
Earl Lee2e463fb2025-04-17 11:22:22 -0700289 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700290 console.error(
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700291 `Failed to send terminal input: ${response.status} ${response.statusText}`
Sean McCullough71941bd2025-04-18 13:31:48 -0700292 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700293 }
294 } catch (error) {
295 console.error("Error sending terminal input:", error);
296 }
Sean McCullough86b56862025-04-18 13:04:03 -0700297
Earl Lee2e463fb2025-04-17 11:22:22 -0700298 // Continue processing the queue (for any new items that may have been added)
299 await this.processTerminalInputQueue();
300 }
301
302 /**
303 * Send terminal resize information to the server
304 */
305 private async sendTerminalResize(): Promise<void> {
306 if (!this.terminal || !this.fitAddon) {
307 return;
308 }
309
310 // Get terminal dimensions
311 try {
312 // Send resize message in a format the server can understand
313 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700314 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
315 const response = await fetch(
316 `${baseUrl}/terminal/input/${this.terminalId}`,
317 {
318 method: "POST",
319 body: JSON.stringify({
320 type: "resize",
321 cols: this.terminal.cols || 80, // Default to 80 if undefined
322 rows: this.terminal.rows || 24, // Default to 24 if undefined
323 }),
324 headers: {
325 "Content-Type": "application/json",
326 },
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700327 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700328 );
Sean McCullough86b56862025-04-18 13:04:03 -0700329
Earl Lee2e463fb2025-04-17 11:22:22 -0700330 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700331 console.error(
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700332 `Failed to send terminal resize: ${response.status} ${response.statusText}`
Sean McCullough71941bd2025-04-18 13:31:48 -0700333 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700334 }
335 } catch (error) {
336 console.error("Error sending terminal resize:", error);
337 }
338 }
339
Sean McCullough86b56862025-04-18 13:04:03 -0700340 render() {
341 return html`
342 <div id="terminalView" class="terminal-view">
Sean McCullough71941bd2025-04-18 13:31:48 -0700343 <div id="terminalContainer" class="terminal-container"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700344 </div>
345 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700346 }
347}
Sean McCullough86b56862025-04-18 13:04:03 -0700348
Philip Zeyliger000c1f72025-04-22 11:57:37 -0700349function base64ToUint8Array(base64String) {
350 // This isn't yet available in Chrome, but Safari has it!
351 // @ts-ignore
352 if (Uint8Array.fromBase64) {
353 // @ts-ignore
354 return Uint8Array.fromBase64(base64String);
355 }
356
357 const binaryString = atob(base64String);
358 return Uint8Array.from(binaryString, (char) => char.charCodeAt(0));
359}
360
Sean McCullough86b56862025-04-18 13:04:03 -0700361declare global {
362 interface HTMLElementTagNameMap {
363 "sketch-terminal": SketchTerminal;
364 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700365}