Terminal.tsx•23.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;