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
JavaScript
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);
});