blob: fbe9a7d1b29533abadc85e55e0641da9db7c8a72 [file] [log] [blame]
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
/**
* Class to handle terminal functionality in the timeline UI.
*/
export class TerminalHandler {
// Terminal instance
private terminal: Terminal | null = null;
// Terminal fit addon for handling resize
private fitAddon: FitAddon | null = null;
// Terminal EventSource for SSE
private terminalEventSource: EventSource | null = null;
// Terminal ID (always 1 for now, will support 1-9 later)
private terminalId: string = "1";
// Queue for serializing terminal inputs
private terminalInputQueue: string[] = [];
// Flag to track if we're currently processing a terminal input
private processingTerminalInput: boolean = false;
// Current view mode (needed for resize handling)
private viewMode: string = "chat";
/**
* Constructor for TerminalHandler
*/
constructor() {}
/**
* Sets the current view mode
* @param mode The current view mode
*/
public setViewMode(mode: string): void {
this.viewMode = mode;
}
/**
* Initialize the terminal component
* @param terminalContainer The DOM element to contain the terminal
*/
public async initializeTerminal(): Promise<void> {
const terminalContainer = document.getElementById("terminalContainer");
if (!terminalContainer) {
console.error("Terminal container not found");
return;
}
// If terminal is already initialized, just focus it
if (this.terminal) {
this.terminal.focus();
if (this.fitAddon) {
this.fitAddon.fit();
}
return;
}
// Clear the terminal container
terminalContainer.innerHTML = "";
// Create new terminal instance
this.terminal = new Terminal({
cursorBlink: true,
theme: {
background: "#f5f5f5",
foreground: "#333333",
cursor: "#0078d7",
selectionBackground: "rgba(0, 120, 215, 0.4)",
},
});
// Add fit addon to handle terminal resizing
this.fitAddon = new FitAddon();
this.terminal.loadAddon(this.fitAddon);
// Open the terminal in the container
this.terminal.open(terminalContainer);
// Connect to WebSocket
await this.connectTerminal();
// Fit the terminal to the container
this.fitAddon.fit();
// Setup resize handler
window.addEventListener("resize", () => {
if (this.viewMode === "terminal" && this.fitAddon) {
this.fitAddon.fit();
// Send resize information to server
this.sendTerminalResize();
}
});
// Focus the terminal
this.terminal.focus();
}
/**
* Connect to terminal events stream
*/
private async connectTerminal(): Promise<void> {
if (!this.terminal) {
return;
}
// Close existing connections if any
this.closeTerminalConnections();
try {
// Connect directly to the SSE endpoint for terminal 1
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
const eventsUrl = `${baseUrl}/terminal/events/${this.terminalId}`;
this.terminalEventSource = new EventSource(eventsUrl);
// Handle SSE events
this.terminalEventSource.onopen = () => {
console.log("Terminal SSE connection opened");
this.sendTerminalResize();
};
this.terminalEventSource.onmessage = (event) => {
if (this.terminal) {
// Decode base64 data before writing to terminal
try {
const decoded = atob(event.data);
this.terminal.write(decoded);
} catch (e) {
console.error('Error decoding terminal data:', e);
// Fallback to raw data if decoding fails
this.terminal.write(event.data);
}
}
};
this.terminalEventSource.onerror = (error) => {
console.error("Terminal SSE error:", error);
if (this.terminal) {
this.terminal.write("\r\n\x1b[1;31mConnection error\x1b[0m\r\n");
}
// Attempt to reconnect if the connection was lost
if (this.terminalEventSource?.readyState === EventSource.CLOSED) {
this.closeTerminalConnections();
}
};
// Send key inputs to the server via POST requests
if (this.terminal) {
this.terminal.onData((data) => {
this.sendTerminalInput(data);
});
}
} catch (error) {
console.error("Failed to connect to terminal:", error);
if (this.terminal) {
this.terminal.write(`\r\n\x1b[1;31mFailed to connect: ${error}\x1b[0m\r\n`);
}
}
}
/**
* Close any active terminal connections
*/
private closeTerminalConnections(): void {
if (this.terminalEventSource) {
this.terminalEventSource.close();
this.terminalEventSource = null;
}
}
/**
* Send input to the terminal
* @param data The input data to send
*/
private async sendTerminalInput(data: string): Promise<void> {
// Add the data to the queue
this.terminalInputQueue.push(data);
// If we're not already processing inputs, start processing
if (!this.processingTerminalInput) {
await this.processTerminalInputQueue();
}
}
/**
* Process the terminal input queue in order
*/
private async processTerminalInputQueue(): Promise<void> {
if (this.terminalInputQueue.length === 0) {
this.processingTerminalInput = false;
return;
}
this.processingTerminalInput = true;
// Concatenate all available inputs from the queue into a single request
let combinedData = '';
// Take all currently available items from the queue
while (this.terminalInputQueue.length > 0) {
combinedData += this.terminalInputQueue.shift()!;
}
try {
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
method: 'POST',
body: combinedData,
headers: {
'Content-Type': 'text/plain'
}
});
if (!response.ok) {
console.error(`Failed to send terminal input: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error("Error sending terminal input:", error);
}
// Continue processing the queue (for any new items that may have been added)
await this.processTerminalInputQueue();
}
/**
* Send terminal resize information to the server
*/
private async sendTerminalResize(): Promise<void> {
if (!this.terminal || !this.fitAddon) {
return;
}
// Get terminal dimensions
try {
// Send resize message in a format the server can understand
// Use relative URL based on current location
const baseUrl = window.location.pathname.endsWith('/') ? '.' : '.';
const response = await fetch(`${baseUrl}/terminal/input/${this.terminalId}`, {
method: 'POST',
body: JSON.stringify({
type: "resize",
cols: this.terminal.cols || 80, // Default to 80 if undefined
rows: this.terminal.rows || 24, // Default to 24 if undefined
}),
headers: {
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.error(`Failed to send terminal resize: ${response.status} ${response.statusText}`);
}
} catch (error) {
console.error("Error sending terminal resize:", error);
}
}
/**
* Clean up resources when component is destroyed
*/
public dispose(): void {
this.closeTerminalConnections();
if (this.terminal) {
this.terminal.dispose();
this.terminal = null;
}
this.fitAddon = null;
}
}