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:
2026-02-13 18:27:17 -06:00
parent 424afa060c
commit f3f0df9cf3
12 changed files with 8325 additions and 116 deletions

View File

@@ -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>