feat: Redesign FloatingTerminal with Windows Vista Aero style
- Slim, transparent Aero Glass design with backdrop blur - Draggable window from titlebar - Resizable from corner handle - Ctrl+E toggle to open at cursor position - Remove minimize functionality (close returns to sidebar button) - Add PWA dev-dist files and lock files
This commit is contained in:
@@ -23,10 +23,10 @@ const terminalRef = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
const isMinimized = ref(false)
|
||||
|
||||
const isDragging = ref(false)
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const hasCustomPosition = ref(false)
|
||||
const dragOffset = ref({ x: 0, y: 0 })
|
||||
|
||||
// Resize state
|
||||
@@ -40,22 +40,55 @@ let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const WS_URL = `ws://${window.location.hostname}:4103`
|
||||
|
||||
const fabRef = ref<HTMLElement | null>(null)
|
||||
const dragStartTime = ref(0)
|
||||
const dragStartPos = ref({ x: 0, y: 0 })
|
||||
const isDraggingFab = ref(false)
|
||||
// Mouse position tracking for Ctrl+E
|
||||
const mousePos = ref({ x: 0, y: 0 })
|
||||
let lastToggle = 0
|
||||
|
||||
function startDrag(e: MouseEvent, isFab = false) {
|
||||
if (!isFab && (e.target as HTMLElement).closest('.window-controls')) return
|
||||
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
|
||||
isDraggingFab.value = isFab
|
||||
dragStartTime.value = Date.now()
|
||||
dragStartPos.value = { x: e.clientX, y: e.clientY }
|
||||
|
||||
const element = isFab ? fabRef.value : terminalRef.value
|
||||
const rect = element?.getBoundingClientRect()
|
||||
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
|
||||
@@ -72,35 +105,26 @@ function onDrag(e: MouseEvent) {
|
||||
const newX = e.clientX - dragOffset.value.x
|
||||
const newY = e.clientY - dragOffset.value.y
|
||||
|
||||
const elementWidth = isDraggingFab.value ? 120 : (terminalRef.value?.offsetWidth || 580)
|
||||
const elementHeight = isDraggingFab.value ? 28 : (terminalRef.value?.offsetHeight || 360)
|
||||
const maxX = window.innerWidth - elementWidth
|
||||
const maxY = window.innerHeight - elementHeight
|
||||
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(0, Math.min(newX, maxX)),
|
||||
y: Math.max(0, Math.min(newY, maxY))
|
||||
x: Math.max(minX, Math.min(newX, maxX)),
|
||||
y: Math.max(minY, Math.min(newY, maxY))
|
||||
}
|
||||
}
|
||||
|
||||
function stopDrag(e: MouseEvent) {
|
||||
const wasDraggingFab = isDraggingFab.value
|
||||
function stopDrag() {
|
||||
isDragging.value = false
|
||||
hasCustomPosition.value = true
|
||||
document.removeEventListener('mousemove', onDrag)
|
||||
document.removeEventListener('mouseup', stopDrag)
|
||||
|
||||
if (wasDraggingFab) {
|
||||
const elapsed = Date.now() - dragStartTime.value
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(e.clientX - dragStartPos.value.x, 2) +
|
||||
Math.pow(e.clientY - dragStartPos.value.y, 2)
|
||||
)
|
||||
if (elapsed < 200 && distance < 5) {
|
||||
toggleMinimize()
|
||||
}
|
||||
}
|
||||
|
||||
isDraggingFab.value = false
|
||||
}
|
||||
|
||||
// Resize functions
|
||||
@@ -144,7 +168,7 @@ const terminalStyle = computed(() => {
|
||||
width: `${size.value.w}px`,
|
||||
height: `${size.value.h}px`
|
||||
}
|
||||
if (position.value.x === 0 && position.value.y === 0) {
|
||||
if (!hasCustomPosition.value) {
|
||||
return { ...base, bottom: '16px', right: '16px' }
|
||||
}
|
||||
return {
|
||||
@@ -156,18 +180,6 @@ const terminalStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const minimizedStyle = computed(() => {
|
||||
if (position.value.x === 0 && position.value.y === 0) {
|
||||
return { bottom: '16px', right: '16px' }
|
||||
}
|
||||
return {
|
||||
top: `${position.value.y}px`,
|
||||
left: `${position.value.x}px`,
|
||||
bottom: 'auto',
|
||||
right: 'auto'
|
||||
}
|
||||
})
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalContainer.value || terminal) return
|
||||
|
||||
@@ -210,7 +222,7 @@ function initTerminal() {
|
||||
nextTick(() => fitAddon?.fit())
|
||||
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
if (fitAddon && terminal && !isMinimized.value) {
|
||||
if (fitAddon && terminal) {
|
||||
fitAddon.fit()
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({
|
||||
@@ -228,6 +240,16 @@ function initTerminal() {
|
||||
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() {
|
||||
@@ -282,16 +304,6 @@ async function connect() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
isMinimized.value = !isMinimized.value
|
||||
if (!isMinimized.value) {
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
@@ -304,18 +316,28 @@ function runClaude() {
|
||||
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
isMinimized.value = false
|
||||
await nextTick()
|
||||
if (!terminal) initTerminal()
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// Global listeners for Ctrl+E
|
||||
document.addEventListener('mousemove', trackMouse)
|
||||
document.addEventListener('keydown', handleKeydown)
|
||||
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
@@ -331,33 +353,46 @@ onBeforeUnmount(() => {
|
||||
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
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<!-- Minimized -->
|
||||
<Transition name="fab-pop">
|
||||
<button
|
||||
v-if="isOpen && isMinimized"
|
||||
ref="fabRef"
|
||||
class="aero-btn"
|
||||
:class="{ dragging: isDragging }"
|
||||
:style="minimizedStyle"
|
||||
@mousedown="startDrag($event, true)"
|
||||
>
|
||||
<svg width="12" height="12" 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>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<!-- Window -->
|
||||
<Transition name="win-slide">
|
||||
<div
|
||||
v-if="isOpen && !isMinimized"
|
||||
v-if="isOpen"
|
||||
ref="terminalRef"
|
||||
class="aero-win"
|
||||
:class="{ dragging: isDragging, resizing: isResizing }"
|
||||
@@ -365,7 +400,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="glass">
|
||||
<!-- Titlebar -->
|
||||
<div class="titlebar" @mousedown="startDrag($event, false)" @dblclick="toggleMinimize">
|
||||
<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"/>
|
||||
@@ -376,7 +411,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="window-controls">
|
||||
<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="toggleMinimize" title="Minimize"><svg width="8" height="1"><rect width="8" height="1" fill="currentColor"/></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>
|
||||
@@ -393,34 +427,6 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.aero-btn {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid rgba(255,255,255,0.5);
|
||||
border-radius: 4px;
|
||||
color: #111;
|
||||
font: 500 10px/1 system-ui, sans-serif;
|
||||
cursor: grab;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15), inset 0 1px 0 rgba(255,255,255,0.7);
|
||||
}
|
||||
.aero-btn:hover { background: rgba(255,255,255,0.4); }
|
||||
.aero-btn.dragging { cursor: grabbing; }
|
||||
|
||||
.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; }
|
||||
|
||||
.aero-win {
|
||||
position: fixed;
|
||||
min-width: 400px;
|
||||
@@ -467,6 +473,14 @@ onBeforeUnmount(() => {
|
||||
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;
|
||||
@@ -553,9 +567,6 @@ onBeforeUnmount(() => {
|
||||
background: rgba(255,255,255,0.25);
|
||||
}
|
||||
|
||||
.fab-pop-enter-active, .fab-pop-leave-active { transition: all .12s ease; }
|
||||
.fab-pop-enter-from, .fab-pop-leave-to { opacity: 0; transform: scale(0.9); }
|
||||
|
||||
.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); }
|
||||
|
||||
@@ -564,10 +575,9 @@ onBeforeUnmount(() => {
|
||||
@media (max-width: 640px) {
|
||||
.aero-win {
|
||||
inset: auto 0 0 0 !important;
|
||||
width: 100%;
|
||||
height: 55%;
|
||||
width: 100% !important;
|
||||
height: 55% !important;
|
||||
}
|
||||
.glass { border-radius: 6px 6px 0 0; }
|
||||
.aero-btn { bottom: 12px !important; right: 12px !important; left: auto !important; top: auto !important; }
|
||||
}
|
||||
</style>
|
||||
|
||||
119
frontend/src/services/tools/handlers/terminalHandlers.ts
Normal file
119
frontend/src/services/tools/handlers/terminalHandlers.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Terminal UI control handlers
|
||||
* Controls the FloatingTerminal window (open, close, move, resize)
|
||||
*/
|
||||
|
||||
import type { ToolConfig } from './index'
|
||||
|
||||
export interface TerminalControls {
|
||||
open: (x?: number, y?: number) => void
|
||||
close: () => void
|
||||
toggle: () => void
|
||||
move: (x: number, y: number) => void
|
||||
resize: (width: number, height: number) => void
|
||||
getState: () => { isOpen: boolean; position: { x: number; y: number }; size: { w: number; h: number } }
|
||||
}
|
||||
|
||||
// Global reference to terminal controls (set by App.vue)
|
||||
let terminalControls: TerminalControls | null = null
|
||||
|
||||
export function setTerminalControls(controls: TerminalControls) {
|
||||
terminalControls = controls
|
||||
;(window as any).__terminalControls = controls
|
||||
}
|
||||
|
||||
export function getTerminalControls(): TerminalControls | null {
|
||||
return terminalControls
|
||||
}
|
||||
|
||||
export function createTerminalHandlers(): ToolConfig[] {
|
||||
return [
|
||||
{
|
||||
name: 'terminal_open',
|
||||
description: 'Abre la ventana flotante del terminal. Opcionalmente en una posicion especifica.',
|
||||
category: 'terminal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number', description: 'Posicion X en pixels (opcional)' },
|
||||
y: { type: 'number', description: 'Posicion Y en pixels (opcional)' }
|
||||
}
|
||||
},
|
||||
handler: (args: { x?: number; y?: number }) => {
|
||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
||||
terminalControls.open(args.x, args.y)
|
||||
const pos = args.x !== undefined && args.y !== undefined
|
||||
? ` en posicion (${args.x}, ${args.y})`
|
||||
: ''
|
||||
return `Terminal abierta${pos}`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'terminal_close',
|
||||
description: 'Cierra la ventana flotante del terminal.',
|
||||
category: 'terminal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => {
|
||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
||||
terminalControls.close()
|
||||
return 'Terminal cerrada'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'terminal_toggle',
|
||||
description: 'Alterna el estado de la ventana del terminal (abre si esta cerrada, cierra si esta abierta).',
|
||||
category: 'terminal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
},
|
||||
handler: () => {
|
||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
||||
const wasOpen = terminalControls.getState().isOpen
|
||||
terminalControls.toggle()
|
||||
return wasOpen ? 'Terminal cerrada' : 'Terminal abierta'
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'terminal_move',
|
||||
description: 'Mueve la ventana del terminal a una posicion especifica en pixels.',
|
||||
category: 'terminal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
x: { type: 'number', description: 'Posicion X en pixels' },
|
||||
y: { type: 'number', description: 'Posicion Y en pixels' }
|
||||
},
|
||||
required: ['x', 'y']
|
||||
},
|
||||
handler: (args: { x: number; y: number }) => {
|
||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
||||
terminalControls.move(args.x, args.y)
|
||||
return `Terminal movida a (${args.x}, ${args.y})`
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'terminal_resize',
|
||||
description: 'Cambia el tamano de la ventana del terminal.',
|
||||
category: 'terminal',
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
width: { type: 'number', description: 'Ancho en pixels (min 400)' },
|
||||
height: { type: 'number', description: 'Alto en pixels (min 250)' }
|
||||
},
|
||||
required: ['width', 'height']
|
||||
},
|
||||
handler: (args: { width: number; height: number }) => {
|
||||
if (!terminalControls) return 'Error: Terminal controls not initialized'
|
||||
const w = Math.max(400, args.width)
|
||||
const h = Math.max(250, args.height)
|
||||
terminalControls.resize(w, h)
|
||||
return `Terminal redimensionada a ${w}x${h}`
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user