The issue was that switchToTerminal() was not being awaited, so launchCommand() was called while switchToTerminal() was still executing. This caused a race condition where the WebSocket closed before the command could be sent. By awaiting switchToTerminal(), we ensure the terminal is fully switched before attempting to send any commands.
1458 lines
49 KiB
JavaScript
1458 lines
49 KiB
JavaScript
/**
|
||
* Terminal Manager - Frontend for xterm.js terminals
|
||
*/
|
||
|
||
class TerminalManager {
|
||
constructor() {
|
||
this.terminals = new Map(); // terminalId -> { terminal, ws, fitAddon, container, mode, ready }
|
||
this.activeTerminalId = null;
|
||
this.xtermLoaded = false;
|
||
this.terminalsContainer = null;
|
||
this.terminalTabsContainer = null;
|
||
this.debugMessages = [];
|
||
|
||
// 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',
|
||
'WS': '#a78bfa',
|
||
'CMD': '#51cf66',
|
||
'ERROR': '#ff6b6b',
|
||
'READY': '#ffd43b',
|
||
'PTY': '#ffa94d'
|
||
};
|
||
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 FIRST (before connecting WebSocket)
|
||
// This ensures this.terminals map has the entry ready
|
||
this.debugLog('INIT', `Initializing xterm.js...`);
|
||
await this.initializeXTerm(terminalId);
|
||
this.debugLog('INIT', `xterm.js initialized, terminal should be in map now`);
|
||
|
||
// NOW connect WebSocket (terminal entry exists in map)
|
||
// This waits for the 'ready' message from backend
|
||
this.debugLog('INIT', `Connecting WebSocket...`);
|
||
await this.connectTerminal(terminalId);
|
||
this.debugLog('INIT', `WebSocket connected and ready`);
|
||
|
||
// 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...`);
|
||
// Launch Claude CLI with skip permissions flag
|
||
// Note: Keep mode as 'mixed' since we're not attaching to a session
|
||
// connectTerminal now waits for 'ready' message, so PTY is definitely ready
|
||
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">×</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">×</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') {
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
|
||
console.error('[TerminalManager] Terminal not ready for CLI launch');
|
||
return;
|
||
}
|
||
|
||
// Construct command
|
||
const command = sessionId
|
||
? `claude --session ${sessionId}\n`
|
||
: 'claude\n';
|
||
|
||
// Send command to terminal
|
||
terminal.ws.send(JSON.stringify({
|
||
type: 'input',
|
||
data: 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
|
||
if (sessionId) {
|
||
terminal.sessionId = sessionId;
|
||
terminal.sessionSource = source;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Launch a command in the terminal
|
||
* Waits for terminal to be ready before sending command
|
||
*/
|
||
async launchCommand(terminalId, command) {
|
||
this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`);
|
||
|
||
// Wait for terminal to be ready (max 5 seconds)
|
||
this.debugLog('CMD', `Waiting for terminal ${terminalId} to be ready...`);
|
||
const ready = await this.waitForTerminalReady(terminalId, 5000);
|
||
|
||
if (!ready) {
|
||
this.debugLog('ERROR', `Terminal ${terminalId} NOT ready (timeout after 5s)`);
|
||
showToast('Terminal not ready. Please try again.', 'error');
|
||
return;
|
||
}
|
||
|
||
this.debugLog('CMD', `Terminal ${terminalId} is ready!`);
|
||
|
||
// Small delay to ensure WebSocket is stable after switching terminals
|
||
this.debugLog('CMD', `Waiting 100ms for WebSocket to stabilize...`);
|
||
await new Promise(resolve => setTimeout(resolve, 100));
|
||
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (!terminal) {
|
||
this.debugLog('ERROR', `Terminal ${terminalId} not found in map`);
|
||
return;
|
||
}
|
||
|
||
if (!terminal.ws) {
|
||
this.debugLog('ERROR', `WebSocket not set for terminal ${terminalId}`);
|
||
return;
|
||
}
|
||
|
||
// Check WebSocket state
|
||
const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
|
||
this.debugLog('CMD', `WebSocket state: ${readyStates[terminal.ws.readyState]} (${terminal.ws.readyState})`);
|
||
|
||
if (terminal.ws.readyState !== WebSocket.OPEN) {
|
||
this.debugLog('ERROR', `Cannot send - WebSocket not open`, {
|
||
wsState: terminal.ws.readyState,
|
||
stateName: readyStates[terminal.ws.readyState]
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Check if WebSocket has any buffered amount (indicating pending sends)
|
||
if (terminal.ws.bufferedAmount > 0) {
|
||
this.debugLog('CMD', `WebSocket has ${terminal.ws.bufferedAmount} bytes buffered, waiting...`);
|
||
await new Promise(resolve => setTimeout(resolve, 200));
|
||
}
|
||
|
||
// Send command to terminal
|
||
const message = JSON.stringify({
|
||
type: 'input',
|
||
data: command
|
||
});
|
||
this.debugLog('CMD', `Sending to WebSocket: ${message}`);
|
||
|
||
try {
|
||
terminal.ws.send(message);
|
||
this.debugLog('CMD', `Command sent to terminal ${terminalId}: ${command.trim()}`);
|
||
} catch (error) {
|
||
this.debugLog('ERROR', `Failed to send command`, { error: error.message, name: error.name });
|
||
showToast(`Failed to send command: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Wait for terminal to be ready
|
||
*/
|
||
async waitForTerminalReady(terminalId, timeout = 5000) {
|
||
const startTime = Date.now();
|
||
|
||
return new Promise((resolve) => {
|
||
const checkReady = () => {
|
||
const terminal = this.terminals.get(terminalId);
|
||
|
||
if (terminal && terminal.ready) {
|
||
console.log(`[TerminalManager] Terminal ${terminalId} is ready`);
|
||
resolve(true);
|
||
return;
|
||
}
|
||
|
||
if (Date.now() - startTime > timeout) {
|
||
console.error(`[TerminalManager] Terminal ${terminalId} ready timeout`);
|
||
resolve(false);
|
||
return;
|
||
}
|
||
|
||
// Check again in 100ms
|
||
setTimeout(checkReady, 100);
|
||
};
|
||
|
||
checkReady();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 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">×</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);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Connect terminal WebSocket
|
||
* Waits for 'ready' message from backend before resolving
|
||
*/
|
||
async connectTerminal(terminalId) {
|
||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsUrl = `${protocol}//${window.location.host}/claude/api/terminals/${terminalId}/ws`;
|
||
|
||
this.debugLog('WS', `Connecting to WebSocket for terminal ${terminalId}`, { url: wsUrl });
|
||
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
const ws = new WebSocket(wsUrl);
|
||
|
||
ws.onopen = () => {
|
||
this.debugLog('WS', `WebSocket OPENED for terminal ${terminalId}`);
|
||
|
||
// Store WebSocket in terminal entry
|
||
// NOTE: This assumes initializeXTerm() has already been called
|
||
// and this.terminals has the entry
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (terminal) {
|
||
terminal.ws = ws;
|
||
terminal.ready = false; // Will be set to true when 'ready' message received
|
||
this.debugLog('WS', `WebSocket stored in terminal map, waiting for 'ready' message`);
|
||
} else {
|
||
this.debugLog('ERROR', `CRITICAL: Terminal ${terminalId} not found in map!`, { terminalsInMap: Array.from(this.terminals.keys()) });
|
||
reject(new Error(`Terminal ${terminalId} not initialized`));
|
||
ws.close();
|
||
return;
|
||
}
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const message = JSON.parse(event.data);
|
||
this.debugLog('WS', `Message received: type="${message.type}"`, message);
|
||
this.handleTerminalMessage(terminalId, message);
|
||
|
||
// If this is the ready message, resolve the promise
|
||
if (message.type === 'ready') {
|
||
this.debugLog('READY', `✅ Ready message received for ${terminalId}, PTY initialized`, message);
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (terminal) {
|
||
terminal.ready = true;
|
||
}
|
||
resolve();
|
||
}
|
||
} catch (error) {
|
||
this.debugLog('ERROR', `Failed to parse WebSocket message`, { error: error.message, data: event.data });
|
||
}
|
||
};
|
||
|
||
ws.onerror = (error) => {
|
||
this.debugLog('ERROR', `WebSocket error for terminal ${terminalId}`, error);
|
||
reject(error);
|
||
};
|
||
|
||
ws.onclose = (event) => {
|
||
this.debugLog('WS', `WebSocket CLOSED for terminal ${terminalId}`, { code: event.code, reason: event.reason, wasClean: event.wasClean });
|
||
};
|
||
} catch (error) {
|
||
this.debugLog('ERROR', `Exception connecting WebSocket`, error);
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle terminal message from WebSocket
|
||
*/
|
||
handleTerminalMessage(terminalId, message) {
|
||
this.debugLog('WS', `Handling message: type="${message.type}"`, message);
|
||
|
||
const terminal = this.terminals.get(terminalId);
|
||
|
||
if (!terminal) {
|
||
this.debugLog('ERROR', `Cannot handle message - terminal ${terminalId} not found in map`, { terminalsInMap: Array.from(this.terminals.keys()) });
|
||
return;
|
||
}
|
||
|
||
switch (message.type) {
|
||
case 'ready':
|
||
this.debugLog('READY', `PTY initialized for ${terminalId}`);
|
||
break;
|
||
|
||
case 'data':
|
||
// Write to xterm.js
|
||
if (terminal.terminal) {
|
||
this.debugLog('PTY', `Writing data to xterm.js (${message.data.length} chars)`);
|
||
terminal.terminal.write(message.data);
|
||
} else {
|
||
this.debugLog('ERROR', `No xterm.js instance for terminal ${terminalId}`);
|
||
}
|
||
break;
|
||
|
||
case 'exit':
|
||
this.debugLog('PTY', `Terminal exited: ${message.exitCode || signal}`);
|
||
showToast(`Terminal exited: ${message.exitCode || 'terminated'}`, 'info');
|
||
break;
|
||
|
||
case 'modeChanged':
|
||
this.debugLog('INIT', `Mode changed to ${message.mode}`);
|
||
this.updateModeDisplay(terminalId, message.mode);
|
||
break;
|
||
|
||
default:
|
||
this.debugLog('WS', `Unknown message type: ${message.type}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
terminal.onData((data) => {
|
||
this.sendTerminalInput(terminalId, data);
|
||
});
|
||
|
||
// Handle resize
|
||
terminal.onResize(({ cols, rows }) => {
|
||
this.sendTerminalResize(terminalId, cols, rows);
|
||
});
|
||
|
||
// Store terminal instance in map
|
||
// This MUST happen before connectTerminal() is called
|
||
this.terminals.set(terminalId, {
|
||
terminal,
|
||
fitAddon,
|
||
ws: null, // Will be set by connectTerminal()
|
||
container,
|
||
mode: 'mixed',
|
||
ready: false // Will be set to true when 'ready' message received
|
||
});
|
||
|
||
this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}, terminals now has`, { terminalIds: Array.from(this.terminals.keys()) });
|
||
|
||
return terminal;
|
||
}
|
||
|
||
/**
|
||
* Send input to terminal
|
||
*/
|
||
sendTerminalInput(terminalId, data) {
|
||
const terminal = this.terminals.get(terminalId);
|
||
|
||
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
|
||
terminal.ws.send(JSON.stringify({
|
||
type: 'input',
|
||
data
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Send terminal resize
|
||
*/
|
||
sendTerminalResize(terminalId, cols, rows) {
|
||
const terminal = this.terminals.get(terminalId);
|
||
|
||
if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) {
|
||
return;
|
||
}
|
||
|
||
terminal.ws.send(JSON.stringify({
|
||
type: 'resize',
|
||
cols,
|
||
rows
|
||
}));
|
||
}
|
||
|
||
/**
|
||
* Switch to specific terminal
|
||
*/
|
||
async switchToTerminal(terminalId) {
|
||
this.debugLog('INIT', `switchToTerminal called for ${terminalId}, checking if terminal in map...`);
|
||
|
||
// 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';
|
||
|
||
// Initialize xterm.js if not already done
|
||
if (!this.terminals.has(terminalId)) {
|
||
this.debugLog('INIT', `Terminal ${terminalId} NOT in map, calling initializeXTerm...`);
|
||
await this.initializeXTerm(terminalId);
|
||
} else {
|
||
this.debugLog('INIT', `Terminal ${terminalId} already in map, skipping initialization`);
|
||
}
|
||
|
||
// Fit terminal
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (terminal && terminal.fitAddon) {
|
||
this.debugLog('INIT', `Calling fitAddon.fit() for ${terminalId} in 100ms...`);
|
||
setTimeout(() => {
|
||
this.debugLog('INIT', `Executing fitAddon.fit() for ${terminalId}`);
|
||
try {
|
||
terminal.fitAddon.fit();
|
||
this.debugLog('INIT', `fitAddon.fit() completed for ${terminalId}`);
|
||
} catch (error) {
|
||
this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message });
|
||
}
|
||
}, 100);
|
||
} else {
|
||
this.debugLog('INIT', `No fitAddon for terminal ${terminalId} (terminal=${!!terminal}, fitAddon=${!!terminal?.fitAddon})`);
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
// 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 Claude Code session. Start a chat first!', 'warning');
|
||
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 (!data.success) {
|
||
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;
|
||
}
|