Terminal-MultiTenant.tsxā¢17.3 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' | 'session';
interface Session {
id: string;
status: 'active' | 'idle' | 'expired';
workspaceDir: string;
hasGitConfig: boolean;
createdAt: string;
lastActivity: string;
}
const CWD_MARKER = "CWD_MARKER_EXEC_FINISH";
export const TerminalMultiTenant: React.FC<{
terminalOutput: string[];
setTerminalOutput: React.Dispatch<React.SetStateAction<string[]>>;
currentSession: Session | null;
onSessionCreate: (gitConfig?: { name?: string; email?: string; pat?: string }) => void;
onSessionUpdate: (sessionId: string, gitConfig: { name?: string; email?: string; pat?: string }) => void;
}> = ({ terminalOutput, setTerminalOutput, currentSession, onSessionCreate, onSessionUpdate }) => {
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);
const [gitConfig, setGitConfig] = useState({ name: '', email: '', pat: '' });
const { call: runCommand, call: createSession, call: updateSessionGit, call: destroySession, isPending } = useTool('run_bash_command');
const { call: getSessionInfo } = useTool('session_info');
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$ `;
// Create new session
const handleCreateSession = useCallback(async () => {
try {
const result = await createSession('session_create', {
gitName: gitConfig.name || undefined,
gitEmail: gitConfig.email || undefined,
gitPat: gitConfig.pat || undefined
});
const response = JSON.parse(result?.content?.[0]?.text || '{}');
if (response.success) {
termWrite(`ā
Session created: ${response.sessionId.substring(0, 8)}...\r\n`);
termWrite(`š Workspace: ${response.workspaceDir}\r\n`);
setCwd('/workspace');
} else {
termWrite(`ā Failed to create session: ${response.error}\r\n`);
}
} catch (error) {
termWrite(`ā Error creating session: ${error.message}\r\n`);
}
}, [gitConfig, createSession]);
// Update session git config
const handleUpdateGitConfig = useCallback(async () => {
if (!currentSession) {
termWrite(`ā No active session. Create a session first.\r\n`);
return;
}
try {
const result = await updateSessionGit('session_update_git', {
sessionId: currentSession.id,
gitName: gitConfig.name || undefined,
gitEmail: gitConfig.email || undefined,
gitPat: gitConfig.pat || undefined
});
const response = JSON.parse(result?.content?.[0]?.text || '{}');
if (response.success) {
termWrite(`ā
Git configuration updated for session ${currentSession.id.substring(0, 8)}...\r\n`);
} else {
termWrite(`ā Failed to update git config: ${response.error}\r\n`);
}
} catch (error) {
termWrite(`ā Error updating git config: ${error.message}\r\n`);
}
}, [currentSession, gitConfig, updateSessionGit]);
// Show session info
const handleShowSessions = useCallback(async () => {
try {
const result = await getSessionInfo('session_info', {});
const response = JSON.parse(result?.content?.[0]?.text || '{}');
if (response.success) {
termWrite(`š Session Overview:\r\n`);
termWrite(` Total Sessions: ${response.info.totalSessions}\r\n`);
termWrite(` Active Sessions: ${response.info.activeSessions}\r\n\r\n`);
response.info.sessions.forEach((session: any) => {
termWrite(` š·ļø ${session.id} - ${session.status} (${session.age})\r\n`);
});
} else {
termWrite(`ā Failed to get session info: ${response.error}\r\n`);
}
} catch (error) {
termWrite(`ā Error getting session info: ${error.message}\r\n`);
}
}, [getSessionInfo]);
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 Multi-Tenant Bash Terminal!");
term.writeln("š Each user gets their own isolated workspace with git configuration.");
term.writeln("");
term.writeln("š§ Session Management Commands:");
term.writeln(" \x1b[38;5;244msession-create\x1b[0m - Create new isolated session");
term.writeln(" \x1b[38;5;244msession-info\x1b[0m - Show all active sessions");
term.writeln(" \x1b[38;5;244mgit-config-set\x1b[0m - Set git configuration for current session");
term.writeln("");
term.writeln("š» Regular bash commands work normally in your session workspace.");
term.writeln("š Internet connectivity is enabled for all sessions.");
term.writeln("");
if (currentSession) {
term.writeln(`š·ļø Current Session: ${currentSession.id.substring(0, 8)}... (${currentSession.status})`);
term.writeln(`š Workspace: ${currentSession.workspaceDir}`);
term.writeln(`š§ Git Config: ${currentSession.hasGitConfig ? 'ā
Configured' : 'ā Not configured'}`);
term.writeln("");
} else {
term.writeln("ā No active session. Use 'session-create' to start.");
term.writeln("");
}
term.write(getPrompt(cwd));
let currentLine = '';
const executeCommand = async (command: string) => {
if (!command.trim()) {
term.write(getPrompt(cwd));
return;
}
if (commandHistory.current[commandHistory.current.length - 1] !== command) {
commandHistory.current.push(command);
}
historyIndex.current = commandHistory.current.length;
// Handle special session commands
if (command === 'session-create') {
term.write('\r\n');
handleCreateSession();
term.write(getPrompt(cwd));
return;
}
if (command === 'session-info') {
term.write('\r\n');
handleShowSessions();
term.write(getPrompt(cwd));
return;
}
if (command === 'git-config-set') {
term.write('\r\n');
handleUpdateGitConfig();
term.write(getPrompt(cwd));
return;
}
// Regular bash command execution
if (!currentSession) {
term.write('\r\nā No active session. Use "session-create" to start.\r\n');
term.write(getPrompt(cwd));
return;
}
try {
const result = await runCommand('run_bash_command', {
sessionId: currentSession.id,
command,
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(output);
setCwd(newCwd);
} catch (error) {
termWrite(`ā Command execution failed: ${error.message}\r\n`);
}
term.write(getPrompt(cwd));
};
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') {
if(historyIndex.current > 0) {
historyIndex.current--;
const cmd = commandHistory.current[historyIndex.current];
term.write(`\x1b[2K\r${getPrompt(cwd).trim()}${cmd}`);
currentLine = cmd;
}
} else if (domEvent.key === 'ArrowDown') {
if(historyIndex.current < commandHistory.current.length - 1) {
historyIndex.current++;
const cmd = commandHistory.current[historyIndex.current];
term.write(`\x1b[2K\r${getPrompt(cwd).trim()}${cmd}`);
currentLine = cmd;
} else {
historyIndex.current = commandHistory.current.length;
term.write(`\x1b[2K\r${getPrompt(cwd).trim()}`);
currentLine = '';
}
} else if (printable) {
currentLine += key;
term.write(key);
}
});
const resizeObserver = new ResizeObserver(() => fitAddon.fit());
resizeObserver.observe(termElRef.current);
return () => {
keyListener.dispose();
resizeObserver.disconnect();
};
}, [isPending, cwd, runCommand, currentSession, handleCreateSession, handleShowSessions, handleUpdateGitConfig]);
useEffect(() => {
if (activeTab === 'terminal') {
setTimeout(() => {
fitAddonRef.current?.fit();
termRef.current?.focus();
}, 10);
}
}, [activeTab]);
const TabButton: React.FC<{ name: string; tabId: Tab; }> = ({ name, tabId }) => (
<button
onClick={() => setActiveTab(tabId)}
style={{...styles.tabButton, ...(activeTab === tabId ? styles.activeTabButton : {})}}
>{name}</button>
);
return (
<div style={styles.container}>
<div style={styles.tabHeader}>
<TabButton name="Terminal" tabId="terminal" />
<TabButton name="Console" tabId="console" />
<TabButton name="Session" tabId="session" />
</div>
<div style={styles.tabContent}>
<div style={{...styles.tabPanel, display: activeTab === 'terminal' ? 'block' : 'none' }}>
<div ref={termElRef} style={{width: '100%', height: '100%'}} />
</div>
<div style={{...styles.tabPanel, display: activeTab === 'console' ? 'block' : 'none' }}>
<Console output={terminalOutput} onClear={() => setTerminalOutput(['Console cleared.'])} />
</div>
<div style={{...styles.tabPanel, display: activeTab === 'session' ? 'block' : 'none' }}>
<div style={styles.sessionPanel}>
<h3>Session Configuration</h3>
<div style={styles.currentSessionInfo}>
{currentSession ? (
<div>
<p><strong>Current Session:</strong> {currentSession.id.substring(0, 8)}...</p>
<p><strong>Status:</strong> {currentSession.status}</p>
<p><strong>Workspace:</strong> {currentSession.workspaceDir}</p>
<p><strong>Git Configured:</strong> {currentSession.hasGitConfig ? 'ā
Yes' : 'ā No'}</p>
</div>
) : (
<p><strong>No active session</strong> - Create one to start working</p>
)}
</div>
<div style={styles.configSection}>
<h4>Git Configuration</h4>
<input
type="text"
placeholder="Git User Name"
value={gitConfig.name}
onChange={(e) => setGitConfig(prev => ({ ...prev, name: e.target.value }))}
style={styles.input}
/>
<input
type="email"
placeholder="Git User Email"
value={gitConfig.email}
onChange={(e) => setGitConfig(prev => ({ ...prev, email: e.target.value }))}
style={styles.input}
/>
<input
type="password"
placeholder="GitHub Personal Access Token"
value={gitConfig.pat}
onChange={(e) => setGitConfig(prev => ({ ...prev, pat: e.target.value }))}
style={styles.input}
/>
<div style={styles.buttonGroup}>
<button onClick={handleCreateSession} style={styles.button}>
Create New Session
</button>
<button onClick={handleUpdateGitConfig} style={styles.button} disabled={!currentSession}>
Update Git Config
</button>
<button onClick={handleShowSessions} style={styles.button}>
Show All Sessions
</button>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
const styles: { [key: string]: React.CSSProperties } = {
container: { height: '100%', width: '100%', display: 'flex', flexDirection: 'column', backgroundColor: 'var(--background-secondary)' },
tabHeader: { display: 'flex', flexShrink: 0 },
tabButton: { background: 'var(--background-tertiary)', color: 'var(--text-secondary)', border: 'none', padding: '8px 12px', cursor: 'pointer', borderBottom: '1px solid var(--border-color)' },
activeTabButton: { background: 'var(--background-primary)', color: 'var(--text-primary)', borderBottom: '1px solid transparent' },
tabContent: { flex: 1, overflow: 'hidden', backgroundColor: 'var(--background-primary)' },
tabPanel: { width: '100%', height: '100%', padding: '5px' },
sessionPanel: { padding: '20px', height: '100%', overflow: 'auto' },
currentSessionInfo: { marginBottom: '20px', padding: '15px', backgroundColor: 'var(--background-secondary)', borderRadius: '8px' },
configSection: { marginBottom: '20px' },
input: {
width: '100%',
padding: '10px',
margin: '5px 0',
border: '1px solid var(--border-color)',
borderRadius: '4px',
backgroundColor: 'var(--background-primary)',
color: 'var(--text-primary)'
},
buttonGroup: { display: 'flex', gap: '10px', marginTop: '15px' },
button: {
padding: '10px 15px',
backgroundColor: 'var(--accent-primary)',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '14px'
}
};
export default TerminalMultiTenant;