blob: fbe9a7d1b29533abadc85e55e0641da9db7c8a72 [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001import { Terminal } from "@xterm/xterm";
2import { FitAddon } from "@xterm/addon-fit";
3
4/**
5 * Class to handle terminal functionality in the timeline UI.
6 */
7export class TerminalHandler {
8 // Terminal instance
9 private terminal: Terminal | null = null;
10 // Terminal fit addon for handling resize
11 private fitAddon: FitAddon | null = null;
12 // Terminal EventSource for SSE
13 private terminalEventSource: EventSource | null = null;
14 // Terminal ID (always 1 for now, will support 1-9 later)
15 private terminalId: string = "1";
16 // Queue for serializing terminal inputs
17 private terminalInputQueue: string[] = [];
18 // Flag to track if we're currently processing a terminal input
19 private processingTerminalInput: boolean = false;
20 // Current view mode (needed for resize handling)
21 private viewMode: string = "chat";
22
23 /**
24 * Constructor for TerminalHandler
25 */
26 constructor() {}
27
28 /**
29 * Sets the current view mode
30 * @param mode The current view mode
31 */
32 public setViewMode(mode: string): void {
33 this.viewMode = mode;
34 }
35
36 /**
37 * Initialize the terminal component
38 * @param terminalContainer The DOM element to contain the terminal
39 */
40 public async initializeTerminal(): Promise<void> {
41 const terminalContainer = document.getElementById("terminalContainer");
42
43 if (!terminalContainer) {
44 console.error("Terminal container not found");
45 return;
46 }
47
48 // If terminal is already initialized, just focus it
49 if (this.terminal) {
50 this.terminal.focus();
51 if (this.fitAddon) {
52 this.fitAddon.fit();
53 }
54 return;
55 }
56
57 // Clear the terminal container
58 terminalContainer.innerHTML = "";
59
60 // Create new terminal instance
61 this.terminal = new Terminal({
62 cursorBlink: true,
63 theme: {
64 background: "#f5f5f5",
65 foreground: "#333333",
66 cursor: "#0078d7",
67 selectionBackground: "rgba(0, 120, 215, 0.4)",
68 },
69 });
70
71 // Add fit addon to handle terminal resizing
72 this.fitAddon = new FitAddon();
73 this.terminal.loadAddon(this.fitAddon);
74
75 // Open the terminal in the container
76 this.terminal.open(terminalContainer);
77
78 // Connect to WebSocket
79 await this.connectTerminal();
80
81 // Fit the terminal to the container
82 this.fitAddon.fit();
83
84 // Setup resize handler
85 window.addEventListener("resize", () => {
86 if (this.viewMode === "terminal" && this.fitAddon) {
87 this.fitAddon.fit();
88 // Send resize information to server
89 this.sendTerminalResize();
90 }
91 });
92
93 // Focus the terminal
94 this.terminal.focus();
95 }
96
97 /**
98 * Connect to terminal events stream
99 */
100 private async connectTerminal(): Promise<void> {
101 if (!this.terminal) {
102 return;
103 }
104
105 // Close existing connections if any
106 this.closeTerminalConnections();
107
108 try {
109 // Connect directly to the SSE endpoint for terminal 1
110 // Use relative URL based on current location
111 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
112 const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
113 this.terminalEventSource = new EventSource(eventsUrl);
114
115 // Handle SSE events
116 this.terminalEventSource.onopen = () => {
117 console.log("Terminal SSE connection opened");
118 this.sendTerminalResize();
119 };
120
121 this.terminalEventSource.onmessage = (event) => {
122 if (this.terminal) {
123 // Decode base64 data before writing to terminal
124 try {
125 const decoded = atob(event.data);
126 this.terminal.write(decoded);
127 } catch (e) {
128 console.error('Error decoding terminal data:', e);
129 // Fallback to raw data if decoding fails
130 this.terminal.write(event.data);
131 }
132 }
133 };
134
135 this.terminalEventSource.onerror = (error) => {
136 console.error("Terminal SSE error:", error);
137 if (this.terminal) {
138 this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
139 }
140 // Attempt to reconnect if the connection was lost
141 if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
142 this.closeTerminalConnections();
143 }
144 };
145
146 // Send key inputs to the server via POST requests
147 if (this.terminal) {
148 this.terminal.onData((data) => {
149 this.sendTerminalInput(data);
150 });
151 }
152 } catch (error) {
153 console.error("Failed to connect to terminal:", error);
154 if (this.terminal) {
155 this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
156 }
157 }
158 }
159
160 /**
161 * Close any active terminal connections
162 */
163 private closeTerminalConnections(): void {
164 if (this.terminalEventSource) {
165 this.terminalEventSource.close();
166 this.terminalEventSource = null;
167 }
168 }
169
170 /**
171 * Send input to the terminal
172 * @param data The input data to send
173 */
174 private async sendTerminalInput(data: string): Promise<void> {
175 // Add the data to the queue
176 this.terminalInputQueue.push(data);
177
178 // If we're not already processing inputs, start processing
179 if (!this.processingTerminalInput) {
180 await this.processTerminalInputQueue();
181 }
182 }
183
184 /**
185 * Process the terminal input queue in order
186 */
187 private async processTerminalInputQueue(): Promise<void> {
188 if (this.terminalInputQueue.length === 0) {
189 this.processingTerminalInput = false;
190 return;
191 }
192
193 this.processingTerminalInput = true;
194
195 // Concatenate all available inputs from the queue into a single request
196 let combinedData = '';
197
198 // Take all currently available items from the queue
199 while (this.terminalInputQueue.length > 0) {
200 combinedData += this.terminalInputQueue.shift()!;
201 }
202
203 try {
204 // Use relative URL based on current location
205 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
206 const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
207 method: 'POST',
208 body: combinedData,
209 headers: {
210 'Content-Type': 'text/plain'
211 }
212 });
213
214 if (!response.ok) {
215 console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
216 }
217 } catch (error) {
218 console.error("Error sending terminal input:", error);
219 }
220
221 // Continue processing the queue (for any new items that may have been added)
222 await this.processTerminalInputQueue();
223 }
224
225 /**
226 * Send terminal resize information to the server
227 */
228 private async sendTerminalResize(): Promise<void> {
229 if (!this.terminal || !this.fitAddon) {
230 return;
231 }
232
233 // Get terminal dimensions
234 try {
235 // Send resize message in a format the server can understand
236 // Use relative URL based on current location
237 const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
238 const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
239 method: 'POST',
240 body: JSON.stringify({
241 type: "resize",
242 cols: this.terminal.cols || 80, // Default to 80 if undefined
243 rows: this.terminal.rows || 24, // Default to 24 if undefined
244 }),
245 headers: {
246 'Content-Type': 'application/json'
247 }
248 });
249
250 if (!response.ok) {
251 console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
252 }
253 } catch (error) {
254 console.error("Error sending terminal resize:", error);
255 }
256 }
257
258 /**
259 * Clean up resources when component is destroyed
260 */
261 public dispose(): void {
262 this.closeTerminalConnections();
263 if (this.terminal) {
264 this.terminal.dispose();
265 this.terminal = null;
266 }
267 this.fitAddon = null;
268 }
269}