The WebSocket was closing exactly 100ms after switchToTerminal completed, which correlated with the setTimeout(fitAddon.fit(), 100) call. Hypothesis: The fitAddon.fit() call or the 100ms delay is causing the WebSocket to close through an unknown mechanism (possibly triggering browser GC, event loop blocking, or some resource cleanup). Changes: - Removed 100ms stabilization delay in launchCommand - Disabled fitAddon.fit() call in switchToTerminal This should prevent the WebSocket closure and allow commands to be sent.
1452 lines
49 KiB
JavaScript
1452 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!`);
|
||
|
||
// NO DELAY - send command immediately to avoid WebSocket closure
|
||
this.debugLog('CMD', `Sending command immediately without delay...`);
|
||
|
||
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;
|
||
}
|
||
|
||
// Send command to terminal immediately
|
||
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 - DISABLED to avoid WebSocket closure issue
|
||
const terminal = this.terminals.get(terminalId);
|
||
if (terminal && terminal.fitAddon) {
|
||
this.debugLog('INIT', `Skipping fitAddon.fit() for ${terminalId} to avoid WebSocket closure`);
|
||
// TODO: Investigate why fitAddon.fit() causes WebSocket to close
|
||
// 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;
|
||
}
|