Skip to main content
Glama

Interactive MCP

MIT License
97
299
  • Apple
  • Linux
ui.tsx12.8 kB
import React, { FC, useState, useEffect, useRef } from 'react'; import { render, Box, Text, useApp } from 'ink'; import { ProgressBar } from '@inkjs/ui'; import fs from 'fs/promises'; import path from 'path'; import crypto from 'crypto'; import { InteractiveInput } from '@/components/InteractiveInput.js'; import { USER_INPUT_TIMEOUT_SECONDS } from '@/constants.js'; // Import the constant import logger from '../../utils/logger.js'; // Interface for chat message interface ChatMessage { text: string; isQuestion: boolean; answer?: string; } // Parse command line arguments from a single JSON-encoded argument const parseArgs = () => { const args = process.argv.slice(2); const defaults = { sessionId: crypto.randomUUID(), title: 'Interactive Chat Session', outputDir: undefined as string | undefined, timeoutSeconds: USER_INPUT_TIMEOUT_SECONDS, }; if (args[0]) { try { // Decode base64-encoded JSON payload to avoid quoting issues const decoded = Buffer.from(args[0], 'base64').toString('utf8'); const parsed = JSON.parse(decoded); return { ...defaults, ...parsed }; } catch (e) { logger.error( { error: e }, 'Invalid input options payload, using defaults.', ); } } return defaults; }; // Get command line arguments const options = parseArgs(); // Function to write response to output file const writeResponseToFile = async (questionId: string, response: string) => { if (!options.outputDir) return; // Create response file path const responseFilePath = path.join( options.outputDir, `response-${questionId}.txt`, ); // Write file in UTF-8 format await fs.writeFile(responseFilePath, response, 'utf8'); //wait 500 ms await new Promise((resolve) => setTimeout(resolve, 500)); }; // Create a heartbeat file to indicate the session is still active const updateHeartbeat = async () => { if (!options.outputDir) return; const heartbeatPath = path.join(options.outputDir, 'heartbeat.txt'); try { const dir = path.dirname(heartbeatPath); await fs.mkdir(dir, { recursive: true }); // Ensure directory exists await fs.writeFile(heartbeatPath, Date.now().toString(), 'utf8'); } catch (writeError) { // Log the specific error but allow the poll cycle to continue logger.error( { heartbeatPath, error: writeError }, `Failed to write heartbeat file ${heartbeatPath}`, ); } }; // Register process termination handlers const handleExit = () => { if (options.outputDir) { // Write exit file to indicate session has ended fs.writeFile(path.join(options.outputDir, 'session-closed.txt'), '', 'utf8') .then(() => process.exit(0)) .catch((error) => { logger.error({ error }, 'Failed to write exit file'); process.exit(1); }); } else { process.exit(0); } }; // Listen for termination signals process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); process.on('beforeExit', handleExit); interface AppProps { sessionId: string; title: string; outputDir?: string; timeoutSeconds: number; } const App: FC<AppProps> = ({ sessionId, title, outputDir, timeoutSeconds }) => { // console.clear(); // Clear console before rendering UI - Removed from here const { exit: appExit } = useApp(); const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]); const [currentQuestionId, setCurrentQuestionId] = useState<string | null>( null, ); const [currentPredefinedOptions, setCurrentPredefinedOptions] = useState< string[] | undefined >(undefined); const [timeLeft, setTimeLeft] = useState<number | null>(null); // State for countdown timer const timerRef = useRef<NodeJS.Timeout | null>(null); // Ref to hold timer ID // Clear console only once on mount useEffect(() => { console.clear(); }, []); // Empty dependency array ensures this runs only once // Check for new questions periodically useEffect(() => { // Set up polling for new inputs const questionPoller = setInterval(async () => { if (!outputDir) return; try { // Update heartbeat to indicate we're still running await updateHeartbeat(); // Look for the session-specific input file const inputFilePath = path.join(outputDir, `${sessionId}.json`); // Check if new input file exists try { const inputExists = await fs.stat(inputFilePath); if (inputExists) { // Read input file content const inputFileContent = await fs.readFile(inputFilePath, 'utf8'); let questionId: string | null = null; let questionText: string | null = null; let options: string[] | undefined = undefined; try { // Parse input file content as JSON { id: string, text: string, options?: string[] } const inputData = JSON.parse(inputFileContent); if ( typeof inputData === 'object' && inputData !== null && typeof inputData.id === 'string' && typeof inputData.text === 'string' && (inputData.options === undefined || Array.isArray(inputData.options)) ) { questionId = inputData.id; questionText = inputData.text; // Ensure options are strings if they exist options = Array.isArray(inputData.options) ? inputData.options.map(String) : undefined; } else { logger.error( `Invalid format in ${sessionId}.json. Expected JSON with id (string), text (string), and optional options (array).`, ); } } catch (parseError) { logger.error( { file: `${sessionId}.json`, error: parseError }, `Error parsing ${sessionId}.json as JSON`, ); } // Proceed only if we successfully parsed the question ID and text if (questionId && questionText) { // Add question to chat using the ID and options from the file addNewQuestion(questionId, questionText, options); // Delete the input file await fs.unlink(inputFilePath); } else { // If parsing failed or format was invalid, delete the problematic file logger.error(`Deleting invalid input file: ${inputFilePath}`); await fs.unlink(inputFilePath); } } } catch (e: unknown) { // Type guard to check if it's an error with a code property if ( typeof e === 'object' && e !== null && 'code' in e && (e as { code: unknown }).code !== 'ENOENT' ) { logger.error( { inputFilePath, error: e }, `Error checking/reading input file ${inputFilePath}`, ); } // If it's not an error with a code or the code is ENOENT, we ignore it silently. } // Check if we should exit const closeFilePath = path.join(outputDir, 'close-session.txt'); try { await fs.stat(closeFilePath); // If close file exists, exit the process handleExit(); } catch (_e) { // No close request } } catch (error) { logger.error({ error }, 'Error in poll cycle'); } }, 100); return () => clearInterval(questionPoller); }, [outputDir, sessionId]); // Countdown timer effect useEffect(() => { if (timeLeft === null || timeLeft <= 0 || !currentQuestionId) { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } return; // No timer needed or timer expired } // Start timer if not already running if (!timerRef.current) { timerRef.current = setInterval(() => { setTimeLeft((prev) => (prev !== null ? prev - 1 : null)); }, 1000); } // Check if timer reached zero if (timeLeft <= 0 && timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; // Auto-submit timeout indicator on timeout handleSubmit(currentQuestionId, '__TIMEOUT__'); } // Cleanup function to clear interval on unmount or when dependencies change return () => { if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } }; }, [timeLeft, currentQuestionId]); // Rerun effect when timeLeft or currentQuestionId changes // Add a new question to the chat const addNewQuestion = ( questionId: string, questionText: string, options?: string[], ) => { console.clear(); // Clear console before displaying new question // Clear existing timer before starting new one if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } setChatHistory((prev) => [ ...prev, { text: questionText, isQuestion: true, }, ]); setCurrentQuestionId(questionId); setCurrentPredefinedOptions(options); setTimeLeft(timeoutSeconds); // Use timeout from props }; // Handle user submitting an answer const handleSubmit = async (questionId: string, value: string) => { // Clear the timer if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } setTimeLeft(null); // Reset timer state // Update the chat history with the answer setChatHistory((prev) => prev.map((msg) => { // Find the last question in history that matches the ID and doesn't have an answer yet // Use slice().reverse().find() for broader compatibility instead of findLast() if ( msg.isQuestion && !msg.answer && msg === prev .slice() .reverse() .find((m: ChatMessage) => m.isQuestion && !m.answer) ) { return { ...msg, answer: value }; } return msg; }), ); // Reset current question state setCurrentQuestionId(null); setCurrentPredefinedOptions(undefined); // Write response to file if (outputDir) { await writeResponseToFile(questionId, value); } }; // Calculate progress bar value (moved slightly down, renamed to percentage) const percentage = timeLeft !== null ? (timeLeft / timeoutSeconds) * 100 : 0; // Use timeout from props return ( <Box flexDirection="column" padding={1} borderStyle="round" borderColor="blue" > <Box marginBottom={1} flexDirection="column" width="100%"> <Text bold color="magentaBright" wrap="wrap"> {title} </Text> <Text color="gray">Session ID: {sessionId}</Text> <Text color="gray">Press Ctrl+C to exit the chat session</Text> </Box> <Box flexDirection="column" width="100%"> {/* Chat history */} {chatHistory.map((msg, i) => ( <Box key={i} flexDirection="column" marginY={1}> {msg.isQuestion ? ( <Text color="cyan" wrap="wrap"> Q: {msg.text} </Text> ) : null} {msg.answer ? ( <Text color="green" wrap="wrap"> A: {msg.answer} </Text> ) : null} </Box> ))} </Box> {/* Current question input */} {currentQuestionId && ( <Box flexDirection="column" marginTop={1} padding={1} borderStyle="single" borderColor={timeLeft !== null && timeLeft <= 10 ? 'red' : 'yellow'} // Highlight border when time is low > <InteractiveInput // Use slice().reverse().find() for broader compatibility instead of findLast() question={ chatHistory .slice() .reverse() .find((m: ChatMessage) => m.isQuestion && !m.answer)?.text || '' } questionId={currentQuestionId} predefinedOptions={currentPredefinedOptions} onSubmit={handleSubmit} /> {/* Countdown Timer and Progress Bar */} {timeLeft !== null && ( <Box flexDirection="column" marginTop={1}> <Text color={timeLeft <= 10 ? 'red' : 'yellow'}> Time remaining: {timeLeft}s </Text> <ProgressBar value={percentage} /> </Box> )} </Box> )} </Box> ); }; // Render the app render(<App {...options} />);

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/ttommyth/interactive-mcp'

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