Skip to main content
Glama
Terminal.tsx23.5 kB
import React, { useEffect, useRef, useState, useCallback } from 'react'; import { Terminal as Xterm } from 'xterm'; import { FitAddon } from 'xterm-addon-fit'; import 'xterm/css/xterm.css'; import { useTool } from '@modelcontextprotocol/sdk/react'; import Console from './Console.js'; type Tab = 'terminal' | 'console' | 'preview'; type PreviewMode = 'local' | 'public'; const CWD_MARKER = "CWD_MARKER_EXEC_FINISH"; // PAT masking functions for security const maskPAT = (text: string): string => { return text.replace(/https:\/\/ghp_[a-zA-Z0-9]+@github\.com/g, 'https://ghp_*****@github.com'); }; const maskToken = (text: string): string => { return text.replace(/ghp_[a-zA-Z0-9]+/g, 'ghp_*****'); }; export const Terminal: React.FC<{ terminalOutput: string[]; setTerminalOutput: React.Dispatch<React.SetStateAction<string[]>>; }> = ({ terminalOutput, setTerminalOutput }) => { const termElRef = useRef<HTMLDivElement>(null); const termRef = useRef<Xterm | null>(null); const fitAddonRef = useRef<FitAddon | null>(null); const [activeTab, setActiveTab] = useState<Tab>('terminal'); const [cwd, setCwd] = useState('/workspace'); const commandHistory = useRef<string[]>([]); const historyIndex = useRef<number>(-1); // Preview state const [previewContent, setPreviewContent] = useState<string>(''); const [previewMode, setPreviewMode] = useState<PreviewMode>('local'); const [previewUrl, setPreviewUrl] = useState<string>(''); const [previewPort, setPreviewPort] = useState<number>(3000); const [serverRunning, setServerRunning] = useState<boolean>(false); const [publicUrl, setPublicUrl] = useState<string>(''); const [availableFiles, setAvailableFiles] = useState<string[]>([]); const [isSandboxed, setIsSandboxed] = useState<boolean>(false); const { call: runCommand, isPending } = useTool('bash'); const termWrite = (text: string) => termRef.current?.write(text.replace(/\n/g, '\r\n')); const getPrompt = (path: string) => `\r\n\x1b[1;34mpyforge\x1b[0m:\x1b[1;32m${path}\x1b[0m$ `; // Utility: Check if file is a web-viewable type const isPreviewable = (filename: string): boolean => { const ext = filename.toLowerCase().split('.').pop(); return ['html', 'htm', 'md', 'txt', 'json', 'xml', 'css', 'js'].includes(ext || ''); }; // Utility: Get file list in current directory const refreshFileList = useCallback(async () => { const result = await runCommand({ command: `find . -maxdepth 1 -type f \\( -name "*.html" -o -name "*.htm" -o -name "*.md" \\) | sort`, workspace: cwd }); if (result?.content?.[0]?.type === 'text') { const files = result.content[0].text .split('\n') .filter((f: string) => f.trim()) .map((f: string) => f.replace('./', '')); setAvailableFiles(files); } }, [runCommand, cwd]); // Preview: Load local HTML file const previewLocalFile = useCallback(async (filePath: string) => { const result = await runCommand({ command: `cat "${filePath}"`, workspace: cwd }); if (result?.content?.[0]?.type === 'text') { const content = result.content[0].text; setPreviewContent(content); setPreviewMode('local'); setActiveTab('preview'); termWrite(`\x1b[1;32m✓ Preview loaded: ${filePath}\x1b[0m`); } else { termWrite(`\x1b[1;31m✗ Failed to load file: ${filePath}\x1b[0m`); } }, [runCommand, cwd]); // Server: Start local preview server with port collision detection and auto-selection const startPreviewServer = useCallback(async (port: number = 3000) => { if (serverRunning) { termWrite(`\x1b[1;33m⚠ Server already running on port ${previewPort}\x1b[0m`); return; } // Check if port is already in use and find available port const checkPortCmd = `lsof -ti:${port} 2>/dev/null || echo "free"`; const portCheck = await runCommand({ command: checkPortCmd, workspace: cwd }); let actualPort = port; if (portCheck?.content?.[0]?.type === 'text' && !portCheck.content[0].text.includes('free')) { termWrite(`\x1b[1;33m⚠ Port ${port} is in use, finding available port...\x1b[0m`); // Find available port starting from the requested port const findPortCmd = `for p in $(seq ${port} 3010); do (lsof -ti:$p >/dev/null 2>&1) || { echo $p; break; }; done`; const findPortResult = await runCommand({ command: findPortCmd, workspace: cwd }); if (findPortResult?.content?.[0]?.type === 'text') { actualPort = parseInt(findPortResult.content[0].text.trim()); termWrite(`\x1b[1;32m✓ Found available port: ${actualPort}\x1b[0m`); } else { termWrite(`\x1b[1;31m✗ Could not find available port in range ${port}-3010\x1b[0m`); return; } } const startCmd = `python3 -m http.server ${actualPort} --directory "${cwd}" > /tmp/preview_server_${actualPort}.log 2>&1 & echo $! > /tmp/preview_server_${actualPort}.pid`; await runCommand({ command: startCmd, workspace: cwd }); // Wait a moment and verify server started successfully await new Promise(resolve => setTimeout(resolve, 1000)); const verifyCmd = `lsof -ti:${actualPort} 2>/dev/null && echo "running" || echo "failed"`; const verifyResult = await runCommand({ command: verifyCmd, workspace: cwd }); if (verifyResult?.content?.[0]?.type === 'text' && verifyResult.content[0].text.includes('running')) { setPreviewPort(actualPort); setServerRunning(true); const localUrl = `http://localhost:${actualPort}`; setPreviewUrl(localUrl); termWrite(`\x1b[1;32m✓ Preview server started on ${localUrl}\x1b[0m`); termWrite(`\x1b[1;34m→ Open http://localhost:${actualPort} in your browser\x1b[0m`); termWrite(`\x1b[1;36m💾 Server PID saved for cleanup\x1b[0m`); } else { termWrite(`\x1b[1;31m✗ Failed to start preview server on port ${actualPort}\x1b[0m`); } }, [runCommand, cwd, serverRunning, previewPort]); // Server: Stop preview server with proper cleanup const stopPreviewServer = useCallback(async () => { if (!serverRunning) { termWrite(`\x1b[1;33m⚠ No server running\x1b[0m`); return; } // Kill process using saved PID or port lookup const cleanupCmd = `if [ -f /tmp/preview_server_${previewPort}.pid ]; then kill -9 $(cat /tmp/preview_server_${previewPort}.pid) 2>/dev/null && rm -f /tmp/preview_server_${previewPort}.pid; else lsof -ti:${previewPort} | xargs kill -9 2>/dev/null; fi && rm -f /tmp/preview_server_${previewPort}.log`; await runCommand({ command: cleanupCmd, workspace: cwd }); setServerRunning(false); setPreviewUrl(''); setPublicUrl(''); // Clear public URL too termWrite(`\x1b[1;32m✓ Preview server stopped and cleaned up\x1b[0m`); }, [runCommand, cwd, serverRunning, previewPort]); // Public: Expose via ngrok or similar tunneling service const exposePublicUrl = useCallback(async () => { if (!serverRunning) { termWrite(`\x1b[1;31m✗ Start preview server first with 'preview server start'\x1b[0m`); return; } termWrite(`\x1b[1;33m⟳ Generating public URL...\x1b[0m`); // Check if ngrok is installed, otherwise suggest alternatives const checkNgrok = await runCommand({ command: `which ngrok`, workspace: cwd }); if (checkNgrok?.content?.[0]?.text?.includes('ngrok')) { const tunnelCmd = `ngrok http ${previewPort} --log=stdout 2>&1 | grep -oP 'https://[a-z0-9]+.ngrok.io' | head -1`; const result = await runCommand({ command: tunnelCmd, workspace: cwd }); if (result?.content?.[0]?.type === 'text') { const url = result.content[0].text.trim(); setPublicUrl(url); setPreviewMode('public'); setPreviewUrl(url); termWrite(`\x1b[1;32m✓ Public URL generated: ${url}\x1b[0m`); setActiveTab('preview'); } } else { termWrite(`\x1b[1;33m⚠ ngrok not found. Install with: npm install -g ngrok\x1b[0m`); termWrite(`\x1b[1;34m→ Or use alternatives: Cloudflare Tunnel, localtunnel, or serveo\x1b[0m`); } }, [runCommand, cwd, serverRunning, previewPort]); // Detect sandbox environment on component mount useEffect(() => { const detectSandbox = async () => { try { // Check for common sandbox indicators const checks = [ 'hostname', 'whoami', 'echo $HOME' ]; let sandboxIndicators = 0; for (const check of checks) { const result = await runCommand({ command: check, workspace: cwd }); if (result?.content?.[0]?.type === 'text') { const output = result.content[0].text.trim(); // Check for common sandbox patterns if (output.includes('sandbox') || output.includes('container') || output.includes('vm') || output.includes('/tmp') || output.includes('node') || output.includes('docker')) { sandboxIndicators++; } } } // Also check for network restrictions try { await runCommand({ command: 'curl -s --connect-timeout 3 http://httpbin.org/ip || echo "blocked"', workspace: cwd }); } catch (error) { sandboxIndicators++; } setIsSandboxed(sandboxIndicators >= 2); } catch (error) { // Assume sandboxed if detection fails setIsSandboxed(true); } }; detectSandbox(); }, [runCommand, cwd]); // Handle terminal initialization useEffect(() => { if (!termElRef.current || termRef.current) return; const term = new Xterm({ cursorBlink: true, fontFamily: 'monospace', fontSize: 13, theme: { background: 'var(--background-primary)', foreground: 'var(--text-primary)', cursor: 'var(--accent-primary)', }, }); const fitAddon = new FitAddon(); term.loadAddon(fitAddon); term.open(termElRef.current); fitAddon.fit(); termRef.current = term; fitAddonRef.current = fitAddon; term.writeln("🚀 Welcome to PyForge Terminal with Browser Preview!"); term.writeln("📋 Preview Commands:"); term.writeln(" • 'preview list' - Show available files"); term.writeln(" • 'preview <file>' - Load file preview"); term.writeln(" • 'preview server start [port]' - Start local server"); term.writeln(" • 'preview public' - Create shareable URL (ngrok)"); term.writeln("💡 Browser Access:"); term.writeln(" - Local: Use Preview tab or '🚀 Open in Browser' if network allows"); term.writeln(" - Public: Use 'preview public' for shareable external access"); term.writeln(" - Sandbox: Preview tab always works (iframe-based)"); term.write(getPrompt(cwd)); let currentLine = ''; const executeCommand = async (command: string) => { if (!command.trim()) { term.write(getPrompt(cwd)); return; } // Handle preview commands if (command.startsWith('preview ')) { const args = command.substring(8).trim(); if (args === 'list') { await refreshFileList(); if (availableFiles.length > 0) { term.writeln(`\x1b[1;32mAvailable files:\x1b[0m`); availableFiles.forEach(f => term.writeln(` ${f}`)); } else { term.writeln(`\x1b[1;33mNo previewable files found\x1b[0m`); } term.write(getPrompt(cwd)); return; } if (args.startsWith('server start')) { const portMatch = args.match(/\d+/); const port = portMatch ? parseInt(portMatch[0]) : 3000; await startPreviewServer(port); term.write(getPrompt(cwd)); return; } if (args === 'server stop') { await stopPreviewServer(); term.write(getPrompt(cwd)); return; } if (args === 'public') { await exposePublicUrl(); term.write(getPrompt(cwd)); return; } if (!args.startsWith('server') && !args.startsWith('public') && !args.startsWith('list')) { // Preview specific file await previewLocalFile(args); term.write(getPrompt(cwd)); return; } } // Regular bash command if (commandHistory.current[commandHistory.current.length - 1] !== command) { commandHistory.current.push(command); } historyIndex.current = commandHistory.current.length; const result = await runCommand({ command, workspace: cwd }); let newCwd = cwd; let output = result?.content?.[0]?.type === 'text' ? result.content[0].text : 'Command execution failed.'; const cwdMarkerIndex = output.lastIndexOf(CWD_MARKER); if (cwdMarkerIndex !== -1) { const parts = output.substring(cwdMarkerIndex).split(':'); newCwd = parts.slice(1).join(':').trim(); output = output.substring(0, cwdMarkerIndex).trimEnd(); } termWrite(maskToken(output)); setCwd(newCwd); term.write(getPrompt(newCwd)); }; const keyListener = term.onKey(({ key, domEvent }) => { if (isPending) return; const printable = !domEvent.altKey && !domEvent.ctrlKey && !domEvent.metaKey; if (domEvent.key === 'Enter') { term.write('\r\n'); executeCommand(currentLine); currentLine = ''; } else if (domEvent.key === 'Backspace') { if (currentLine.length > 0) { term.write('\b \b'); currentLine = currentLine.slice(0, -1); } } else if (domEvent.key === 'ArrowUp') { domEvent.preventDefault(); historyIndex.current = Math.max(-1, historyIndex.current - 1); const histItem = commandHistory.current[historyIndex.current] || ''; term.write(`\x1b[2K\r${getPrompt(cwd).substring(2)}${histItem}`); currentLine = histItem; } else if (domEvent.key === 'ArrowDown') { domEvent.preventDefault(); historyIndex.current = Math.min(commandHistory.current.length, historyIndex.current + 1); const histItem = commandHistory.current[historyIndex.current] || ''; term.write(`\x1b[2K\r${getPrompt(cwd).substring(2)}${histItem}`); currentLine = histItem; } else if (printable) { term.write(key); currentLine += key; } }); return () => { keyListener.dispose(); }; }, [cwd, isPending, runCommand, previewLocalFile, startPreviewServer, stopPreviewServer, exposePublicUrl, availableFiles, refreshFileList]); return ( <div style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}> {/* Tab Navigation */} <div style={{ display: 'flex', borderBottom: '1px solid var(--border-color)', backgroundColor: 'var(--background-secondary)' }}> {(['terminal', 'console', 'preview'] as Tab[]).map(tab => ( <button key={tab} onClick={() => setActiveTab(tab)} style={{ padding: '10px 20px', backgroundColor: activeTab === tab ? 'var(--accent-primary)' : 'transparent', color: activeTab === tab ? 'white' : 'var(--text-secondary)', border: 'none', cursor: 'pointer', fontWeight: activeTab === tab ? 'bold' : 'normal', }} > {tab.charAt(0).toUpperCase() + tab.slice(1)} {tab === 'preview' && ( <> {previewUrl && ' ✓'} {serverRunning && ( <span style={{ marginLeft: '5px', color: '#22c55e', fontSize: '10px' }}> ● </span> )} </> )} </button> ))} {previewUrl && ( <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '10px' }}> {isSandboxed && previewMode === 'local' && ( <span style={{ fontSize: '11px', color: '#f59e0b', backgroundColor: '#fef3c7', padding: '2px 6px', borderRadius: '3px', fontWeight: 'bold' }}> ⚠️ Sandbox Limited </span> )} <span style={{ fontSize: '12px', color: 'var(--text-secondary)', marginRight: '5px' }}> {previewMode === 'public' ? '🌐 Public URL:' : '🏠 Local URL:'} </span> <code style={{ backgroundColor: 'var(--background-tertiary)', padding: '4px 8px', borderRadius: '4px', fontSize: '12px', color: 'var(--text-primary)' }}> {previewUrl} </code> <a href={previewUrl} target="_blank" rel="noopener noreferrer" style={{ padding: '8px 16px', backgroundColor: 'var(--accent-primary)', color: 'white', textDecoration: 'none', cursor: 'pointer', borderRadius: '4px', fontSize: '12px', fontWeight: 'bold', border: 'none', transition: 'background-color 0.2s' }} onMouseOver={(e) => { e.currentTarget.style.backgroundColor = '#2563eb'; }} onMouseOut={(e) => { e.currentTarget.style.backgroundColor = 'var(--accent-primary)'; }} title={`Open ${previewMode === 'public' ? 'public' : 'local'} preview in new browser tab`} > 🚀 Open in Browser </a> </div> )} </div> {/* Tab Content */} <div style={{ flex: 1, overflow: 'hidden' }}> {activeTab === 'terminal' && <div ref={termElRef} style={{ height: '100%', width: '100%' }} />} {activeTab === 'console' && ( <Console output={terminalOutput} onClear={() => setTerminalOutput([])} /> )} {activeTab === 'preview' && previewContent && ( <iframe srcDoc={previewContent} sandbox="allow-scripts allow-same-origin" style={{ width: '100%', height: '100%', border: 'none', }} /> )} {activeTab === 'preview' && !previewContent && previewUrl && ( <iframe src={previewUrl} style={{ width: '100%', height: '100%', border: 'none', }} /> )} {activeTab === 'preview' && !previewContent && !previewUrl && ( <div style={{ padding: '20px', color: 'var(--text-secondary)' }}> <p>No preview loaded. Try:</p> <ul> <li><code>preview list</code> - List previewable files</li> <li><code>preview server start</code> - Start local preview server</li> <li><code>preview public</code> - Generate public URL (requires ngrok)</li> <li><code>preview index.html</code> - Preview specific file</li> </ul> </div> )} </div> </div> ); }; export default Terminal;

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pythondev-pro/egw_writings_mcp_server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server