/* 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,
}));
},
});
})();