feat: use xterm for terminal UI and match app controls

This commit is contained in:
archipelago
2026-10-09 10:17:33 -04:00
parent 46b38f9800
commit 85fe925763
3 changed files with 102 additions and 40 deletions
+17
View File
@@ -11,6 +11,8 @@
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
@@ -4417,6 +4419,21 @@
}
}
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
"integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==",
"license": "MIT",
"peerDependencies": {
"@xterm/xterm": "^5.0.0"
}
},
"node_modules/@xterm/xterm": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
},
"node_modules/abbrev": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
+3 -1
View File
@@ -10,7 +10,7 @@
"test:watch": "vitest",
"test:mock-parity": "node scripts/mock-rpc-parity.mjs",
"dev": "vite",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI \u2014 chat will show placeholder'\"",
"dev:mock": "concurrently --raw \"node mock-backend.js\" \"VITE_AIUI_URL=http://localhost:5173 vite\" \"cd ../../AIUI && perl -MPOSIX -e 'POSIX::setsid(); exec @ARGV' -- pnpm dev 2>/dev/null || echo '[AIUI] Not found at ../../AIUI — chat will show placeholder'\"",
"dev:boot": "VITE_DEV_MODE=boot concurrently --raw \"VITE_DEV_MODE=boot node mock-backend.js\" \"VITE_DEV_MODE=boot vite\"",
"dev:real": "echo 'Start backend: cd ../core && cargo run --release' && vite",
"backend:mock": "node mock-backend.js",
@@ -28,6 +28,8 @@
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"buffer": "^6.0.3",
"d3": "^7.9.0",
"dompurify": "^3.3.3",
+82 -39
View File
@@ -1,31 +1,34 @@
<template>
<Teleport to="body">
<Transition name="cli-popup">
<div v-if="cliStore.isOpen" class="fixed inset-0 z-[2500] pointer-events-none p-4">
<section ref="panelRef" class="terminal-panel relative z-10 w-full max-w-5xl overflow-hidden pointer-events-auto" :class="{ minimized }" :style="panelStyle" aria-label="Archipelago terminal">
<div v-if="cliStore.isOpen" class="fixed inset-0 z-[2500] flex items-center justify-center p-4 pointer-events-none">
<section ref="panelRef" class="glass-card terminal-panel relative z-10 w-full overflow-hidden pointer-events-auto" :class="{ minimized }" :style="panelStyle" aria-label="Archipelago terminal">
<header class="terminal-header" @mousedown="startDrag">
<div class="terminal-title"><span class="terminal-dot"></span><span>Archipelago terminal</span><span class="terminal-host">{{ activeSession?.name || 'No session' }}</span></div>
<div class="terminal-drag-handle" title="Drag to move">
<svg class="w-4 h-4 text-white/50" fill="currentColor" viewBox="0 0 24 24"><path d="M8 6h2v2H8V6zm0 5h2v2H8v-2zm0 5h2v2H8v-2zm5-10h2v2h-2V6zm0 5h2v2h-2v-2zm0 5h2v2h-2v-2z" /></svg>
</div>
<div class="terminal-title"><svg class="w-5 h-5 text-white/60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg><span>CLI Access</span><span class="terminal-host">{{ activeSession?.name || 'No session' }}</span></div>
<div class="terminal-actions">
<select v-model="selectedId" class="terminal-select" aria-label="Terminal session" @change="connectSelected">
<option value="">Select session</option>
<option v-for="session in sessions" :key="session.id" :value="session.id">{{ session.name }} · {{ session.state }}</option>
</select>
<button class="terminal-button" type="button" @click="createSession">New</button>
<button class="terminal-button" type="button" aria-label="Refresh terminal" title="Refresh terminal" @click="refreshTerminal">Refresh</button>
<button class="terminal-button" type="button" :aria-label="minimized ? 'Restore terminal' : 'Minimize terminal'" @click="minimized = !minimized">{{ minimized ? 'Restore' : 'Minimize' }}</button>
<button class="terminal-button" type="button" @click="toggleFullscreen">{{ fullscreen ? 'Windowed' : 'Fullscreen' }}</button>
<button class="terminal-close" type="button" aria-label="Close terminal" @click="closeTerminal">Close</button>
<button class="terminal-icon-button" type="button" @click="createSession">New</button>
<button class="terminal-icon-button" type="button" aria-label="Refresh" title="Refresh" @click="refreshTerminal"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg></button>
<button class="terminal-icon-button" type="button" :aria-label="minimized ? 'Restore terminal' : 'Minimize terminal'" @click="minimized = !minimized"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="minimized ? 'M5 12h14' : 'M6 12h12'" /></svg></button>
<button class="terminal-icon-button" type="button" :aria-label="fullscreen ? 'Windowed' : 'Fullscreen'" @click="toggleFullscreen"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" :d="fullscreen ? 'M8 3v5H3m18 0h-5V3M3 16h5v5m8 0v-5h5' : 'M8 3H3v5m13-5h5v5M3 16v5h5m8 0h5v-5'" /></svg></button>
<button class="terminal-icon-button" type="button" aria-label="Close" @click="closeTerminal"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg></button>
</div>
</header>
<div v-if="!minimized" class="terminal-body">
<div v-if="error" class="terminal-empty terminal-error"><strong>Terminal unavailable</strong><span>{{ error }}</span><button class="terminal-button" type="button" @click="loadSessions">Retry</button></div>
<div v-else-if="!sessions.length" class="terminal-empty"><strong>Create your first developer session</strong><span>Sessions stay running when this window closes and can be resumed later.</span><button class="terminal-button terminal-primary" type="button" @click="createSession">Start session</button></div>
<div v-else-if="!selectedId" class="terminal-empty"><strong>Choose a session</strong><span>Resume an existing workspace or start a new one.</span></div>
<pre v-else ref="outputRef" class="terminal-output" tabindex="0" @click="focusInput">{{ output }}</pre>
<div v-else ref="terminalRef" class="terminal-output" tabindex="0" aria-label="Archipelago terminal session" @click="focusInput"></div>
</div>
<footer v-if="selectedId && !minimized" class="terminal-input-bar">
<span class="terminal-prompt">›</span><input ref="inputRef" class="terminal-input" aria-label="Terminal input" autocomplete="off" autocapitalize="off" spellcheck="false" @keydown="onKeydown" @blur="keepFocus" />
<span class="terminal-status" :class="{ connected: connected }">{{ connected ? 'Connected' : 'Reconnecting…' }}</span>
<span class="terminal-hint">Click terminal to focus · drag the header to move · resize from the corner</span>
</footer>
</section>
</div>
@@ -35,6 +38,9 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { useCLIStore } from '@/stores/cli'
type Session = { id: string; name: string; workspace: string; state: string; updated_at: number }
@@ -46,11 +52,12 @@ const position = ref({ x: 0, y: 0 })
const dragging = ref<{ x: number; y: number; px: number; py: number } | null>(null)
const sessions = ref<Session[]>([])
const selectedId = ref('')
const output = ref('')
const error = ref('')
const connected = ref(false)
const outputRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLInputElement | null>(null)
const terminalRef = ref<HTMLElement | null>(null)
let terminal: Terminal | null = null
let fitAddon: FitAddon | null = null
let resizeObserver: ResizeObserver | null = null
let socket: WebSocket | null = null
const activeSession = computed(() => sessions.value.find(session => session.id === selectedId.value))
@@ -98,50 +105,86 @@ async function toggleFullscreen() {
}
function disconnect() { socket?.close(); socket = null; connected.value = false }
function connectSelected() {
disconnect(); output.value = ''
disconnect(); terminal?.reset()
if (!selectedId.value) return
socket = new WebSocket(wsUrl(selectedId.value))
socket.onopen = () => { connected.value = true; focusInput() }
socket.onopen = () => { connected.value = true; resizeTerminal(); focusInput() }
socket.onclose = () => { connected.value = false }
socket.onerror = () => { connected.value = false }
socket.onmessage = event => {
try { const message = JSON.parse(event.data); if (message.type === 'output') { output.value = message.data; nextTick(() => { if (outputRef.value) outputRef.value.scrollTop = outputRef.value.scrollHeight }) } } catch { /* ignore malformed frames */ }
try {
const message = JSON.parse(event.data)
if (message.type === 'output') { terminal?.write(message.data); terminal?.scrollToBottom() }
} catch { /* ignore malformed frames */ }
}
}
function send(data: string) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })) }
function onKeydown(event: KeyboardEvent) {
if (event.ctrlKey && event.key.toLowerCase() === 'c') { event.preventDefault(); send('\u0003'); return }
if (event.ctrlKey && event.key.toLowerCase() === 'd') { event.preventDefault(); send('\u0004'); return }
const special: Record<string, string> = { Enter: '\n', Backspace: '\u007f', Tab: '\t', ArrowUp: '\u001b[A', ArrowDown: '\u001b[B', ArrowRight: '\u001b[C', ArrowLeft: '\u001b[D' }
const specialKey = special[event.key]
if (specialKey !== undefined) { event.preventDefault(); send(specialKey); return }
if (event.key.length === 1 && !event.metaKey && !event.altKey) { event.preventDefault(); send(event.key) }
function resizeTerminal() {
fitAddon?.fit()
if (socket?.readyState === WebSocket.OPEN && terminal) socket.send(JSON.stringify({ type: 'resize', cols: terminal.cols, rows: terminal.rows }))
}
function focusInput() { nextTick(() => inputRef.value?.focus()) }
function keepFocus() { if (cliStore.isOpen) setTimeout(focusInput, 0) }
function send(data: string) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: 'input', data })) }
function focusInput() { nextTick(() => terminal?.focus()) }
watch(() => cliStore.isOpen, open => { if (open) loadSessions(); else disconnect() })
onMounted(() => { window.addEventListener('mousemove', onDrag); window.addEventListener('mouseup', stopDrag) })
onBeforeUnmount(() => { window.removeEventListener('mousemove', onDrag); window.removeEventListener('mouseup', stopDrag) })
onBeforeUnmount(disconnect)
watch(terminalRef, element => {
if (!element || !terminal || element.querySelector('.xterm')) return
terminal.open(element)
fitAddon?.fit()
resizeObserver?.observe(element)
})
onMounted(() => {
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
terminal = new Terminal({
convertEol: true,
cursorBlink: true,
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
fontSize: 13,
lineHeight: 1.45,
scrollback: 10000,
theme: { background: 'transparent', foreground: '#f3f4f6', cursor: '#f7931a', selectionBackground: 'rgba(247,147,26,.3)' },
})
fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.onData(data => send(data))
resizeObserver = new ResizeObserver(() => resizeTerminal())
if (terminalRef.value) {
terminal.open(terminalRef.value)
resizeObserver.observe(terminalRef.value)
}
nextTick(resizeTerminal)
})
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
resizeObserver?.disconnect()
terminal?.dispose()
disconnect()
})
</script>
<style scoped>
.terminal-panel { display: flex; flex-direction: column; width: min(960px, calc(100vw - 2rem)); height: min(680px, calc(100vh - 2rem)); min-width: 320px; min-height: 220px; max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); resize: both; border: 1px solid rgba(247,147,26,.28); border-radius: 18px; background: #0b0e12; box-shadow: 0 28px 90px rgba(0,0,0,.65); color: #f6f7f9; }
.terminal-panel { display: flex; flex-direction: column; width: min(672px, calc(100vw - 2rem)); height: min(640px, calc(100vh - 2rem)); min-width: 320px; min-height: 220px; max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); resize: both; }
.terminal-panel.minimized { width: auto; height: auto; min-width: 320px; resize: none; }
.terminal-panel:fullscreen { width: 100vw; height: 100vh; max-width: none; max-height: none; border-radius: 0; }
.terminal-header, .terminal-input-bar { display: flex; align-items: center; gap: 12px; padding: 13px 16px; background: rgba(255,255,255,.045); }
.terminal-header { justify-content: space-between; border-bottom: 1px solid rgba(255,255,255,.1); cursor: grab; user-select: none; }
.terminal-header, .terminal-input-bar { display: flex; align-items: center; gap: 12px; padding: 13px 16px; }
.terminal-header { border-bottom: 1px solid rgba(255,255,255,.1); cursor: grab; user-select: none; }
.terminal-header:active { cursor: grabbing; }
.terminal-title, .terminal-actions { display: flex; align-items: center; gap: 10px; min-width: 0; }
.terminal-title { font-weight: 650; } .terminal-host { color: rgba(255,255,255,.45); font-size: 12px; font-weight: 400; }
.terminal-dot { width: 9px; height: 9px; border-radius: 50%; background: #f7931a; box-shadow: 0 0 12px #f7931a; }
.terminal-body { flex: 1 1 auto; min-height: 0; height: 0; overflow: hidden; background: #050608; }
.terminal-output { box-sizing: border-box; height: 100%; max-height: 100%; margin: 0; overflow-y: auto; overflow-x: hidden; padding: 18px; color: #d7f7d3; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-anchor: auto; outline: none; }
.terminal-title { flex: 1; font-weight: 500; } .terminal-host { color: rgba(255,255,255,.45); font-size: 12px; font-weight: 400; }
.terminal-drag-handle { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 6px; flex-shrink: 0; }
.terminal-drag-handle:hover { background: rgba(255,255,255,.1); }
.terminal-body { flex: 1 1 auto; min-height: 0; height: 0; overflow: hidden; }
.terminal-output { box-sizing: border-box; height: 100%; max-height: 100%; margin: 0; overflow: hidden; padding: 14px 16px; color: rgba(255,255,255,.9); outline: none; }
.terminal-output :deep(.xterm) { height: 100%; width: 100%; }
.terminal-output :deep(.xterm-viewport) { background: transparent !important; }
.terminal-input-bar { border-top: 1px solid rgba(255,255,255,.1); } .terminal-prompt { color: #f7931a; font-size: 20px; } .terminal-input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: white; font: 13px ui-monospace, monospace; }
.terminal-status { color: #e8a7a7; font-size: 11px; } .terminal-status.connected { color: #92d6a0; }
.terminal-select, .terminal-button, .terminal-close { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.15); background: rgba(255,255,255,.07); color: white; padding: 0 11px; font-size: 12px; }
.terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); } .terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; } .terminal-close { color: rgba(255,255,255,.65); }
.terminal-status { color: rgba(255,255,255,.5); font-size: 11px; } .terminal-status.connected { color: #92d6a0; }
.terminal-hint { margin-left: auto; color: rgba(255,255,255,.35); font-size: 11px; }
.terminal-select, .terminal-icon-button, .terminal-button { min-height: 36px; border-radius: 9px; border: 1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.06); color: white; padding: 0 9px; font-size: 12px; }
.terminal-icon-button:hover, .terminal-button:hover, .terminal-select:hover { background: rgba(255,255,255,.13); }
.terminal-primary { background: #f7931a; border-color: #f7931a; color: #17100a; font-weight: 700; }
.terminal-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; height: 100%; min-height: 300px; padding: 32px; text-align: center; color: rgba(255,255,255,.55); } .terminal-empty strong { color: white; font-size: 16px; } .terminal-error strong { color: #ffaaa6; }
@media (max-width: 640px) { .terminal-panel { width: calc(100vw - 1rem); height: calc(100vh - 1rem); max-width: calc(100vw - 1rem); max-height: calc(100vh - 1rem); } .terminal-header { align-items: flex-start; flex-direction: column; } .terminal-actions { width: 100%; flex-wrap: wrap; } .terminal-select { flex: 1; min-width: 0; } .terminal-status { display: none; } }
</style>