/* Atlus — Terminal app (xterm.js + persistent PTY sessions) */ (function () { 'use strict'; let sessions = []; // {terminalId, ws, term, fitAddon, title} let activeTerminalId = null; let container = null; let termContainer = null; let tabsContainer = null; let kbdVisible = true; let resizeObserver = null; // ---- Create or attach to a terminal ---- async function createNewTerminal() { // Ask backend to create a persistent terminal try { const res = await Atlus.apiFetch('/api/session/terminals', { method: 'POST', body: { cols: 120, rows: 30 }, }); if (!res.ok) return null; const data = await res.json(); return attachToTerminal(data.terminal_id, data.title); } catch (e) { console.error('Failed to create terminal:', e); return null; } } function attachToTerminal(terminalId, title) { // Don't attach twice const existing = sessions.find(s => s.terminalId === terminalId); if (existing) { switchSession(terminalId); return existing; } const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: "'IBM Plex Mono', monospace", theme: { background: '#111318', foreground: '#c8ccd8', cursor: '#6ea6f0', selectionBackground: 'rgba(110, 166, 240, 0.3)', black: '#0d0f14', red: '#e05a4a', green: '#3ab86a', yellow: '#e09a2a', blue: '#6ea6f0', magenta: '#c678dd', cyan: '#56b6c2', white: '#c8ccd8', brightBlack: '#4a5068', brightRed: '#e05a4a', brightGreen: '#3ab86a', brightYellow: '#e09a2a', brightBlue: '#6ea6f0', brightMagenta: '#c678dd', brightCyan: '#56b6c2', brightWhite: '#ffffff', }, allowProposedApi: true, }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); // Connect WebSocket to the persistent terminal const wsUrl = Atlus.wsUrl(`/api/terminal/ws?terminal_id=${terminalId}`); const ws = new WebSocket(wsUrl); ws.onopen = () => { setTimeout(() => { fitAddon.fit(); ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })); }, 100); }; ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'output') { term.write(msg.data); } else if (msg.type === 'scrollback') { // Replay scrollback from server term.write(msg.data); } else if (msg.type === 'error') { term.write(`\r\n\x1b[31m[Error: ${msg.data}]\x1b[0m\r\n`); } }; ws.onclose = () => { term.write('\r\n\x1b[33m[Disconnected — refresh to reconnect]\x1b[0m\r\n'); }; // Send input to PTY term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })); } }); // Handle resize term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', cols, rows })); } }); const session = { terminalId, ws, term, fitAddon, title: title || 'Shell' }; sessions.push(session); // Connect on-screen keyboard if (Atlus.keyboard) { Atlus.keyboard.setTerminal({ input: (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })); } }, }); } return session; } function switchSession(terminalId) { activeTerminalId = terminalId; renderTabs(); renderTerminal(); if (Atlus.saveDesktopState) Atlus.saveDesktopState(); } async function closeSession(terminalId) { const idx = sessions.findIndex(s => s.terminalId === terminalId); if (idx === -1) return; const session = sessions[idx]; // Close WebSocket (doesn't kill PTY) if (session.ws.readyState === WebSocket.OPEN) { session.ws.close(); } session.term.dispose(); sessions.splice(idx, 1); // Tell server to kill the PTY try { await Atlus.apiFetch(`/api/session/terminals/${terminalId}`, { method: 'DELETE', }); } catch (e) { /* best effort */ } if (sessions.length === 0) { Atlus.closeApp('terminal'); return; } if (activeTerminalId === terminalId) { activeTerminalId = sessions[sessions.length - 1].terminalId; } renderTabs(); renderTerminal(); if (Atlus.saveDesktopState) Atlus.saveDesktopState(); } function renderTabs() { if (!tabsContainer) return; tabsContainer.innerHTML = ''; sessions.forEach((s, i) => { const tab = document.createElement('button'); tab.className = 'terminal-tab' + (s.terminalId === activeTerminalId ? ' active' : ''); tab.innerHTML = `${s.title || 'Shell ' + (i + 1)}`; if (sessions.length > 1) { tab.innerHTML += `×`; } tab.addEventListener('click', (e) => { if (e.target.classList.contains('terminal-tab-close')) { closeSession(s.terminalId); } else { switchSession(s.terminalId); } }); tabsContainer.appendChild(tab); }); } function renderTerminal() { if (!termContainer) return; termContainer.innerHTML = ''; const session = sessions.find(s => s.terminalId === activeTerminalId); if (!session) return; const wrap = document.createElement('div'); wrap.className = 'terminal-container'; termContainer.appendChild(wrap); session.term.open(wrap); requestAnimationFrame(() => { session.fitAddon.fit(); }); // Update keyboard terminal ref if (Atlus.keyboard) { Atlus.keyboard.setTerminal({ input: (data) => { if (session.ws.readyState === WebSocket.OPEN) { session.ws.send(JSON.stringify({ type: 'input', data })); } }, }); } } // ---- Session restore logic ---- async function initTerminals() { const restored = Atlus._restoredTerminals || []; const tabInfo = Atlus._restoredTerminalTabs || []; if (restored.length > 0) { // Re-attach to existing server-side terminals for (const t of restored) { // Find title from tab info if available const info = tabInfo.find(ti => ti.terminal_id === t.terminal_id); const title = (info && info.title) || t.title || 'Shell'; attachToTerminal(t.terminal_id, title); } if (sessions.length > 0) { activeTerminalId = sessions[0].terminalId; } } // If no terminals were restored, create a fresh one if (sessions.length === 0) { const s = await createNewTerminal(); if (s) activeTerminalId = s.terminalId; } // Clear restored data so re-opening terminal app doesn't double-attach delete Atlus._restoredTerminals; delete Atlus._restoredTerminalTabs; renderTabs(); renderTerminal(); } // ---- App registration ---- Atlus.registerApp('terminal', { title: 'Terminal', init(el) { container = el; container.classList.add('app-terminal'); // Toolbar const toolbar = document.createElement('div'); toolbar.className = 'terminal-toolbar'; tabsContainer = document.createElement('div'); tabsContainer.className = 'terminal-tabs'; toolbar.appendChild(tabsContainer); const newTabBtn = document.createElement('button'); newTabBtn.className = 'terminal-new-tab'; newTabBtn.textContent = '+'; newTabBtn.title = 'New tab'; newTabBtn.addEventListener('click', async () => { const s = await createNewTerminal(); if (s) switchSession(s.terminalId); }); toolbar.appendChild(newTabBtn); const kbdBtn = document.createElement('button'); kbdBtn.className = 'terminal-kbd-toggle' + (kbdVisible ? ' active' : ''); kbdBtn.textContent = '⌨'; kbdBtn.title = 'Toggle keyboard'; kbdBtn.addEventListener('click', () => { kbdVisible = !kbdVisible; kbdBtn.classList.toggle('active', kbdVisible); if (Atlus.keyboard) Atlus.keyboard.toggle(); const session = sessions.find(s => s.terminalId === activeTerminalId); if (session) { requestAnimationFrame(() => session.fitAddon.fit()); } }); toolbar.appendChild(kbdBtn); container.appendChild(toolbar); // Terminal area termContainer = document.createElement('div'); termContainer.style.flex = '1'; termContainer.style.overflow = 'hidden'; termContainer.style.display = 'flex'; termContainer.style.flexDirection = 'column'; container.appendChild(termContainer); // On-screen keyboard if (Atlus.keyboard) { const kbdEl = Atlus.keyboard.create(); if (!kbdVisible) kbdEl.classList.add('hidden'); container.appendChild(kbdEl); } // Initialize — restore or create terminals initTerminals(); // Resize observer resizeObserver = new ResizeObserver(() => { const session = sessions.find(s => s.terminalId === activeTerminalId); if (session) { try { session.fitAddon.fit(); } catch (e) {} } }); resizeObserver.observe(termContainer); }, destroy() { if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } // Close WebSockets but do NOT kill PTYs — they persist on server sessions.forEach(s => { if (s.ws.readyState === WebSocket.OPEN) s.ws.close(); s.term.dispose(); }); sessions = []; activeTerminalId = null; container = null; termContainer = null; tabsContainer = null; }, onFocus() { const session = sessions.find(s => s.terminalId === activeTerminalId); if (session) { requestAnimationFrame(() => { session.fitAddon.fit(); session.term.focus(); }); } }, /** Public API: get terminal IDs for session state saving */ getTerminalIds() { return sessions.map(s => ({ terminal_id: s.terminalId, title: s.title, })); }, }); })();