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:
2026-02-13 07:33:43 -06:00
parent ccbf542480
commit 2cf869d2e9
4 changed files with 1268 additions and 3 deletions

View File

@@ -1,16 +1,18 @@
<script setup lang="ts">
import { onMounted, watch } from 'vue'
import { ref, onMounted, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import StatusBar from './components/StatusBar.vue'
import Toolbar from './components/Toolbar.vue'
import ComponentsDropdown from './components/ComponentsDropdown.vue'
import FloatingTerminal from './components/FloatingTerminal.vue'
import { initWebMCP } from './services/webmcp'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
const route = useRoute()
const router = useRouter()
const showTerminal = ref(false)
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source'
type PageName = 'home' | 'canvas' | 'components' | 'themes' | 'projects' | 'project-canvas' | 'database' | 'source' | 'terminal'
onMounted(async () => {
// Initialize WebMCP connection
@@ -49,6 +51,26 @@ watch(() => route.name, (newPage) => {
</Transition>
</RouterView>
</main>
<!-- Floating Terminal Toggle Button -->
<button
class="terminal-fab"
:class="{ active: showTerminal }"
@click="showTerminal = !showTerminal"
title="Toggle Terminal"
>
<svg v-if="!showTerminal" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline>
<line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
<!-- Floating Terminal -->
<FloatingTerminal v-model="showTerminal" />
</div>
</template>
@@ -103,4 +125,53 @@ watch(() => route.name, (newPage) => {
opacity: 0;
transform: translateX(-20px);
}
/* Terminal FAB */
.terminal-fab {
position: fixed;
bottom: 20px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
z-index: 9998;
}
.terminal-fab:hover {
transform: scale(1.05);
box-shadow: 0 12px 32px rgba(99, 102, 241, 0.5);
}
.terminal-fab.active {
background: #ef4444;
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.4);
transform: rotate(90deg);
}
.terminal-fab.active:hover {
box-shadow: 0 12px 32px rgba(239, 68, 68, 0.5);
}
@media (max-width: 768px) {
.terminal-fab {
bottom: 16px;
right: 16px;
width: 52px;
height: 52px;
}
.terminal-fab.active {
opacity: 0;
pointer-events: none;
}
}
</style>

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

View File

@@ -0,0 +1,501 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } 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 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 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) return
terminal = new Terminal({
cursorBlink: true,
cursorStyle: 'block',
fontSize: 14,
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)
fitAddon.fit()
// Handle resize
resizeObserver = new ResizeObserver(() => {
if (fitAddon && terminal) {
fitAddon.fit()
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows
}))
}
}
})
resizeObserver.observe(terminalContainer.value)
// Handle terminal input
terminal.onData((data) => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'input', data }))
}
})
}
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
terminal?.focus()
// Send initial resize
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
isResumedSession.value = !msg.isNew
if (!msg.isNew) {
terminal?.write('\x1b[36m[Reconnected to existing session]\x1b[0m\r\n')
}
} else if (msg.type === 'replay') {
// Replay buffer from persistent session
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') {
error.value = msg.message
terminal?.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`)
}
}
socket.onclose = () => {
connected.value = false
connecting.value = false
if (sessionId.value) {
terminal?.write('\r\n\x1b[33mDisconnected - session preserved on server\x1b[0m\r\n')
} else {
terminal?.write('\r\n\x1b[33mConnection closed\x1b[0m\r\n')
}
}
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() {
terminal?.clear()
terminal?.reset()
}
async function copyContent() {
if (!terminal) return
// Get all terminal content
const buffer = terminal.buffer.active
let content = ''
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i)
if (line) {
content += line.translateToString(true) + '\n'
}
}
try {
await navigator.clipboard.writeText(content.trim())
// Visual feedback
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()
initTerminal()
// Auto-connect
connect()
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
socket?.close()
terminal?.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>