@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 josippapez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @rawwee/interactive-mcp
2
+
3
+ [![npm version](https://img.shields.io/npm/v/%40rawwee%2Finteractive-mcp)](https://www.npmjs.com/package/@rawwee/interactive-mcp) [![npm downloads](https://img.shields.io/npm/dm/%40rawwee%2Finteractive-mcp)](https://www.npmjs.com/package/@rawwee/interactive-mcp) [![GitHub license](https://img.shields.io/github/license/josippapez/interactive-mcp-server)](https://github.com/josippapez/interactive-mcp-server/blob/main/LICENSE) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) [![Platforms](https://img.shields.io/badge/Platform-Windows%20%7C%20macOS%20%7C%20Linux-blue)](https://github.com/josippapez/interactive-mcp-server) [![GitHub last commit](https://img.shields.io/github/last-commit/josippapez/interactive-mcp-server)](https://github.com/josippapez/interactive-mcp-server/commits/main)
4
+
5
+ ![Screenshot 2025-05-13 213745](https://github.com/user-attachments/assets/40208534-5910-4eb2-bfbc-58f7d93aec95)
6
+
7
+ A MCP Server implemented in Node.js/TypeScript, facilitating interactive communication between LLMs and users. **Note:** This server is designed to run locally alongside the MCP client (e.g., Claude Desktop, VS Code), as it needs direct access to the user's operating system to display notifications and command-line prompts.
8
+
9
+ _(Note: This project is in its early stages.)_
10
+
11
+ ## Tools
12
+
13
+ This server exposes the following tools via the Model Context Protocol (MCP):
14
+
15
+ - `request_user_input`: Asks the user a question and returns their answer. Can display predefined options.
16
+ - `message_complete_notification`: Sends a simple OS notification.
17
+ - `start_intensive_chat`: Initiates a persistent command-line chat session.
18
+ - `ask_intensive_chat`: Asks a question within an active intensive chat session.
19
+ - `stop_intensive_chat`: Closes an active intensive chat session.
20
+
21
+ ## Usage Scenarios
22
+
23
+ This server is ideal for scenarios where an LLM needs to interact directly with the user on their local machine, such as:
24
+
25
+ - Interactive setup or configuration processes.
26
+ - Gathering feedback during code generation or modification.
27
+ - Clarifying instructions or confirming actions in pair programming.
28
+ - Any workflow requiring user input or confirmation during LLM operation.
29
+
30
+ ## Client Configuration
31
+
32
+ This section explains how to configure MCP clients to use the `@rawwee/interactive-mcp` server package.
33
+
34
+ By default, user prompts will time out after 30 seconds. You can customize server options like timeout or disabled tools by adding command-line flags directly to the `args` array when configuring your client.
35
+
36
+ Please make sure you have the `npx` command available.
37
+
38
+ ### Usage with Claude Desktop / Cursor
39
+
40
+ Add the following minimal configuration to your `claude_desktop_config.json` (Claude Desktop) or `mcp.json` (Cursor):
41
+
42
+ ```json
43
+ {
44
+ "mcpServers": {
45
+ "interactive": {
46
+ "command": "npx",
47
+ "args": ["-y", "@rawwee/interactive-mcp"]
48
+ }
49
+ }
50
+ }
51
+ ```
52
+
53
+ **With specific version**
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "interactive": {
59
+ "command": "npx",
60
+ "args": ["-y", "@rawwee/interactive-mcp@1.9.0"]
61
+ }
62
+ }
63
+ }
64
+ ```
65
+
66
+ **Example with Custom Timeout (30s):**
67
+
68
+ ```json
69
+ {
70
+ "mcpServers": {
71
+ "interactive": {
72
+ "command": "npx",
73
+ "args": ["-y", "@rawwee/interactive-mcp", "-t", "30"]
74
+ }
75
+ }
76
+ }
77
+ ```
78
+
79
+ ### Usage with VS Code
80
+
81
+ Add the following minimal configuration to your User Settings (JSON) file or `.vscode/mcp.json`:
82
+
83
+ ```json
84
+ {
85
+ "mcp": {
86
+ "servers": {
87
+ "interactive-mcp": {
88
+ "command": "npx",
89
+ "args": ["-y", "@rawwee/interactive-mcp"]
90
+ }
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ #### macOS Recommendations
97
+
98
+ For a smoother experience on macOS using the default `Terminal.app`, consider this profile setting:
99
+
100
+ - **(Shell Tab):** Under **"When the shell exits"** (**Terminal > Settings > Profiles > _[Your Profile]_ > Shell**), select **"Close if the shell exited cleanly"** or **"Close the window"**. This helps manage windows when the MCP server starts and stops.
101
+
102
+ ## Development Setup
103
+
104
+ This section is primarily for developers looking to modify or contribute to the server. If you just want to _use_ the server with an MCP client, see the "Client Configuration" section above.
105
+
106
+ ### Prerequisites
107
+
108
+ - **Bun:** Required for runtime execution and dependency management.
109
+ - **Node.js:** Required for TypeScript tooling and Node APIs used by the server.
110
+
111
+ ### Installation (Developers)
112
+
113
+ 1. Clone the repository:
114
+
115
+ ```bash
116
+ git clone https://github.com/josippapez/interactive-mcp-server
117
+ cd interactive-mcp-server
118
+ ```
119
+
120
+ 2. Install dependencies:
121
+
122
+ ```bash
123
+ bun install
124
+ ```
125
+
126
+ ### Running the Application (Developers)
127
+
128
+ ```bash
129
+ bun run start
130
+ ```
131
+
132
+ ### Terminal UI backend status
133
+
134
+ `interactive-mcp` now uses OpenTUI (`@opentui/core`, `@opentui/react`) for terminal UI rendering.
135
+
136
+ OpenTUI is the terminal UI renderer backend.
137
+
138
+ #### Command-Line Options
139
+
140
+ The `interactive-mcp` server accepts the following command-line options. These should typically be configured in your MCP client's JSON settings by adding them directly to the `args` array (see "Client Configuration" examples).
141
+
142
+ | Option | Alias | Description |
143
+ | ----------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. Defaults to 30 seconds. |
145
+ | `--disable-tools` | `-d` | Disables specific tools or groups (comma-separated list). Prevents the server from advertising or registering them. Options: `request_user_input`, `message_complete_notification`, `intensive_chat`. |
146
+
147
+ **Example:** Setting multiple options in the client config `args` array:
148
+
149
+ ```jsonc
150
+ // Example combining options in client config's "args":
151
+ "args": [
152
+ "-y", "@rawwee/interactive-mcp",
153
+ "-t", "30", // Set timeout to 30 seconds
154
+ "--disable-tools", "message_complete_notification,intensive_chat" // Disable notifications and intensive chat
155
+ ]
156
+ ```
157
+
158
+ ## Development Commands
159
+
160
+ - **Build:** `bun run build`
161
+ - **Lint:** `bun run lint`
162
+ - **Format:** `bun run format`
163
+
164
+ ## Release & Publishing Workflow
165
+
166
+ Package releases are handled by **GitHub Actions** in `.github/workflows/publish.yml` (manual `workflow_dispatch`).
167
+
168
+ - Choose `release_type` when running the workflow:
169
+ - `stable` → releases from `main`
170
+ - `rc` → releases from `next`
171
+ - The workflow uses:
172
+ - **Node.js 24** (`actions/setup-node`)
173
+ - **Bun** (`oven-sh/setup-bun`)
174
+ - For semantic-release + trusted publishing, keep `actions/setup-node` without `registry-url` to avoid npm auth conflicts (`EINVALIDNPMTOKEN`).
175
+ - Release pipeline commands:
176
+ - `bun install --frozen-lockfile`
177
+ - `bun run build`
178
+ - `bunx semantic-release`
179
+ - npm publishing uses **trusted publishing (OIDC)** via GitHub Actions (`id-token: write`), not a long-lived npm token.
180
+
181
+ ## Guiding Principles for Interaction
182
+
183
+ When interacting with this MCP server (e.g., as an LLM client), please adhere to the following principles to ensure clarity and reduce unexpected changes:
184
+
185
+ - **Prioritize Interaction:** Utilize the provided MCP tools (`request_user_input`, `start_intensive_chat`, etc.) frequently to engage with the user.
186
+ - **Seek Clarification:** If requirements, instructions, or context are unclear, **always** ask clarifying questions before proceeding. Do not make assumptions.
187
+ - **Confirm Actions:** Before performing significant actions (like modifying files, running complex commands, or making architectural decisions), confirm the plan with the user.
188
+ - **Provide Options:** Whenever possible, present the user with predefined options through the MCP tools to facilitate quick decisions.
189
+
190
+ You can provide these instructions to an LLM client like this:
191
+
192
+ ```markdown
193
+ # Interaction
194
+
195
+ - Please use the interactive MCP tools
196
+ - Please provide options to interactive MCP if possible
197
+
198
+ # Reduce Unexpected Changes
199
+
200
+ - Do not make assumption.
201
+ - Ask more questions before executing, until you think the requirement is clear enough.
202
+ ```
203
+
204
+ ## Contributing
205
+
206
+ Contributions are welcome! Please follow standard development practices. (Further details can be added later).
207
+
208
+ ## License
209
+
210
+ MIT (See `LICENSE` file for details - if applicable, or specify license directly).
@@ -0,0 +1,216 @@
1
+ import path from 'path';
2
+ import { fileURLToPath } from 'url';
3
+ import fsPromises from 'fs/promises';
4
+ import { watch } from 'fs';
5
+ import os from 'os';
6
+ import crypto from 'crypto';
7
+ // Updated import to use @ alias
8
+ import { USER_INPUT_TIMEOUT_SECONDS, USER_INPUT_TIMEOUT_SENTINEL, } from '../../constants.js';
9
+ import logger from '../../utils/logger.js';
10
+ import { spawnDetachedTerminal } from '../../utils/spawn-detached-terminal.js';
11
+ import { SEARCH_ROOT_ENV_KEY } from '../../utils/search-root.js';
12
+ // Get the directory name of the current module
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ // Define cleanupResources outside the promise to be accessible in the final catch
15
+ async function cleanupResources(heartbeatPath, responsePath, optionsPath) {
16
+ await Promise.allSettled([
17
+ fsPromises.unlink(responsePath).catch(() => { }),
18
+ fsPromises.unlink(heartbeatPath).catch(() => { }),
19
+ fsPromises.unlink(optionsPath).catch(() => { }), // Cleanup options file
20
+ // Potentially add cleanup for other session-related files if needed
21
+ ]);
22
+ }
23
+ /**
24
+ * Display a command window with a prompt and return user input
25
+ * @param projectName Name of the project requesting input (used for title)
26
+ * @param promptMessage Message to display to the user
27
+ * @param timeoutSeconds Timeout in seconds
28
+ * @param showCountdown Whether to show a countdown timer
29
+ * @param baseDirectory Base directory for autocomplete/search root
30
+ * @param predefinedOptions Optional list of predefined options for quick selection
31
+ * @returns User input, timeout sentinel, or empty string if process exits unexpectedly
32
+ */
33
+ export async function getCmdWindowInput(projectName, promptMessage, timeoutSeconds = USER_INPUT_TIMEOUT_SECONDS, // Use constant as default
34
+ showCountdown = true, baseDirectory, predefinedOptions) {
35
+ // Create a temporary file for the detached process to write to
36
+ const sessionId = crypto.randomBytes(8).toString('hex');
37
+ const tempDir = os.tmpdir();
38
+ const tempFilePath = path.join(tempDir, `cmd-ui-response-${sessionId}.txt`);
39
+ const heartbeatFilePath = path.join(tempDir, `cmd-ui-heartbeat-${sessionId}.txt`);
40
+ const optionsFilePath = path.join(tempDir, `cmd-ui-options-${sessionId}.json`); // New options file path
41
+ return new Promise((resolve) => {
42
+ // Wrap the async setup logic in an IIFE
43
+ void (async () => {
44
+ // Path to the UI script (will be in the same directory after compilation)
45
+ const uiScriptPath = path.join(__dirname, 'ui.js');
46
+ // Gather options
47
+ const options = {
48
+ projectName,
49
+ prompt: promptMessage,
50
+ timeout: timeoutSeconds,
51
+ showCountdown,
52
+ searchRoot: baseDirectory,
53
+ sessionId,
54
+ outputFile: tempFilePath,
55
+ heartbeatFile: heartbeatFilePath, // Pass heartbeat file path too
56
+ predefinedOptions,
57
+ };
58
+ let ui;
59
+ // Moved setup into try block
60
+ try {
61
+ logger.info({
62
+ sessionId,
63
+ timeoutSeconds,
64
+ showCountdown,
65
+ hasPredefinedOptions: Array.isArray(predefinedOptions) && predefinedOptions.length > 0,
66
+ }, 'Starting command input UI with timeout configuration.');
67
+ // Write options to the file before spawning
68
+ await fsPromises.writeFile(optionsFilePath, JSON.stringify(options), 'utf8');
69
+ // Platform-specific detached terminal spawning
70
+ const encodedSearchRoot = Buffer.from(baseDirectory, 'utf8').toString('base64');
71
+ ui = spawnDetachedTerminal({
72
+ scriptPath: uiScriptPath,
73
+ args: [sessionId, tempDir, encodedSearchRoot],
74
+ darwinArgs: [sessionId, tempDir, encodedSearchRoot],
75
+ macLauncherPath: path.join(tempDir, `interactive-mcp-launch-${sessionId}.command`),
76
+ macFallbackLogMessage: 'Fallback open -a Terminal failed',
77
+ env: {
78
+ [SEARCH_ROOT_ENV_KEY]: baseDirectory,
79
+ },
80
+ });
81
+ let watcher = null;
82
+ let timeoutHandle = null;
83
+ let heartbeatInterval = null;
84
+ let heartbeatFileSeen = false; // Track if we've ever seen the heartbeat file
85
+ const startTime = Date.now(); // Record start time for initial grace period
86
+ const startupGraceMs = timeoutSeconds * 1000 + 5000;
87
+ // Define cleanupAndResolve inside the promise scope
88
+ const cleanupAndResolve = async (response) => {
89
+ if (heartbeatInterval) {
90
+ clearInterval(heartbeatInterval);
91
+ heartbeatInterval = null;
92
+ }
93
+ if (watcher) {
94
+ watcher.close();
95
+ watcher = null;
96
+ }
97
+ if (timeoutHandle) {
98
+ clearTimeout(timeoutHandle);
99
+ timeoutHandle = null;
100
+ }
101
+ // Pass optionsFilePath to cleanupResources
102
+ await cleanupResources(heartbeatFilePath, tempFilePath, optionsFilePath);
103
+ resolve(response);
104
+ };
105
+ // Listen for process exit events - moved definition before IIFE start
106
+ const handleExit = (code) => {
107
+ // If the process exited with a non-zero code and watcher/timeout still exist
108
+ if (code !== 0 && (watcher || timeoutHandle)) {
109
+ void cleanupAndResolve('');
110
+ }
111
+ };
112
+ const handleError = () => {
113
+ if (watcher || timeoutHandle) {
114
+ // Only cleanup if not already cleaned up
115
+ void cleanupAndResolve('');
116
+ }
117
+ };
118
+ ui.on('exit', handleExit);
119
+ ui.on('error', handleError);
120
+ // Unref the child process so the parent can exit independently
121
+ ui.unref();
122
+ // Create an empty temp file before watching for user response
123
+ await fsPromises.writeFile(tempFilePath, '', 'utf8'); // Use renamed import
124
+ // Wait briefly for the heartbeat file to potentially be created
125
+ await new Promise((res) => setTimeout(res, 500));
126
+ // Watch for content being written to the temp file
127
+ watcher = watch(tempFilePath, (eventType) => {
128
+ // Removed async
129
+ if (eventType === 'change') {
130
+ // Read the response and cleanup
131
+ // Use an async IIFE inside the non-async callback
132
+ void (async () => {
133
+ try {
134
+ const data = await fsPromises.readFile(tempFilePath, 'utf8'); // Use renamed import
135
+ if (data) {
136
+ const response = data.trim();
137
+ if (response === USER_INPUT_TIMEOUT_SENTINEL) {
138
+ logger.info({ sessionId, timeoutSeconds }, 'Input UI reported timeout sentinel.');
139
+ }
140
+ void cleanupAndResolve(response); // Mark promise as intentionally ignored
141
+ }
142
+ }
143
+ catch (readError) {
144
+ logger.error({ err: readError }, 'Error reading response file');
145
+ void cleanupAndResolve('');
146
+ }
147
+ })();
148
+ }
149
+ });
150
+ // Start heartbeat check interval
151
+ heartbeatInterval = setInterval(() => {
152
+ // Removed async
153
+ // Use an async IIFE inside the non-async callback
154
+ void (async () => {
155
+ try {
156
+ const stats = await fsPromises.stat(heartbeatFilePath); // Use renamed import
157
+ const now = Date.now();
158
+ // If file hasn't been modified in the last 3 seconds, assume dead
159
+ if (now - stats.mtime.getTime() > 3000) {
160
+ logger.info(`Heartbeat file ${heartbeatFilePath} hasn't been updated recently. Process likely exited.`);
161
+ void cleanupAndResolve(''); // Mark promise as intentionally ignored
162
+ }
163
+ else {
164
+ heartbeatFileSeen = true; // Mark that we've seen the file
165
+ }
166
+ }
167
+ catch (err) {
168
+ // Type err as unknown
169
+ // Check if err is an error object with a code property
170
+ if (err && typeof err === 'object' && 'code' in err) {
171
+ const error = err; // Type assertion
172
+ if (error.code === 'ENOENT') {
173
+ // File not found
174
+ if (heartbeatFileSeen) {
175
+ // File existed before but is now gone, assume dead
176
+ logger.info(`Heartbeat file ${heartbeatFilePath} not found after being seen. Process likely exited.`);
177
+ void cleanupAndResolve(''); // Mark promise as intentionally ignored
178
+ }
179
+ else if (Date.now() - startTime > startupGraceMs) {
180
+ // File never appeared before configured timeout budget passed, assume dead
181
+ logger.info(`Heartbeat file ${heartbeatFilePath} never appeared within ${Math.floor(startupGraceMs / 1000)}s. Process likely failed to start or was blocked by permissions.`);
182
+ void cleanupAndResolve(USER_INPUT_TIMEOUT_SENTINEL);
183
+ }
184
+ // Otherwise, file just hasn't appeared yet, wait longer
185
+ }
186
+ else {
187
+ // Removed check for !== 'ENOENT' as it's implied
188
+ // Log other errors and resolve
189
+ logger.error({ error }, 'Heartbeat check error');
190
+ void cleanupAndResolve('');
191
+ }
192
+ }
193
+ else {
194
+ // Handle cases where err is not an object with a code property
195
+ logger.error({ error: err }, 'Unexpected heartbeat check error');
196
+ void cleanupAndResolve(''); // Mark promise as intentionally ignored
197
+ }
198
+ }
199
+ })();
200
+ }, 1500); // Check every 1.5 seconds
201
+ // Timeout to stop watching if no response within limit
202
+ timeoutHandle = setTimeout(() => {
203
+ logger.info(`Input timeout reached after ${timeoutSeconds} seconds.`); // Added logger info
204
+ void cleanupAndResolve(USER_INPUT_TIMEOUT_SENTINEL); // Mark promise as intentionally ignored
205
+ }, timeoutSeconds * 1000 + 5000); // Add a bit more buffer
206
+ }
207
+ catch (setupError) {
208
+ logger.error({ error: setupError }, 'Error during cmd-input setup');
209
+ // Ensure cleanup happens even if setup fails
210
+ // Pass optionsFilePath to cleanupResources
211
+ await cleanupResources(heartbeatFilePath, tempFilePath, optionsFilePath);
212
+ resolve(''); // Resolve with empty string after attempting cleanup
213
+ }
214
+ })(); // Execute the IIFE
215
+ });
216
+ }
@@ -0,0 +1,231 @@
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 os from 'os';
8
+ import logger from '../../utils/logger.js';
9
+ import { InteractiveInput } from '../../components/InteractiveInput.js';
10
+ import { PromptStatus } from '../../components/PromptStatus.js';
11
+ import { USER_INPUT_TIMEOUT_SENTINEL } from '../../constants.js';
12
+ import { resolveSearchRoot } from '../../utils/search-root.js';
13
+ const { createCliRenderer } = OpenTuiCore;
14
+ const { createRoot } = OpenTuiReact;
15
+ const { useTerminalDimensions } = OpenTuiReact;
16
+ const defaultOptions = {
17
+ prompt: 'Enter your response:',
18
+ timeout: 30,
19
+ showCountdown: false,
20
+ projectName: undefined,
21
+ searchRoot: undefined,
22
+ predefinedOptions: undefined,
23
+ };
24
+ const decodeSearchRootArg = (encodedSearchRoot) => {
25
+ if (!encodedSearchRoot) {
26
+ return undefined;
27
+ }
28
+ try {
29
+ return Buffer.from(encodedSearchRoot, 'base64').toString('utf8');
30
+ }
31
+ catch {
32
+ return undefined;
33
+ }
34
+ };
35
+ const readOptionsFromFile = async () => {
36
+ const args = process.argv.slice(2);
37
+ const sessionId = args[0];
38
+ const searchRootFromArg = decodeSearchRootArg(args[2]);
39
+ if (!sessionId) {
40
+ logger.error('No sessionId provided. Exiting.');
41
+ throw new Error('No sessionId provided');
42
+ }
43
+ let tempDir = args[1];
44
+ if (!tempDir) {
45
+ tempDir = os.tmpdir();
46
+ }
47
+ const optionsFilePath = path.join(tempDir, `cmd-ui-options-${sessionId}.json`);
48
+ try {
49
+ const optionsData = await fs.readFile(optionsFilePath, 'utf8');
50
+ const parsedOptions = JSON.parse(optionsData);
51
+ if (!parsedOptions.sessionId ||
52
+ !parsedOptions.outputFile ||
53
+ !parsedOptions.heartbeatFile) {
54
+ throw new Error('Required options missing in options file.');
55
+ }
56
+ return {
57
+ ...defaultOptions,
58
+ ...parsedOptions,
59
+ searchRoot: parsedOptions.searchRoot ?? searchRootFromArg,
60
+ sessionId: parsedOptions.sessionId,
61
+ outputFile: parsedOptions.outputFile,
62
+ heartbeatFile: parsedOptions.heartbeatFile,
63
+ };
64
+ }
65
+ catch (error) {
66
+ logger.error({
67
+ optionsFilePath,
68
+ error: error instanceof Error ? error.message : String(error),
69
+ }, `Failed to read or parse options file ${optionsFilePath}`);
70
+ throw error;
71
+ }
72
+ };
73
+ const writeResponseToFile = async (outputFile, response) => {
74
+ if (!outputFile)
75
+ return;
76
+ await fs.writeFile(outputFile, response, 'utf8');
77
+ };
78
+ let options = null;
79
+ let exitHandlerAttached = false;
80
+ async function initialize() {
81
+ try {
82
+ options = await readOptionsFromFile();
83
+ options.searchRoot = await resolveSearchRoot(options.searchRoot, {
84
+ argvEntry: process.argv[1],
85
+ });
86
+ if (!exitHandlerAttached) {
87
+ const handleExit = () => {
88
+ if (options && options.outputFile) {
89
+ writeResponseToFile(options.outputFile, '')
90
+ .catch((error) => {
91
+ logger.error({ error }, 'Failed to write exit file');
92
+ })
93
+ .finally(() => process.exit(0));
94
+ }
95
+ else {
96
+ process.exit(0);
97
+ }
98
+ };
99
+ process.on('SIGINT', handleExit);
100
+ process.on('SIGTERM', handleExit);
101
+ process.on('beforeExit', handleExit);
102
+ exitHandlerAttached = true;
103
+ }
104
+ }
105
+ catch (error) {
106
+ logger.error({ error }, 'Initialization failed');
107
+ process.exit(1);
108
+ }
109
+ }
110
+ const App = ({ options: appOptions, onExit }) => {
111
+ const { projectName, prompt, timeout, showCountdown, outputFile, heartbeatFile, predefinedOptions, searchRoot, } = appOptions;
112
+ const [timeLeft, setTimeLeft] = useState(timeout);
113
+ const [followInput, setFollowInput] = useState(false);
114
+ const hasCompletedRef = useRef(false);
115
+ const scrollRef = useRef(null);
116
+ const { width, height } = useTerminalDimensions();
117
+ const isNarrow = width < 90;
118
+ const keepInputVisible = useCallback(() => {
119
+ setFollowInput(true);
120
+ scrollRef.current?.scrollTo?.({ x: 0, y: Number.MAX_SAFE_INTEGER });
121
+ }, []);
122
+ const finishPrompt = useCallback((response) => {
123
+ if (hasCompletedRef.current) {
124
+ return;
125
+ }
126
+ hasCompletedRef.current = true;
127
+ writeResponseToFile(outputFile, response)
128
+ .catch((err) => {
129
+ logger.error('Failed to write response file:', err);
130
+ })
131
+ .finally(() => {
132
+ onExit();
133
+ });
134
+ }, [onExit, outputFile]);
135
+ useEffect(() => {
136
+ console.clear();
137
+ }, []);
138
+ useEffect(() => {
139
+ setFollowInput(false);
140
+ scrollRef.current?.scrollTo?.({ x: 0, y: 0 });
141
+ }, [prompt]);
142
+ useEffect(() => {
143
+ scrollRef.current?.scrollTo?.({
144
+ x: 0,
145
+ y: followInput ? Number.MAX_SAFE_INTEGER : 0,
146
+ });
147
+ }, [followInput, height, width]);
148
+ useEffect(() => {
149
+ const timer = setInterval(() => {
150
+ setTimeLeft((prev) => {
151
+ if (prev <= 1) {
152
+ clearInterval(timer);
153
+ finishPrompt(USER_INPUT_TIMEOUT_SENTINEL);
154
+ return 0;
155
+ }
156
+ return prev - 1;
157
+ });
158
+ }, 1000);
159
+ return () => {
160
+ clearInterval(timer);
161
+ };
162
+ }, [finishPrompt]);
163
+ useEffect(() => {
164
+ if (!heartbeatFile) {
165
+ return;
166
+ }
167
+ const heartbeatInterval = setInterval(() => {
168
+ void (async () => {
169
+ try {
170
+ const now = new Date();
171
+ await fs.utimes(heartbeatFile, now, now);
172
+ }
173
+ catch (err) {
174
+ if (err &&
175
+ typeof err === 'object' &&
176
+ 'code' in err &&
177
+ err.code === 'ENOENT') {
178
+ try {
179
+ await fs.writeFile(heartbeatFile, '', 'utf8');
180
+ }
181
+ catch {
182
+ // Ignore heartbeat create failures (permissions/path issues).
183
+ }
184
+ }
185
+ }
186
+ })();
187
+ }, 1000);
188
+ return () => {
189
+ clearInterval(heartbeatInterval);
190
+ };
191
+ }, [heartbeatFile]);
192
+ const handleSubmit = (value) => {
193
+ logger.debug(`User submitted: ${value}`);
194
+ finishPrompt(value);
195
+ };
196
+ const handleInputSubmit = (_questionId, value) => {
197
+ handleSubmit(value);
198
+ };
199
+ const progressValue = timeout > 0 ? (timeLeft / timeout) * 100 : 0;
200
+ return (_jsxs("box", { flexDirection: "column", width: "100%", height: "100%", backgroundColor: "black", paddingLeft: isNarrow ? 0 : 1, paddingRight: isNarrow ? 0 : 1, children: [_jsx("scrollbox", { ref: scrollRef, flexGrow: 1, width: "100%", scrollY: true, stickyScroll: followInput, stickyStart: followInput ? 'bottom' : undefined, viewportCulling: false, scrollbarOptions: {
201
+ showArrows: false,
202
+ }, children: _jsxs("box", { flexDirection: "column", width: "100%", paddingBottom: 1, gap: 2, children: [_jsx("box", { width: "100%", paddingLeft: 1, paddingRight: 1, children: _jsxs("box", { flexDirection: "column", width: "100%", gap: 0, children: [projectName && (_jsx("text", { fg: "magenta", children: _jsx("strong", { children: projectName }) })), _jsx("text", { fg: "gray", wrapMode: "word", children: isNarrow
203
+ ? 'Keyboard-first prompt mode'
204
+ : 'Keyboard-first prompt mode • Tab / Shift+Tab switches mode' })] }) }), _jsx("box", { width: "100%", paddingLeft: 1, paddingRight: 1, gap: 1, children: _jsx(InteractiveInput, { question: prompt, questionId: prompt, predefinedOptions: predefinedOptions, searchRoot: searchRoot, onSubmit: handleInputSubmit, onInputActivity: keepInputVisible }) })] }) }), showCountdown && (_jsx("box", { marginTop: 0, paddingLeft: 1, paddingRight: 1, children: _jsx(PromptStatus, { value: progressValue, timeLeftSeconds: timeLeft, critical: timeLeft <= 10 }) }))] }));
205
+ };
206
+ async function startUi() {
207
+ await initialize();
208
+ if (!options) {
209
+ logger.error('Options could not be initialized. Cannot render App.');
210
+ process.exit(1);
211
+ return;
212
+ }
213
+ const renderer = await createCliRenderer({
214
+ exitOnCtrlC: false,
215
+ });
216
+ const root = createRoot(renderer);
217
+ let closed = false;
218
+ const closeApp = () => {
219
+ if (closed) {
220
+ return;
221
+ }
222
+ closed = true;
223
+ renderer.destroy();
224
+ process.exit(0);
225
+ };
226
+ root.render(_jsx(App, { options: options, onExit: closeApp }));
227
+ }
228
+ void startUi().catch((error) => {
229
+ logger.error({ error }, 'Failed to start input UI');
230
+ process.exit(1);
231
+ });