Files
SuperCharged-Claude-Code-Up…/public/claude-ide/terminal.js
uroma 6894c8bed4 fix: remove process.env.HOME from browser code
process.env.HOME is a Node.js environment variable that doesn't exist
in browser JavaScript, causing attach to fail.

Changed to hardcoded '/home/uroma' path for session creation.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-20 05:59:22 +00:00

1373 lines
45 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Terminal Manager - Frontend for xterm.js terminals with HTTP polling
* Uses HTTP polling to bypass WebSocket/SSL issues
*/
class TerminalManager {
constructor() {
this.terminals = new Map(); // terminalId -> { terminal, fitAddon, container, mode, polling, currentOutputIndex }
this.activeTerminalId = null;
this.xtermLoaded = false;
this.terminalsContainer = null;
this.terminalTabsContainer = null;
this.debugMessages = [];
this.pollingInterval = 100; // ms between polls
this.pollingTimers = new Map(); // terminalId -> timer
// Bind methods
this.createTerminal = this.createTerminal.bind(this);
this.switchToTerminal = this.switchToTerminal.bind(this);
this.closeTerminal = this.closeTerminal.bind(this);
this.setMode = this.setMode.bind(this);
this.clearScreen = this.clearScreen.bind(this);
}
/**
* Log debug message to both console and visual debug panel
*/
debugLog(category, message, data = null) {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] [${category}] ${message}`;
// Log to console
console.log(logEntry, data || '');
// Add to debug panel
this.debugMessages.push({ timestamp, category, message, data });
if (this.debugMessages.length > 50) {
this.debugMessages.shift(); // Keep only last 50 messages
}
const debugContent = document.getElementById('terminal-debug-content');
if (debugContent) {
const colorMap = {
'INIT': '#4a9eff',
'HTTP': '#a78bfa',
'CMD': '#51cf66',
'ERROR': '#ff6b6b',
'READY': '#ffd43b',
'PTY': '#ffa94d',
'POLL': '#ff922b'
};
const color = colorMap[category] || '#e0e0e0';
debugContent.innerHTML = this.debugMessages.map(msg => {
const displayColor = colorMap[msg.category] || '#e0e0e0';
return `<div style="margin-bottom: 4px;"><span style="color: #888;">[${msg.timestamp}]</span> <span style="color: ${displayColor}; font-weight: bold;">[${msg.category}]</span> ${msg.message}${msg.data ? ` <span style="color: #888;">- ${JSON.stringify(msg.data)}</span>` : ''}</div>`;
}).join('');
// Auto-scroll to bottom
debugContent.scrollTop = debugContent.scrollHeight;
}
}
/**
* Load xterm.js CSS dynamically
*/
async loadXTerm() {
if (this.xtermLoaded) return;
try {
// Load xterm.js CSS
const cssLink = document.createElement('link');
cssLink.rel = 'stylesheet';
cssLink.href = 'https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css';
document.head.appendChild(cssLink);
// Load xterm.js library
await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.js');
// Load addons
await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.js');
await this.loadScript('https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.js');
this.xtermLoaded = true;
console.log('[TerminalManager] xterm.js loaded');
} catch (error) {
console.error('[TerminalManager] Failed to load xterm.js:', error);
throw error;
}
}
/**
* Helper to load script dynamically
*/
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
/**
* Initialize terminal UI
*/
async initialize() {
// Load xterm.js
await this.loadXTerm();
// Get container elements
this.terminalsContainer = document.getElementById('terminals-container');
this.terminalTabsContainer = document.getElementById('terminal-tabs');
// Check for restore state
await this.checkForRestore();
// Set up keyboard shortcuts
this.setupKeyboardShortcuts();
}
/**
* Check for saved terminal state and offer restore
*/
async checkForRestore() {
try {
const res = await fetch('/claude/api/claude/terminal-restore');
const data = await res.json();
if (data.success && data.state && data.state.terminals && data.state.terminals.length > 0) {
this.showRestorePrompt(data.state);
}
} catch (error) {
console.error('[TerminalManager] Error checking for restore:', error);
}
}
/**
* Show restore prompt toast
*/
showRestorePrompt(state) {
const terminalCount = state.terminals.length;
const toast = document.createElement('div');
toast.className = 'toast-notification toast-info restore-toast';
toast.innerHTML = `
<div class="restore-content">
<span class="toast-icon">📝</span>
<div class="restore-message">
<strong>Previous session detected</strong><br>
${terminalCount} terminal${terminalCount > 1 ? 's' : ''} from last session
</div>
<div class="restore-actions">
<button class="btn-restore-all">Restore All</button>
<button class="btn-dismiss">Dismiss</button>
</div>
</div>
`;
document.body.appendChild(toast);
setTimeout(() => toast.classList.add('visible'), 10);
// Handle restore
toast.querySelector('.btn-restore-all').addEventListener('click', async () => {
toast.classList.remove('visible');
await this.restoreTerminals(state.terminals);
setTimeout(() => toast.remove(), 300);
});
// Handle dismiss
toast.querySelector('.btn-dismiss').addEventListener('click', () => {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 300);
});
// Auto-dismiss after 30 seconds
setTimeout(() => {
if (document.body.contains(toast)) {
toast.classList.remove('visible');
setTimeout(() => toast.remove(), 300);
}
}, 30000);
}
/**
* Restore terminals from saved state
*/
async restoreTerminals(savedTerminals) {
for (const saved of savedTerminals) {
await this.createTerminal({
workingDir: saved.workingDir,
sessionId: saved.sessionId,
mode: saved.mode,
silent: true
});
}
showToast(`Restored ${savedTerminals.length} terminal${savedTerminals.length > 1 ? 's' : ''}`, 'success');
}
/**
* Create a new terminal
*/
async createTerminal(options = {}) {
const {
workingDir = null,
sessionId = null,
mode = 'mixed',
silent = false,
skipSessionPicker = false,
terminalType = null
} = options;
this.debugLog('INIT', `createTerminal called with options`, { workingDir, sessionId, mode, terminalType });
// Show directory picker if no working directory provided
const selection = workingDir
? { directory: workingDir, terminalType: terminalType || 'standard' }
: await this.showDirectoryPicker();
if (!selection) {
this.debugLog('INIT', `User cancelled directory picker`);
return null;
}
const { directory: selectedDir, terminalType: selectedTerminalType } = selection;
this.debugLog('INIT', `Directory selected: ${selectedDir}, terminalType: ${selectedTerminalType}`);
// If no session provided and not skipping picker, show session picker
let sessionSelection = null;
if (!sessionId && !skipSessionPicker && selectedTerminalType !== 'claude-cli') {
// Skip session picker if Claude Code CLI terminal is selected
this.debugLog('INIT', `Showing session picker...`);
sessionSelection = await this.showSessionPicker();
// If user cancelled session picker, still create terminal but without session
// sessionSelection will be null or { sessionId: string, source: 'web'|'local' } or { sessionId: 'new', source: 'new' }
this.debugLog('INIT', `Session picker result:`, sessionSelection);
}
try {
// Create terminal via API
this.debugLog('INIT', `Calling /claude/api/terminals to create terminal...`);
const res = await fetch('/claude/api/terminals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
workingDir: selectedDir,
sessionId: sessionSelection && sessionSelection.sessionId !== 'new' ? sessionSelection.sessionId : null,
mode
})
});
const data = await res.json();
if (!data.success) {
this.debugLog('ERROR', `API call failed:`, data);
throw new Error(data.error || 'Failed to create terminal');
}
const terminalId = data.terminalId;
this.debugLog('INIT', `Terminal created with ID: ${terminalId}`);
// Create terminal UI
this.debugLog('INIT', `Creating terminal UI...`);
await this.createTerminalUI(terminalId, selectedDir, mode);
// Initialize xterm.js
this.debugLog('INIT', `Initializing xterm.js...`);
await this.initializeXTerm(terminalId);
this.debugLog('INIT', `xterm.js initialized`);
// Start HTTP polling for output (NO WebSocket needed!)
this.debugLog('POLL', `Starting HTTP polling for ${terminalId}...`);
this.startPolling(terminalId);
// Switch to new terminal
this.debugLog('INIT', `Switching to terminal ${terminalId}...`);
await this.switchToTerminal(terminalId);
this.debugLog('INIT', `Switched to terminal ${terminalId}`);
// Handle terminal type specific initialization
if (selectedTerminalType === 'claude-cli') {
this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`);
await this.launchCommand(terminalId, 'claude --dangerously-skip-permissions\n');
if (!silent) {
showToast('Claude Code CLI terminal created', 'success');
}
} else if (sessionSelection && sessionSelection.sessionId) {
// Auto-launch Claude CLI if session was selected
if (sessionSelection.sessionId !== 'new') {
await this.launchClaudeCLI(terminalId, sessionSelection.sessionId, sessionSelection.source);
} else {
// Launch Claude CLI without session for new session
await this.launchClaudeCLI(terminalId, null, 'new');
}
if (!silent) {
const sessionMsg = sessionSelection && sessionSelection.sessionId && sessionSelection.sessionId !== 'new'
? ` with ${sessionSelection.source === 'local' ? 'local' : ''} session ${sessionSelection.sessionId.substring(0, 12)}`
: sessionSelection && sessionSelection.sessionId === 'new' ? ' (New Session)' : '';
showToast(`Terminal created${sessionMsg}`, 'success');
}
} else {
if (!silent) {
showToast('Terminal created', 'success');
}
}
return terminalId;
} catch (error) {
console.error('[TerminalManager] Error creating terminal:', error);
showToast(error.message, 'error');
return null;
}
}
/**
* Show directory picker modal
*/
async showDirectoryPicker() {
return new Promise(async (resolve) => {
// Fetch recent directories
let recentDirs = ['/home/uroma/obsidian-vault', '/home/uroma'];
try {
const res = await fetch('/claude/api/files/recent-dirs');
const data = await res.json();
if (data.success) {
recentDirs = data.directories;
}
} catch (error) {
console.error('Error fetching recent directories:', error);
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal directory-picker-modal">
<div class="modal-header">
<h2>Select Working Directory</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="recent-directories">
<label>Recent Directories</label>
<div class="directory-list">
${recentDirs.map(dir => `
<button class="directory-item" data-dir="${escapeHtml(dir)}">
<span class="dir-icon">📁</span>
<span class="dir-path">${escapeHtml(dir)}</span>
</button>
`).join('')}
</div>
</div>
<div class="custom-directory">
<label>Custom Path</label>
<input type="text" class="directory-input" placeholder="Enter absolute path..." value="${escapeHtml(recentDirs[0])}">
</div>
<div class="terminal-type-selection">
<label>Terminal Type</label>
<select class="terminal-type-select">
<option value="standard">Standard Shell (bash/zsh)</option>
<option value="claude-cli">Claude Code CLI (with --dangerously-skip-permissions)</option>
</select>
<small style="display: block; margin-top: 0.5rem; color: var(--text-secondary);">
Standard Shell: Regular terminal with bash/zsh<br>
Claude Code CLI: Automatically starts with claude --dangerously-skip-permissions
</small>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary btn-cancel">Cancel</button>
<button class="btn-primary btn-create">Create Terminal</button>
</div>
</div>
`;
document.body.appendChild(modal);
setTimeout(() => modal.classList.add('visible'), 10);
const input = modal.querySelector('.directory-input');
const terminalTypeSelect = modal.querySelector('.terminal-type-select');
const createBtn = modal.querySelector('.btn-create');
let selectedDir = recentDirs[0];
let selectedTerminalType = 'standard';
// Handle directory selection
modal.querySelectorAll('.directory-item').forEach(item => {
item.addEventListener('click', () => {
modal.querySelectorAll('.directory-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedDir = item.dataset.dir;
input.value = selectedDir;
});
});
// Handle input change
input.addEventListener('input', () => {
selectedDir = input.value;
});
// Handle terminal type selection
terminalTypeSelect.addEventListener('change', () => {
selectedTerminalType = terminalTypeSelect.value;
});
// Handle enter key
input.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
resolve({ directory: selectedDir, terminalType: selectedTerminalType });
cleanup();
}
});
// Handle create
createBtn.addEventListener('click', () => {
resolve({ directory: selectedDir, terminalType: selectedTerminalType });
cleanup();
});
// Handle cancel/close
const cancel = () => {
resolve(null);
cleanup();
};
modal.querySelector('.btn-cancel').addEventListener('click', cancel);
modal.querySelector('.modal-close').addEventListener('click', cancel);
modal.addEventListener('click', (e) => {
if (e.target === modal) cancel();
});
const cleanup = () => {
modal.classList.remove('visible');
setTimeout(() => modal.remove(), 300);
};
input.focus();
});
}
/**
* Show session picker modal
*/
async showSessionPicker() {
return new Promise(async (resolve) => {
// Fetch web sessions and local sessions in parallel
let webSessions = [];
let localSessions = [];
try {
const [webRes, localRes] = await Promise.all([
fetch('/claude/api/claude/sessions'),
fetch('/claude/api/claude/local-sessions')
]);
const webData = await webRes.json();
const localData = await localRes.json();
// Process web sessions
if (webData.success) {
const allWebSessions = [
...(webData.active || []).map(s => ({...s, type: 'web', source: 'web'})),
...(webData.historical || []).map(s => ({...s, type: 'web', source: 'web'}))
];
// Sort by last activity and take last 10
allWebSessions.sort((a, b) => {
const dateA = new Date(a.lastActivity || a.createdAt || a.created_at);
const dateB = new Date(b.lastActivity || b.createdAt || b.created_at);
return dateB - dateA;
});
webSessions = allWebSessions.slice(0, 10);
}
// Process local sessions
if (localData.success && localData.sessions) {
localSessions = localData.sessions.slice(0, 10).map(s => ({
...s,
source: 'local'
}));
}
} catch (error) {
console.error('Error fetching sessions:', error);
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal session-picker-modal">
<div class="modal-header">
<h2>Select Claude Session</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="session-list">
<button class="session-list-item create-new" data-session="new">
<span class="session-icon"></span>
<div class="session-info">
<div class="session-name">Create New Session</div>
<div class="session-detail">Start a fresh Claude CLI session</div>
</div>
</button>
${webSessions.length > 0 ? `
<div class="session-section">
<div class="section-header">Web Sessions</div>
${webSessions.map(session => {
const projectName = this.getProjectName(session);
const relativeTime = this.getRelativeTime(session);
const status = this.getStatus(session);
const statusClass = this.getStatusClass(session);
return `
<button class="session-list-item" data-session="${session.id}" data-source="web">
<span class="session-icon">💬</span>
<div class="session-info">
<div class="session-name">${escapeHtml(projectName)}</div>
<div class="session-detail">
<span class="session-time">${relativeTime}</span>
<span class="session-id">${session.id.substring(0, 12)}</span>
</div>
</div>
<span class="status-badge ${statusClass}">${status}</span>
</button>
`;
}).join('')}
</div>
` : ''}
${localSessions.length > 0 ? `
<div class="session-section">
<div class="section-header">Local CLI Sessions</div>
${localSessions.map(session => {
const projectName = session.projectName || 'Local Session';
const relativeTime = this.getRelativeTime(session);
const status = 'Local';
const statusClass = 'local';
return `
<button class="session-list-item" data-session="${session.id}" data-source="local">
<span class="session-icon">💻</span>
<div class="session-info">
<div class="session-name">${escapeHtml(projectName)}</div>
<div class="session-detail">
<span class="session-time">${relativeTime}</span>
<span class="session-id">${session.id.substring(0, 12)}</span>
</div>
</div>
<span class="status-badge ${statusClass}">${status}</span>
</button>
`;
}).join('')}
</div>
` : ''}
${webSessions.length === 0 && localSessions.length === 0 ? `
<div class="no-sessions">
<p>No sessions found. Create a new one to get started!</p>
</div>
` : ''}
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary btn-cancel">Cancel</button>
<button class="btn-primary btn-open" disabled>Open Terminal</button>
</div>
</div>
`;
document.body.appendChild(modal);
setTimeout(() => modal.classList.add('visible'), 10);
const openBtn = modal.querySelector('.btn-open');
let selectedSession = null;
let selectedSource = null;
// Handle session selection
modal.querySelectorAll('.session-list-item:not(.create-new)').forEach(item => {
item.addEventListener('click', () => {
modal.querySelectorAll('.session-list-item').forEach(i => i.classList.remove('selected'));
item.classList.add('selected');
selectedSession = item.dataset.session;
selectedSource = item.dataset.source;
openBtn.disabled = false;
});
});
// Handle "Create New"
const createNewItem = modal.querySelector('.create-new');
createNewItem.addEventListener('click', () => {
modal.querySelectorAll('.session-list-item').forEach(i => i.classList.remove('selected'));
createNewItem.classList.add('selected');
selectedSession = 'new';
selectedSource = 'new';
openBtn.disabled = false;
});
// Auto-select "Create New Session"
createNewItem.classList.add('selected');
selectedSession = 'new';
selectedSource = 'new';
openBtn.disabled = false;
// Handle open
openBtn.addEventListener('click', () => {
resolve({ sessionId: selectedSession, source: selectedSource });
cleanup();
});
// Handle cancel/close
const cancel = () => {
resolve(null); // Return null to skip session attachment
cleanup();
};
modal.querySelector('.btn-cancel').addEventListener('click', cancel);
modal.querySelector('.modal-close').addEventListener('click', cancel);
modal.addEventListener('click', (e) => {
if (e.target === modal) cancel();
});
const cleanup = () => {
modal.classList.remove('visible');
setTimeout(() => modal.remove(), 300);
};
// Focus on first item
createNewItem.focus();
});
}
/**
* Launch Claude CLI in terminal with optional session
*/
async launchClaudeCLI(terminalId, sessionId, source = 'web') {
// Construct command
const command = sessionId
? `claude --session ${sessionId}\n`
: 'claude\n';
// Send command to terminal via HTTP
await this.sendTerminalInput(terminalId, command);
// Update mode to session
await this.setMode(terminalId, 'session');
// Update UI to show attached session
const sessionLabel = document.getElementById(`session-${terminalId}`);
if (sessionLabel) {
if (sessionId) {
const sourceLabel = source === 'local' ? '💻 ' : '🔗 ';
sessionLabel.textContent = `${sourceLabel}${sessionId.substring(0, 12)}`;
sessionLabel.title = `Attached to ${source} session ${sessionId}`;
} else {
sessionLabel.textContent = '🆕 New Session';
sessionLabel.title = 'New Claude CLI session';
}
}
// Store session ID and source in terminal data
const terminal = this.terminals.get(terminalId);
if (terminal) {
if (sessionId) {
terminal.sessionId = sessionId;
terminal.sessionSource = source;
}
}
}
/**
* Launch a command in the terminal via HTTP
*/
async launchCommand(terminalId, command) {
this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`);
await this.sendTerminalInput(terminalId, command);
}
/**
* Start HTTP polling for terminal output
*/
startPolling(terminalId) {
const terminal = this.terminals.get(terminalId);
if (!terminal) return;
terminal.currentOutputIndex = 0;
terminal.polling = true;
const poll = async () => {
const term = this.terminals.get(terminalId);
if (!term || !term.polling) {
this.debugLog('POLL', `Stopped polling for ${terminalId}`);
return;
}
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/output?since=${term.currentOutputIndex}`);
const data = await res.json();
if (data.success && data.output && data.output.length > 0) {
this.debugLog('POLL', `Received ${data.output.length} output entries for ${terminalId}`);
// Write output to xterm.js
for (const entry of data.output) {
if (entry.type === 'data') {
if (term.terminal) {
term.terminal.write(entry.data);
}
} else if (entry.type === 'exit') {
this.debugLog('PTY', `Terminal exited: ${entry.exitCode || entry.signal}`);
showToast(`Terminal exited: ${entry.exitCode || 'terminated'}`, 'info');
} else if (entry.type === 'modeChanged') {
this.debugLog('INIT', `Mode changed to ${entry.mode}`);
this.updateModeDisplay(terminalId, entry.mode);
}
}
// Update current index
term.currentOutputIndex = data.currentIndex;
}
} catch (error) {
this.debugLog('ERROR', `Polling error for ${terminalId}:`, { error: error.message });
}
// Schedule next poll
if (term.polling) {
this.pollingTimers.set(terminalId, setTimeout(poll, this.pollingInterval));
}
};
// Start polling
poll();
this.debugLog('POLL', `Started polling for ${terminalId} at ${this.pollingInterval}ms interval`);
}
/**
* Stop HTTP polling for terminal
*/
stopPolling(terminalId) {
const timer = this.pollingTimers.get(terminalId);
if (timer) {
clearTimeout(timer);
this.pollingTimers.delete(terminalId);
}
const terminal = this.terminals.get(terminalId);
if (terminal) {
terminal.polling = false;
}
this.debugLog('POLL', `Stopped polling for ${terminalId}`);
}
/**
* Extract project name from session metadata
*/
getProjectName(session) {
return session.metadata?.project ||
session.metadata?.projectName ||
session.workingDir?.split('/').pop() ||
'Session ' + session.id.substring(0, 8);
}
/**
* Get relative time string for session
*/
getRelativeTime(session) {
const date = new Date(session.lastActivity || session.createdAt || session.created_at);
const now = new Date();
const diffMins = Math.floor((now - date) / 60000);
const diffHours = Math.floor((now - date) / 3600000);
const diffDays = Math.floor((now - date) / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Get status text for session
*/
getStatus(session) {
if (session.status === 'running') return 'Active';
return 'Done';
}
/**
* Get status CSS class for session
*/
getStatusClass(session) {
if (session.status === 'running') return 'active';
return 'done';
}
/**
* Create terminal UI elements
*/
async createTerminalUI(terminalId, workingDir, mode) {
// Create tab
const tab = document.createElement('div');
tab.className = 'terminal-tab';
tab.dataset.terminalId = terminalId;
tab.innerHTML = `
<span class="tab-id">${terminalId.substring(0, 12)}</span>
<span class="tab-mode">${this.getModeIcon(mode)}</span>
<button class="tab-close" title="Close terminal">&times;</button>
`;
this.terminalTabsContainer.appendChild(tab);
// Handle tab click
tab.addEventListener('click', (e) => {
if (!e.target.classList.contains('tab-close')) {
this.switchToTerminal(terminalId);
}
});
// Handle close button
tab.querySelector('.tab-close').addEventListener('click', (e) => {
e.stopPropagation();
this.closeTerminal(terminalId);
});
// Create terminal container
const container = document.createElement('div');
container.className = 'terminal-container';
container.dataset.terminalId = terminalId;
container.innerHTML = `
<div class="terminal-toolbar">
<div class="terminal-info">
<span class="terminal-id">${terminalId}</span>
<span class="terminal-path">${escapeHtml(workingDir)}</span>
<span class="terminal-mode" data-mode="${mode}">${this.getModeLabel(mode)}</span>
<span class="terminal-session" id="session-${terminalId}"></span>
</div>
<div class="terminal-actions">
<button class="btn-terminal-attach" data-terminal-id="${terminalId}" title="Attach to Claude Code session">🔗 Attach</button>
<button class="btn-terminal-mode" title="Change mode">🔄 Mode</button>
<button class="btn-terminal-clear" title="Clear screen">🧹 Clear</button>
<button class="btn-terminal-close" title="Close terminal">✕ Close</button>
</div>
</div>
<div class="terminal-xterm" id="xterm-${terminalId}"></div>
`;
this.terminalsContainer.appendChild(container);
// Hide placeholder
const placeholder = this.terminalsContainer.querySelector('.terminal-placeholder');
if (placeholder) {
placeholder.style.display = 'none';
}
// Handle toolbar buttons
container.querySelector('.btn-terminal-attach').addEventListener('click', () => {
this.attachToSession(terminalId);
});
container.querySelector('.btn-terminal-mode').addEventListener('click', () => {
this.cycleMode(terminalId);
});
container.querySelector('.btn-terminal-clear').addEventListener('click', () => {
this.clearScreen(terminalId);
});
container.querySelector('.btn-terminal-close').addEventListener('click', () => {
this.closeTerminal(terminalId);
});
}
/**
* Initialize xterm.js instance for terminal
*/
async initializeXTerm(terminalId) {
this.debugLog('INIT', `initializeXTerm called for ${terminalId}`);
const container = document.getElementById(`xterm-${terminalId}`);
if (!container) {
this.debugLog('ERROR', `Terminal container not found: ${terminalId}`);
throw new Error(`Terminal container not found: ${terminalId}`);
}
// Create Terminal instance
const Terminal = window.Terminal;
const FitAddon = window.FitAddon;
const WebLinksAddon = window.WebLinksAddon;
const terminal = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1a1a1a',
foreground: '#e0e0e0',
cursor: '#4a9eff',
selection: 'rgba(74, 158, 255, 0.3)',
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff'
}
});
// Load addons
const fitAddon = new FitAddon.FitAddon();
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
terminal.loadAddon(fitAddon);
terminal.loadAddon(webLinksAddon);
// Open in container
terminal.open(container);
// Fit to container
fitAddon.fit();
// Focus terminal on click
container.addEventListener('click', () => {
terminal.focus();
});
// Auto-focus terminal when created
setTimeout(() => terminal.focus(), 100);
// Handle user input - send via HTTP POST
terminal.onData((data) => {
this.sendTerminalInput(terminalId, data);
});
// Handle resize - send via HTTP POST
terminal.onResize(({ cols, rows }) => {
this.sendTerminalResize(terminalId, cols, rows);
});
// Store terminal instance in map
this.terminals.set(terminalId, {
terminal,
fitAddon,
container,
mode: 'mixed',
polling: false,
currentOutputIndex: 0
});
this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}`);
return terminal;
}
/**
* Send input to terminal via HTTP POST
*/
async sendTerminalInput(terminalId, data) {
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const result = await res.json();
if (!result.success) {
this.debugLog('ERROR', `Failed to send input: ${result.error}`);
}
} catch (error) {
this.debugLog('ERROR', `Error sending input:`, { error: error.message });
}
}
/**
* Send terminal resize via HTTP POST
*/
async sendTerminalResize(terminalId, cols, rows) {
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const result = await res.json();
if (!result.success) {
this.debugLog('ERROR', `Failed to resize terminal: ${result.error}`);
}
} catch (error) {
this.debugLog('ERROR', `Error resizing terminal:`, { error: error.message });
}
}
/**
* Switch to specific terminal
*/
async switchToTerminal(terminalId) {
this.debugLog('INIT', `switchToTerminal called for ${terminalId}`);
// Hide all containers and remove active class
document.querySelectorAll('.terminal-container').forEach(c => {
c.classList.remove('active');
c.style.display = 'none';
});
// Deactivate all tabs
document.querySelectorAll('.terminal-tab').forEach(t => {
t.classList.remove('active');
});
// Show selected container
const container = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"]`);
if (container) {
container.classList.add('active');
container.style.display = 'flex';
// Fit terminal
const terminal = this.terminals.get(terminalId);
if (terminal && terminal.fitAddon) {
setTimeout(() => {
try {
terminal.fitAddon.fit();
} catch (error) {
this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message });
}
}, 100);
}
}
// Activate tab
const tab = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"]`);
if (tab) {
tab.classList.add('active');
}
this.debugLog('INIT', `switchToTerminal completed for ${terminalId}`);
this.activeTerminalId = terminalId;
}
/**
* Close terminal
*/
async closeTerminal(terminalId) {
try {
// Stop polling
this.stopPolling(terminalId);
// Close via API
await fetch(`/claude/api/terminals/${terminalId}`, {
method: 'DELETE'
});
// Remove tab
const tab = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"]`);
if (tab) tab.remove();
// Remove container
const container = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"]`);
if (container) container.remove();
// Remove from map
this.terminals.delete(terminalId);
// If this was active terminal, switch to another
if (this.activeTerminalId === terminalId) {
const remainingTabs = document.querySelectorAll('.terminal-tab');
if (remainingTabs.length > 0) {
this.switchToTerminal(remainingTabs[0].dataset.terminalId);
} else {
this.activeTerminalId = null;
// Show placeholder when no terminals remain
const placeholder = this.terminalsContainer.querySelector('.terminal-placeholder');
if (placeholder) {
placeholder.style.display = 'block';
}
}
}
showToast('Terminal closed', 'info');
} catch (error) {
console.error('[TerminalManager] Error closing terminal:', error);
showToast('Failed to close terminal', 'error');
}
}
/**
* Set terminal mode
*/
async setMode(terminalId, mode) {
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/mode`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode })
});
const data = await res.json();
if (data.success) {
const terminal = this.terminals.get(terminalId);
if (terminal) {
terminal.mode = mode;
}
this.updateModeDisplay(terminalId, mode);
showToast(`Mode changed to ${this.getModeLabel(mode)}`, 'success');
}
} catch (error) {
console.error('[TerminalManager] Error setting mode:', error);
showToast('Failed to change mode', 'error');
}
}
/**
* Cycle through modes
*/
cycleMode(terminalId) {
const terminal = this.terminals.get(terminalId);
if (!terminal) return;
const modes = ['shell', 'session', 'mixed'];
const currentIndex = modes.indexOf(terminal.mode);
const nextMode = modes[(currentIndex + 1) % modes.length];
this.setMode(terminalId, nextMode);
}
/**
* Update mode display
*/
updateModeDisplay(terminalId, mode) {
// Update toolbar
const modeLabel = document.querySelector(`.terminal-container[data-terminal-id="${terminalId}"] .terminal-mode`);
if (modeLabel) {
modeLabel.textContent = this.getModeLabel(mode);
modeLabel.dataset.mode = mode;
}
// Update tab
const tabMode = document.querySelector(`.terminal-tab[data-terminal-id="${terminalId}"] .tab-mode`);
if (tabMode) {
tabMode.textContent = this.getModeIcon(mode);
}
}
/**
* Clear terminal screen
*/
clearScreen(terminalId) {
const terminal = this.terminals.get(terminalId);
if (terminal && terminal.terminal) {
terminal.terminal.clear();
}
}
/**
* Get mode icon
*/
getModeIcon(mode) {
const icons = {
session: '🔵',
shell: '🟢',
mixed: '🟡'
};
return icons[mode] || '⚪';
}
/**
* Get mode label
*/
getModeLabel(mode) {
const labels = {
session: 'Session',
shell: 'Shell',
mixed: 'Mixed'
};
return labels[mode] || 'Unknown';
}
/**
* Attach terminal to current Claude Code session
*/
async attachToSession(terminalId) {
// Check if there's an active session
const currentSessionId = window.chatSessionId;
if (!currentSessionId) {
showToast('No active session. Creating a new session...', 'warning');
// Create a new session automatically
try {
const res = await fetch('/claude/api/claude/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/home/uroma' })
});
const data = await res.json();
if (!data.success) {
throw new Error(data.error || 'Failed to create session');
}
const newSessionId = data.session.id;
// Update global session ID
window.chatSessionId = newSessionId;
window.attachedSessionId = newSessionId;
// Update UI
const sessionIdEl = document.getElementById('current-session-id');
if (sessionIdEl) sessionIdEl.textContent = newSessionId;
// Now attach to the new session
return await this.attachToSession(terminalId);
} catch (error) {
console.error('[TerminalManager] Error creating session:', error);
showToast('Failed to create session. Please try creating one from the chat panel.', 'error');
return;
}
}
try {
const res = await fetch(`/claude/api/terminals/${terminalId}/attach`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: currentSessionId })
});
const data = await res.json();
if (!res.ok || !data.success) {
// Handle "Session not found" error specifically
if (data.error && data.error.includes('not found')) {
showToast(`Session "${currentSessionId.substring(0, 12)}..." no longer exists. Creating a new one...`, 'warning');
// Clear the invalid session ID
window.chatSessionId = null;
window.attachedSessionId = null;
// Recursively call to create a new session
return await this.attachToSession(terminalId);
}
throw new Error(data.error || 'Failed to attach');
}
// Update terminal mode to session
await this.setMode(terminalId, 'session');
// Auto-launch Claude CLI with the session
await this.launchClaudeCLI(terminalId, currentSessionId);
showToast(`Attached to session ${currentSessionId.substring(0, 12)}`, 'success');
} catch (error) {
console.error('[TerminalManager] Error attaching to session:', error);
showToast(error.message || 'Failed to attach to session', 'error');
}
}
/**
* Setup keyboard shortcuts
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Ctrl+Shift+T - New terminal
if (e.ctrlKey && e.shiftKey && e.key === 'T') {
e.preventDefault();
this.createTerminal();
}
// Ctrl+Shift+W - Close terminal
if (e.ctrlKey && e.shiftKey && e.key === 'W') {
e.preventDefault();
if (this.activeTerminalId) {
this.closeTerminal(this.activeTerminalId);
}
}
// Ctrl+Tab - Next terminal
if (e.ctrlKey && e.key === 'Tab') {
e.preventDefault();
const tabs = Array.from(document.querySelectorAll('.terminal-tab'));
if (tabs.length > 0) {
const currentIndex = tabs.findIndex(t => t.dataset.terminalId === this.activeTerminalId);
const nextIndex = (currentIndex + 1) % tabs.length;
this.switchToTerminal(tabs[nextIndex].dataset.terminalId);
}
}
// Ctrl+Shift+M - Toggle mode
if (e.ctrlKey && e.shiftKey && e.key === 'M') {
e.preventDefault();
if (this.activeTerminalId) {
this.cycleMode(this.activeTerminalId);
}
}
});
}
/**
* Save terminal state for restoration
*/
async saveState() {
const terminals = [];
for (const [id, terminal] of this.terminals.entries()) {
const container = document.querySelector(`.terminal-container[data-terminal-id="${id}"]`);
if (container) {
const path = container.querySelector('.terminal-path')?.textContent;
const mode = container.querySelector('.terminal-mode')?.dataset.mode;
terminals.push({
id,
workingDir: path,
mode
});
}
}
const state = {
timestamp: new Date().toISOString(),
terminals
};
// Save to server
try {
await fetch('/claude/api/claude/terminal-state', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(state)
});
} catch (error) {
console.error('[TerminalManager] Error saving state:', error);
}
}
}
// Helper function
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}