/* Atlus — Terminal app (xterm.js + PTY WebSocket) */ (function () { 'use strict'; let sessions = []; // {id, ws, term, fitAddon} let activeSession = 0; let sessionCounter = 0; let container = null; let termContainer = null; let tabsContainer = null; let kbdVisible = true; function createSession() { const id = ++sessionCounter; 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); const ws = new WebSocket(Atlus.wsUrl('/api/terminal/ws')); ws.onopen = () => { // Send initial size 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); } }; ws.onclose = () => { term.write('\r\n\x1b[31m[Session ended]\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 = { id, ws, term, fitAddon }; sessions.push(session); // Connect keyboard — only send to PTY, let PTY echo handle display Atlus.keyboard.setTerminal({ input: (data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })); } }, }); return session; } function switchSession(id) { activeSession = id; renderTabs(); renderTerminal(); } function closeSession(id) { const idx = sessions.findIndex(s => s.id === id); if (idx === -1) return; const session = sessions[idx]; if (session.ws.readyState === WebSocket.OPEN) { session.ws.close(); } session.term.dispose(); sessions.splice(idx, 1); if (sessions.length === 0) { Atlus.closeApp('terminal'); return; } if (activeSession === id) { activeSession = sessions[sessions.length - 1].id; } renderTabs(); renderTerminal(); } function renderTabs() { if (!tabsContainer) return; tabsContainer.innerHTML = ''; sessions.forEach(s => { const tab = document.createElement('button'); tab.className = 'terminal-tab' + (s.id === activeSession ? ' active' : ''); tab.innerHTML = `Shell ${s.id}`; if (sessions.length > 1) { tab.innerHTML += `×`; } tab.addEventListener('click', (e) => { if (e.target.classList.contains('terminal-tab-close')) { closeSession(s.id); } else { switchSession(s.id); } }); tabsContainer.appendChild(tab); }); } function renderTerminal() { if (!termContainer) return; // Clear termContainer.innerHTML = ''; const session = sessions.find(s => s.id === activeSession); if (!session) return; const wrap = document.createElement('div'); wrap.className = 'terminal-container'; termContainer.appendChild(wrap); session.term.open(wrap); // Fit after render requestAnimationFrame(() => { session.fitAddon.fit(); }); // Update keyboard terminal ref — only send to PTY Atlus.keyboard.setTerminal({ input: (data) => { if (session.ws.readyState === WebSocket.OPEN) { session.ws.send(JSON.stringify({ type: 'input', data })); } }, }); } // Resize observer let resizeObserver = null; 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', () => { const s = createSession(); switchSession(s.id); }); 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); Atlus.keyboard.toggle(); // Refit terminal const session = sessions.find(s => s.id === activeSession); 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 const kbdEl = Atlus.keyboard.create(); if (!kbdVisible) kbdEl.classList.add('hidden'); container.appendChild(kbdEl); // Create first session const s = createSession(); activeSession = s.id; renderTabs(); renderTerminal(); // Resize observer resizeObserver = new ResizeObserver(() => { const session = sessions.find(s => s.id === activeSession); if (session) { try { session.fitAddon.fit(); } catch (e) {} } }); resizeObserver.observe(termContainer); }, destroy() { if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null; } sessions.forEach(s => { if (s.ws.readyState === WebSocket.OPEN) s.ws.close(); s.term.dispose(); }); sessions = []; sessionCounter = 0; container = null; termContainer = null; tabsContainer = null; }, onFocus() { const session = sessions.find(s => s.id === activeSession); if (session) { requestAnimationFrame(() => { session.fitAddon.fit(); session.term.focus(); }); } }, }); })();