- Create useTerminalRenderer.ts with all xterm.js logic - Support custom theme, fontSize, fontFamily options - Add handleReplay() for proper visibility handling - Add getBufferContent() for copying terminal content - Refactor FloatingTerminal.vue to use composable - Refactor TerminalPage.vue to use composable - Server: Add request-replay message type for on-demand replay - Server: Remove auto-replay on connect (client requests when ready) - Fix xterm.js rendering issues with hidden containers (v-show)
386 lines
10 KiB
Vue
386 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
|
import { endpoints } from '../config/endpoints'
|
|
import { useTerminalRenderer } from '../composables/useTerminalRenderer'
|
|
|
|
const terminalContainer = ref<HTMLElement | null>(null)
|
|
const connected = ref(false)
|
|
const connecting = ref(false)
|
|
const error = ref<string | null>(null)
|
|
const sessionId = ref<string | null>(null)
|
|
const isResumedSession = ref(false)
|
|
|
|
let socket: WebSocket | null = null
|
|
|
|
const WS_URL = endpoints.terminal
|
|
|
|
// Terminal theme for TerminalPage (different from FloatingTerminal)
|
|
const TERMINAL_PAGE_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'
|
|
}
|
|
|
|
// Use the composable
|
|
const renderer = useTerminalRenderer({
|
|
container: terminalContainer,
|
|
theme: TERMINAL_PAGE_THEME,
|
|
fontSize: 14,
|
|
fontFamily: "'JetBrains Mono', 'Fira Code', Consolas, monospace",
|
|
onData: (data) => {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'input', data }))
|
|
}
|
|
},
|
|
onResize: (cols, rows) => {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'resize', cols, rows }))
|
|
}
|
|
}
|
|
})
|
|
|
|
async function connect() {
|
|
if (connecting.value) return
|
|
|
|
connecting.value = true
|
|
error.value = null
|
|
|
|
try {
|
|
socket = new WebSocket(WS_URL)
|
|
|
|
socket.onopen = () => {
|
|
connected.value = true
|
|
connecting.value = false
|
|
renderer.focus()
|
|
|
|
const term = renderer.terminal.value
|
|
if (term) {
|
|
socket?.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
|
}
|
|
}
|
|
|
|
socket.onmessage = async (event) => {
|
|
const msg = JSON.parse(event.data)
|
|
|
|
if (msg.type === 'connected') {
|
|
sessionId.value = msg.sessionId
|
|
isResumedSession.value = !msg.isNew
|
|
|
|
if (msg.hasHistory) {
|
|
// Request replay
|
|
socket?.send(JSON.stringify({ type: 'request-replay', tailOnly: false }))
|
|
} else if (!msg.isNew) {
|
|
renderer.writeln('\x1b[36m[Reconnected to existing session]\x1b[0m')
|
|
}
|
|
} else if (msg.type === 'replay') {
|
|
await renderer.handleReplay(msg.data || '')
|
|
} else if (msg.type === 'output') {
|
|
renderer.write(msg.data)
|
|
} else if (msg.type === 'exit') {
|
|
renderer.write(msg.data)
|
|
sessionId.value = null
|
|
} else if (msg.type === 'error') {
|
|
error.value = msg.message
|
|
renderer.writeln(`\x1b[31mError: ${msg.message}\x1b[0m`)
|
|
}
|
|
}
|
|
|
|
socket.onclose = () => {
|
|
connected.value = false
|
|
connecting.value = false
|
|
if (sessionId.value) {
|
|
renderer.writeln('\x1b[33mDisconnected - session preserved on server\x1b[0m')
|
|
} else {
|
|
renderer.writeln('\x1b[33mConnection closed\x1b[0m')
|
|
}
|
|
}
|
|
|
|
socket.onerror = () => {
|
|
error.value = 'WebSocket connection failed. Make sure terminal server is running.'
|
|
connecting.value = false
|
|
}
|
|
} catch (e: any) {
|
|
error.value = e.message
|
|
connecting.value = false
|
|
}
|
|
}
|
|
|
|
function disconnect() {
|
|
if (socket) {
|
|
socket.close()
|
|
socket = null
|
|
}
|
|
connected.value = false
|
|
}
|
|
|
|
function clearTerminal() {
|
|
renderer.clear()
|
|
renderer.reset()
|
|
}
|
|
|
|
async function copyContent() {
|
|
const content = renderer.getBufferContent()
|
|
if (!content) return
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(content)
|
|
const btn = document.querySelector('.btn-copy') as HTMLButtonElement
|
|
if (btn) {
|
|
btn.classList.add('copied')
|
|
setTimeout(() => btn.classList.remove('copied'), 1500)
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to copy:', e)
|
|
}
|
|
}
|
|
|
|
function runClaudeCode() {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(JSON.stringify({ type: 'input', data: 'claude\r' }))
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
renderer.init()
|
|
connect()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
socket?.close()
|
|
renderer.dispose()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="terminal-page">
|
|
<!-- Toolbar -->
|
|
<div class="terminal-toolbar">
|
|
<div class="toolbar-left">
|
|
<h2>Terminal</h2>
|
|
<span v-if="connected" class="status connected">
|
|
Connected
|
|
<span v-if="sessionId" class="session-badge">{{ sessionId }}</span>
|
|
</span>
|
|
<span v-else-if="connecting" class="status connecting">Connecting...</span>
|
|
<span v-else class="status disconnected">
|
|
Disconnected
|
|
<span v-if="sessionId" class="session-hint">(session active on server)</span>
|
|
</span>
|
|
</div>
|
|
|
|
<div class="toolbar-actions">
|
|
<button v-if="!connected" class="btn-primary" @click="connect" :disabled="connecting">
|
|
Connect
|
|
</button>
|
|
<button v-else class="btn-secondary" @click="disconnect">
|
|
Disconnect
|
|
</button>
|
|
|
|
<button class="btn-accent" @click="runClaudeCode" :disabled="!connected" title="Run Claude Code">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
|
<path d="M2 17l10 5 10-5"/>
|
|
<path d="M2 12l10 5 10-5"/>
|
|
</svg>
|
|
Claude
|
|
</button>
|
|
|
|
<button class="btn-icon btn-copy" @click="copyContent" title="Copy terminal content">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
|
</svg>
|
|
</button>
|
|
|
|
<button class="btn-icon" @click="clearTerminal" title="Clear terminal">
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<path d="M3 6h18"/>
|
|
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
|
|
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<div v-if="error && !connected" class="error-banner">
|
|
{{ error }}
|
|
<p class="error-hint">Make sure the server is running with <code>bun start</code> in the server folder</p>
|
|
</div>
|
|
|
|
<!-- Terminal container -->
|
|
<div ref="terminalContainer" class="terminal-container"></div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.terminal-page {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1;
|
|
overflow: hidden;
|
|
background: #0f0f14;
|
|
}
|
|
|
|
.terminal-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0.75rem 1rem;
|
|
background: var(--bg-secondary);
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.toolbar-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.toolbar-left h2 {
|
|
margin: 0;
|
|
font-size: 1rem;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.status {
|
|
font-size: 0.75rem;
|
|
padding: 0.25rem 0.625rem;
|
|
border-radius: 9999px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.status.connected { background: var(--success-bg); color: var(--success); }
|
|
.status.connecting { background: var(--warning-bg); color: var(--warning); }
|
|
.status.disconnected { background: var(--error-bg); color: var(--error); }
|
|
|
|
.session-badge {
|
|
margin-left: 0.5rem;
|
|
padding: 0.125rem 0.375rem;
|
|
background: rgba(255, 255, 255, 0.15);
|
|
border-radius: 4px;
|
|
font-size: 0.7rem;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.session-hint {
|
|
margin-left: 0.25rem;
|
|
font-size: 0.7rem;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.toolbar-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.btn-primary {
|
|
padding: 0.5rem 1rem;
|
|
background: var(--accent);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.btn-primary:hover:not(:disabled) { background: var(--accent-hover); }
|
|
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
|
|
|
|
.btn-secondary {
|
|
padding: 0.5rem 1rem;
|
|
background: var(--bg-hover);
|
|
color: var(--text-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
}
|
|
.btn-secondary:hover { background: var(--bg-tertiary); }
|
|
|
|
.btn-accent {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 0.5rem 1rem;
|
|
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
|
|
color: white;
|
|
border: none;
|
|
border-radius: 6px;
|
|
font-size: 0.85rem;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s;
|
|
}
|
|
.btn-accent:hover:not(:disabled) { opacity: 0.9; }
|
|
.btn-accent:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
.btn-icon {
|
|
padding: 0.5rem;
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 6px;
|
|
color: var(--text-secondary);
|
|
cursor: pointer;
|
|
}
|
|
.btn-icon:hover { background: var(--bg-hover); color: var(--text-primary); }
|
|
.btn-icon.btn-copy.copied { color: var(--success); background: var(--success-bg); }
|
|
|
|
.error-banner {
|
|
padding: 1rem;
|
|
background: var(--error-bg);
|
|
color: var(--error);
|
|
font-size: 0.875rem;
|
|
border-bottom: 1px solid var(--error);
|
|
}
|
|
|
|
.error-hint {
|
|
margin: 0.5rem 0 0;
|
|
font-size: 0.8rem;
|
|
opacity: 0.8;
|
|
}
|
|
|
|
.error-hint code {
|
|
background: rgba(0, 0, 0, 0.2);
|
|
padding: 0.125rem 0.375rem;
|
|
border-radius: 4px;
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.terminal-container {
|
|
flex: 1;
|
|
padding: 0.5rem;
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* xterm overrides */
|
|
.terminal-container :deep(.xterm) { height: 100%; padding: 0.5rem; }
|
|
.terminal-container :deep(.xterm-viewport) { overflow-y: auto !important; }
|
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar) { width: 10px; }
|
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-track) { background: #16161d; }
|
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb) { background: #2a2a3a; border-radius: 5px; }
|
|
.terminal-container :deep(.xterm-viewport::-webkit-scrollbar-thumb:hover) { background: #3a3a4a; }
|
|
</style>
|