blob: 788521d0e18be1d88ceb2e77fe9d0f620e2d7104 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { Terminal } from "@xterm/xterm";
2import { FitAddon } from "@xterm/addon-fit";
3
Sean McCullough86b56862025-04-18 13:04:03 -07004import {css, html, LitElement} from 'lit';
5import {customElement, property, state} from 'lit/decorators.js';
6import {DataManager, ConnectionStatus} from '../data';
7import {State, TimelineMessage} from '../types';
8import './sketch-container-status';
9
10@customElement('sketch-terminal')
11export class SketchTerminal extends LitElement {
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;
16 // 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`
26/* 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 McCullough86b56862025-04-18 13:04:03 -070038.terminal-container {
39 width: 100%;
40 height: 100%;
41 overflow: hidden;
42}
43`;
44
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);
55 }
56
57 disconnectedCallback() {
58 super.disconnectedCallback();
59
60 window.removeEventListener("resize", this._resizeHandler);
61
62 this.closeTerminalConnections();
63
64 if (this.terminal) {
65 this.terminal.dispose();
66 this.terminal = null;
67 }
68 this.fitAddon = null;
69 }
70
71 firstUpdated() {
72 this.initializeTerminal();
73 }
74
75 _resizeHandler() {
76 if (this.fitAddon) {
77 this.fitAddon.fit();
78 // Send resize information to server
79 this.sendTerminalResize();
80 }
81 }
82
83 // Load xterm CSS into the shadow DOM
84 private async loadXtermlCSS() {
85 try {
86 // Check if diff2html styles are already loaded
87 const styleId = 'xterm-styles';
88 if (this.shadowRoot?.getElementById(styleId)) {
89 return; // Already loaded
90 }
91
92 // Fetch the diff2html CSS
93 const response = await fetch('static/xterm.css');
94
95 if (!response.ok) {
96 console.error(`Failed to load xterm CSS: ${response.status} ${response.statusText}`);
97 return;
98 }
99
100 const cssText = await response.text();
101
102 // Create a style element and append to shadow DOM
103 const style = document.createElement('style');
104 style.id = styleId;
105 style.textContent = cssText;
106 this.renderRoot?.appendChild(style);
107
108 console.log('xterm CSS loaded into shadow DOM');
109 } catch (error) {
110 console.error('Error loading xterm CSS:', error);
111 }
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 McCullough86b56862025-04-18 13:04:03 -0700119 const terminalContainer = this.renderRoot.querySelector("#terminalContainer") as HTMLElement;
Earl Lee2e463fb2025-04-17 11:22:22 -0700120
121 if (!terminalContainer) {
122 console.error("Terminal container not found");
123 return;
124 }
125
126 // If terminal is already initialized, just focus it
127 if (this.terminal) {
128 this.terminal.focus();
129 if (this.fitAddon) {
130 this.fitAddon.fit();
131 }
132 return;
133 }
134
135 // Clear the terminal container
136 terminalContainer.innerHTML = "";
137
138 // Create new terminal instance
139 this.terminal = new Terminal({
140 cursorBlink: true,
141 theme: {
142 background: "#f5f5f5",
143 foreground: "#333333",
144 cursor: "#0078d7",
145 selectionBackground: "rgba(0, 120, 215, 0.4)",
146 },
147 });
148
149 // Add fit addon to handle terminal resizing
150 this.fitAddon = new FitAddon();
151 this.terminal.loadAddon(this.fitAddon);
152
153 // Open the terminal in the container
154 this.terminal.open(terminalContainer);
155
156 // Connect to WebSocket
157 await this.connectTerminal();
158
159 // Fit the terminal to the container
160 this.fitAddon.fit();
161
Earl Lee2e463fb2025-04-17 11:22:22 -0700162 // Focus the terminal
163 this.terminal.focus();
164 }
165
166 /**
167 * Connect to terminal events stream
168 */
169 private async connectTerminal(): Promise<void> {
170 if (!this.terminal) {
171 return;
172 }
173
174 // Close existing connections if any
175 this.closeTerminalConnections();
176
177 try {
178 // Connect directly to the SSE endpoint for terminal 1
179 // Use relative URL based on current location
180 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
181 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
182 this.terminalEventSource = new EventSource(eventsUrl);
Sean McCullough86b56862025-04-18 13:04:03 -0700183
Earl Lee2e463fb2025-04-17 11:22:22 -0700184 // Handle SSE events
185 this.terminalEventSource.onopen = () => {
186 console.log("Terminal SSE connection opened");
187 this.sendTerminalResize();
188 };
Sean McCullough86b56862025-04-18 13:04:03 -0700189
Earl Lee2e463fb2025-04-17 11:22:22 -0700190 this.terminalEventSource.onmessage = (event) => {
191 if (this.terminal) {
192 // Decode base64 data before writing to terminal
193 try {
194 const decoded = atob(event.data);
195 this.terminal.write(decoded);
196 } catch (e) {
197 console.error('Error decoding terminal data:', e);
198 // Fallback to raw data if decoding fails
199 this.terminal.write(event.data);
200 }
201 }
202 };
Sean McCullough86b56862025-04-18 13:04:03 -0700203
Earl Lee2e463fb2025-04-17 11:22:22 -0700204 this.terminalEventSource.onerror = (error) => {
205 console.error("Terminal SSE error:", error);
206 if (this.terminal) {
207 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
208 }
209 // Attempt to reconnect if the connection was lost
210 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
211 this.closeTerminalConnections();
212 }
213 };
Sean McCullough86b56862025-04-18 13:04:03 -0700214
Earl Lee2e463fb2025-04-17 11:22:22 -0700215 // Send key inputs to the server via POST requests
216 if (this.terminal) {
217 this.terminal.onData((data) => {
218 this.sendTerminalInput(data);
219 });
220 }
221 } catch (error) {
222 console.error("Failed to connect to terminal:", error);
223 if (this.terminal) {
224 this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
225 }
226 }
227 }
228
229 /**
230 * Close any active terminal connections
231 */
232 private closeTerminalConnections(): void {
233 if (this.terminalEventSource) {
234 this.terminalEventSource.close();
235 this.terminalEventSource = null;
236 }
237 }
238
239 /**
240 * Send input to the terminal
241 * @param data The input data to send
242 */
243 private async sendTerminalInput(data: string): Promise<void> {
244 // Add the data to the queue
245 this.terminalInputQueue.push(data);
Sean McCullough86b56862025-04-18 13:04:03 -0700246
Earl Lee2e463fb2025-04-17 11:22:22 -0700247 // If we're not already processing inputs, start processing
248 if (!this.processingTerminalInput) {
249 await this.processTerminalInputQueue();
250 }
251 }
252
253 /**
254 * Process the terminal input queue in order
255 */
256 private async processTerminalInputQueue(): Promise<void> {
257 if (this.terminalInputQueue.length === 0) {
258 this.processingTerminalInput = false;
259 return;
260 }
Sean McCullough86b56862025-04-18 13:04:03 -0700261
Earl Lee2e463fb2025-04-17 11:22:22 -0700262 this.processingTerminalInput = true;
Sean McCullough86b56862025-04-18 13:04:03 -0700263
Earl Lee2e463fb2025-04-17 11:22:22 -0700264 // Concatenate all available inputs from the queue into a single request
265 let combinedData = '';
Sean McCullough86b56862025-04-18 13:04:03 -0700266
Earl Lee2e463fb2025-04-17 11:22:22 -0700267 // Take all currently available items from the queue
268 while (this.terminalInputQueue.length > 0) {
269 combinedData += this.terminalInputQueue.shift()!;
270 }
Sean McCullough86b56862025-04-18 13:04:03 -0700271
Earl Lee2e463fb2025-04-17 11:22:22 -0700272 try {
273 // Use relative URL based on current location
274 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
275 const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
276 method: 'POST',
277 body: combinedData,
278 headers: {
279 'Content-Type': 'text/plain'
280 }
281 });
Sean McCullough86b56862025-04-18 13:04:03 -0700282
Earl Lee2e463fb2025-04-17 11:22:22 -0700283 if (!response.ok) {
284 console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
285 }
286 } catch (error) {
287 console.error("Error sending terminal input:", error);
288 }
Sean McCullough86b56862025-04-18 13:04:03 -0700289
Earl Lee2e463fb2025-04-17 11:22:22 -0700290 // Continue processing the queue (for any new items that may have been added)
291 await this.processTerminalInputQueue();
292 }
293
294 /**
295 * Send terminal resize information to the server
296 */
297 private async sendTerminalResize(): Promise<void> {
298 if (!this.terminal || !this.fitAddon) {
299 return;
300 }
301
302 // Get terminal dimensions
303 try {
304 // Send resize message in a format the server can understand
305 // Use relative URL based on current location
306 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
307 const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
308 method: 'POST',
309 body: JSON.stringify({
310 type: "resize",
311 cols: this.terminal.cols || 80, // Default to 80 if undefined
312 rows: this.terminal.rows || 24, // Default to 24 if undefined
313 }),
314 headers: {
315 'Content-Type': 'application/json'
316 }
317 });
Sean McCullough86b56862025-04-18 13:04:03 -0700318
Earl Lee2e463fb2025-04-17 11:22:22 -0700319 if (!response.ok) {
320 console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
321 }
322 } catch (error) {
323 console.error("Error sending terminal resize:", error);
324 }
325 }
326
Sean McCullough86b56862025-04-18 13:04:03 -0700327
328 render() {
329 return html`
330 <div id="terminalView" class="terminal-view">
331 <div id="terminalContainer" class="terminal-container"></div>
332 </div>
333 `;
Earl Lee2e463fb2025-04-17 11:22:22 -0700334 }
335}
Sean McCullough86b56862025-04-18 13:04:03 -0700336
337declare global {
338 interface HTMLElementTagNameMap {
339 "sketch-terminal": SketchTerminal;
340 }
341}