@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.
@@ -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
+ });