Spaces:
Paused
Paused
| import React, { useState, useRef, useEffect } from 'react'; | |
| import { Terminal as XTerm } from 'xterm'; | |
| import { FitAddon } from 'xterm-addon-fit'; | |
| import 'xterm/css/xterm.css'; | |
| import { FaPlus, FaTimes, FaRobot } from 'react-icons/fa'; | |
| interface Tab { | |
| id: number; | |
| terminal: XTerm; | |
| socket: WebSocket; | |
| } | |
| const TerminalTabs: React.FC = () => { | |
| const [tabs, setTabs] = useState<Tab[]>([]); | |
| const [activeTab, setActiveTab] = useState(0); | |
| const termRefs = useRef<Map<number, HTMLDivElement>>(new Map()); | |
| const nextId = useRef(1); | |
| const [suggestionInput, setSuggestionInput] = useState(''); | |
| const [suggesting, setSuggesting] = useState(false); | |
| const createTerminal = () => { | |
| const id = nextId.current++; | |
| const term = new XTerm({ | |
| cursorBlink: true, | |
| theme: { | |
| background: '#0d1117', | |
| foreground: '#c9d1d9', | |
| cursor: '#58a6ff', | |
| black: '#484f58', | |
| red: '#f85149', | |
| green: '#3fb950', | |
| yellow: '#d29922', | |
| blue: '#58a6ff', | |
| magenta: '#bc8cff', | |
| cyan: '#39c5cf', | |
| white: '#c9d1d9', | |
| }, | |
| fontSize: 13, | |
| fontFamily: 'monospace', | |
| }); | |
| const fit = new FitAddon(); | |
| term.loadAddon(fit); | |
| const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; | |
| const socket = new WebSocket(protocol + '//' + window.location.host + '/ws/terminal'); | |
| term.onData(data => socket.send('exec:' + data)); | |
| socket.onmessage = (event) => term.write(event.data); | |
| const newTab: Tab = { id, terminal: term, socket }; | |
| setTabs(prev => [...prev, newTab]); | |
| setActiveTab(id); | |
| return { term, socket, fit }; | |
| }; | |
| useEffect(() => { createTerminal(); }, []); | |
| useEffect(() => { | |
| const tab = tabs.find(t => t.id === activeTab); | |
| if (tab) { | |
| const container = termRefs.current.get(tab.id); | |
| if (container && !tab.terminal.element) { | |
| tab.terminal.open(container); | |
| const fitAddon = new FitAddon(); | |
| tab.terminal.loadAddon(fitAddon); | |
| fitAddon.fit(); | |
| window.addEventListener('resize', () => fitAddon.fit()); | |
| } | |
| } | |
| }, [activeTab, tabs]); | |
| const removeTab = (id: number) => { | |
| const tab = tabs.find(t => t.id === id); | |
| if (tab) { | |
| tab.socket.close(); | |
| tab.terminal.dispose(); | |
| setTabs(prev => prev.filter(t => t.id !== id)); | |
| if (activeTab === id) { | |
| const remaining = tabs.filter(t => t.id !== id); | |
| if (remaining.length > 0) setActiveTab(remaining[0].id); | |
| } | |
| } | |
| }; | |
| const handleSuggestion = async () => { | |
| if (!suggestionInput.trim()) return; | |
| setSuggesting(true); | |
| try { | |
| const resp = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| model: 'groq-llama-3.1-8b-instant', | |
| messages: [{ role: 'user', content: 'Suggest a terminal command for: ' + suggestionInput + '. Only respond with the command, nothing else.' }], | |
| project_path: '/app/sandbox', | |
| }), | |
| }); | |
| const reader = resp.body?.getReader(); | |
| let command = ''; | |
| while (reader) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| const text = new TextDecoder().decode(value); | |
| const lines = text.split('\n').filter(l => l.startsWith('data: ')).map(l => l.slice(6)); | |
| for (const line of lines) { | |
| if (line === '[DONE]') break; | |
| try { | |
| const { token } = JSON.parse(line); | |
| command += token; | |
| } catch (e) {} | |
| } | |
| } | |
| const tab = tabs.find(t => t.id === activeTab); | |
| if (tab) { | |
| tab.terminal.write(command + '\r\n'); | |
| } | |
| } catch (e) {} | |
| setSuggesting(false); | |
| setSuggestionInput(''); | |
| }; | |
| return ( | |
| <div style={{ height: '100%', display: 'flex', flexDirection: 'column', background: '#0d1117' }}> | |
| <div style={{ display: 'flex', background: 'var(--bg-tertiary)', borderBottom: '1px solid var(--border)', alignItems: 'center' }}> | |
| {tabs.map(tab => ( | |
| <div key={tab.id} | |
| onClick={() => setActiveTab(tab.id)} | |
| style={{ | |
| padding: '4px 12px', | |
| cursor: 'pointer', | |
| background: activeTab === tab.id ? 'var(--bg-primary)' : 'transparent', | |
| borderRight: '1px solid var(--border)', | |
| display: 'flex', | |
| alignItems: 'center', | |
| gap: 6, | |
| fontSize: 12, | |
| color: activeTab === tab.id ? 'var(--text-primary)' : 'var(--text-secondary)', | |
| }}> | |
| <span>Terminal {tab.id}</span> | |
| <FaTimes size={10} onClick={(e) => { e.stopPropagation(); removeTab(tab.id); }} style={{ opacity: 0.6 }} /> | |
| </div> | |
| ))} | |
| <button onClick={() => createTerminal()} style={{ padding: '4px 8px', color: 'var(--text-secondary)' }}> | |
| <FaPlus size={12} /> | |
| </button> | |
| <div style={{ flex: 1 }} /> | |
| <div style={{ display: 'flex', alignItems: 'center', gap: 4, paddingRight: 8 }}> | |
| <input | |
| style={{ width: 150, fontSize: 12, padding: '2px 6px' }} | |
| placeholder="Describe command..." | |
| value={suggestionInput} | |
| onChange={e => setSuggestionInput(e.target.value)} | |
| onKeyDown={e => e.key === 'Enter' && handleSuggestion()} | |
| /> | |
| <button onClick={handleSuggestion} disabled={suggesting} style={{ color: 'var(--accent)', fontSize: 12 }}> | |
| <FaRobot /> {suggesting ? '...' : 'Suggest'} | |
| </button> | |
| </div> | |
| </div> | |
| <div style={{ flex: 1, position: 'relative' }}> | |
| {tabs.map(tab => ( | |
| <div | |
| key={tab.id} | |
| ref={el => { if (el) termRefs.current.set(tab.id, el); }} | |
| style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, display: activeTab === tab.id ? 'block' : 'none' }} | |
| /> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default TerminalTabs; |