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