Files
agent-ui/frontend/src/components/FloatingTerminal.vue
josedario87 3c401c4c2b feat: Add quick action buttons to floating terminal
Add virtual keyboard buttons for common actions:
- Clear: clears terminal screen
- MCP: request WebMCP token
- Claude: start claude session
- Cont/Resume: claude --continue and --resume shortcuts
2026-02-14 11:16:49 -06:00

1317 lines
36 KiB
Vue

<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'
import { connectWithToken, stopTokenPolling } from '../services/webmcp'
import { useCanvasStore } from '../stores/canvas'
import { endpoints } from '../config/endpoints'
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()
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)
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) // percentage of viewport
const isDraggingSheet = ref(false)
const sheetDragStart = ref({ y: 0, height: 0 })
const keyboardHeight = ref(0)
const snapPoints = [20, 55, 85] // collapsed, half, full (percentages)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let socket: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
const MAX_RECONNECT_ATTEMPTS = 10
const RECONNECT_DELAY_MS = 2000
// Buffer for detecting WebMCP token
let tokenBuffer = ''
let tokenTimeout: number | null = null
const waitingForToken = ref(false)
const WS_URL = endpoints.terminal
// Mouse position tracking for Ctrl+E
const mousePos = ref({ x: 0, y: 0 })
let lastToggle = 0
function trackMouse(e: MouseEvent) {
mousePos.value = { x: e.clientX, y: e.clientY }
}
// Mobile/tablet detection - show virtual keys on touch devices
function checkMobile() {
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0
const isSmallScreen = window.innerWidth <= 1024
isMobile.value = isTouchDevice && isSmallScreen
}
// Virtual keyboard detection using visualViewport API
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 difference > 100px, keyboard is probably open
if (diff > 100) {
keyboardHeight.value = diff
// Auto-expand sheet when keyboard opens if it's too small
if (sheetHeight.value < 55 && isOpen.value) {
sheetHeight.value = 55
}
} else {
keyboardHeight.value = 0
}
}
// Find nearest snap point
function findNearestSnap(height: number): number {
return snapPoints.reduce((prev, curr) =>
Math.abs(curr - height) < Math.abs(prev - height) ? curr : prev
)
}
// Mobile sheet touch handlers
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
const newHeight = sheetDragStart.value.height + deltaPercent
// Clamp between 15% and 90%
sheetHeight.value = Math.max(15, Math.min(90, newHeight))
}
function stopSheetDrag() {
if (!isDraggingSheet.value) return
isDraggingSheet.value = false
// Snap to nearest point
sheetHeight.value = findNearestSnap(sheetHeight.value)
nextTick(() => fitAddon?.fit())
}
function toggleTerminal() {
const now = Date.now()
if (now - lastToggle < 150) return // Debounce 150ms
lastToggle = now
if (!isOpen.value) {
// Open at mouse position (allow 75% occlusion)
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
// On mobile, use sheet drag instead
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) {
// Capture actual position if using default bottom/right
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 newX = clientX - dragOffset.value.x
const newY = clientY - dragOffset.value.y
const w = terminalRef.value?.offsetWidth || 580
const h = terminalRef.value?.offsetHeight || 360
// Allow up to 75% occlusion per side (25% must remain visible)
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(newX, maxX)),
y: Math.max(minY, Math.min(newY, 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)
}
// Resize functions
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
const deltaX = e.clientX - resizeStart.value.x
const deltaY = e.clientY - resizeStart.value.y
size.value = {
w: Math.max(400, Math.min(resizeStart.value.w + deltaX, window.innerWidth - 40)),
h: Math.max(250, Math.min(resizeStart.value.h + deltaY, window.innerHeight - 40))
}
}
function stopResize() {
isResizing.value = false
document.removeEventListener('mousemove', onResize)
document.removeEventListener('mouseup', stopResize)
nextTick(() => fitAddon?.fit())
}
const terminalStyle = computed((): Record<string, string> => {
// Mobile: bottom sheet with dynamic height
if (isMobile.value) {
// When keyboard is open, position above it
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
}
}
// Desktop: floating window
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'
}
})
function initTerminal() {
if (!terminalContainer.value || terminal) return
terminal = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 12,
fontFamily: "'Consolas', 'Lucida Console', monospace",
theme: {
background: 'rgba(12, 12, 12, 0.95)',
foreground: '#ffffff',
cursor: '#ffffff',
cursorAccent: '#000000',
selectionBackground: 'rgba(100, 150, 255, 0.4)',
black: '#0c0c0c',
red: '#c50f1f',
green: '#13a10e',
yellow: '#c19c00',
blue: '#0037da',
magenta: '#881798',
cyan: '#3a96dd',
white: '#cccccc',
brightBlack: '#767676',
brightRed: '#e74856',
brightGreen: '#16c60c',
brightYellow: '#f9f1a5',
brightBlue: '#3b78ff',
brightMagenta: '#b4009e',
brightCyan: '#61d6d6',
brightWhite: '#f2f2f2'
},
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) {
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 }))
}
})
// Capture Ctrl+E and Ctrl+V when terminal has focus
terminal.attachCustomKeyEventHandler((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((err) => {
console.error('[Terminal] Clipboard read failed:', err)
})
return false
}
// Ctrl+C: Copy selection (if any)
if (e.ctrlKey && e.key === 'c' && e.type === 'keydown') {
const selection = terminal?.getSelection()
if (selection) {
navigator.clipboard.writeText(selection).catch(console.error)
return false
}
// If no selection, let Ctrl+C pass through as SIGINT
}
return true // Let terminal handle other keys
})
}
async function connect() {
if (connecting.value || connected.value) return
connecting.value = true
// Connection timeout - if not connected in 10s, retry
const connectionTimeout = window.setTimeout(() => {
if (connecting.value && !connected.value) {
console.log('[Terminal] Connection timeout, retrying...')
connecting.value = false
socket?.close()
socket = null
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
scheduleReconnect()
}
}
}, 10000)
try {
socket = new WebSocket(WS_URL)
// Clear timeout on successful connection
socket.addEventListener('open', () => clearTimeout(connectionTimeout), { once: true })
socket.onopen = () => {
connected.value = true
connecting.value = false
reconnectAttempts = 0 // Reset on successful connection
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]\x1b[0m\r\n')
}
} else if (msg.type === 'replay') {
terminal?.write(msg.data)
} else if (msg.type === 'output') {
// Only detect token when waiting for it
if (waitingForToken.value) {
tokenBuffer += msg.data
// Debounce: process buffer after output stops (300ms)
if (tokenTimeout) clearTimeout(tokenTimeout)
tokenTimeout = window.setTimeout(() => {
if (tokenBuffer.includes('Token copiado')) {
// Clean ANSI codes and whitespace
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)
console.log('[Terminal] WebMCP token detected:', match[0])
waitingForToken.value = false
tokenBuffer = ''
stopTokenPolling()
connectWithToken(match[0]).then(success => {
if (success) {
canvasStore.showNotification('WebMCP connected!', 'success')
}
}).catch(console.error)
} catch {
// Token incomplete, keep waiting
}
}
}
}, 300)
}
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 = null
// Auto-reconnect if terminal is still open
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
terminal?.write('\r\n\x1b[33m[Disconnected - reconnecting...]\x1b[0m\r\n')
scheduleReconnect()
}
}
socket.onerror = () => {
connecting.value = false
}
} catch (e) {
connecting.value = false
// Try to reconnect on connection error
if (isOpen.value && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
scheduleReconnect()
}
}
}
function scheduleReconnect() {
if (reconnectTimeout) clearTimeout(reconnectTimeout)
reconnectAttempts++
const delay = RECONNECT_DELAY_MS * Math.min(reconnectAttempts, 5) // Max 10s delay
console.log(`[Terminal] Reconnecting in ${delay}ms (attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
reconnectTimeout = window.setTimeout(() => {
if (isOpen.value && !connected.value && !connecting.value) {
connect()
}
}, delay)
}
function cancelReconnect() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
reconnectAttempts = 0
}
function close() {
isOpen.value = false
}
function runClaude() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
}
}
function runClaudeContinue() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude --continue\r' }))
}
}
function runClaudeResume() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'claude --resume\r' }))
}
}
function sendClear() {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: 'clear\r' }))
}
}
function requestToken() {
if (socket && socket.readyState === WebSocket.OPEN) {
tokenBuffer = ''
waitingForToken.value = true
// Send character by character then Enter (same as VoiceFloat)
const text = 'genera token usando tu mcp'
const chars = (text + '\r').split('')
let i = 0
const typeChar = () => {
if (i < chars.length && socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data: chars[i] }))
i++
setTimeout(typeChar, 15)
}
}
typeChar()
}
}
// Mobile virtual keys - send special key sequences
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 }))
}
}
// Mobile scroll controls
function scrollTerminal(direction: 'up' | 'down' | 'end') {
if (!terminal) return
if (direction === 'up') {
terminal.scrollLines(-10)
} else if (direction === 'down') {
terminal.scrollLines(10)
} else if (direction === 'end') {
terminal.scrollToBottom()
}
}
// Refresh terminal display
function refreshTerminal() {
// If disconnected, try to reconnect
if (!connected.value && !connecting.value) {
reconnectAttempts = 0
connect()
return
}
if (!terminal || !fitAddon) return
// Clear and refit terminal
fitAddon.fit()
terminal.scrollToBottom()
terminal.focus()
}
watch(isOpen, async (open) => {
if (open) {
await nextTick()
initTerminal()
if (!connected.value && !connecting.value) connect()
nextTick(() => {
fitAddon?.fit()
terminal?.focus()
})
} else {
// Cleanup when closing
cancelReconnect() // Stop any pending reconnection
resizeObserver?.disconnect()
resizeObserver = null
terminal?.dispose()
terminal = null
fitAddon = null
waitingForToken.value = false
tokenBuffer = ''
if (tokenTimeout) clearTimeout(tokenTimeout)
}
})
// Refit terminal when mobile sheet height or keyboard changes
watch([sheetHeight, keyboardHeight], () => {
if (isMobile.value && isOpen.value) {
nextTick(() => fitAddon?.fit())
}
})
onMounted(async () => {
// Global listeners for Ctrl+E
document.addEventListener('mousemove', trackMouse)
document.addEventListener('keydown', handleKeydown)
// Mobile detection
checkMobile()
window.addEventListener('resize', checkMobile)
// Virtual keyboard detection
setupKeyboardDetection()
if (isOpen.value) {
await nextTick()
initTerminal()
connect()
}
})
onBeforeUnmount(() => {
cancelReconnect()
resizeObserver?.disconnect()
socket?.close()
terminal?.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 controls for MCP tools
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(() => fitAddon?.fit())
},
getState: () => ({
isOpen: isOpen.value,
position: position.value,
size: size.value
}),
sendInput: (text: string) => {
if (socket && 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-if="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">
<!-- Nucleo Logo -->
<div class="nucleo-logo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<!-- Core nucleus -->
<circle cx="12" cy="12" r="4" fill="url(#nucleoGradient)"/>
<!-- Orbital rings -->
<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)"/>
<!-- Electrons -->
<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>
<!-- Gradients -->
<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 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>
<!-- Disconnected overlay -->
<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>
<!-- Connecting overlay -->
<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" title="Clear">Clear</button>
<button @click="requestToken" class="vk token" :class="{ waiting: waitingForToken }" title="Connect MCP">MCP</button>
<button @click="runClaude" class="vk claude" title="Claude">Claude</button>
<button @click="runClaudeContinue" class="vk claude-cont" title="Claude Continue">Cont</button>
<button @click="runClaudeResume" class="vk claude-resume" title="Claude Resume">Resume</button>
<button @click="refreshTerminal" class="vk refresh" title="Refrescar"></button>
<!-- Scroll controls -->
<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>
<!-- Arrow keys for input -->
<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>
<!-- Resize handle -->
<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;
}
/* Disconnected/Connecting overlay */
.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;
/* Enable touch scrolling on mobile */
-webkit-overflow-scrolling: touch;
touch-action: pan-y;
}
.term :deep(.xterm-screen) {
/* Allow touch events to pass through for scrolling */
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/tablet bottom sheet styles */
.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;
/* Allow touch scrolling in terminal area */
touch-action: pan-y;
overflow: hidden;
}
/* Mobile animations */
.aero-win.mobile.win-slide-enter-from,
.aero-win.mobile.win-slide-leave-to {
transform: translateY(100%);
opacity: 1;
}
/* Virtual keys for mobile */
.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>