- Change default to tailOnly=true (was false) - Reduce chunks from 500 to 200 - Applies to both FloatingTerminal and TerminalPage
1015 lines
34 KiB
Vue
1015 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 clearServerBuffer() {
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'clear-buffer' }))
|
|
renderer.reset()
|
|
}
|
|
}
|
|
|
|
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 = true) {
|
|
if (socket?.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'request-replay', tailOnly, chunks: 200 }))
|
|
}
|
|
}
|
|
|
|
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="clearServerBuffer" class="clear-buf" title="Clear Buffer"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18M8 6V4a2 2 0 012-2h4a2 2 0 012 2v2m3 0v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6h14"/></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.clear-buf:hover { background: linear-gradient(180deg, #f90 0%, #c60 100%); border-color: #a50; 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>
|