@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
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
|
+
[](https://www.npmjs.com/package/@rawwee/interactive-mcp) [](https://www.npmjs.com/package/@rawwee/interactive-mcp) [](https://github.com/josippapez/interactive-mcp-server/blob/main/LICENSE) [](https://github.com/prettier/prettier) [](https://github.com/josippapez/interactive-mcp-server) [](https://github.com/josippapez/interactive-mcp-server/commits/main)
|
|
4
|
+
|
|
5
|
+

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