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
This commit is contained in:
@@ -1,16 +1,18 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, watch } from 'vue'
|
import { ref, onMounted, watch } from 'vue'
|
||||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||||
import StatusBar from './components/StatusBar.vue'
|
import StatusBar from './components/StatusBar.vue'
|
||||||
import Toolbar from './components/Toolbar.vue'
|
import Toolbar from './components/Toolbar.vue'
|
||||||
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
import ComponentsDropdown from './components/ComponentsDropdown.vue'
|
||||||
|
import FloatingTerminal from './components/FloatingTerminal.vue'
|
||||||
import { initWebMCP } from './services/webmcp'
|
import { initWebMCP } from './services/webmcp'
|
||||||
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
|
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const showTerminal = ref(false)
|
||||||
|
|
||||||
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source'
|
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal'
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// Initialize WebMCP connection
|
// Initialize WebMCP connection
|
||||||
@@ -49,6 +51,26 @@ watch(() => route.name, (newPage) => {
|
|||||||
</Transition>
|
</Transition>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<!-- Floating Terminal Toggle Button -->
|
||||||
|
<button
|
||||||
|
class="terminal-fab"
|
||||||
|
:class="{ active: showTerminal }"
|
||||||
|
@click="showTerminal = !showTerminal"
|
||||||
|
title="Toggle Terminal"
|
||||||
|
>
|
||||||
|
<svg v-if="!showTerminal" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<polyline points="4 17 10 11 4 5"></polyline>
|
||||||
|
<line x1="12" y1="19" x2="20" y2="19"></line>
|
||||||
|
</svg>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Floating Terminal -->
|
||||||
|
<FloatingTerminal v-model="showTerminal" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -103,4 +125,53 @@ watch(() => route.name, (newPage) => {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateX(-20px);
|
transform: translateX(-20px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Terminal FAB */
|
||||||
|
.terminal-fab {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
z-index: 9998;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-fab:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-fab.active {
|
||||||
|
background: #ef4444;
|
||||||
|
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-fab.active:hover {
|
||||||
|
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.terminal-fab {
|
||||||
|
bottom: 16px;
|
||||||
|
right: 16px;
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-fab.active {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
490
frontend/src/components/FloatingTerminal.vue
Normal file
490
frontend/src/components/FloatingTerminal.vue
Normal file
@@ -0,0 +1,490 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
})
|
||||||
|
|
||||||
|
const terminalContainer = ref<HTMLElement | null>(null)
|
||||||
|
const connected = ref(false)
|
||||||
|
const connecting = ref(false)
|
||||||
|
const sessionId = ref<string | null>(null)
|
||||||
|
const isMinimized = ref(false)
|
||||||
|
|
||||||
|
let terminal: Terminal | null = null
|
||||||
|
let fitAddon: FitAddon | null = null
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
const WS_URL = 'ws://localhost:4103'
|
||||||
|
|
||||||
|
function initTerminal() {
|
||||||
|
if (!terminalContainer.value || terminal) return
|
||||||
|
|
||||||
|
terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#0f0f14',
|
||||||
|
foreground: '#e4e4e7',
|
||||||
|
cursor: '#6366f1',
|
||||||
|
cursorAccent: '#0f0f14',
|
||||||
|
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
||||||
|
black: '#16161d',
|
||||||
|
red: '#ef4444',
|
||||||
|
green: '#22c55e',
|
||||||
|
yellow: '#eab308',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
magenta: '#a855f7',
|
||||||
|
cyan: '#06b6d4',
|
||||||
|
white: '#e4e4e7',
|
||||||
|
brightBlack: '#52525b',
|
||||||
|
brightRed: '#f87171',
|
||||||
|
brightGreen: '#4ade80',
|
||||||
|
brightYellow: '#facc15',
|
||||||
|
brightBlue: '#60a5fa',
|
||||||
|
brightMagenta: '#c084fc',
|
||||||
|
brightCyan: '#22d3ee',
|
||||||
|
brightWhite: '#ffffff'
|
||||||
|
},
|
||||||
|
allowProposedApi: true
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.loadAddon(new WebLinksAddon())
|
||||||
|
|
||||||
|
terminal.open(terminalContainer.value)
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
fitAddon?.fit()
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (fitAddon && terminal && !isMinimized.value) {
|
||||||
|
fitAddon.fit()
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(terminalContainer.value)
|
||||||
|
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (connecting.value || connected.value) return
|
||||||
|
|
||||||
|
connecting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(WS_URL)
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
connecting.value = false
|
||||||
|
terminal?.focus()
|
||||||
|
|
||||||
|
if (terminal) {
|
||||||
|
socket?.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if (msg.type === 'connected') {
|
||||||
|
sessionId.value = msg.sessionId
|
||||||
|
if (!msg.isNew) {
|
||||||
|
terminal?.write('\x1b[36m[Reconnected to session]\x1b[0m\r\n')
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'replay') {
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
} else if (msg.type === 'output') {
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
} else if (msg.type === 'exit') {
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
sessionId.value = null
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (socket) {
|
||||||
|
socket.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleMinimize() {
|
||||||
|
isMinimized.value = !isMinimized.value
|
||||||
|
if (!isMinimized.value) {
|
||||||
|
nextTick(() => {
|
||||||
|
fitAddon?.fit()
|
||||||
|
terminal?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function runClaude() {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Watch for open state
|
||||||
|
watch(isOpen, async (open) => {
|
||||||
|
if (open) {
|
||||||
|
await nextTick()
|
||||||
|
if (!terminal) {
|
||||||
|
initTerminal()
|
||||||
|
}
|
||||||
|
if (!connected.value && !connecting.value) {
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
nextTick(() => {
|
||||||
|
fitAddon?.fit()
|
||||||
|
terminal?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
await nextTick()
|
||||||
|
initTerminal()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
socket?.close()
|
||||||
|
terminal?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="terminal-slide">
|
||||||
|
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="terminal-header" @dblclick="toggleMinimize">
|
||||||
|
<div class="header-left">
|
||||||
|
<div class="traffic-lights">
|
||||||
|
<button class="light red" @click="close" title="Close"></button>
|
||||||
|
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
|
||||||
|
<button class="light green" @click="runClaude" title="Run Claude"></button>
|
||||||
|
</div>
|
||||||
|
<span class="terminal-title">
|
||||||
|
Terminal
|
||||||
|
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-right">
|
||||||
|
<span v-if="connected" class="status-dot connected"></span>
|
||||||
|
<span v-else-if="connecting" class="status-dot connecting"></span>
|
||||||
|
<span v-else class="status-dot disconnected"></span>
|
||||||
|
|
||||||
|
<button v-if="!connected" class="btn-connect" @click="connect" :disabled="connecting">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal body -->
|
||||||
|
<div v-show="!isMinimized" class="terminal-body">
|
||||||
|
<div ref="terminalContainer" class="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Minimized bar -->
|
||||||
|
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
|
||||||
|
<span>Click to expand</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-terminal {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 700px;
|
||||||
|
height: 450px;
|
||||||
|
background: #0f0f14;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-terminal.minimized {
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.terminal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #16161d;
|
||||||
|
border-bottom: 1px solid #2a2a3a;
|
||||||
|
cursor: default;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-lights {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.red {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.yellow {
|
||||||
|
background: #eab308;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light.green {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-id {
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(99, 102, 241, 0.2);
|
||||||
|
color: #818cf8;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background: #22c55e;
|
||||||
|
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connecting {
|
||||||
|
background: #eab308;
|
||||||
|
animation: pulse 1s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.disconnected {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect {
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect:hover:not(:disabled) {
|
||||||
|
background: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connect:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Terminal body */
|
||||||
|
.terminal-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport) {
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||||
|
background: #2a2a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minimized bar */
|
||||||
|
.minimized-bar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #52525b;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minimized-bar:hover {
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Transition */
|
||||||
|
.terminal-slide-enter-active,
|
||||||
|
.terminal-slide-leave-active {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-slide-enter-from,
|
||||||
|
.terminal-slide-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(100px) scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.floating-terminal {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-terminal.minimized {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 16px 16px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.traffic-lights {
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablet */
|
||||||
|
@media (min-width: 769px) and (max-width: 1024px) {
|
||||||
|
.floating-terminal {
|
||||||
|
width: 550px;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
501
frontend/src/pages/TerminalPage.vue
Normal file
501
frontend/src/pages/TerminalPage.vue
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
|
import { Terminal } from '@xterm/xterm'
|
||||||
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links'
|
||||||
|
import '@xterm/xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const terminalContainer = ref<HTMLElement | null>(null)
|
||||||
|
const connected = ref(false)
|
||||||
|
const connecting = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const sessionId = ref<string | null>(null)
|
||||||
|
const isResumedSession = ref(false)
|
||||||
|
|
||||||
|
let terminal: Terminal | null = null
|
||||||
|
let fitAddon: FitAddon | null = null
|
||||||
|
let socket: WebSocket | null = null
|
||||||
|
let resizeObserver: ResizeObserver | null = null
|
||||||
|
|
||||||
|
const WS_URL = 'ws://localhost:4103'
|
||||||
|
|
||||||
|
function initTerminal() {
|
||||||
|
if (!terminalContainer.value) return
|
||||||
|
|
||||||
|
terminal = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
cursorStyle: 'block',
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||||
|
theme: {
|
||||||
|
background: '#0f0f14',
|
||||||
|
foreground: '#e4e4e7',
|
||||||
|
cursor: '#6366f1',
|
||||||
|
cursorAccent: '#0f0f14',
|
||||||
|
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
||||||
|
black: '#16161d',
|
||||||
|
red: '#ef4444',
|
||||||
|
green: '#22c55e',
|
||||||
|
yellow: '#eab308',
|
||||||
|
blue: '#3b82f6',
|
||||||
|
magenta: '#a855f7',
|
||||||
|
cyan: '#06b6d4',
|
||||||
|
white: '#e4e4e7',
|
||||||
|
brightBlack: '#52525b',
|
||||||
|
brightRed: '#f87171',
|
||||||
|
brightGreen: '#4ade80',
|
||||||
|
brightYellow: '#facc15',
|
||||||
|
brightBlue: '#60a5fa',
|
||||||
|
brightMagenta: '#c084fc',
|
||||||
|
brightCyan: '#22d3ee',
|
||||||
|
brightWhite: '#ffffff'
|
||||||
|
},
|
||||||
|
allowProposedApi: true
|
||||||
|
})
|
||||||
|
|
||||||
|
fitAddon = new FitAddon()
|
||||||
|
terminal.loadAddon(fitAddon)
|
||||||
|
terminal.loadAddon(new WebLinksAddon())
|
||||||
|
|
||||||
|
terminal.open(terminalContainer.value)
|
||||||
|
fitAddon.fit()
|
||||||
|
|
||||||
|
// Handle resize
|
||||||
|
resizeObserver = new ResizeObserver(() => {
|
||||||
|
if (fitAddon && terminal) {
|
||||||
|
fitAddon.fit()
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
resizeObserver.observe(terminalContainer.value)
|
||||||
|
|
||||||
|
// Handle terminal input
|
||||||
|
terminal.onData((data) => {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect() {
|
||||||
|
if (connecting.value) return
|
||||||
|
|
||||||
|
connecting.value = true
|
||||||
|
error.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket = new WebSocket(WS_URL)
|
||||||
|
|
||||||
|
socket.onopen = () => {
|
||||||
|
connected.value = true
|
||||||
|
connecting.value = false
|
||||||
|
terminal?.focus()
|
||||||
|
|
||||||
|
// Send initial resize
|
||||||
|
if (terminal) {
|
||||||
|
socket?.send(JSON.stringify({
|
||||||
|
type: 'resize',
|
||||||
|
cols: terminal.cols,
|
||||||
|
rows: terminal.rows
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data)
|
||||||
|
if (msg.type === 'connected') {
|
||||||
|
sessionId.value = msg.sessionId
|
||||||
|
isResumedSession.value = !msg.isNew
|
||||||
|
if (!msg.isNew) {
|
||||||
|
terminal?.write('\x1b[36m[Reconnected to existing session]\x1b[0m\r\n')
|
||||||
|
}
|
||||||
|
} else if (msg.type === 'replay') {
|
||||||
|
// Replay buffer from persistent session
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
} else if (msg.type === 'output') {
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
} else if (msg.type === 'exit') {
|
||||||
|
terminal?.write(msg.data)
|
||||||
|
sessionId.value = null
|
||||||
|
} else if (msg.type === 'error') {
|
||||||
|
error.value = msg.message
|
||||||
|
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onclose = () => {
|
||||||
|
connected.value = false
|
||||||
|
connecting.value = false
|
||||||
|
if (sessionId.value) {
|
||||||
|
terminal?.write('\r\n\x1b[33mDisconnected - session preserved on server\x1b[0m\r\n')
|
||||||
|
} else {
|
||||||
|
terminal?.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.onerror = () => {
|
||||||
|
error.value = 'WebSocket connection failed. Make sure terminal server is running.'
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
connecting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
if (socket) {
|
||||||
|
socket.close()
|
||||||
|
socket = null
|
||||||
|
}
|
||||||
|
connected.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTerminal() {
|
||||||
|
terminal?.clear()
|
||||||
|
terminal?.reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyContent() {
|
||||||
|
if (!terminal) return
|
||||||
|
|
||||||
|
// Get all terminal content
|
||||||
|
const buffer = terminal.buffer.active
|
||||||
|
let content = ''
|
||||||
|
|
||||||
|
for (let i = 0; i < buffer.length; i++) {
|
||||||
|
const line = buffer.getLine(i)
|
||||||
|
if (line) {
|
||||||
|
content += line.translateToString(true) + '\n'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(content.trim())
|
||||||
|
// Visual feedback
|
||||||
|
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('copied')
|
||||||
|
setTimeout(() => btn.classList.remove('copied'), 1500)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to copy:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function runClaudeCode() {
|
||||||
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
initTerminal()
|
||||||
|
// Auto-connect
|
||||||
|
connect()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
socket?.close()
|
||||||
|
terminal?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="terminal-page">
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="terminal-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<h2>Terminal</h2>
|
||||||
|
<span v-if="connected" class="status connected">
|
||||||
|
Connected
|
||||||
|
<span v-if="sessionId" class="session-badge">{{ sessionId }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else-if="connecting" class="status connecting">Connecting...</span>
|
||||||
|
<span v-else class="status disconnected">
|
||||||
|
Disconnected
|
||||||
|
<span v-if="sessionId" class="session-hint">(session active on server)</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button
|
||||||
|
v-if="!connected"
|
||||||
|
class="btn-primary"
|
||||||
|
@click="connect"
|
||||||
|
:disabled="connecting"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn-secondary"
|
||||||
|
@click="disconnect"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-accent"
|
||||||
|
@click="runClaudeCode"
|
||||||
|
:disabled="!connected"
|
||||||
|
title="Run Claude Code"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
|
<path d="M2 17l10 5 10-5"/>
|
||||||
|
<path d="M2 12l10 5 10-5"/>
|
||||||
|
</svg>
|
||||||
|
Claude
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-icon btn-copy"
|
||||||
|
@click="copyContent"
|
||||||
|
title="Copy terminal content"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||||
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-icon"
|
||||||
|
@click="clearTerminal"
|
||||||
|
title="Clear terminal"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M3 6h18"/>
|
||||||
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
||||||
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<div v-if="error && !connected" class="error-banner">
|
||||||
|
{{ error }}
|
||||||
|
<p class="error-hint">Make sure the server is running with <code>bun start</code> in the server folder</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terminal container -->
|
||||||
|
<div ref="terminalContainer" class="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.terminal-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #0f0f14;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-left h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.625rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connected {
|
||||||
|
background: var(--success-bg);
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.connecting {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.disconnected {
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-badge {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-hint {
|
||||||
|
margin-left: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-accent:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon.btn-copy.copied {
|
||||||
|
color: var(--success);
|
||||||
|
background: var(--success-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--error-bg);
|
||||||
|
color: var(--error);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
border-bottom: 1px solid var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-hint code {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* xterm overrides */
|
||||||
|
.terminal-container :deep(.xterm) {
|
||||||
|
height: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport) {
|
||||||
|
overflow-y: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||||
|
width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||||
|
background: #16161d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||||
|
background: #2a2a3a;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) {
|
||||||
|
background: #3a3a4a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
205
server/index.ts
205
server/index.ts
@@ -1,6 +1,18 @@
|
|||||||
import { Database } from 'bun:sqlite'
|
import { Database } from 'bun:sqlite'
|
||||||
|
import { spawn, type IPty } from '@skitee3000/bun-pty'
|
||||||
|
|
||||||
const PORT_HTTP = 4101
|
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<any> // WebSockets conectados a esta sesión
|
||||||
|
createdAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
// Inicializar base de datos
|
// Inicializar base de datos
|
||||||
const db = new Database('agent-ui.db')
|
const db = new Database('agent-ui.db')
|
||||||
@@ -1144,10 +1156,201 @@ Bun.serve({
|
|||||||
})
|
})
|
||||||
|
|
||||||
console.log(`[HTTP] API corriendo en http://localhost:${PORT_HTTP}`)
|
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<string, TerminalSession>()
|
||||||
|
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<any, string>()
|
||||||
|
|
||||||
|
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=<id> - 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('')
|
||||||
console.log('='.repeat(50))
|
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(` API: http://localhost:${PORT_HTTP}`)
|
||||||
|
console.log(` Terminal: ws://localhost:${PORT_TERMINAL}`)
|
||||||
|
console.log(` Working Dir: ${WORKING_DIR}`)
|
||||||
console.log('')
|
console.log('')
|
||||||
console.log('WebMCP se inicia por separado con Claude Code MCP')
|
console.log('WebMCP se inicia por separado con Claude Code MCP')
|
||||||
console.log('='.repeat(50))
|
console.log('='.repeat(50))
|
||||||
|
|||||||
Reference in New Issue
Block a user