feat: Add persistent terminal with floating UI
- Server keeps PTY sessions alive on client disconnect - Output buffer replays history on reconnect - FloatingTerminal component accessible from any page - Responsive: floating window on desktop, fullscreen on mobile - macOS-style header with traffic light buttons
This commit is contained in:
490
frontend/src/components/FloatingTerminal.vue
Normal file
490
frontend/src/components/FloatingTerminal.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<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'
|
||||
|
||||
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 terminalContainer = ref<HTMLElement | null>(null)
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const sessionId = ref<string | null>(null)
|
||||
const isMinimized = ref(false)
|
||||
|
||||
let terminal: Terminal | null = null
|
||||
let fitAddon: FitAddon | null = null
|
||||
let socket: WebSocket | null = null
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
|
||||
const WS_URL = 'ws://localhost:4103'
|
||||
|
||||
function initTerminal() {
|
||||
if (!terminalContainer.value || terminal) return
|
||||
|
||||
terminal = new Terminal({
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
fontSize: 13,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
||||
theme: {
|
||||
background: '#0f0f14',
|
||||
foreground: '#e4e4e7',
|
||||
cursor: '#6366f1',
|
||||
cursorAccent: '#0f0f14',
|
||||
selectionBackground: 'rgba(99, 102, 241, 0.3)',
|
||||
black: '#16161d',
|
||||
red: '#ef4444',
|
||||
green: '#22c55e',
|
||||
yellow: '#eab308',
|
||||
blue: '#3b82f6',
|
||||
magenta: '#a855f7',
|
||||
cyan: '#06b6d4',
|
||||
white: '#e4e4e7',
|
||||
brightBlack: '#52525b',
|
||||
brightRed: '#f87171',
|
||||
brightGreen: '#4ade80',
|
||||
brightYellow: '#facc15',
|
||||
brightBlue: '#60a5fa',
|
||||
brightMagenta: '#c084fc',
|
||||
brightCyan: '#22d3ee',
|
||||
brightWhite: '#ffffff'
|
||||
},
|
||||
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 && !isMinimized.value) {
|
||||
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 }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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 to session]\x1b[0m\r\n')
|
||||
}
|
||||
} else if (msg.type === 'replay') {
|
||||
terminal?.write(msg.data)
|
||||
} else if (msg.type === 'output') {
|
||||
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 disconnect() {
|
||||
if (socket) {
|
||||
socket.close()
|
||||
socket = null
|
||||
}
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
function toggleMinimize() {
|
||||
isMinimized.value = !isMinimized.value
|
||||
if (!isMinimized.value) {
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function runClaude() {
|
||||
if (socket && socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for open state
|
||||
watch(isOpen, async (open) => {
|
||||
if (open) {
|
||||
await nextTick()
|
||||
if (!terminal) {
|
||||
initTerminal()
|
||||
}
|
||||
if (!connected.value && !connecting.value) {
|
||||
connect()
|
||||
}
|
||||
nextTick(() => {
|
||||
fitAddon?.fit()
|
||||
terminal?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
if (isOpen.value) {
|
||||
await nextTick()
|
||||
initTerminal()
|
||||
connect()
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
socket?.close()
|
||||
terminal?.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="terminal-slide">
|
||||
<div v-if="isOpen" class="floating-terminal" :class="{ minimized: isMinimized }">
|
||||
<!-- Header -->
|
||||
<div class="terminal-header" @dblclick="toggleMinimize">
|
||||
<div class="header-left">
|
||||
<div class="traffic-lights">
|
||||
<button class="light red" @click="close" title="Close"></button>
|
||||
<button class="light yellow" @click="toggleMinimize" title="Minimize"></button>
|
||||
<button class="light green" @click="runClaude" title="Run Claude"></button>
|
||||
</div>
|
||||
<span class="terminal-title">
|
||||
Terminal
|
||||
<span v-if="sessionId" class="session-id">{{ sessionId }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<span v-if="connected" class="status-dot connected"></span>
|
||||
<span v-else-if="connecting" class="status-dot connecting"></span>
|
||||
<span v-else class="status-dot disconnected"></span>
|
||||
|
||||
<button v-if="!connected" class="btn-connect" @click="connect" :disabled="connecting">
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terminal body -->
|
||||
<div v-show="!isMinimized" class="terminal-body">
|
||||
<div ref="terminalContainer" class="terminal-container"></div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized bar -->
|
||||
<div v-if="isMinimized" class="minimized-bar" @click="toggleMinimize">
|
||||
<span>Click to expand</span>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-terminal {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
width: 700px;
|
||||
height: 450px;
|
||||
background: #0f0f14;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 9999;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.floating-terminal.minimized {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.terminal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #16161d;
|
||||
border-bottom: 1px solid #2a2a3a;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.light:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.light.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.light.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.light.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.terminal-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
.session-id {
|
||||
margin-left: 8px;
|
||||
padding: 2px 8px;
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
color: #818cf8;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.connected {
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.connecting {
|
||||
background: #eab308;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.btn-connect {
|
||||
padding: 4px 12px;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.btn-connect:hover:not(:disabled) {
|
||||
background: #818cf8;
|
||||
}
|
||||
|
||||
.btn-connect:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Terminal body */
|
||||
.terminal-body {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport) {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) {
|
||||
background: #2a2a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Minimized bar */
|
||||
.minimized-bar {
|
||||
padding: 8px 16px;
|
||||
text-align: center;
|
||||
color: #52525b;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.minimized-bar:hover {
|
||||
color: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Transition */
|
||||
.terminal-slide-enter-active,
|
||||
.terminal-slide-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.terminal-slide-enter-from,
|
||||
.terminal-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100px) scale(0.95);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.floating-terminal {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.floating-terminal.minimized {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
height: auto;
|
||||
border-radius: 16px 16px 0 0;
|
||||
}
|
||||
|
||||
.terminal-header {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.traffic-lights {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.light {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablet */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.floating-terminal {
|
||||
width: 550px;
|
||||
height: 400px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user