Files
agent-ui/frontend/src/components/FloatingTerminal.vue
josedario87 8118356999 feat: Add FloatingVoice component for voice-to-text input
- 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
2026-02-13 20:24:57 -06:00

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>