import { useEffect, useRef, useState } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import '@xterm/xterm/css/xterm.css' import { Settings2, Plus, X, Columns2, Grid2x2, SquareSlash } from 'lucide-react' import { api, getToken, type Integration } from '../lib/api' const GOLD = '#C8A434' const TEXT_SECONDARY = '#7A7D85' interface TermTheme { name: string background: string foreground: string cursor: string } const TERM_THEMES: TermTheme[] = [ { name: 'ArchNest Dark', background: '#15161A', foreground: '#E8E6E0', cursor: GOLD }, { name: 'Matrix', background: '#000000', foreground: '#33FF66', cursor: '#33FF66' }, { name: 'Solarized', background: '#002B36', foreground: '#93A1A1', cursor: '#CB4B16' }, { name: 'Midnight Blue', background: '#0B1021', foreground: '#C7D3F2', cursor: '#5FA8FF' }, ] const FONT_SIZES = [11, 12, 13, 14, 15, 16] const FONT_FAMILIES = [ { name: 'Monospace', value: 'ui-monospace, SFMono-Regular, Menlo, monospace' }, { name: 'Fira Code', value: '"Fira Code", ui-monospace, monospace' }, { name: 'JetBrains Mono', value: '"JetBrains Mono", ui-monospace, monospace' }, ] interface TerminalPrefs { themeName: string fontSize: number fontFamily: string } const PREFS_KEY = 'archnest-terminal-prefs' function loadPrefs(): TerminalPrefs { try { const raw = localStorage.getItem(PREFS_KEY) if (raw) return { ...defaultPrefs(), ...JSON.parse(raw) } } catch { /* ignore malformed local storage */ } return defaultPrefs() } function defaultPrefs(): TerminalPrefs { return { themeName: TERM_THEMES[0].name, fontSize: 13, fontFamily: FONT_FAMILIES[0].value } } function savePrefs(prefs: TerminalPrefs) { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)) } interface PaneState { id: number hostId: number | null } interface TabState { id: number name: string panes: PaneState[] } let nextId = 1 const genId = () => nextId++ function newTab(): TabState { return { id: genId(), name: 'Terminal', panes: [{ id: genId(), hostId: null }] } } export default function Terminal() { const [hosts, setHosts] = useState([]) const [tabs, setTabs] = useState(() => [newTab()]) const [activeTabId, setActiveTabId] = useState(() => tabs[0].id) const [activePaneId, setActivePaneId] = useState(null) const [prefs, setPrefs] = useState(loadPrefs) const [showPrefs, setShowPrefs] = useState(false) useEffect(() => { api.listIntegrations().then(({ integrations }) => { setHosts(integrations.filter((i) => i.type === 'ssh')) }) }, []) useEffect(() => savePrefs(prefs), [prefs]) const activeTab = tabs.find((t) => t.id === activeTabId) ?? tabs[0] function addTab() { const tab = newTab() setTabs((prev) => [...prev, tab]) setActiveTabId(tab.id) } function closeTab(id: number) { setTabs((prev) => { const next = prev.filter((t) => t.id !== id) if (next.length === 0) { const t = newTab() setActiveTabId(t.id) return [t] } if (id === activeTabId) setActiveTabId(next[0].id) return next }) } function updateActiveTab(fn: (tab: TabState) => TabState) { setTabs((prev) => prev.map((t) => (t.id === activeTabId ? fn(t) : t))) } function setPaneCount(count: number) { updateActiveTab((tab) => { const panes = [...tab.panes] while (panes.length < count) panes.push({ id: genId(), hostId: null }) while (panes.length > count) panes.pop() return { ...tab, panes } }) } function setPaneHost(paneId: number, hostId: number) { updateActiveTab((tab) => ({ ...tab, panes: tab.panes.map((p) => (p.id === paneId ? { ...p, hostId } : p)), })) setActivePaneId(paneId) } const paneGridClass = activeTab.panes.length === 1 ? 'grid-cols-1 grid-rows-1' : activeTab.panes.length === 2 ? 'grid-cols-2 grid-rows-1' : 'grid-cols-2 grid-rows-2' return (

SSH Hosts

{hosts.length === 0 && (

No SSH integrations configured. Add one in Settings → Integrations.

)}
{hosts.map((h) => ( ))}

Click a pane, then a host to connect it.

{tabs.map((tab) => (
setActiveTabId(tab.id)} className="flex shrink-0 cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-xs" style={{ background: tab.id === activeTabId ? 'rgba(200,164,52,0.15)' : 'rgba(255,255,255,0.04)', color: tab.id === activeTabId ? GOLD : '#E8E6E0', }} > {tab.name} { e.stopPropagation() closeTab(tab.id) }} />
))}
{showPrefs && (
)}
{activeTab.panes.map((pane) => ( setActivePaneId(pane.id)} /> ))}
) } function TerminalPane({ hostId, hosts, prefs, active, onFocus, }: { hostId: number | null hosts: Integration[] prefs: TerminalPrefs active: boolean onFocus: () => void }) { const [connected, setConnected] = useState(false) const [tmuxSessions, setTmuxSessions] = useState([]) const [selectedTmux, setSelectedTmux] = useState('') const containerRef = useRef(null) const termRef = useRef(null) const fitRef = useRef(null) const wsRef = useRef(null) const lastHostIdRef = useRef(null) useEffect(() => { if (!containerRef.current) return const theme = TERM_THEMES.find((t) => t.name === prefs.themeName) ?? TERM_THEMES[0] const term = new XTerm({ cursorBlink: true, fontSize: prefs.fontSize, fontFamily: prefs.fontFamily, theme: { background: theme.background, foreground: theme.foreground, cursor: theme.cursor }, }) const fit = new FitAddon() term.loadAddon(fit) term.open(containerRef.current) fit.fit() termRef.current = term fitRef.current = fit const onResize = () => fit.fit() window.addEventListener('resize', onResize) return () => { window.removeEventListener('resize', onResize) term.dispose() wsRef.current?.close() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [prefs.themeName, prefs.fontSize, prefs.fontFamily]) useEffect(() => { fitRef.current?.fit() }) useEffect(() => { if (hostId === null || hostId === lastHostIdRef.current) return lastHostIdRef.current = hostId setSelectedTmux('') fetchTmuxSessions(hostId) connect(hostId) // eslint-disable-next-line react-hooks/exhaustive-deps }, [hostId]) function fetchTmuxSessions(id: number) { setTmuxSessions([]) const token = getToken() const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) ws.onopen = () => ws.send(JSON.stringify({ type: 'list_tmux', integrationId: id })) ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'tmux_sessions') { setTmuxSessions(msg.sessions ?? []) ws.close() } } } function connect(id: number, tmuxSession?: string) { wsRef.current?.close() setConnected(false) const term = termRef.current if (!term) return term.reset() term.writeln('Connecting…') const token = getToken() const proto = window.location.protocol === 'https:' ? 'wss' : 'ws' const ws = new WebSocket(`${proto}://${window.location.host}/api/terminal?token=${encodeURIComponent(token ?? '')}`) wsRef.current = ws ws.onopen = () => { ws.send(JSON.stringify({ type: 'connect', integrationId: id, cols: term.cols, rows: term.rows, tmuxSession })) } ws.onmessage = (event) => { const msg = JSON.parse(event.data) if (msg.type === 'connected') { setConnected(true) term.reset() } else if (msg.type === 'data') { term.write(msg.data) } else if (msg.type === 'error') { term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`) setConnected(false) } else if (msg.type === 'closed') { term.writeln('\r\n\x1b[33mConnection closed.\x1b[0m') setConnected(false) } } ws.onclose = () => setConnected(false) term.onData((data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'input', data })) }) term.onResize(({ cols, rows }) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols, rows })) }) } const host = hosts.find((h) => h.id === hostId) return (
{host ? (connected ? `Connected — ${host.name}` : `Disconnected — ${host.name}`) : 'Select a host to connect'} {host && ( )}
) }