@rawwee/interactive-mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/dist/commands/input/index.js +216 -0
- package/dist/commands/input/ui.js +231 -0
- package/dist/commands/intensive-chat/index.js +289 -0
- package/dist/commands/intensive-chat/ui.js +301 -0
- package/dist/components/InteractiveInput.js +420 -0
- package/dist/components/MarkdownText.js +285 -0
- package/dist/components/PromptStatus.js +46 -0
- package/dist/components/TextProgressBar.js +10 -0
- package/dist/components/interactive-input/autocomplete.js +127 -0
- package/dist/components/interactive-input/keyboard.js +51 -0
- package/dist/components/interactive-input/types.js +1 -0
- package/dist/constants.js +13 -0
- package/dist/index.js +318 -0
- package/dist/tool-definitions/intensive-chat.js +236 -0
- package/dist/tool-definitions/message-complete-notification.js +66 -0
- package/dist/tool-definitions/request-user-input.js +117 -0
- package/dist/tool-definitions/types.js +1 -0
- package/dist/utils/base-directory.js +44 -0
- package/dist/utils/clipboard.js +67 -0
- package/dist/utils/logger.js +65 -0
- package/dist/utils/search-root.js +85 -0
- package/dist/utils/spawn-detached-terminal.js +101 -0
- package/package.json +74 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import fs from 'fs/promises';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
import crypto from 'crypto';
|
|
6
|
+
import logger from '../../utils/logger.js';
|
|
7
|
+
import { spawnDetachedTerminal } from '../../utils/spawn-detached-terminal.js';
|
|
8
|
+
import { SEARCH_ROOT_ENV_KEY } from '../../utils/search-root.js';
|
|
9
|
+
import { USER_INPUT_TIMEOUT_SECONDS, USER_INPUT_TIMEOUT_SENTINEL, } from '../../constants.js';
|
|
10
|
+
// Get the directory name of the current module
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
// Global object to keep track of active intensive chat sessions
|
|
13
|
+
const activeSessions = {};
|
|
14
|
+
// Start heartbeat monitoring for sessions
|
|
15
|
+
startSessionMonitoring();
|
|
16
|
+
/**
|
|
17
|
+
* Generate a unique temporary directory path for a session
|
|
18
|
+
* @returns Path to a temporary directory
|
|
19
|
+
*/
|
|
20
|
+
async function createSessionDir() {
|
|
21
|
+
const tempDir = os.tmpdir();
|
|
22
|
+
const sessionId = crypto.randomBytes(8).toString('hex');
|
|
23
|
+
const sessionDir = path.join(tempDir, `intensive-chat-${sessionId}`);
|
|
24
|
+
// Create the session directory
|
|
25
|
+
await fs.mkdir(sessionDir, { recursive: true });
|
|
26
|
+
return sessionDir;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Start an intensive chat session
|
|
30
|
+
* @param title Title for the chat session
|
|
31
|
+
* @param baseDirectory Default base directory for autocomplete/search
|
|
32
|
+
* @param timeoutSeconds Optional timeout for each question in seconds
|
|
33
|
+
* @returns Session ID for the created session
|
|
34
|
+
*/
|
|
35
|
+
export async function startIntensiveChatSession(title, baseDirectory, timeoutSeconds) {
|
|
36
|
+
// Create a session directory
|
|
37
|
+
const sessionDir = await createSessionDir();
|
|
38
|
+
// Generate a unique session ID
|
|
39
|
+
const sessionId = path.basename(sessionDir).replace('intensive-chat-', '');
|
|
40
|
+
// Path to the UI script - Updated to use the compiled 'ui.js' filename
|
|
41
|
+
const uiScriptPath = path.join(__dirname, 'ui.js');
|
|
42
|
+
// Create options payload for the UI
|
|
43
|
+
const options = {
|
|
44
|
+
sessionId,
|
|
45
|
+
title,
|
|
46
|
+
outputDir: sessionDir,
|
|
47
|
+
timeoutSeconds,
|
|
48
|
+
searchRoot: baseDirectory,
|
|
49
|
+
};
|
|
50
|
+
logger.info({
|
|
51
|
+
sessionId,
|
|
52
|
+
title,
|
|
53
|
+
timeoutSeconds: timeoutSeconds ?? USER_INPUT_TIMEOUT_SECONDS,
|
|
54
|
+
}, 'Starting intensive chat session with timeout configuration.');
|
|
55
|
+
// Encode options as base64 payload
|
|
56
|
+
const payload = Buffer.from(JSON.stringify(options)).toString('base64');
|
|
57
|
+
const encodedSearchRoot = Buffer.from(baseDirectory, 'utf8').toString('base64');
|
|
58
|
+
const childProcess = spawnDetachedTerminal({
|
|
59
|
+
scriptPath: uiScriptPath,
|
|
60
|
+
args: [payload, encodedSearchRoot],
|
|
61
|
+
macLauncherPath: path.join(sessionDir, `interactive-mcp-intchat-${sessionId}.command`),
|
|
62
|
+
macFallbackLogMessage: 'Fallback open -a Terminal failed (intensive chat)',
|
|
63
|
+
env: {
|
|
64
|
+
[SEARCH_ROOT_ENV_KEY]: baseDirectory,
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
// Unref the process so it can run independently
|
|
68
|
+
childProcess.unref();
|
|
69
|
+
// Store session info
|
|
70
|
+
activeSessions[sessionId] = {
|
|
71
|
+
id: sessionId,
|
|
72
|
+
process: childProcess, // Use the conditionally spawned process
|
|
73
|
+
outputDir: sessionDir,
|
|
74
|
+
lastHeartbeatTime: Date.now(),
|
|
75
|
+
isActive: true,
|
|
76
|
+
title,
|
|
77
|
+
timeoutSeconds,
|
|
78
|
+
baseDirectory,
|
|
79
|
+
};
|
|
80
|
+
// Wait a bit to ensure the UI has started
|
|
81
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
82
|
+
return sessionId;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Ask a new question in an existing intensive chat session
|
|
86
|
+
* @param sessionId ID of the session to ask in
|
|
87
|
+
* @param question The question text to ask
|
|
88
|
+
* @param baseDirectory Base directory override for this question
|
|
89
|
+
* @param predefinedOptions Optional predefined options for the question
|
|
90
|
+
* @returns The user's response or null if session is not active
|
|
91
|
+
*/
|
|
92
|
+
export async function askQuestionInSession(sessionId, question, baseDirectory, predefinedOptions) {
|
|
93
|
+
const session = activeSessions[sessionId];
|
|
94
|
+
if (!session || !session.isActive) {
|
|
95
|
+
return null; // Session doesn't exist or is not active
|
|
96
|
+
}
|
|
97
|
+
const effectiveSearchRoot = baseDirectory || session.baseDirectory;
|
|
98
|
+
// Generate a unique ID for this question-answer pair
|
|
99
|
+
const questionId = crypto.randomUUID();
|
|
100
|
+
// Create the input data object
|
|
101
|
+
const inputData = {
|
|
102
|
+
id: questionId,
|
|
103
|
+
text: question,
|
|
104
|
+
searchRoot: effectiveSearchRoot,
|
|
105
|
+
};
|
|
106
|
+
if (predefinedOptions && predefinedOptions.length > 0) {
|
|
107
|
+
inputData.options = predefinedOptions;
|
|
108
|
+
}
|
|
109
|
+
// Write the combined input data to a session-specific JSON file
|
|
110
|
+
const inputFilePath = path.join(session.outputDir, `${sessionId}.json`);
|
|
111
|
+
await fs.writeFile(inputFilePath, JSON.stringify(inputData), 'utf8');
|
|
112
|
+
// Wait for the response file corresponding to the generated ID
|
|
113
|
+
const responseFilePath = path.join(session.outputDir, `response-${questionId}.txt`);
|
|
114
|
+
// Wait for response with timeout
|
|
115
|
+
const effectiveTimeoutSeconds = session.timeoutSeconds ?? USER_INPUT_TIMEOUT_SECONDS;
|
|
116
|
+
const maxWaitTime = effectiveTimeoutSeconds * 1000;
|
|
117
|
+
const pollInterval = 100; // 100ms polling interval
|
|
118
|
+
const startTime = Date.now();
|
|
119
|
+
logger.info({ sessionId, questionId, timeoutSeconds: effectiveTimeoutSeconds }, 'Waiting for intensive chat response using effective timeout.');
|
|
120
|
+
while (Date.now() - startTime < maxWaitTime) {
|
|
121
|
+
try {
|
|
122
|
+
// Check if the response file exists
|
|
123
|
+
await fs.access(responseFilePath);
|
|
124
|
+
// Read the response
|
|
125
|
+
const response = await fs.readFile(responseFilePath, 'utf8');
|
|
126
|
+
// Clean up the response file
|
|
127
|
+
await fs.unlink(responseFilePath).catch(() => { });
|
|
128
|
+
return response;
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Response file doesn't exist yet, check session status
|
|
132
|
+
if (!(await isSessionActive(sessionId))) {
|
|
133
|
+
return null; // Session has ended
|
|
134
|
+
}
|
|
135
|
+
// Wait before polling again
|
|
136
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Timeout reached
|
|
140
|
+
logger.info({ sessionId, questionId, timeoutSeconds: effectiveTimeoutSeconds }, 'Intensive chat question timed out.');
|
|
141
|
+
return USER_INPUT_TIMEOUT_SENTINEL;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Stop an active intensive chat session
|
|
145
|
+
* @param sessionId ID of the session to stop
|
|
146
|
+
* @returns True if session was stopped, false otherwise
|
|
147
|
+
*/
|
|
148
|
+
export async function stopIntensiveChatSession(sessionId) {
|
|
149
|
+
const session = activeSessions[sessionId];
|
|
150
|
+
if (!session || !session.isActive) {
|
|
151
|
+
return false; // Session doesn't exist or is already inactive
|
|
152
|
+
}
|
|
153
|
+
// Write close signal file
|
|
154
|
+
const closeFilePath = path.join(session.outputDir, 'close-session.txt');
|
|
155
|
+
await fs.writeFile(closeFilePath, '', 'utf8');
|
|
156
|
+
// Give the process some time to exit gracefully
|
|
157
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
158
|
+
try {
|
|
159
|
+
// Force kill the process if it's still running
|
|
160
|
+
if (!session.process.killed) {
|
|
161
|
+
// Kill process group on Unix-like systems, standard kill on Windows
|
|
162
|
+
try {
|
|
163
|
+
if (os.platform() !== 'win32') {
|
|
164
|
+
process.kill(-session.process.pid, 'SIGTERM');
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
process.kill(session.process.pid, 'SIGTERM');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
// console.error("Error killing process:", killError);
|
|
172
|
+
// Fallback or ignore if process already exited or group kill failed
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Process might have already exited
|
|
178
|
+
}
|
|
179
|
+
// Mark session as inactive
|
|
180
|
+
session.isActive = false;
|
|
181
|
+
// Clean up session directory after a delay
|
|
182
|
+
setTimeout(() => {
|
|
183
|
+
// Use void to mark intentionally unhandled promise
|
|
184
|
+
void (async () => {
|
|
185
|
+
try {
|
|
186
|
+
await fs.rm(session.outputDir, { recursive: true, force: true });
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Ignore errors during cleanup
|
|
190
|
+
}
|
|
191
|
+
// Remove from active sessions
|
|
192
|
+
delete activeSessions[sessionId];
|
|
193
|
+
})();
|
|
194
|
+
}, 2000);
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Check if a session is still active
|
|
199
|
+
* @param sessionId ID of the session to check
|
|
200
|
+
* @returns True if session is active, false otherwise
|
|
201
|
+
*/
|
|
202
|
+
export async function isSessionActive(sessionId) {
|
|
203
|
+
const session = activeSessions[sessionId];
|
|
204
|
+
if (!session) {
|
|
205
|
+
return false; // Session doesn't exist
|
|
206
|
+
}
|
|
207
|
+
if (!session.isActive) {
|
|
208
|
+
return false; // Session was manually marked as inactive
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
// Check the heartbeat file
|
|
212
|
+
const heartbeatPath = path.join(session.outputDir, 'heartbeat.txt');
|
|
213
|
+
const stats = await fs.stat(heartbeatPath);
|
|
214
|
+
// Check if heartbeat was updated recently (within last 2 seconds)
|
|
215
|
+
const heartbeatAge = Date.now() - stats.mtime.getTime();
|
|
216
|
+
if (heartbeatAge > 2000) {
|
|
217
|
+
// Heartbeat is too old, session is likely dead
|
|
218
|
+
session.isActive = false;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
// If error is ENOENT (file not found), assume session is still starting
|
|
225
|
+
// Check if err is an object and has a code property before accessing it
|
|
226
|
+
if (err &&
|
|
227
|
+
typeof err === 'object' &&
|
|
228
|
+
'code' in err &&
|
|
229
|
+
err.code === 'ENOENT') {
|
|
230
|
+
// Optional: Could add a check here to see if the session is very new
|
|
231
|
+
// e.g., if (Date.now() - session.startTime < 2000) return true;
|
|
232
|
+
// For now, let's assume ENOENT means it's possibly still starting.
|
|
233
|
+
return true;
|
|
234
|
+
}
|
|
235
|
+
// Handle cases where err is not an object with a code property or other errors
|
|
236
|
+
logger.error({ sessionId, error: err instanceof Error ? err.message : String(err) }, `Error checking heartbeat for session ${sessionId}`);
|
|
237
|
+
session.isActive = false;
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Start background monitoring of all active sessions
|
|
243
|
+
*/
|
|
244
|
+
function startSessionMonitoring() {
|
|
245
|
+
// Remove async from setInterval callback
|
|
246
|
+
setInterval(() => {
|
|
247
|
+
// Use void to mark intentionally unhandled promise
|
|
248
|
+
void (async () => {
|
|
249
|
+
for (const sessionId of Object.keys(activeSessions)) {
|
|
250
|
+
const isActive = await isSessionActive(sessionId);
|
|
251
|
+
if (!isActive && activeSessions[sessionId]) {
|
|
252
|
+
// Clean up inactive session
|
|
253
|
+
try {
|
|
254
|
+
// Kill process if it's somehow still running
|
|
255
|
+
if (!activeSessions[sessionId].process.killed) {
|
|
256
|
+
try {
|
|
257
|
+
if (os.platform() !== 'win32') {
|
|
258
|
+
process.kill(-activeSessions[sessionId].process.pid, 'SIGTERM');
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
process.kill(activeSessions[sessionId].process.pid, 'SIGTERM');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
// console.error("Error killing process:", killError);
|
|
266
|
+
// Ignore errors during cleanup
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// Ignore errors during cleanup
|
|
272
|
+
}
|
|
273
|
+
// Clean up session directory
|
|
274
|
+
try {
|
|
275
|
+
await fs.rm(activeSessions[sessionId].outputDir, {
|
|
276
|
+
recursive: true,
|
|
277
|
+
force: true,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
// Ignore errors during cleanup
|
|
282
|
+
}
|
|
283
|
+
// Remove from active sessions
|
|
284
|
+
delete activeSessions[sessionId];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
})();
|
|
288
|
+
}, 5000); // Check every 5 seconds
|
|
289
|
+
}
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "@opentui/react/jsx-runtime";
|
|
2
|
+
import * as OpenTuiCore from '@opentui/core';
|
|
3
|
+
import * as OpenTuiReact from '@opentui/react';
|
|
4
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import crypto from 'crypto';
|
|
8
|
+
import { InteractiveInput } from '../../components/InteractiveInput.js';
|
|
9
|
+
import { MarkdownText } from '../../components/MarkdownText.js';
|
|
10
|
+
import { PromptStatus } from '../../components/PromptStatus.js';
|
|
11
|
+
import { USER_INPUT_TIMEOUT_SECONDS, USER_INPUT_TIMEOUT_SENTINEL, } from '../../constants.js';
|
|
12
|
+
import logger from '../../utils/logger.js';
|
|
13
|
+
import { resolveSearchRoot } from '../../utils/search-root.js';
|
|
14
|
+
const { createCliRenderer } = OpenTuiCore;
|
|
15
|
+
const { createRoot } = OpenTuiReact;
|
|
16
|
+
const { useTerminalDimensions } = OpenTuiReact;
|
|
17
|
+
const decodeSearchRootArg = (encodedSearchRoot) => {
|
|
18
|
+
if (!encodedSearchRoot) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
return Buffer.from(encodedSearchRoot, 'base64').toString('utf8');
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
const parseArgs = () => {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
const searchRootFromArg = decodeSearchRootArg(args[1]);
|
|
31
|
+
const defaults = {
|
|
32
|
+
sessionId: crypto.randomUUID(),
|
|
33
|
+
title: 'Interactive Chat Session',
|
|
34
|
+
outputDir: undefined,
|
|
35
|
+
searchRoot: searchRootFromArg,
|
|
36
|
+
timeoutSeconds: USER_INPUT_TIMEOUT_SECONDS,
|
|
37
|
+
};
|
|
38
|
+
if (args[0]) {
|
|
39
|
+
try {
|
|
40
|
+
const decoded = Buffer.from(args[0], 'base64').toString('utf8');
|
|
41
|
+
const parsed = JSON.parse(decoded);
|
|
42
|
+
return {
|
|
43
|
+
...defaults,
|
|
44
|
+
...parsed,
|
|
45
|
+
searchRoot: parsed.searchRoot ?? searchRootFromArg,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
catch (e) {
|
|
49
|
+
logger.error({ error: e }, 'Invalid input options payload, using defaults.');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return defaults;
|
|
53
|
+
};
|
|
54
|
+
const options = parseArgs();
|
|
55
|
+
const writeResponseToFile = async (questionId, response) => {
|
|
56
|
+
if (!options.outputDir)
|
|
57
|
+
return;
|
|
58
|
+
const responseFilePath = path.join(options.outputDir, `response-${questionId}.txt`);
|
|
59
|
+
await fs.writeFile(responseFilePath, response, 'utf8');
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
61
|
+
};
|
|
62
|
+
const updateHeartbeat = async () => {
|
|
63
|
+
if (!options.outputDir)
|
|
64
|
+
return;
|
|
65
|
+
const heartbeatPath = path.join(options.outputDir, 'heartbeat.txt');
|
|
66
|
+
try {
|
|
67
|
+
const dir = path.dirname(heartbeatPath);
|
|
68
|
+
await fs.mkdir(dir, { recursive: true });
|
|
69
|
+
await fs.writeFile(heartbeatPath, Date.now().toString(), 'utf8');
|
|
70
|
+
}
|
|
71
|
+
catch (writeError) {
|
|
72
|
+
logger.error({ heartbeatPath, error: writeError }, `Failed to write heartbeat file ${heartbeatPath}`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
const handleExit = () => {
|
|
76
|
+
if (options.outputDir) {
|
|
77
|
+
fs.writeFile(path.join(options.outputDir, 'session-closed.txt'), '', 'utf8')
|
|
78
|
+
.then(() => process.exit(0))
|
|
79
|
+
.catch((error) => {
|
|
80
|
+
logger.error({ error }, 'Failed to write exit file');
|
|
81
|
+
process.exit(1);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
process.exit(0);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
process.on('SIGINT', handleExit);
|
|
89
|
+
process.on('SIGTERM', handleExit);
|
|
90
|
+
process.on('beforeExit', handleExit);
|
|
91
|
+
const App = ({ sessionId, title, outputDir, searchRoot, timeoutSeconds, onCloseSession, }) => {
|
|
92
|
+
const [chatHistory, setChatHistory] = useState([]);
|
|
93
|
+
const [currentQuestionId, setCurrentQuestionId] = useState(null);
|
|
94
|
+
const [currentPredefinedOptions, setCurrentPredefinedOptions] = useState(undefined);
|
|
95
|
+
const [sessionSearchRoot, setSessionSearchRoot] = useState(undefined);
|
|
96
|
+
const [currentSearchRoot, setCurrentSearchRoot] = useState(undefined);
|
|
97
|
+
const [timeLeft, setTimeLeft] = useState(null);
|
|
98
|
+
const [followInput, setFollowInput] = useState(false);
|
|
99
|
+
const timerRef = useRef(null);
|
|
100
|
+
const scrollRef = useRef(null);
|
|
101
|
+
const { width, height } = useTerminalDimensions();
|
|
102
|
+
const isNarrow = width < 90;
|
|
103
|
+
const keepInputVisible = useCallback(() => {
|
|
104
|
+
setFollowInput(true);
|
|
105
|
+
scrollRef.current?.scrollTo?.({ x: 0, y: Number.MAX_SAFE_INTEGER });
|
|
106
|
+
}, []);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
console.clear();
|
|
109
|
+
}, []);
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
let mounted = true;
|
|
112
|
+
void resolveSearchRoot(searchRoot, { argvEntry: process.argv[1] }).then((resolvedSearchRoot) => {
|
|
113
|
+
if (!mounted) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
setSessionSearchRoot(resolvedSearchRoot);
|
|
117
|
+
setCurrentSearchRoot((prev) => prev ?? resolvedSearchRoot);
|
|
118
|
+
});
|
|
119
|
+
return () => {
|
|
120
|
+
mounted = false;
|
|
121
|
+
};
|
|
122
|
+
}, [searchRoot]);
|
|
123
|
+
useEffect(() => {
|
|
124
|
+
if (!currentQuestionId) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
setFollowInput(false);
|
|
128
|
+
scrollRef.current?.scrollTo?.({ x: 0, y: 0 });
|
|
129
|
+
}, [currentQuestionId]);
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
scrollRef.current?.scrollTo?.({
|
|
132
|
+
x: 0,
|
|
133
|
+
y: followInput ? Number.MAX_SAFE_INTEGER : 0,
|
|
134
|
+
});
|
|
135
|
+
}, [followInput, height, width]);
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
const questionPoller = setInterval(async () => {
|
|
138
|
+
if (!outputDir)
|
|
139
|
+
return;
|
|
140
|
+
try {
|
|
141
|
+
await updateHeartbeat();
|
|
142
|
+
const inputFilePath = path.join(outputDir, `${sessionId}.json`);
|
|
143
|
+
try {
|
|
144
|
+
const inputExists = await fs.stat(inputFilePath);
|
|
145
|
+
if (inputExists) {
|
|
146
|
+
const inputFileContent = await fs.readFile(inputFilePath, 'utf8');
|
|
147
|
+
let questionId = null;
|
|
148
|
+
let questionText = null;
|
|
149
|
+
let incomingOptions;
|
|
150
|
+
let incomingSearchRoot;
|
|
151
|
+
try {
|
|
152
|
+
const inputData = JSON.parse(inputFileContent);
|
|
153
|
+
if (typeof inputData === 'object' &&
|
|
154
|
+
inputData !== null &&
|
|
155
|
+
typeof inputData.id === 'string' &&
|
|
156
|
+
typeof inputData.text === 'string' &&
|
|
157
|
+
(inputData.options === undefined ||
|
|
158
|
+
Array.isArray(inputData.options)) &&
|
|
159
|
+
(inputData.searchRoot === undefined ||
|
|
160
|
+
typeof inputData.searchRoot === 'string')) {
|
|
161
|
+
questionId = inputData.id;
|
|
162
|
+
questionText = inputData.text;
|
|
163
|
+
incomingOptions = Array.isArray(inputData.options)
|
|
164
|
+
? inputData.options.map(String)
|
|
165
|
+
: undefined;
|
|
166
|
+
incomingSearchRoot = inputData.searchRoot;
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
logger.error(`Invalid format in ${sessionId}.json. Expected JSON with id (string), text (string), and optional options (array), and optional searchRoot (string).`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
catch (parseError) {
|
|
173
|
+
logger.error({ file: `${sessionId}.json`, error: parseError }, `Error parsing ${sessionId}.json as JSON`);
|
|
174
|
+
}
|
|
175
|
+
if (questionId && questionText) {
|
|
176
|
+
await addNewQuestion(questionId, questionText, incomingOptions, incomingSearchRoot);
|
|
177
|
+
await fs.unlink(inputFilePath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
logger.error(`Deleting invalid input file: ${inputFilePath}`);
|
|
181
|
+
await fs.unlink(inputFilePath);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
if (typeof e === 'object' &&
|
|
187
|
+
e !== null &&
|
|
188
|
+
'code' in e &&
|
|
189
|
+
e.code !== 'ENOENT') {
|
|
190
|
+
logger.error({ inputFilePath, error: e }, `Error checking/reading input file ${inputFilePath}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
const closeFilePath = path.join(outputDir, 'close-session.txt');
|
|
194
|
+
try {
|
|
195
|
+
await fs.stat(closeFilePath);
|
|
196
|
+
onCloseSession();
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// No close request.
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
logger.error({ error }, 'Error in poll cycle');
|
|
204
|
+
}
|
|
205
|
+
}, 100);
|
|
206
|
+
return () => clearInterval(questionPoller);
|
|
207
|
+
}, [onCloseSession, outputDir, sessionId]);
|
|
208
|
+
useEffect(() => {
|
|
209
|
+
if (timeLeft === null || !currentQuestionId) {
|
|
210
|
+
if (timerRef.current) {
|
|
211
|
+
clearInterval(timerRef.current);
|
|
212
|
+
timerRef.current = null;
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (timeLeft <= 0) {
|
|
217
|
+
if (timerRef.current) {
|
|
218
|
+
clearInterval(timerRef.current);
|
|
219
|
+
timerRef.current = null;
|
|
220
|
+
}
|
|
221
|
+
void handleSubmit(currentQuestionId, USER_INPUT_TIMEOUT_SENTINEL);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!timerRef.current) {
|
|
225
|
+
timerRef.current = setInterval(() => {
|
|
226
|
+
setTimeLeft((prev) => (prev !== null ? prev - 1 : null));
|
|
227
|
+
}, 1000);
|
|
228
|
+
}
|
|
229
|
+
return () => {
|
|
230
|
+
if (timerRef.current) {
|
|
231
|
+
clearInterval(timerRef.current);
|
|
232
|
+
timerRef.current = null;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}, [timeLeft, currentQuestionId]);
|
|
236
|
+
const addNewQuestion = async (questionId, questionText, incomingOptions, incomingSearchRoot) => {
|
|
237
|
+
console.clear();
|
|
238
|
+
if (timerRef.current) {
|
|
239
|
+
clearInterval(timerRef.current);
|
|
240
|
+
timerRef.current = null;
|
|
241
|
+
}
|
|
242
|
+
setChatHistory((prev) => [
|
|
243
|
+
...prev,
|
|
244
|
+
{
|
|
245
|
+
text: questionText,
|
|
246
|
+
isQuestion: true,
|
|
247
|
+
},
|
|
248
|
+
]);
|
|
249
|
+
setCurrentQuestionId(questionId);
|
|
250
|
+
setCurrentPredefinedOptions(incomingOptions);
|
|
251
|
+
const resolvedSearchRoot = await resolveSearchRoot(incomingSearchRoot ?? sessionSearchRoot ?? searchRoot, { argvEntry: process.argv[1] });
|
|
252
|
+
setCurrentSearchRoot(resolvedSearchRoot);
|
|
253
|
+
setTimeLeft(timeoutSeconds);
|
|
254
|
+
};
|
|
255
|
+
const handleSubmit = async (questionId, value) => {
|
|
256
|
+
if (timerRef.current) {
|
|
257
|
+
clearInterval(timerRef.current);
|
|
258
|
+
timerRef.current = null;
|
|
259
|
+
}
|
|
260
|
+
setTimeLeft(null);
|
|
261
|
+
setChatHistory((prev) => prev.map((msg) => {
|
|
262
|
+
if (msg.isQuestion &&
|
|
263
|
+
!msg.answer &&
|
|
264
|
+
msg ===
|
|
265
|
+
prev
|
|
266
|
+
.slice()
|
|
267
|
+
.reverse()
|
|
268
|
+
.find((m) => m.isQuestion && !m.answer)) {
|
|
269
|
+
return { ...msg, answer: value };
|
|
270
|
+
}
|
|
271
|
+
return msg;
|
|
272
|
+
}));
|
|
273
|
+
setCurrentQuestionId(null);
|
|
274
|
+
setCurrentPredefinedOptions(undefined);
|
|
275
|
+
setCurrentSearchRoot(sessionSearchRoot);
|
|
276
|
+
if (outputDir) {
|
|
277
|
+
await writeResponseToFile(questionId, value);
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
const percentage = timeLeft !== null && timeoutSeconds > 0
|
|
281
|
+
? (timeLeft / timeoutSeconds) * 100
|
|
282
|
+
: 0;
|
|
283
|
+
return (_jsxs("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "black", paddingLeft: isNarrow ? 0 : 1, paddingRight: isNarrow ? 0 : 1, children: [_jsxs("box", { marginBottom: 1, flexDirection: "column", width: "100%", paddingLeft: 1, paddingRight: 1, gap: 0, children: [_jsx("text", { fg: "magenta", children: _jsx("strong", { children: title }) }), _jsxs("text", { fg: "gray", wrapMode: "word", children: ["Session ", sessionId] }), !isNarrow && _jsx("text", { fg: "gray", children: "Waiting for prompts\u2026" })] }), _jsx("scrollbox", { ref: scrollRef, flexGrow: 1, width: "100%", scrollY: true, stickyScroll: followInput, stickyStart: followInput ? 'bottom' : undefined, viewportCulling: false, scrollbarOptions: {
|
|
284
|
+
showArrows: false,
|
|
285
|
+
}, children: _jsxs("box", { flexDirection: "column", width: "100%", paddingBottom: 1, children: [_jsx("box", { flexDirection: "column", width: "100%", gap: 2, children: chatHistory.map((msg, i) => (_jsxs("box", { flexDirection: "column", width: "100%", paddingLeft: 1, paddingRight: 1, gap: 1, children: [msg.isQuestion ? (_jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [_jsx("text", { fg: "cyan", children: _jsx("strong", { children: "QUESTION" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.text, showCodeCopyControls: true }) })] })) : null, msg.answer ? (_jsxs("box", { flexDirection: "column", width: "100%", marginTop: 0, children: [_jsx("text", { fg: "green", children: _jsx("strong", { children: "ANSWER" }) }), _jsx("box", { paddingLeft: isNarrow ? 1 : 2, children: _jsx(MarkdownText, { content: msg.answer, showCodeCopyControls: true }) })] })) : null] }, `msg-${i}`))) }), currentQuestionId && (_jsxs("box", { flexDirection: "column", marginTop: 1, paddingLeft: 1, paddingRight: 1, gap: 1, children: [_jsx(InteractiveInput, { question: chatHistory
|
|
286
|
+
.slice()
|
|
287
|
+
.reverse()
|
|
288
|
+
.find((m) => m.isQuestion && !m.answer)
|
|
289
|
+
?.text || '', questionId: currentQuestionId, predefinedOptions: currentPredefinedOptions, searchRoot: currentSearchRoot, onSubmit: handleSubmit, onInputActivity: keepInputVisible }), timeLeft !== null && (_jsx("box", { marginTop: 0, children: _jsx(PromptStatus, { value: percentage, timeLeftSeconds: timeLeft, critical: timeLeft <= 10 }) }))] }))] }) })] }));
|
|
290
|
+
};
|
|
291
|
+
async function startUi() {
|
|
292
|
+
const renderer = await createCliRenderer({
|
|
293
|
+
exitOnCtrlC: false,
|
|
294
|
+
});
|
|
295
|
+
const root = createRoot(renderer);
|
|
296
|
+
root.render(_jsx(App, { ...options, onCloseSession: handleExit }));
|
|
297
|
+
}
|
|
298
|
+
void startUi().catch((error) => {
|
|
299
|
+
logger.error({ error }, 'Failed to start intensive chat UI');
|
|
300
|
+
process.exit(1);
|
|
301
|
+
});
|