atlus/frontend/js/apps/terminal.js
roberts 220a5234cd Add persistent/roaming desktop sessions
PTY terminals now survive browser refresh and close. Session manager
owns PTY lifecycle independently of WebSocket connections, with
background readers storing scrollback for replay on reconnect. Desktop
state (open apps, active app, terminal tabs) persists server-side and
restores automatically on login. Auth tokens moved to localStorage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 21:41:28 -05:00

362 lines
12 KiB
JavaScript

/* 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 = `<span>${s.title || 'Shell ' + (i + 1)}</span>`;
if (sessions.length > 1) {
tab.innerHTML += `<span class="terminal-tab-close">&times;</span>`;
}
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,
}));
},
});
})();