treesap
Version:
AI Agent Framework
461 lines (392 loc) • 16.4 kB
JavaScript
// Terminal component JavaScript using Xterm.js with WebSocket
import { Terminal } from 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/+esm';
import { terminalStore } from '/signals/TerminalSignal.js';
class TerminalManager {
constructor(terminalId) {
this.terminalId = terminalId;
this.container = document.getElementById(terminalId);
this.xtermContainer = document.getElementById(`${terminalId}-xterm`);
this.resetBtn = document.getElementById(`${terminalId}-reset-btn`);
this.status = document.getElementById(`${terminalId}-status`);
// Get terminal data from window
const terminalData = window[`terminalData_${terminalId.replace(/-/g, '_')}`];
if (!terminalData) {
console.error(`No terminal data found for ${terminalId}`);
return;
}
this.sessionId = terminalData.sessionId;
this.index = terminalData.index;
console.log(`Terminal ${terminalId} initialized with:`, terminalData);
// Register terminal in store
terminalStore.addTerminal(this.index);
terminalStore.updateTerminalStatus(terminalId, 'connecting');
// WebSocket connection
this.websocket = null;
this.terminal = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 5;
this.reconnectDelay = 1000; // Start with 1 second
this.init();
}
init() {
console.log('Terminal init called with ID:', this.terminalId);
console.log('Container found:', !!this.container);
console.log('Xterm container found:', !!this.xtermContainer);
if (!this.container || !this.xtermContainer) {
console.error('Terminal containers not found! Looking for ID:', this.terminalId);
terminalStore.updateTerminalStatus(this.terminalId, 'error');
return;
}
// Initialize Xterm.js
this.setupXterm();
// Set up event listeners
this.setupEventListeners();
// Connect to terminal via WebSocket
this.connectToTerminal();
}
setupXterm() {
// Detect mobile devices for responsive terminal setup
const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Create terminal instance with VS Code-like theme and responsive settings
this.terminal = new Terminal({
cursorBlink: true,
fontSize: 12,
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
theme: {
background: '#1e1e1e',
foreground: '#cccccc',
cursor: '#ffffff',
cursorAccent: '#1e1e1e',
selection: '#ffffff40',
black: '#000000',
red: '#f14c4c',
green: '#23d18b',
yellow: '#f5f543',
blue: '#3b8eea',
magenta: '#d670d6',
cyan: '#29b8db',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff'
},
scrollback: isMobile ? 500 : 1000, // Smaller scrollback for mobile performance
tabStopWidth: 4,
allowTransparency: false,
// Mobile-specific options
...(isMobile && {
convertEol: true,
disableStdin: false
})
});
// Open terminal in container
this.terminal.open(this.xtermContainer);
// Focus terminal
this.terminal.focus();
// Handle terminal input - pass through to shell
this.terminal.onData((data) => {
// Log the input data for debugging
console.log('Terminal input (manual typing):', JSON.stringify(data), 'char codes:', data.split('').map(c => c.charCodeAt(0)));
// Send all input directly to the shell session
this.sendInput(data);
});
// Fit terminal to container
this.fitTerminal();
// Resize handler
window.addEventListener('resize', () => {
// Add a small delay for mobile orientation changes
clearTimeout(this.resizeTimeout);
this.resizeTimeout = setTimeout(() => {
this.fitTerminal();
}, 100);
});
// Mobile orientation change handler
window.addEventListener('orientationchange', () => {
// Longer delay for orientation changes as they can be slower
clearTimeout(this.orientationTimeout);
this.orientationTimeout = setTimeout(() => {
this.fitTerminal();
}, 300);
});
}
fitTerminal() {
if (this.terminal && this.xtermContainer) {
// Wait a moment to ensure container is properly sized
setTimeout(() => {
const containerRect = this.xtermContainer.getBoundingClientRect();
if (containerRect.width > 0 && containerRect.height > 0) {
// Detect mobile devices for responsive sizing
const isMobile = window.innerWidth <= 768 || /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Use consistent font size
const fontSize = 12;
// Calculate character dimensions more accurately
// Create a temporary element to measure actual character size
const testElement = document.createElement('div');
testElement.style.fontFamily = this.terminal.options.fontFamily;
testElement.style.fontSize = `${fontSize}px`;
testElement.style.position = 'absolute';
testElement.style.visibility = 'hidden';
testElement.style.whiteSpace = 'pre';
testElement.textContent = 'M'; // Use 'M' as it's typically the widest character
document.body.appendChild(testElement);
const charWidth = testElement.getBoundingClientRect().width;
const charHeight = testElement.getBoundingClientRect().height;
document.body.removeChild(testElement);
// Calculate columns and rows based on actual character dimensions
const cols = Math.max(20, Math.floor(containerRect.width / charWidth)); // Minimum 20 columns
const rows = Math.max(10, Math.floor(containerRect.height / charHeight)); // Minimum 10 rows
// Store previous dimensions to detect significant changes
const prevCols = this.lastCols || 0;
const prevRows = this.lastRows || 0;
const significantChange = Math.abs(cols - prevCols) > Math.max(10, prevCols * 0.3) ||
Math.abs(rows - prevRows) > Math.max(5, prevRows * 0.3);
console.log(`Fitting terminal ${this.terminalId}: ${cols}x${rows} (container: ${containerRect.width}x${containerRect.height}, charSize: ${charWidth}x${charHeight}, mobile: ${isMobile})`);
// Resize the terminal
this.terminal.resize(cols, rows);
// Send resize notification to backend PTY
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const resizeMessage = {
type: 'resize',
sessionId: this.sessionId,
terminalId: this.terminalId,
cols: cols,
rows: rows
};
this.websocket.send(JSON.stringify(resizeMessage));
}
// Force a refresh if there was a significant size change (like mobile rotation)
if (significantChange && (prevCols > 0 || prevRows > 0)) {
console.log(`Significant terminal size change detected, forcing refresh`);
// Small delay to ensure resize is processed, then refresh
setTimeout(() => {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
// Send Ctrl+L to clear and refresh the display
const refreshMessage = {
type: 'input',
sessionId: this.sessionId,
terminalId: this.terminalId,
data: '\x0C' // Ctrl+L (form feed) to refresh
};
this.websocket.send(JSON.stringify(refreshMessage));
}
}, 100);
}
// Store current dimensions for next comparison
this.lastCols = cols;
this.lastRows = rows;
} else {
console.warn(`Terminal ${this.terminalId} container has zero dimensions, retrying...`);
// Retry after a short delay
setTimeout(() => this.fitTerminal(), 100);
}
}, 10);
}
}
setupEventListeners() {
// Handle reset button
if (this.resetBtn) {
this.resetBtn.addEventListener('click', () => {
this.clearTerminal();
});
}
}
sendInput(data) {
// Send input via WebSocket
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const message = {
type: 'input',
sessionId: this.sessionId,
terminalId: this.terminalId,
data: data
};
this.websocket.send(JSON.stringify(message));
} else {
console.error('WebSocket not connected, cannot send input');
this.updateStatus('Disconnected');
terminalStore.updateTerminalStatus(this.terminalId, 'disconnected');
}
}
connectToTerminal() {
if (this.websocket) {
this.websocket.close();
}
this.updateStatus('Connecting...');
// Create WebSocket connection
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/terminal/ws`;
console.log(`Connecting to WebSocket: ${wsUrl}`);
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log('WebSocket connected, joining terminal session');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
// Join the terminal session
const joinMessage = {
type: 'join',
sessionId: this.sessionId,
terminalId: this.terminalId
};
this.websocket.send(JSON.stringify(joinMessage));
};
this.websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('Received WebSocket message:', data.type);
switch (data.type) {
case 'connected':
this.updateStatus('Ready');
terminalStore.updateTerminalStatus(this.terminalId, 'connected');
console.log('Terminal session joined successfully');
// Dispatch global status for cross-tab sync
document.dispatchEvent(new CustomEvent('terminal:global_status', {
detail: { status: 'connected' }
}));
break;
case 'output':
if (data.content) {
this.terminal.write(data.content);
}
break;
case 'error':
if (data.content) {
this.terminal.write(`\x1b[31m${data.content}\x1b[0m`);
} else if (data.data) {
this.terminal.write(`\x1b[31m${data.data}\x1b[0m`);
}
break;
case 'exit':
this.terminal.writeln(`\x1b[90mProcess exited with code ${data.code || 0}\x1b[0m`);
this.terminal.write('\x1b[32m$ \x1b[0m');
break;
case 'clients_count':
console.log(`${data.count} clients connected to this session`);
// Dispatch event for TerminalSignal to handle cross-tab sync
document.dispatchEvent(new CustomEvent('terminal:clients_count', {
detail: {
sessionId: this.sessionId,
count: data.count
}
}));
break;
case 'session_closed':
console.log('Terminal session was closed');
this.updateStatus('Session Closed');
terminalStore.updateTerminalStatus(this.terminalId, 'closed');
// Dispatch event for TerminalSignal to handle cross-tab sync
document.dispatchEvent(new CustomEvent('terminal:session_closed', {
detail: {
sessionId: this.sessionId
}
}));
break;
case 'pong':
// Connection health check response
break;
default:
console.log('Unknown message type:', data.type, data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.websocket.onerror = (error) => {
console.error('WebSocket error:', error);
this.updateStatus('Connection Error');
terminalStore.updateTerminalStatus(this.terminalId, 'error');
};
this.websocket.onclose = (event) => {
console.log('WebSocket closed:', event.code, event.reason);
this.updateStatus('Disconnected');
terminalStore.updateTerminalStatus(this.terminalId, 'disconnected');
// Attempt to reconnect with exponential backoff
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(`Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts}) in ${this.reconnectDelay}ms`);
setTimeout(() => {
this.connectToTerminal();
}, this.reconnectDelay);
// Exponential backoff: double the delay each time, max 10 seconds
this.reconnectDelay = Math.min(this.reconnectDelay * 2, 10000);
} else {
console.error('Max reconnection attempts reached');
this.updateStatus('Connection Failed');
terminalStore.updateTerminalStatus(this.terminalId, 'failed');
}
};
}
updateStatus(status) {
if (this.status) {
this.status.textContent = status;
}
}
clearTerminal() {
if (this.terminal) {
this.terminal.clear();
this.terminal.writeln('\x1b[36mTerminal cleared\x1b[0m');
this.terminal.write('\x1b[32m$ \x1b[0m');
}
}
async destroy() {
// Send leave message to WebSocket before closing
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
const leaveMessage = {
type: 'leave',
sessionId: this.sessionId,
terminalId: this.terminalId
};
this.websocket.send(JSON.stringify(leaveMessage));
}
if (this.websocket) {
this.websocket.close();
}
if (this.terminal) {
this.terminal.dispose();
}
// Note: We don't destroy the server-side session anymore since other tabs might be using it
// The WebSocket service will manage session cleanup when all clients disconnect
// Remove from store
terminalStore.removeTerminal(this.terminalId);
}
}
// Auto-initialize when script loads
console.log('Terminal.js loaded, looking for terminal containers...');
let terminalManagers = new Map();
function initializeTerminals() {
// Look for all sapling-islands and find the ones with terminal content
const saplingIslands = document.querySelectorAll('sapling-island');
for (const island of saplingIslands) {
// Look for a div with terminal ID (updated selector for new background class)
const terminalDiv = island.querySelector('div[id^="terminal-"]');
if (terminalDiv && terminalDiv.id && terminalDiv.id.startsWith('terminal-')) {
const terminalId = terminalDiv.id;
console.log('Found terminal component with ID:', terminalId);
// Check if we already have a manager for this terminal
if (!terminalManagers.has(terminalId)) {
const manager = new TerminalManager(terminalId);
terminalManagers.set(terminalId, manager);
}
}
}
console.log(`Initialized ${terminalManagers.size} terminal(s)`);
}
// Try immediate initialization
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeTerminals);
} else {
initializeTerminals();
}
// Make available globally
window.terminalManagers = terminalManagers;
window.TerminalManager = TerminalManager;
window.initializeTerminals = initializeTerminals;
// Cleanup on page unload
window.addEventListener('beforeunload', () => {
for (const manager of terminalManagers.values()) {
manager.destroy();
}
});