- Add FloatingVoice component with Web Speech API transcription - Each component has its own independent WebSocket session - Voice panel connects on open, disconnects on close - Sends transcribed text to Claude Code with Enter key
649 lines
17 KiB
Vue
649 lines
17 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'
|
|
|
|
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 })
|
|
|
|
let terminal: Terminal | null = null
|
|
let fitAddon: FitAddon | null = null
|
|
let socket: WebSocket | null = null
|
|
let resizeObserver: ResizeObserver | null = null
|
|
|
|
// Buffer for detecting WebMCP token
|
|
let tokenBuffer = ''
|
|
let tokenTimeout: number | null = null
|
|
const waitingForToken = ref(false)
|
|
|
|
const WS_URL = `ws://${window.location.hostname}:4103`
|
|
|
|
// 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 }
|
|
}
|
|
|
|
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) {
|
|
if ((e.target as HTMLElement).closest('.window-controls')) return
|
|
|
|
isDragging.value = true
|
|
const rect = terminalRef.value?.getBoundingClientRect()
|
|
if (rect) {
|
|
// Capture actual position if using default bottom/right
|
|
if (!hasCustomPosition.value) {
|
|
position.value = { x: rect.left, y: rect.top }
|
|
}
|
|
dragOffset.value = {
|
|
x: e.clientX - rect.left,
|
|
y: e.clientY - rect.top
|
|
}
|
|
}
|
|
|
|
document.addEventListener('mousemove', onDrag)
|
|
document.addEventListener('mouseup', stopDrag)
|
|
}
|
|
|
|
function onDrag(e: MouseEvent) {
|
|
if (!isDragging.value) return
|
|
|
|
const newX = e.clientX - dragOffset.value.x
|
|
const newY = e.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)
|
|
}
|
|
|
|
// 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(() => {
|
|
const base = {
|
|
width: `${size.value.w}px`,
|
|
height: `${size.value.h}px`
|
|
}
|
|
if (!hasCustomPosition.value) {
|
|
return { ...base, bottom: '16px', right: '16px' }
|
|
}
|
|
return {
|
|
...base,
|
|
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 even when terminal has focus
|
|
terminal.attachCustomKeyEventHandler((e) => {
|
|
if (e.ctrlKey && e.key === 'e') {
|
|
e.preventDefault()
|
|
toggleTerminal()
|
|
return false // Prevent terminal from processing
|
|
}
|
|
return true // Let terminal handle other keys
|
|
})
|
|
}
|
|
|
|
async function connect() {
|
|
if (connecting.value || connected.value) return
|
|
connecting.value = true
|
|
|
|
try {
|
|
socket = new WebSocket(WS_URL)
|
|
|
|
socket.onopen = () => {
|
|
connected.value = true
|
|
connecting.value = false
|
|
terminal?.focus()
|
|
if (terminal) {
|
|
socket?.send(JSON.stringify({
|
|
type: 'resize',
|
|
cols: terminal.cols,
|
|
rows: terminal.rows
|
|
}))
|
|
}
|
|
}
|
|
|
|
socket.onmessage = (event) => {
|
|
const msg = JSON.parse(event.data)
|
|
if (msg.type === 'connected') {
|
|
sessionId.value = msg.sessionId
|
|
if (!msg.isNew) {
|
|
terminal?.write('\x1b[36m[Reconnected]\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.onerror = () => {
|
|
connecting.value = false
|
|
}
|
|
} catch (e) {
|
|
connecting.value = false
|
|
}
|
|
}
|
|
|
|
function close() {
|
|
isOpen.value = false
|
|
}
|
|
|
|
function runClaude() {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
|
}
|
|
}
|
|
|
|
function requestToken() {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
tokenBuffer = ''
|
|
waitingForToken.value = true
|
|
socket.send(JSON.stringify({ type: 'input', data: 'genera token usando tu mcp\r' }))
|
|
}
|
|
}
|
|
|
|
watch(isOpen, async (open) => {
|
|
if (open) {
|
|
await nextTick()
|
|
initTerminal()
|
|
if (!connected.value && !connecting.value) connect()
|
|
nextTick(() => {
|
|
fitAddon?.fit()
|
|
terminal?.focus()
|
|
})
|
|
} else {
|
|
// Cleanup when closing
|
|
resizeObserver?.disconnect()
|
|
resizeObserver = null
|
|
terminal?.dispose()
|
|
terminal = null
|
|
fitAddon = null
|
|
waitingForToken.value = false
|
|
tokenBuffer = ''
|
|
if (tokenTimeout) clearTimeout(tokenTimeout)
|
|
}
|
|
})
|
|
|
|
onMounted(async () => {
|
|
// Global listeners for Ctrl+E
|
|
document.addEventListener('mousemove', trackMouse)
|
|
document.addEventListener('keydown', handleKeydown)
|
|
|
|
if (isOpen.value) {
|
|
await nextTick()
|
|
initTerminal()
|
|
connect()
|
|
}
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
resizeObserver?.disconnect()
|
|
socket?.close()
|
|
terminal?.dispose()
|
|
document.removeEventListener('mousemove', onDrag)
|
|
document.removeEventListener('mouseup', stopDrag)
|
|
document.removeEventListener('mousemove', onResize)
|
|
document.removeEventListener('mouseup', stopResize)
|
|
document.removeEventListener('mousemove', trackMouse)
|
|
document.removeEventListener('keydown', handleKeydown)
|
|
})
|
|
|
|
// 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 }"
|
|
:style="terminalStyle"
|
|
>
|
|
<div class="glass">
|
|
<!-- Titlebar -->
|
|
<div class="titlebar" @mousedown="startDrag">
|
|
<div class="left">
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
|
|
<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>
|
|
</svg>
|
|
<span>Terminal</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>
|
|
</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: 5px;
|
|
color: #222;
|
|
font: 500 10px/1 system-ui, sans-serif;
|
|
}
|
|
|
|
.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);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
.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; } }
|
|
|
|
@media (max-width: 640px) {
|
|
.aero-win {
|
|
inset: auto 0 0 0 !important;
|
|
width: 100% !important;
|
|
height: 55% !important;
|
|
}
|
|
.glass { border-radius: 6px 6px 0 0; }
|
|
}
|
|
</style>
|