UNPKG

interactive-mcp-enhanced

Version:

Enhanced MCP Server for interactive communication between LLMs and users with custom sound notifications, approval workflows, clipboard support, and improved user experience.

193 lines (192 loc) 8.2 kB
import React, { useState, useEffect } from 'react'; import { render, Box, Text, useApp } from 'ink'; import { ProgressBar } from '@inkjs/ui'; import fs from 'fs/promises'; import path from 'path'; // Import path module import os from 'os'; // Import os module for tmpdir import logger from '../../utils/logger.js'; import { InteractiveInput } from '../../components/InteractiveInput.js'; // Import shared component import { USER_INPUT_TIMEOUT_SECONDS } from '../../constants.js'; // Import the constant // Define defaults separately (timeout should come from passed options, not hardcoded) const defaultOptions = { prompt: 'Enter your response:', timeout: USER_INPUT_TIMEOUT_SECONDS, // Use the same default as the main process showCountdown: false, projectName: undefined, predefinedOptions: undefined, }; // Function to read options from the file specified by sessionId const readOptionsFromFile = async () => { const args = process.argv.slice(2); const sessionId = args[0]; if (!sessionId) { logger.error('No sessionId provided. Exiting.'); throw new Error('No sessionId provided'); // Throw error to prevent proceeding } let tempDir = args[1]; if (!tempDir) { tempDir = os.tmpdir(); } const optionsFilePath = path.join(tempDir, `cmd-ui-options-${sessionId}.json`); try { const optionsData = await fs.readFile(optionsFilePath, 'utf8'); const parsedOptions = JSON.parse(optionsData); // Parse as partial // Validate required fields after parsing if (!parsedOptions.sessionId || !parsedOptions.outputFile || !parsedOptions.heartbeatFile) { throw new Error('Required options missing in options file.'); } // Merge defaults with parsed options, ensuring required fields are fully typed return { ...defaultOptions, ...parsedOptions, sessionId: parsedOptions.sessionId, // Ensure these are strings outputFile: parsedOptions.outputFile, heartbeatFile: parsedOptions.heartbeatFile, }; } catch (error) { logger.error(`Failed to read or parse options file ${optionsFilePath}:`, error instanceof Error ? error.message : error); // Re-throw to ensure the calling code knows initialization failed throw error; } }; // Function to write response to output file if provided const writeResponseToFile = async (outputFile, response) => { if (!outputFile) return; // write file in UTF-8 format, errors propagate to caller await fs.writeFile(outputFile, response, 'utf8'); }; // Global state for options and exit handler setup let options = null; let exitHandlerAttached = false; // Async function to initialize options and setup exit handlers async function initialize() { try { options = await readOptionsFromFile(); // Setup exit handlers only once after options are successfully read if (!exitHandlerAttached) { const handleExit = () => { if (options && options.outputFile) { // Write empty string to indicate abnormal exit (e.g., Ctrl+C) writeResponseToFile(options.outputFile, '') .catch((error) => { logger.error('Failed to write exit file:', error); }) .finally(() => process.exit(0)); // Exit gracefully after attempting write } else { process.exit(0); } }; process.on('SIGINT', handleExit); process.on('SIGTERM', handleExit); process.on('beforeExit', handleExit); // Catches graceful exits too exitHandlerAttached = true; } } catch (error) { logger.error('Initialization failed:', error); process.exit(1); // Exit if initialization fails } } const App = ({ options: appOptions }) => { const { exit } = useApp(); const { projectName, prompt, timeout, showCountdown, outputFile, heartbeatFile, predefinedOptions, } = appOptions; const [timeLeft, setTimeLeft] = useState(timeout); // Clear console only once on mount useEffect(() => { console.clear(); }, []); // Handle countdown and auto-exit on timeout useEffect(() => { const timer = setInterval(() => { setTimeLeft((prev) => { if (prev <= 1) { clearInterval(timer); writeResponseToFile(outputFile, '__TIMEOUT__') // Use outputFile from props .catch((err) => logger.error('Failed to write timeout file:', err)) .finally(() => exit()); // Use Ink's exit for timeout return 0; } return prev - 1; }); }, 1000); // Add heartbeat interval let heartbeatInterval; if (heartbeatFile) { heartbeatInterval = setInterval(async () => { try { // Touch the file (create if not exists, update mtime if exists) const now = new Date(); await fs.utimes(heartbeatFile, now, now); } catch (err) { // If file doesn't exist, try to create it if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') { try { await fs.writeFile(heartbeatFile, '', 'utf8'); } catch { // Ignore errors creating heartbeat file (e.g., permissions) } } else { // Ignore other errors writing heartbeat file } } }, 1000); // Update every second } return () => { clearInterval(timer); if (heartbeatInterval) { clearInterval(heartbeatInterval); } }; }, [exit, outputFile, heartbeatFile, timeout]); // Added timeout to dependencies // Handle final submission const handleSubmit = (value) => { logger.debug(`User submitted: ${value}`); writeResponseToFile(outputFile, value) // Use outputFile from props .catch((err) => logger.error('Failed to write response file:', err)) .finally(() => { exit(); // Use Ink's exit for normal submission }); }; // Wrapper for handleSubmit to match the signature of InteractiveInput's onSubmit const handleInputSubmit = (_questionId, value) => { handleSubmit(value); }; const progressValue = (timeLeft / timeout) * 100; return (React.createElement(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "blue" }, projectName && (React.createElement(Box, { marginBottom: 1, justifyContent: "center" }, React.createElement(Text, { bold: true, color: "magenta" }, projectName))), React.createElement(InteractiveInput, { question: prompt, questionId: prompt, predefinedOptions: predefinedOptions, onSubmit: handleInputSubmit }), showCountdown && (React.createElement(Box, { flexDirection: "column", marginTop: 1 }, React.createElement(Text, { color: "yellow" }, "Time remaining: ", timeLeft, "s"), React.createElement(ProgressBar, { value: progressValue }))))); }; // Initialize and render the app initialize() .then(() => { if (options) { render(React.createElement(App, { options: options })); } else { // This case should theoretically not be reached due to error handling in initialize logger.error('Options could not be initialized. Cannot render App.'); process.exit(1); } }) .catch(() => { // Error already logged in initialize or readOptionsFromFile process.exit(1); });