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

@@ -0,0 +1 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })

View File

94
frontend/dev-dist/sw.js Normal file
View File

@@ -0,0 +1,94 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-5a5d9309'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "suppress-warnings.js",
"revision": "d41d8cd98f00b204e9800998ecf8427e"
}, {
"url": "index.html",
"revision": "0.24e3u5ntq78"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/api\//]
}));
}));
//# sourceMappingURL=sw.js.map

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

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>

View 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}`
}
}
]
}