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>
280 lines
8.7 KiB
JavaScript
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">×</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();
|
|
});
|
|
}
|
|
},
|
|
});
|
|
})();
|