Files
agent-ui/frontend/src/components/FloatingTerminal.vue
josedario87 2151255239 refactor: Simplify terminal rendering logic
- Remove nested requestAnimationFrame calls
- Simplify handleReplay: write + refresh + scrollToBottom
- Simplify onBecameVisible: fit + refresh + focus
- Remove excessive console.log statements
- Convert async functions to sync where appropriate
2026-02-14 12:33:03 -06:00

1006 lines
34 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick, watch, computed } from 'vue'
import { connectWithToken, stopTokenPolling } from '../services/webmcp'
import { useCanvasStore } from '../stores/canvas'
import { endpoints } from '../config/endpoints'
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
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 canvasStore = useCanvasStore()
// ============================================================================
// REFS
// ============================================================================
const terminalContainer = ref<HTMLElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
const connected = ref(false)
const connecting = ref(false)
const sessionId = ref<string | null>(null)
// Drag state
const isDragging = ref(false)
const position = ref({ x: 0, y: 0 })
const hasCustomPosition = ref(false)
const dragOffset = ref({ x: 0, y: 0 })
// Resize state
const isResizing = ref(false)
const size = ref({ w: 580, h: 360 })
// Mobile bottom sheet state
const isMobile = ref(false)
const sheetHeight = ref(55)
const isDraggingSheet = ref(false)
const sheetDragStart = ref({ y: 0, height: 0 })
const keyboardHeight = ref(0)
const snapPoints = [20, 55, 85]
// WebSocket
let socket: WebSocket | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_DELAY_MS = 2000
// Token detection
let tokenBuffer = ''
let tokenTimeout: number | null = null
const waitingForToken = ref(false)
const WS_URL = endpoints.terminal
// Mouse tracking for Ctrl+E
const mousePos = ref({ x: 0, y: 0 })
let lastToggle = 0
// ============================================================================
// TERMINAL RENDERER (composable)
// ============================================================================
const renderer = useTerminalRenderer({
container: terminalContainer,
onData: (data) => {
// Send user input to server
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
},
onResize: (cols, rows) => {
// Notify server of resize
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
}
},
onKeyEvent: (e) => {
// Ctrl+E: Toggle terminal
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
return false
}
// Ctrl+V: Paste from clipboard
if (e.ctrlKey && e.key === 'v' && e.type === 'keydown') {
e.preventDefault()
navigator.clipboard.readText().then((text) => {
if (text && socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: text }))
}
}).catch(console.error)
return false
}
// Ctrl+C: Copy selection (if any)
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
const selection = renderer.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(console.error)
return false
}
}
return true
}
})
// ============================================================================
// MOBILE DETECTION
// ============================================================================
function checkMobile() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const isSmallScreen = window.innerWidth <= 1024
isMobile.value = isTouchDevice && isSmallScreen
}
function setupKeyboardDetection() {
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', handleViewportResize)
}
}
function handleViewportResize() {
if (!window.visualViewport || !isMobile.value) return
const viewportHeight = window.visualViewport.height
const windowHeight = window.innerHeight
const diff = windowHeight - viewportHeight
if (diff > 100) {
keyboardHeight.value = diff
if (sheetHeight.value < 55 && isOpen.value) {
sheetHeight.value = 55
}
} else {
keyboardHeight.value = 0
}
}
function findNearestSnap(height: number): number {
return snapPoints.reduce((prev, curr) =>
Math.abs(curr - height) < Math.abs(prev - height) ? curr : prev
)
}
// ============================================================================
// MOBILE SHEET DRAG
// ============================================================================
function startSheetDrag(e: TouchEvent) {
if (!isMobile.value) return
const touch = e.touches[0]
if (!touch) return
isDraggingSheet.value = true
sheetDragStart.value = { y: touch.clientY, height: sheetHeight.value }
}
function onSheetDrag(e: TouchEvent) {
if (!isDraggingSheet.value || !isMobile.value) return
const touch = e.touches[0]
if (!touch) return
const deltaY = sheetDragStart.value.y - touch.clientY
const deltaPercent = (deltaY / window.innerHeight) * 100
sheetHeight.value = Math.max(15, Math.min(90, sheetDragStart.value.height + deltaPercent))
}
function stopSheetDrag() {
if (!isDraggingSheet.value) return
isDraggingSheet.value = false
sheetHeight.value = findNearestSnap(sheetHeight.value)
nextTick(() => renderer.fit())
}
// ============================================================================
// WINDOW DRAG
// ============================================================================
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
function toggleTerminal() {
const now = Date.now()
if (now - lastToggle < 150) return
lastToggle = now
if (!isOpen.value) {
const w = size.value.w
const h = size.value.h
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(mousePos.value.x - w / 2, maxX)),
y: Math.max(minY, Math.min(mousePos.value.y - h / 2, maxY))
}
hasCustomPosition.value = true
isOpen.value = true
} else {
isOpen.value = false
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.ctrlKey && e.key === 'e') {
e.preventDefault()
toggleTerminal()
}
}
function startDrag(e: MouseEvent | TouchEvent) {
if ((e.target as HTMLElement).closest('.window-controls')) return
if (isMobile.value) {
if (e instanceof TouchEvent) startSheetDrag(e)
return
}
isDragging.value = true
const rect = terminalRef.value?.getBoundingClientRect()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
if (rect) {
if (!hasCustomPosition.value) {
position.value = { x: rect.left, y: rect.top }
}
dragOffset.value = { x: clientX - rect.left, y: clientY - rect.top }
}
document.addEventListener('mousemove', onDrag)
document.addEventListener('mouseup', stopDrag)
document.addEventListener('touchmove', onDrag, { passive: false })
document.addEventListener('touchend', stopDrag)
}
function onDrag(e: MouseEvent | TouchEvent) {
if (!isDragging.value) return
if (e instanceof TouchEvent) e.preventDefault()
const touch = e instanceof TouchEvent ? e.touches[0] : null
const clientX = e instanceof MouseEvent ? e.clientX : (touch?.clientX ?? 0)
const clientY = e instanceof MouseEvent ? e.clientY : (touch?.clientY ?? 0)
const w = terminalRef.value?.offsetWidth || 580
const h = terminalRef.value?.offsetHeight || 360
const minX = -w * 0.75
const maxX = window.innerWidth - w * 0.25
const minY = -h * 0.75
const maxY = window.innerHeight - h * 0.25
position.value = {
x: Math.max(minX, Math.min(clientX - dragOffset.value.x, maxX)),
y: Math.max(minY, Math.min(clientY - dragOffset.value.y, maxY))
}
}
function stopDrag() {
isDragging.value = false
hasCustomPosition.value = true
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
}
// ============================================================================
// WINDOW RESIZE
// ============================================================================
const resizeStart = ref({ x: 0, y: 0, w: 0, h: 0 })
function startResize(e: MouseEvent) {
e.preventDefault()
e.stopPropagation()
isResizing.value = true
resizeStart.value = { x: e.clientX, y: e.clientY, w: size.value.w, h: size.value.h }
document.addEventListener('mousemove', onResize)
document.addEventListener('mouseup', stopResize)
}
function onResize(e: MouseEvent) {
if (!isResizing.value) return
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + e.clientX - resizeStart.value.x, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + e.clientY - resizeStart.value.y, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => renderer.fit())
}
// ============================================================================
// TERMINAL STYLE
// ============================================================================
const terminalStyle = computed((): Record<string, string> => {
if (isMobile.value) {
const bottomOffset = keyboardHeight.value > 0 ? `${keyboardHeight.value}px` : '0'
const maxH = keyboardHeight.value > 0 ? `calc(100vh - ${keyboardHeight.value}px)` : '90vh'
return {
position: 'fixed',
left: '0',
right: '0',
bottom: bottomOffset,
width: '100%',
height: `${sheetHeight.value}vh`,
maxHeight: maxH
}
}
if (!hasCustomPosition.value) {
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
bottom: '16px',
right: '16px'
}
}
return {
width: `${size.value.w}px`,
height: `${size.value.h}px`,
top: `${position.value.y}px`,
left: `${position.value.x}px`,
bottom: 'auto',
right: 'auto'
}
})
// ============================================================================
// WEBSOCKET CONNECTION
// ============================================================================
async function connect() {
if (connecting.value || connected.value) return
connecting.value = true
const connectionTimeout = window.setTimeout(() => {
if (connecting.value && !connected.value) {
connecting.value = false
socket?.close()
socket = null
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
scheduleReconnect()
}
}
}, 10000)
try {
socket = new WebSocket(WS_URL)
socket.addEventListener('open', () => clearTimeout(connectionTimeout), { once: true })
socket.onopen = () => {
connected.value = true
connecting.value = false
reconnectAttempts = 0
renderer.focus()
// Send initial resize
const term = renderer.terminal.value
if (term) {
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
socket.onmessage = (event) => {
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
sessionId.value = msg.sessionId
if (msg.hasHistory && isOpen.value) {
setTimeout(() => requestReplay(), 50)
} else if (!msg.isNew) {
renderer.writeln('\x1b[36m[Session restored]\x1b[0m')
}
} else if (msg.type === 'replay') {
renderer.handleReplay(msg.data || '')
} else if (msg.type === 'output') {
// Token detection
if (waitingForToken.value) {
tokenBuffer += msg.data
if (tokenTimeout) clearTimeout(tokenTimeout)
tokenTimeout = window.setTimeout(() => {
if (tokenBuffer.includes('Token copiado')) {
const clean = tokenBuffer.replace(/\x1b\[[0-9;]*m/g, '').replace(/[\r\n\s]/g, '')
const match = clean.match(/eyJ[A-Za-z0-9_\-+/=]+/)
if (match) {
try {
const decoded = atob(match[0])
JSON.parse(decoded)
waitingForToken.value = false
tokenBuffer = ''
stopTokenPolling()
connectWithToken(match[0]).then(success => {
if (success) canvasStore.showNotification('WebMCP connected!', 'success')
}).catch(console.error)
} catch { /* Token incomplete */ }
}
}
}, 300)
}
// Write output using composable
renderer.write(msg.data)
} else if (msg.type === 'exit') {
renderer.write(msg.data)
sessionId.value = null
} else if (msg.type === 'error') {
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
}
}
socket.onclose = () => {
connected.value = false
connecting.value = false
socket = null
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
renderer.writeln('\x1b[33m[Disconnected - reconnecting...]\x1b[0m')
scheduleReconnect()
}
}
socket.onerror = () => {
connecting.value = false
}
} catch {
connecting.value = false
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
scheduleReconnect()
}
}
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
reconnectAttempts++
const delay = RECONNECT_DELAY_MS * Math.min(reconnectAttempts, 5)
reconnectTimeout = window.setTimeout(() => {
if (isOpen.value && !connected.value && !connecting.value) {
connect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
reconnectAttempts = 0
}
// ============================================================================
// ACTIONS
// ============================================================================
function close() {
isOpen.value = false
}
function runClaude() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
}
}
function runClaudeContinue() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude --continue\r' }))
}
}
function runClaudeResume() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude --resume\r' }))
}
}
function sendClear() {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'clear\r' }))
}
}
function requestToken() {
if (socket?.readyState === WebSocket.OPEN) {
tokenBuffer = ''
waitingForToken.value = true
const text = 'genera token usando tu mcp'
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length && socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
i++
setTimeout(typeChar, 15)
}
}
typeChar()
}
}
function sendKey(key: string) {
if (!socket || socket.readyState !== WebSocket.OPEN) return
const keyMap: Record<string, string> = {
'up': '\x1b[A', 'down': '\x1b[B', 'left': '\x1b[D', 'right': '\x1b[C',
'alt-m': '\x1bm', 'ctrl-c': '\x03', 'tab': '\t', 'esc': '\x1b'
}
const data = keyMap[key]
if (data) socket.send(JSON.stringify({ type: 'input', data }))
}
function scrollTerminal(direction: 'up' | 'down' | 'end') {
if (direction === 'up') renderer.scrollLines(-10)
else if (direction === 'down') renderer.scrollLines(10)
else renderer.scrollToBottom()
}
function requestReplay(tailOnly = false) {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 500 }))
}
}
function refreshTerminal(fullReplay = false) {
if (!connected.value && !connecting.value) {
reconnectAttempts = 0
connect()
return
}
requestAnimationFrame(() => {
renderer.fit()
requestReplay(!fullReplay)
})
}
// ============================================================================
// WATCHERS
// ============================================================================
watch(isOpen, (open) => {
if (open) {
nextTick(() => {
renderer.init()
// Wait for CSS transition then setup
setTimeout(() => {
renderer.onBecameVisible()
if (!connected.value && !connecting.value) {
connect()
} else if (connected.value) {
requestReplay()
}
}, 150)
})
} else {
renderer.blur()
waitingForToken.value = false
tokenBuffer = ''
if (tokenTimeout) clearTimeout(tokenTimeout)
}
})
watch([sheetHeight, keyboardHeight], () => {
if (isMobile.value && isOpen.value) {
nextTick(() => renderer.fit())
}
})
// ============================================================================
// LIFECYCLE
// ============================================================================
onMounted(() => {
document.addEventListener('mousemove', trackMouse)
document.addEventListener('keydown', handleKeydown)
checkMobile()
window.addEventListener('resize', checkMobile)
setupKeyboardDetection()
})
onBeforeUnmount(() => {
cancelReconnect()
socket?.close()
renderer.dispose()
document.removeEventListener('mousemove', onDrag)
document.removeEventListener('mouseup', stopDrag)
document.removeEventListener('touchmove', onDrag)
document.removeEventListener('touchend', stopDrag)
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
document.removeEventListener('mousemove', trackMouse)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', checkMobile)
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', handleViewportResize)
}
})
// ============================================================================
// EXPOSE
// ============================================================================
defineExpose({
open: (x?: number, y?: number) => {
if (x !== undefined && y !== undefined) {
position.value = { x, y }
hasCustomPosition.value = true
}
isOpen.value = true
},
close: () => { isOpen.value = false },
toggle: toggleTerminal,
move: (x: number, y: number) => {
position.value = { x, y }
hasCustomPosition.value = true
},
resize: (w: number, h: number) => {
size.value = { w: Math.max(400, w), h: Math.max(250, h) }
nextTick(() => renderer.fit())
},
getState: () => ({ isOpen: isOpen.value, position: position.value, size: size.value }),
sendInput: (text: string) => {
if (socket?.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: text + '\r' }))
return true
}
return false
}
})
</script>
<template>
<Teleport to="body">
<Transition name="win-slide">
<div
v-show="isOpen"
ref="terminalRef"
class="aero-win"
:class="{
dragging: isDragging,
resizing: isResizing,
mobile: isMobile,
'sheet-dragging': isDraggingSheet
}"
:style="terminalStyle"
>
<div class="glass">
<!-- Mobile drag handle -->
<div
v-if="isMobile"
class="sheet-handle"
@touchstart.passive="startSheetDrag"
@touchmove.prevent="onSheetDrag"
@touchend="stopSheetDrag"
>
<div class="handle-bar"></div>
</div>
<!-- Titlebar -->
<div
class="titlebar"
@mousedown="startDrag"
@touchstart.passive="startDrag"
@touchmove="onSheetDrag"
@touchend="stopSheetDrag"
>
<div class="left">
<div class="nucleo-logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="4" fill="url(#nucleoGradient)"/>
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(-30 12 12)"/>
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(30 12 12)"/>
<ellipse cx="12" cy="12" rx="9" ry="4" stroke="url(#orbitGradient)" stroke-width="1.2" fill="none" transform="rotate(90 12 12)"/>
<circle cx="12" cy="3" r="1.5" fill="#a78bfa">
<animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="3s" repeatCount="indefinite"/>
</circle>
<circle cx="20" cy="14" r="1.5" fill="#818cf8">
<animateTransform attributeName="transform" type="rotate" from="120 12 12" to="480 12 12" dur="4s" repeatCount="indefinite"/>
</circle>
<circle cx="4" cy="14" r="1.5" fill="#c4b5fd">
<animateTransform attributeName="transform" type="rotate" from="240 12 12" to="600 12 12" dur="3.5s" repeatCount="indefinite"/>
</circle>
<defs>
<radialGradient id="nucleoGradient" cx="50%" cy="30%" r="60%">
<stop offset="0%" stop-color="#a78bfa"/>
<stop offset="100%" stop-color="#6366f1"/>
</radialGradient>
<linearGradient id="orbitGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.8"/>
<stop offset="50%" stop-color="#a78bfa" stop-opacity="0.4"/>
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.8"/>
</linearGradient>
</defs>
</svg>
</div>
<span class="nucleo-name">Nucleo</span>
<i class="dot" :class="{ on: connected, wait: connecting }"></i>
<a v-if="!connected && !connecting" class="link" @click.stop="connect">connect</a>
</div>
<div class="window-controls">
<button @click="requestToken" :class="{ waiting: waitingForToken }" title="Connect MCP"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg></button>
<button @click="runClaude" title="Claude"><svg width="8" height="8" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg></button>
<button @click="refreshTerminal" title="Refresh Screen"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg></button>
<button class="x" @click="close" title="Close"><svg width="8" height="8" viewBox="0 0 10 10"><line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.5"/><line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.5"/></svg></button>
</div>
</div>
<!-- Content -->
<div class="content">
<div ref="terminalContainer" class="term"></div>
<div v-if="!connected && !connecting" class="disconnect-overlay" @click="connect">
<div class="disconnect-msg">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18.36 6.64a9 9 0 1 1-12.73 0"/>
<line x1="12" y1="2" x2="12" y2="12"/>
</svg>
<span>Desconectado</span>
<small>Toca para reconectar</small>
</div>
</div>
<div v-else-if="connecting" class="disconnect-overlay connecting">
<div class="disconnect-msg">
<div class="spinner"></div>
<span>Conectando...</span>
</div>
</div>
</div>
<!-- Mobile virtual keys -->
<div v-if="isMobile" class="virtual-keys">
<button @click="sendKey('esc')" class="vk">Esc</button>
<button @click="sendKey('tab')" class="vk">Tab</button>
<button @click="sendKey('ctrl-c')" class="vk ctrl">^C</button>
<button @click="sendKey('alt-m')" class="vk alt">Alt+M</button>
<button @click="sendClear" class="vk clear">Clear</button>
<button @click="requestToken" class="vk token" :class="{ waiting: waitingForToken }">MCP</button>
<button @click="runClaude" class="vk claude">Claude</button>
<button @click="runClaudeContinue" class="vk claude-cont">Cont</button>
<button @click="runClaudeResume" class="vk claude-resume">Resume</button>
<button @click="refreshTerminal" class="vk refresh"></button>
<div class="vk-scroll">
<button @click="scrollTerminal('up')" class="vk scroll"></button>
<button @click="scrollTerminal('end')" class="vk scroll end"></button>
<button @click="scrollTerminal('down')" class="vk scroll"></button>
</div>
<div class="vk-arrows">
<button @click="sendKey('up')" class="vk arrow"></button>
<div class="vk-row">
<button @click="sendKey('left')" class="vk arrow"></button>
<button @click="sendKey('down')" class="vk arrow"></button>
<button @click="sendKey('right')" class="vk arrow"></button>
</div>
</div>
</div>
<div class="resize-handle" @mousedown="startResize"></div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.aero-win {
position: fixed;
min-width: 400px;
min-height: 250px;
z-index: 9999;
}
.glass {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: rgba(200,215,235,0.35);
backdrop-filter: blur(24px) saturate(1.6);
-webkit-backdrop-filter: blur(24px) saturate(1.6);
border-radius: 5px;
border: 1px solid rgba(255,255,255,0.6);
box-shadow: 0 0 0 1px rgba(80,120,180,0.25), 0 6px 24px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.6);
overflow: hidden;
}
.titlebar {
display: flex;
align-items: center;
justify-content: space-between;
height: 22px;
padding: 0 2px 0 6px;
background: rgba(255,255,255,0.25);
border-bottom: 1px solid rgba(255,255,255,0.3);
cursor: grab;
user-select: none;
}
.aero-win.dragging .titlebar { cursor: grabbing; }
.left {
display: flex;
align-items: center;
gap: 6px;
color: #222;
font: 500 10px/1 system-ui, sans-serif;
}
.nucleo-logo {
display: flex;
align-items: center;
justify-content: center;
filter: drop-shadow(0 1px 2px rgba(99, 102, 241, 0.3));
}
.nucleo-name {
font-weight: 600;
font-size: 11px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: 0.5px;
}
.dot { width: 5px; height: 5px; border-radius: 50%; background: #999; }
.dot.on { background: #0a0; box-shadow: 0 0 4px #0a0; }
.dot.wait { background: #a80; animation: pulse .8s infinite; }
.link { margin-left: 2px; color: #369; font-size: 9px; text-decoration: underline; cursor: pointer; }
.link:hover { color: #47a; }
.window-controls { display: flex; gap: 1px; }
.window-controls button {
width: 20px; height: 16px;
display: flex; align-items: center; justify-content: center;
background: rgba(255,255,255,0.3);
border: 1px solid rgba(0,0,0,0.1);
border-radius: 2px;
color: #333;
cursor: pointer;
}
.window-controls button:hover { background: rgba(255,255,255,0.5); }
.window-controls button.x:hover { background: linear-gradient(180deg, #e66 0%, #c33 100%); border-color: #a22; color: #fff; }
.window-controls button.waiting { background: rgba(16, 185, 129, 0.3); border-color: #10b981; animation: pulse 0.8s infinite; }
.content {
flex: 1;
margin: 2px;
border-radius: 2px;
overflow: hidden;
background: rgba(0,0,0,0.92);
position: relative;
}
.disconnect-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.85);
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
cursor: pointer;
}
.disconnect-overlay.connecting { cursor: wait; }
.disconnect-msg {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: #fff;
text-align: center;
}
.disconnect-msg svg { color: #ef4444; opacity: 0.8; }
.disconnect-msg span { font-size: 14px; font-weight: 500; }
.disconnect-msg small { font-size: 11px; color: #888; }
.spinner {
width: 24px; height: 24px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.resize-handle {
position: absolute;
right: 0; bottom: 0;
width: 16px; height: 16px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0.1) 100%);
border-radius: 0 0 5px 0;
}
.resize-handle:hover { background: linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0.2) 100%); }
.aero-win.resizing { user-select: none; }
.aero-win.resizing .term { pointer-events: none; }
.term { width: 100%; height: 100%; }
.term :deep(.xterm) { height: 100%; padding: 2px; }
.term :deep(.xterm-viewport) {
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.term :deep(.xterm-screen) { touch-action: pan-y; }
.term :deep(.xterm-viewport::-webkit-scrollbar) { width: 8px; background: rgba(0,0,0,0.2); }
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: rgba(255,255,255,0.15); border-radius: 4px; }
.term :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: rgba(255,255,255,0.25); }
.win-slide-enter-active, .win-slide-leave-active { transition: all .15s ease; }
.win-slide-enter-from, .win-slide-leave-to { opacity: 0; transform: translateY(16px) scale(0.98); }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
/* Mobile */
.aero-win.mobile { min-width: unset; min-height: unset; max-width: 100%; transition: height 0.25s cubic-bezier(0.4, 0, 0.2, 1); }
.aero-win.mobile.sheet-dragging { transition: none; }
.aero-win.mobile .glass { border-radius: 16px 16px 0 0; padding-bottom: env(safe-area-inset-bottom, 0); }
.sheet-handle {
display: flex;
justify-content: center;
align-items: center;
height: 24px;
cursor: grab;
touch-action: none;
background: rgba(255,255,255,0.15);
}
.sheet-handle:active { cursor: grabbing; }
.handle-bar { width: 36px; height: 4px; background: rgba(0,0,0,0.3); border-radius: 2px; }
.aero-win.mobile .titlebar { cursor: grab; touch-action: none; }
.aero-win.mobile .resize-handle { display: none; }
.aero-win.mobile .content { flex: 1; min-height: 100px; touch-action: pan-y; overflow: hidden; }
.aero-win.mobile.win-slide-enter-from,
.aero-win.mobile.win-slide-leave-to { transform: translateY(100%); opacity: 1; }
/* Virtual keys */
.virtual-keys {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: rgba(30, 30, 30, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.1);
flex-shrink: 0;
flex-wrap: wrap;
}
.vk {
min-width: 48px; height: 40px; padding: 0 10px;
background: linear-gradient(180deg, #444 0%, #333 100%);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #fff;
font-size: 13px; font-weight: 500; font-family: system-ui, sans-serif;
cursor: pointer;
touch-action: manipulation;
transition: all 0.1s;
}
.vk:active { background: linear-gradient(180deg, #555 0%, #444 100%); transform: scale(0.95); }
.vk.ctrl { background: linear-gradient(180deg, #c53030 0%, #9b2c2c 100%); border-color: rgba(255, 100, 100, 0.3); }
.vk.alt { background: linear-gradient(180deg, #2563eb 0%, #1d4ed8 100%); border-color: rgba(100, 150, 255, 0.3); }
.vk.refresh { background: linear-gradient(180deg, #f59e0b 0%, #d97706 100%); border-color: rgba(245, 158, 11, 0.3); font-size: 18px; }
.vk.clear { background: linear-gradient(180deg, #6b7280 0%, #4b5563 100%); border-color: rgba(107, 114, 128, 0.3); }
.vk.token { background: linear-gradient(180deg, #ec4899 0%, #db2777 100%); border-color: rgba(236, 72, 153, 0.3); }
.vk.token.waiting { background: linear-gradient(180deg, #10b981 0%, #059669 100%); border-color: rgba(16, 185, 129, 0.3); animation: pulse 0.8s infinite; }
.vk.claude { background: linear-gradient(180deg, #8b5cf6 0%, #7c3aed 100%); border-color: rgba(139, 92, 246, 0.3); }
.vk.claude-cont { background: linear-gradient(180deg, #06b6d4 0%, #0891b2 100%); border-color: rgba(6, 182, 212, 0.3); }
.vk.claude-resume { background: linear-gradient(180deg, #10b981 0%, #059669 100%); border-color: rgba(16, 185, 129, 0.3); }
.vk-scroll { display: flex; flex-direction: column; align-items: center; gap: 2px; margin-left: auto; }
.vk.scroll { min-width: 44px; width: 44px; height: 36px; padding: 0; font-size: 16px; background: linear-gradient(180deg, #065f46 0%, #047857 100%); border-color: rgba(16, 185, 129, 0.3); }
.vk.scroll.end { background: linear-gradient(180deg, #7c3aed 0%, #6d28d9 100%); border-color: rgba(139, 92, 246, 0.3); font-size: 18px; }
.vk-arrows { display: flex; flex-direction: column; align-items: center; gap: 2px; }
.vk-row { display: flex; gap: 2px; }
.vk.arrow { min-width: 38px; width: 38px; height: 32px; padding: 0; font-size: 12px; }
</style>