wraith/frontend/composables/useTerminal.ts
Vantz Stockwell 1f32ce4620 fix: credential picker in host modal, fix xterm dimensions crash
- Added credential dropdown to New Host modal (loads from vault API)
- Fixed xterm.js "Cannot read dimensions" crash by guarding fitAddon.fit()
  with requestAnimationFrame and container dimension checks
- Added WebGL context loss handler
- credentialId now passed when creating hosts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:31:39 -04:00

126 lines
4.2 KiB
TypeScript

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, term: Terminal, fitAddon: FitAddon) {
const wsUrl = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/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':
sessions.addSession({ 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 }
}