import { Terminal } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { SearchAddon } from '@xterm/addon-search' import { WebLinksAddon } from '@xterm/addon-web-links' import { WebglAddon } from '@xterm/addon-webgl' import { useAuthStore } from '~/stores/auth.store' import { useSessionStore } from '~/stores/session.store' import { getTerminalTheme } from '~/composables/useTerminalThemes' export function useTerminal() { const auth = useAuthStore() const sessions = useSessionStore() let ws: WebSocket | null = null function createTerminal(container: HTMLElement, options?: Partial<{ fontSize: number; scrollback: number; themeId: string }>) { const themeId = options?.themeId || localStorage.getItem('wraith_terminal_theme') || 'wraith' const theme = getTerminalTheme(themeId) const term = new Terminal({ cursorBlink: true, fontSize: options?.fontSize || parseInt(localStorage.getItem('wraith_font_size') || '14'), fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace", scrollback: options?.scrollback || parseInt(localStorage.getItem('wraith_scrollback') || '10000'), theme, }) const fitAddon = new FitAddon() const searchAddon = new SearchAddon() term.loadAddon(fitAddon) term.loadAddon(searchAddon) term.loadAddon(new WebLinksAddon()) term.open(container) try { const webgl = new WebglAddon() webgl.onContextLoss(() => { webgl.dispose() }) term.loadAddon(webgl) } catch { // WebGL not available, fall back to canvas } // Delay fit until container has layout dimensions const safeFit = () => { try { if (container.offsetWidth > 0 && container.offsetHeight > 0) { fitAddon.fit() } } catch { /* ignore fit errors on unmounted containers */ } } requestAnimationFrame(safeFit) const resizeObserver = new ResizeObserver(() => safeFit()) resizeObserver.observe(container) return { term, fitAddon, searchAddon, resizeObserver } } function connectToHost(hostId: number, hostName: string, protocol: 'ssh', color: string | null, pendingSessionId: string, term: Terminal, fitAddon: FitAddon) { const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/api/ws/terminal?token=${auth.token}` ws = new WebSocket(wsUrl) ws.onopen = () => { ws!.send(JSON.stringify({ type: 'connect', hostId })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) switch (msg.type) { case 'connected': // Replace the pending placeholder with the real backend session sessions.replaceSession(pendingSessionId, { id: msg.sessionId, hostId, hostName, protocol, color, active: true }) // Send initial terminal size ws!.send(JSON.stringify({ type: 'resize', sessionId: msg.sessionId, cols: term.cols, rows: term.rows })) break case 'data': term.write(msg.data) break case 'disconnected': sessions.removeSession(msg.sessionId) break case 'host-key-verify': // Auto-accept for now — full UX in polish phase ws!.send(JSON.stringify({ type: 'host-key-accept' })) break case 'error': term.write(`\r\n\x1b[31mError: ${msg.message}\x1b[0m\r\n`) break } } ws.onclose = () => { term.write('\r\n\x1b[33mConnection closed.\x1b[0m\r\n') } // Terminal input → WebSocket term.onData((data) => { if (ws?.readyState === WebSocket.OPEN) { const sessionId = sessions.activeSession?.id if (sessionId) { ws.send(JSON.stringify({ type: 'data', sessionId, data })) } } }) // Terminal resize → WebSocket term.onResize(({ cols, rows }) => { if (ws?.readyState === WebSocket.OPEN) { const sessionId = sessions.activeSession?.id if (sessionId) { ws.send(JSON.stringify({ type: 'resize', sessionId, cols, rows })) } } }) return ws } function disconnect(sessionId: string) { if (ws?.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'disconnect', sessionId })) } sessions.removeSession(sessionId) } return { createTerminal, connectToHost, disconnect } }