import { useState, useRef, useEffect, useCallback } from 'react' import { Terminal as XTerm } from '@xterm/xterm' import { FitAddon } from '@xterm/addon-fit' import { WebLinksAddon } from '@xterm/addon-web-links' import { Plus, X, Monitor, Globe, ChevronDown, Pencil, Trash2 } from 'lucide-react' import '@xterm/xterm/css/xterm.css' import { useI18n } from '../i18n' const MAX_TABS = 7 const THEMES = { default: { background: '#0A0A0C', foreground: '#EAE0E2', cursor: '#FF0033', cursorAccent: '#0A0A0C', selectionBackground: '#FF003344', selectionForeground: '#ffffff', black: '#0A0A0C', red: '#FF0033', green: '#00E676', yellow: '#FFD740', blue: '#448AFF', magenta: '#FF1A5E', cyan: '#00BCD4', white: '#EAE0E2', brightBlack: '#5A4F52', brightRed: '#FF5252', brightGreen: '#69F0AE', brightYellow: '#FFFF00', brightBlue: '#82B1FF', brightMagenta: '#FF80AB', brightCyan: '#84FFFF', brightWhite: '#FFFFFF', }, monokai: { background: '#272822', foreground: '#F8F8F2', cursor: '#F8F8F0', cursorAccent: '#272822', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', black: '#272822', red: '#F92672', green: '#A6E22E', yellow: '#E6DB74', blue: '#66D9EF', magenta: '#AE81FF', cyan: '#A1EFE4', white: '#F8F8F2', brightBlack: '#75715E', brightRed: '#F92672', brightGreen: '#A6E22E', brightYellow: '#E6DB74', brightBlue: '#66D9EF', brightMagenta: '#AE81FF', brightCyan: '#A1EFE4', brightWhite: '#F8F8F2', }, gruvbox: { background: '#282828', foreground: '#EBDBB2', cursor: '#FB4934', cursorAccent: '#282828', selectionBackground: '#EBDBB244', selectionForeground: '#ffffff', black: '#282828', red: '#CC241D', green: '#98971A', yellow: '#D79921', blue: '#458588', magenta: '#B16286', cyan: '#689D6A', white: '#EBDBB2', brightBlack: '#928374', brightRed: '#FB4934', brightGreen: '#B8BB26', brightYellow: '#FABC2A', brightBlue: '#83A598', brightMagenta: '#D3869B', brightCyan: '#8EC07C', brightWhite: '#EBDBB2', }, nord: { background: '#2E3440', foreground: '#D8DEE9', cursor: '#D8DEE9', cursorAccent: '#2E3440', selectionBackground: '#D8DEE944', selectionForeground: '#ffffff', black: '#2E3440', red: '#BF616A', green: '#A3BE8C', yellow: '#EBCB8B', blue: '#81A1C1', magenta: '#B48EAD', cyan: '#88C0D0', white: '#D8DEE9', brightBlack: '#4C566A', brightRed: '#BF616A', brightGreen: '#A3BE8C', brightYellow: '#EBCB8B', brightBlue: '#81A1C1', brightMagenta: '#B48EAD', brightCyan: '#8FBCBB', brightWhite: '#ECEFF4', }, 'solarized-dark': { background: '#002B36', foreground: '#839496', cursor: '#D33682', cursorAccent: '#002B36', selectionBackground: '#83949644', selectionForeground: '#ffffff', black: '#002B36', red: '#DC322F', green: '#859900', yellow: '#B58900', blue: '#268BD2', magenta: '#D33682', cyan: '#2AA198', white: '#FDF6E3', brightBlack: '#073642', brightRed: '#CB4B16', brightGreen: '#586E75', brightYellow: '#657B83', brightBlue: '#6C71C4', brightMagenta: '#6C71C4', brightCyan: '#93A1A1', brightWhite: '#FDF6E3', }, dracula: { background: '#282A36', foreground: '#F8F8F2', cursor: '#F8F8F2', cursorAccent: '#282A36', selectionBackground: '#F8F8F244', selectionForeground: '#ffffff', black: '#282A36', red: '#FF5555', green: '#50FA7B', yellow: '#F1FA8C', blue: '#BD93F9', magenta: '#FF79C6', cyan: '#8BE9FD', white: '#F8F8F2', brightBlack: '#6272A4', brightRed: '#FF6E6E', brightGreen: '#69FF94', brightYellow: '#FFFFA5', brightBlue: '#D6ACFF', brightMagenta: '#FF92DF', brightCyan: '#A4FFFF', brightWhite: '#FFFFFF', }, } function getTheme(themeName) { return THEMES[themeName] || THEMES.default } function createTerminal(container, settings = {}) { const theme = getTheme(settings.theme || 'default') const term = new XTerm({ cursorBlink: true, fontSize: settings.fontSize || 14, fontFamily: settings.fontFamily || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme, allowTransparency: false, scrollback: 5000, }) const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) term.open(container) fitAddon.fit() return { term, fitAddon } } function connectWebSocket(term, fitAddon, initPayload) { const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) ws.addEventListener('open', () => { ws.send(JSON.stringify(initPayload)) const dims = fitAddon.proposeDimensions() if (dims) { ws.send(JSON.stringify({ type: 'resize', rows: dims.rows, cols: dims.cols })) } }) ws.addEventListener('message', (event) => { try { const msg = JSON.parse(event.data) if (msg.type === 'output') { term.write(msg.data) } else if (msg.type === 'error') { term.write(`\r\n\x1b[31mError: ${msg.data}\x1b[0m\r\n`) } } catch { term.write(event.data) } }) ws.addEventListener('close', () => { term.write('\r\n\x1b[33m— Connection closed —\x1b[0m\r\n') }) ws.addEventListener('error', () => { term.write('\r\n\x1b[31m— Connection error —\x1b[0m\r\n') }) term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', data })) } }) term.onResize(({ rows, cols }) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', rows, cols })) } }) return ws } export default function Shell({ api }) { const { t } = useI18n() const tabsRef = useRef({}) const nextIdRef = useRef(1) const [tabs, setTabs] = useState([ { id: 1, name: 'Local Shell', type: 'local', shell: '', connected: false }, ]) const [activeTab, setActiveTab] = useState(1) const [sshConnections, setSshConnections] = useState([]) const [systemTerminals, setSystemTerminals] = useState([]) const [showMenu, setShowMenu] = useState(false) const [showSshModal, setShowSshModal] = useState(false) const [editingTab, setEditingTab] = useState(null) const [editName, setEditName] = useState('') const [terminalSettings, setTerminalSettings] = useState({ fontSize: 14, fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: 'default', }) const [sshForm, setSshForm] = useState({ name: '', host: '', port: 22, user: '', key_path: '', }) const [aiMessages, setAiMessages] = useState([ { role: 'ai', content: t('shell.aiWelcome') } ]) const [aiInput, setAiInput] = useState('') const [aiLoading, setAiLoading] = useState(false) const aiMessagesRef = useRef(null) useEffect(() => { aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) }, [aiMessages]) useEffect(() => { api.getTerminalSessions().then(d => { setSshConnections(d.ssh || []) setSystemTerminals(d.system || []) }).catch(() => {}) api.getConfig().then(d => { if (d.terminal) { setTerminalSettings({ fontSize: d.terminal.font_size || 14, fontFamily: d.terminal.font_family || "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', 'Menlo', monospace", theme: d.terminal.theme || 'default', }) } }).catch(() => {}) }, []) const initTerminal = useCallback((tabId, tab) => { if (tabsRef.current[tabId]) return const container = document.getElementById(`terminal-${tabId}`) if (!container) return const { term, fitAddon } = createTerminal(container, { fontSize: terminalSettings.fontSize, fontFamily: terminalSettings.fontFamily, theme: terminalSettings.theme, }) let initPayload if (tab.type === 'ssh') { initPayload = { type: 'ssh', data: JSON.stringify({ host: tab.host, port: tab.port || 22, user: tab.user || 'root', key_path: tab.key_path || '', }), } } else { initPayload = { type: 'shell', data: tab.shell || '', } } const ws = connectWebSocket(term, fitAddon, initPayload) ws.onopen = () => { setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: true } : t)) } ws.onclose = () => { setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) } ws.onerror = () => { setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected: false } : t)) } const onResize = () => { const el = document.getElementById(`terminal-${tabId}`) if (el && el.offsetParent !== null) { fitAddon.fit() } } const resizeObserver = new ResizeObserver(onResize) resizeObserver.observe(container) window.addEventListener('resize', onResize) tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize } }, []) useEffect(() => { const tab = tabs.find(t => t.id === activeTab) if (!tab) return const container = document.getElementById(`terminal-${tab.id}`) if (!container) return if (!tabsRef.current[tab.id]) { const timer = setTimeout(() => { initTerminal(tab.id, tab) requestAnimationFrame(() => { const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) }, 100) return () => clearTimeout(timer) } else { requestAnimationFrame(() => { const entry = tabsRef.current[tab.id] if (entry) entry.fitAddon.fit() }) } }, [activeTab, tabs, initTerminal]) useEffect(() => { const onKey = (e) => { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (!e.altKey) return const num = parseInt(e.key) if (num >= 1 && num <= tabs.length) { e.preventDefault() setActiveTab(tabs[num - 1].id) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [tabs]) const addLocalTab = (shell, name) => { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ const newTab = { id, name: name || `${t('shell.localShell')} ${tabs.length + 1}`, type: 'local', shell: shell || '', connected: false } setTabs(prev => [...prev, newTab]) setActiveTab(id) setShowMenu(false) } const addSSHTab = (conn) => { if (tabs.length >= MAX_TABS) return const id = nextIdRef.current++ const newTab = { id, name: conn.name || `${conn.user}@${conn.host}`, type: 'ssh', host: conn.host, port: conn.port || 22, user: conn.user || 'root', key_path: conn.key_path || '', connected: false, } setTabs(prev => [...prev, newTab]) setActiveTab(id) setShowMenu(false) } const closeTab = (tabId, e) => { if (e) e.stopPropagation() if (tabs.length <= 1) return if (tabsRef.current[tabId]) { const { ws, resizeObserver, onResize, term } = tabsRef.current[tabId] window.removeEventListener('resize', onResize) resizeObserver.disconnect() ws.close() term.dispose() delete tabsRef.current[tabId] } setTabs(prev => { const next = prev.filter(t => t.id !== tabId) if (activeTab === tabId && next.length > 0) { setActiveTab(next[next.length - 1].id) } return next }) } const startRename = (tabId, e) => { if (e) e.stopPropagation() const tab = tabs.find(t => t.id === tabId) setEditingTab(tabId) setEditName(tab.name) } const finishRename = () => { if (editName.trim() && editingTab) { setTabs(prev => prev.map(t => t.id === editingTab ? { ...t, name: editName.trim() } : t)) } setEditingTab(null) setEditName('') } const saveSSHConnection = async () => { if (!sshForm.name.trim() || !sshForm.host.trim()) return try { await api.addSSHConnection(sshForm) setSshConnections(prev => [...prev, { ...sshForm }]) setSshForm({ name: '', host: '', port: 22, user: '', key_path: '' }) setShowSshModal(false) } catch (err) { console.error(err) } } const deleteSSHConnection = async (name) => { try { await api.deleteSSHConnection(name) setSshConnections(prev => prev.filter(c => c.name !== name)) } catch (err) { console.error(err) } } const handleAiSend = async () => { if (!aiInput.trim() || aiLoading) return const text = aiInput.trim() setAiMessages(prev => [...prev, { role: 'user', content: text }]) setAiInput('') setAiLoading(true) const currentTab = tabs.find(t => t.id === activeTab) const context = { cwd: currentTab?.cwd || '', platform: navigator.platform || '', } try { let accumulated = '' await api.sendShellChat(text, context, true, (partial, event) => { if (event && event.tool_call) { setAiMessages(prev => [...prev, { role: 'tool', content: `${t('shell.toolLaunched')}: ${event.tool_call.name || 'tool'}`, args: event.tool_call.args ? JSON.stringify(event.tool_call.args).slice(0, 100) : '', }]) return } if (event && event.tool_result) { const resultText = event.tool_result.result?.content || event.tool_result.error || 'completed' setAiMessages(prev => [...prev, { role: 'tool_result', content: resultText, isError: event.tool_result.result?.is_error, }]) return } if (event && event.done) return accumulated = partial setAiMessages(prev => { const filtered = prev.filter(m => !m._streaming) return [...filtered, { role: 'ai', content: partial, _streaming: true }] }) }) setAiMessages(prev => prev.filter(m => !m._streaming)) if (accumulated) { setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: accumulated }]) } } catch (err) { setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'ai', content: `${t('shell.error')}: ${err.message}` }]) } setAiLoading(false) } return (