atlus/frontend/js/apps/terminal.js
roberts f9743bb29a Initial commit — Atlus web desktop environment for SBCs
Full-stack implementation: FastAPI backend with PAM auth, WebSocket
stats/terminal, and vanilla JS frontend with tiling desktop shell.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:53:46 -05:00

280 lines
8.7 KiB
JavaScript

/* 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 = `<span>Shell ${s.id}</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.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();
});
}
},
});
})();