blob: 8e4805e83498b6610dbb41bd42e819aad8e2a0d1 [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(
95 `Failed to load xterm CSS: ${response.status} ${response.statusText}`,
96 );
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(
120 "#terminalContainer",
121 ) 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 {
196 const decoded = atob(event.data);
197 this.terminal.write(decoded);
198 } catch (e) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700199 console.error("Error decoding terminal data:", e);
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 // Fallback to raw data if decoding fails
201 this.terminal.write(event.data);
202 }
203 }
204 };
Sean McCullough86b56862025-04-18 13:04:03 -0700205
Earl Lee2e463fb2025-04-17 11:22:22 -0700206 this.terminalEventSource.onerror = (error) => {
207 console.error("Terminal SSE error:", error);
208 if (this.terminal) {
209 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
210 }
211 // Attempt to reconnect if the connection was lost
212 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
213 this.closeTerminalConnections();
214 }
215 };
Sean McCullough86b56862025-04-18 13:04:03 -0700216
Earl Lee2e463fb2025-04-17 11:22:22 -0700217 // Send key inputs to the server via POST requests
218 if (this.terminal) {
219 this.terminal.onData((data) => {
220 this.sendTerminalInput(data);
221 });
222 }
223 } catch (error) {
224 console.error("Failed to connect to terminal:", error);
225 if (this.terminal) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700226 this.terminal.write(
227 `\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`,
228 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700229 }
230 }
231 }
232
233 /**
234 * Close any active terminal connections
235 */
236 private closeTerminalConnections(): void {
237 if (this.terminalEventSource) {
238 this.terminalEventSource.close();
239 this.terminalEventSource = null;
240 }
241 }
242
243 /**
244 * Send input to the terminal
245 * @param data The input data to send
246 */
247 private async sendTerminalInput(data: string): Promise<void> {
248 // Add the data to the queue
249 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700250
Earl Lee2e463fb2025-04-17 11:22:22 -0700251 // If we're not already processing inputs, start processing
252 if (!this.processingTerminalInput) {
253 await this.processTerminalInputQueue();
254 }
255 }
256
257 /**
258 * Process the terminal input queue in order
259 */
260 private async processTerminalInputQueue(): Promise<void> {
261 if (this.terminalInputQueue.length === 0) {
262 this.processingTerminalInput = false;
263 return;
264 }
Sean McCullough86b56862025-04-18 13:04:03 -0700265
Earl Lee2e463fb2025-04-17 11:22:22 -0700266 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700267
Earl Lee2e463fb2025-04-17 11:22:22 -0700268 // Concatenate all available inputs from the queue into a single request
Sean McCullough71941bd2025-04-18 13:31:48 -0700269 let combinedData = "";
Sean McCullough86b56862025-04-18 13:04:03 -0700270
Earl Lee2e463fb2025-04-17 11:22:22 -0700271 // Take all currently available items from the queue
272 while (this.terminalInputQueue.length > 0) {
273 combinedData += this.terminalInputQueue.shift()!;
274 }
Sean McCullough86b56862025-04-18 13:04:03 -0700275
Earl Lee2e463fb2025-04-17 11:22:22 -0700276 try {
277 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700278 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
279 const response = await fetch(
280 `${baseUrl}/terminal/input/${this.terminalId}`,
281 {
282 method: "POST",
283 body: combinedData,
284 headers: {
285 "Content-Type": "text/plain",
286 },
287 },
288 );
Sean McCullough86b56862025-04-18 13:04:03 -0700289
Earl Lee2e463fb2025-04-17 11:22:22 -0700290 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700291 console.error(
292 `Failed to send terminal input: ${response.status} ${response.statusText}`,
293 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700294 }
295 } catch (error) {
296 console.error("Error sending terminal input:", error);
297 }
Sean McCullough86b56862025-04-18 13:04:03 -0700298
Earl Lee2e463fb2025-04-17 11:22:22 -0700299 // Continue processing the queue (for any new items that may have been added)
300 await this.processTerminalInputQueue();
301 }
302
303 /**
304 * Send terminal resize information to the server
305 */
306 private async sendTerminalResize(): Promise<void> {
307 if (!this.terminal || !this.fitAddon) {
308 return;
309 }
310
311 // Get terminal dimensions
312 try {
313 // Send resize message in a format the server can understand
314 // Use relative URL based on current location
Sean McCullough71941bd2025-04-18 13:31:48 -0700315 const baseUrl = window.location.pathname.endsWith("/") ? "." : ".";
316 const response = await fetch(
317 `${baseUrl}/terminal/input/${this.terminalId}`,
318 {
319 method: "POST",
320 body: JSON.stringify({
321 type: "resize",
322 cols: this.terminal.cols || 80, // Default to 80 if undefined
323 rows: this.terminal.rows || 24, // Default to 24 if undefined
324 }),
325 headers: {
326 "Content-Type": "application/json",
327 },
328 },
329 );
Sean McCullough86b56862025-04-18 13:04:03 -0700330
Earl Lee2e463fb2025-04-17 11:22:22 -0700331 if (!response.ok) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700332 console.error(
333 `Failed to send terminal resize: ${response.status} ${response.statusText}`,
334 );
Earl Lee2e463fb2025-04-17 11:22:22 -0700335 }
336 } catch (error) {
337 console.error("Error sending terminal resize:", error);
338 }
339 }
340
Sean McCullough86b56862025-04-18 13:04:03 -0700341 render() {
342 return html`
343 <div id="terminalView" class="terminal-view">
Sean McCullough71941bd2025-04-18 13:31:48 -0700344 <div id="terminalContainer" class="terminal-container"></div>
Sean McCullough86b56862025-04-18 13:04:03 -0700345 </div>
346 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700347 }
348}
Sean McCullough86b56862025-04-18 13:04:03 -0700349
350declare global {
351 interface HTMLElementTagNameMap {
352 "sketch-terminal": SketchTerminal;
353 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700354}