Tesseract / studio /src /components /TerminalPanel.jsx
rkbosamia's picture
Tesseract major version upgrade - v2
9c49532
Raw
History Blame Contribute Delete
3.76 kB
/**
* TerminalPanel — xterm.js front-end over the /api/projects/{id}/terminal
* WebSocket PTY bridge.
*
* Lifecycle:
* - On mount + project change: opens a WS, attaches it to a fresh xterm.
* - Resize: ResizeObserver -> fit addon -> send {"resize":[c,r]} control frame.
* - On unmount: closes the WS (server reaps the PTY child).
*/
import { useEffect, useRef } from 'react'
import useAppStore from '../store/appStore'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
export default function TerminalPanel() {
const projectId = useAppStore((s) => s.project.id)
const theme = useAppStore((s) => s.theme)
const hostRef = useRef(null)
const termRef = useRef(null)
const wsRef = useRef(null)
const fitRef = useRef(null)
useEffect(() => {
if (!projectId || !hostRef.current) return
// Colour scheme picked to match the studio surfaces (CSS vars aren't
// accessible from xterm directly so we hardcode a small palette).
const dark = theme !== 'light'
const term = new Terminal({
fontFamily: 'ui-monospace, "JetBrains Mono", Menlo, monospace',
fontSize: 12.5,
lineHeight: 1.15,
cursorBlink: true,
convertEol: false,
scrollback: 5000,
allowProposedApi: true,
theme: dark
? { background: '#0b1220', foreground: '#e2e8f0', cursor: '#22d3ee', selectionBackground: '#1e3a8a' }
: { background: '#f8fafc', foreground: '#0f172a', cursor: '#0284c7', selectionBackground: '#bfdbfe' },
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(hostRef.current)
fit.fit()
term.focus()
termRef.current = term
fitRef.current = fit
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const wsUrl = `${proto}//${window.location.host}`
+ `/api/projects/${projectId}/terminal`
+ `?cols=${term.cols}&rows=${term.rows}`
const ws = new WebSocket(wsUrl)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
ws.onopen = () => {
term.write('\x1b[2m[tesseract] terminal connected — bash/zsh at project root.\x1b[0m\r\n')
}
ws.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) {
term.write(new Uint8Array(ev.data))
} else if (typeof ev.data === 'string') {
term.write(ev.data)
}
}
ws.onclose = () => {
term.write('\r\n\x1b[2m[tesseract] terminal disconnected.\x1b[0m\r\n')
}
ws.onerror = () => {
term.write('\r\n\x1b[31m[tesseract] terminal error.\x1b[0m\r\n')
}
const onData = term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) ws.send(data)
})
const ro = new ResizeObserver(() => {
try {
fit.fit()
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ resize: [term.cols, term.rows] }))
}
} catch { /* ignore */ }
})
ro.observe(hostRef.current)
return () => {
ro.disconnect()
onData.dispose()
try { ws.close() } catch { /* ignore */ }
term.dispose()
termRef.current = null
fitRef.current = null
wsRef.current = null
}
}, [projectId, theme])
if (!projectId) {
return (
<div style={{ height: '100%', display: 'grid', placeItems: 'center', color: 'var(--text-faint)', fontSize: 12 }}>
Open a project to launch a terminal.
</div>
)
}
return (
<div
ref={hostRef}
style={{
width: '100%',
height: '100%',
background: theme === 'light' ? '#f8fafc' : '#0b1220',
padding: 6,
boxSizing: 'border-box',
}}
/>
)
}