From 2cf869d2e9a8f35e5de5cd87d2d165a8681cce47 Mon Sep 17 00:00:00 2001 From: josedario87 Date: Fri, 13 Feb 2026 07:33:43 -0600 Subject: [PATCH] feat: Add persistent terminal with floating UI - Server keeps PTY sessions alive on client disconnect - Output buffer replays history on reconnect - FloatingTerminal component accessible from any page - Responsive: floating window on desktop, fullscreen on mobile - macOS-style header with traffic light buttons --- frontend/src/App.vue | 75 ++- frontend/src/components/FloatingTerminal.vue | 490 ++++++++++++++++++ frontend/src/pages/TerminalPage.vue | 501 +++++++++++++++++++ server/index.ts | 205 +++++++- 4 files changed, 1268 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/FloatingTerminal.vue create mode 100644 frontend/src/pages/TerminalPage.vue diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ac061d2..2e66a42 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -1,16 +1,18 @@ + + + + diff --git a/frontend/src/pages/TerminalPage.vue b/frontend/src/pages/TerminalPage.vue new file mode 100644 index 0000000..9a3b004 --- /dev/null +++ b/frontend/src/pages/TerminalPage.vue @@ -0,0 +1,501 @@ + + + + + diff --git a/server/index.ts b/server/index.ts index b2198ce..fadca1e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,6 +1,18 @@ import { Database } from 'bun:sqlite' +import { spawn, type IPty } from '@skitee3000/bun-pty' const PORT_HTTP = 4101 +const PORT_TERMINAL = 4103 + +// Terminal types +interface TerminalSession { + id: string + pty: IPty + outputBuffer: string[] // Buffer para replay al reconectar + maxBufferSize: number + clients: Set // WebSockets conectados a esta sesión + createdAt: Date +} // Inicializar base de datos const db = new Database('agent-ui.db') @@ -1144,10 +1156,201 @@ Bun.serve({ }) console.log(`[HTTP] API corriendo en http://localhost:${PORT_HTTP}`) + +// ===================== +// Terminal WebSocket Server +// ===================== + +const WORKING_DIR = process.cwd().replace(/[\\\/]server$/, '') // Go to project root +const shell = process.platform === 'win32' ? 'powershell.exe' : 'bash' +const shellArgs = process.platform === 'win32' ? ['-NoLogo', '-NoProfile'] : [] + +// Store active terminal sessions by ID (persisten entre reconexiones) +const sessions = new Map() +const DEFAULT_SESSION_ID = 'main' +const MAX_BUFFER_LINES = 1000 + +// Helper: obtener o crear sesión +function getOrCreateSession(sessionId: string = DEFAULT_SESSION_ID): TerminalSession { + let session = sessions.get(sessionId) + + if (!session) { + console.log(`[Terminal] Creating new session: ${sessionId}`) + const pty = spawn(shell, shellArgs, { + name: 'xterm-256color', + cols: 80, + rows: 24, + cwd: WORKING_DIR + }) + + session = { + id: sessionId, + pty, + outputBuffer: [], + maxBufferSize: MAX_BUFFER_LINES, + clients: new Set(), + createdAt: new Date() + } + + // Capturar output en buffer y enviar a clientes + pty.onData((data: string) => { + // Guardar en buffer (para replay) + session!.outputBuffer.push(data) + if (session!.outputBuffer.length > session!.maxBufferSize) { + session!.outputBuffer.shift() + } + + // Enviar a todos los clientes conectados + for (const ws of session!.clients) { + try { + ws.send(JSON.stringify({ type: 'output', data })) + } catch (e) { + // Cliente desconectado + } + } + }) + + // Handle PTY exit + pty.onExit(({ exitCode, signal }) => { + console.log(`[Terminal] Session ${sessionId} exited with code ${exitCode}`) + for (const ws of session!.clients) { + try { + ws.send(JSON.stringify({ + type: 'exit', + data: `\r\n\x1b[33mSession ended (code ${exitCode})\x1b[0m\r\n` + })) + } catch (e) {} + } + sessions.delete(sessionId) + }) + + sessions.set(sessionId, session) + console.log(`[Terminal] Session ${sessionId} created, PID: ${pty.pid}`) + } + + return session +} + +// Mapa de WebSocket a sessionId +const wsToSession = new Map() + +const terminalServer = Bun.serve({ + port: PORT_TERMINAL, + fetch(req, server) { + const url = new URL(req.url) + + // Health check con info de sesiones + if (url.pathname === '/health') { + const sessionsInfo = Array.from(sessions.entries()).map(([id, s]) => ({ + id, + clients: s.clients.size, + pid: s.pty.pid, + bufferSize: s.outputBuffer.length, + createdAt: s.createdAt.toISOString() + })) + return Response.json({ + status: 'ok', + sessions: sessionsInfo, + cwd: WORKING_DIR + }) + } + + // Listar sesiones activas + if (url.pathname === '/sessions') { + const list = Array.from(sessions.keys()) + return Response.json({ sessions: list }) + } + + // Check if this is a WebSocket upgrade request + const upgradeHeader = req.headers.get('upgrade') + console.log(`[Terminal] Request: ${req.method} ${url.pathname}, Upgrade: ${upgradeHeader}`) + + if (upgradeHeader?.toLowerCase() === 'websocket') { + // Obtener sessionId del query param o usar default + const sessionId = url.searchParams.get('session') || DEFAULT_SESSION_ID + const success = server.upgrade(req, { data: { sessionId } }) + console.log(`[Terminal] WebSocket upgrade for session "${sessionId}": ${success ? 'success' : 'failed'}`) + if (success) { + return undefined + } + return new Response('WebSocket upgrade failed', { status: 400 }) + } + + return new Response('Terminal WebSocket Server - Persistent Sessions\n\nEndpoints:\n /health - Server status\n /sessions - List active sessions\n ws://...?session= - Connect to session', { status: 200 }) + }, + websocket: { + open(ws) { + const sessionId = (ws.data as any)?.sessionId || DEFAULT_SESSION_ID + console.log(`[Terminal] Client connecting to session: ${sessionId}`) + + try { + const session = getOrCreateSession(sessionId) + session.clients.add(ws) + wsToSession.set(ws, sessionId) + + // Enviar info de conexión + ws.send(JSON.stringify({ + type: 'connected', + sessionId: session.id, + isNew: session.outputBuffer.length === 0 + })) + + // Replay del buffer si hay historial + if (session.outputBuffer.length > 0) { + console.log(`[Terminal] Replaying ${session.outputBuffer.length} buffer entries`) + ws.send(JSON.stringify({ + type: 'replay', + data: session.outputBuffer.join('') + })) + } + + console.log(`[Terminal] Client joined session ${sessionId} (${session.clients.size} clients)`) + } catch (e: any) { + console.error('[Terminal] Error:', e) + ws.send(JSON.stringify({ type: 'error', message: e.message })) + } + }, + message(ws, message) { + try { + const msg = JSON.parse(message as string) + const sessionId = wsToSession.get(ws) + if (!sessionId) return + + const session = sessions.get(sessionId) + if (!session) return + + if (msg.type === 'input') { + session.pty.write(msg.data) + } else if (msg.type === 'resize' && msg.cols && msg.rows) { + session.pty.resize(msg.cols, msg.rows) + console.log(`[Terminal] Session ${sessionId} resized to ${msg.cols}x${msg.rows}`) + } + } catch (e: any) { + console.error('[Terminal] Error:', e) + } + }, + close(ws) { + const sessionId = wsToSession.get(ws) + if (sessionId) { + const session = sessions.get(sessionId) + if (session) { + session.clients.delete(ws) + console.log(`[Terminal] Client left session ${sessionId} (${session.clients.size} clients remaining)`) + // NO matamos el PTY - la sesión persiste + } + wsToSession.delete(ws) + } + } + } +}) + +console.log(`[Terminal] WebSocket corriendo en ws://localhost:${PORT_TERMINAL}`) console.log('') console.log('='.repeat(50)) -console.log('Agent UI API Server iniciado') +console.log('Agent UI Server iniciado') console.log(` API: http://localhost:${PORT_HTTP}`) +console.log(` Terminal: ws://localhost:${PORT_TERMINAL}`) +console.log(` Working Dir: ${WORKING_DIR}`) console.log('') console.log('WebMCP se inicia por separado con Claude Code MCP') console.log('='.repeat(50))