@rawwee/interactive-mcp 1.1.0 → 1.3.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/README.md CHANGED
@@ -19,6 +19,7 @@ This server exposes the following tools via the Model Context Protocol (MCP):
19
19
  - `stop_intensive_chat`: Closes an active intensive chat session.
20
20
 
21
21
  Prompt UIs support markdown-friendly question text (including multiline prompts, fenced code blocks, and diff snippets). When useful, you can also include VS Code-style file links in prompt text (for example, `vscode://file/<absolute-path>:<line>:<column>`).
22
+ In TUI input mode, `Cmd/Ctrl+V` supports clipboard text plus file/image includes from pasted paths, copied file objects, and copied images (platform support varies).
22
23
 
23
24
  ## Usage Scenarios
24
25
 
@@ -131,11 +132,10 @@ This section is primarily for developers looking to modify or contribute to the
131
132
  bun run start
132
133
  ```
133
134
 
134
- ### Terminal UI backend status
135
+ ### UI backend status
135
136
 
136
- `interactive-mcp` now uses OpenTUI (`@opentui/core`, `@opentui/react`) for terminal UI rendering.
137
-
138
- OpenTUI is the terminal UI renderer backend.
137
+ `interactive-mcp` currently runs with the OpenTUI terminal backend (`@opentui/core`, `@opentui/react`).
138
+ The VS Code extension and bridge runtime have been removed from the active feature set for now, and may be reconsidered in a future iteration.
139
139
 
140
140
  #### Command-Line Options
141
141
 
@@ -143,7 +143,7 @@ The `interactive-mcp` server accepts the following command-line options. These s
143
143
 
144
144
  | Option | Alias | Description |
145
145
  | ----------------- | ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
- | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. Defaults to 30 seconds. |
146
+ | `--timeout` | `-t` | Sets the default timeout (in seconds) for user input prompts. |
147
147
  | `--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`. |
148
148
 
149
149
  **Example:** Setting multiple options in the client config `args` array:
@@ -1,16 +1,143 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "@opentui/react/jsx-runtime";
2
2
  import * as OpenTuiReact from '@opentui/react';
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import crypto from 'node:crypto';
5
+ import fs from 'node:fs/promises';
4
6
  import path from 'node:path';
5
7
  import { getAutocompleteTarget, rankFileSuggestions, readRepositoryFiles, } from './interactive-input/autocomplete.js';
6
- import { isPrintableCharacter, isCopyShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
8
+ import { extractPastedText, isPrintableCharacter, isCopyShortcut, isPasteShortcut, isReverseTabShortcut, isSubmitShortcut, textareaKeyBindings, } from './interactive-input/keyboard.js';
7
9
  import { InputEditor, ModeTabs, OptionList, SuggestionsPanel, } from './interactive-input/sections.js';
8
10
  import { getTextareaDimensions } from './interactive-input/textarea-height.js';
9
11
  import { MarkdownText } from './MarkdownText.js';
10
- import { copyTextToClipboard } from '../utils/clipboard.js';
12
+ import { copyTextToClipboard, readFilePathsFromClipboard, readImageDataUrlFromClipboard, readTextFromClipboard, } from '../utils/clipboard.js';
11
13
  const { useKeyboard } = OpenTuiReact;
12
14
  const { useTerminalDimensions } = OpenTuiReact;
13
15
  const repositoryFileCache = new Map();
16
+ const IMAGE_MIME_BY_EXTENSION = {
17
+ '.png': 'image/png',
18
+ '.jpg': 'image/jpeg',
19
+ '.jpeg': 'image/jpeg',
20
+ '.gif': 'image/gif',
21
+ '.webp': 'image/webp',
22
+ '.bmp': 'image/bmp',
23
+ '.svg': 'image/svg+xml',
24
+ };
25
+ const IMAGE_EMBED_MAX_BYTES = 2 * 1024 * 1024;
26
+ const TEXT_EMBED_MAX_BYTES = 512 * 1024;
27
+ const TEXT_EMBED_MAX_CHARS = 20000;
28
+ const COLLAPSE_TEXT_PASTE_CHARS = 800;
29
+ const COLLAPSE_TEXT_PASTE_LINES = 12;
30
+ const inferCodeFenceLanguage = (fileName) => {
31
+ const extension = path.extname(fileName).slice(1).toLowerCase();
32
+ const mapping = {
33
+ js: 'javascript',
34
+ jsx: 'jsx',
35
+ ts: 'typescript',
36
+ tsx: 'tsx',
37
+ json: 'json',
38
+ md: 'markdown',
39
+ py: 'python',
40
+ sh: 'bash',
41
+ yml: 'yaml',
42
+ yaml: 'yaml',
43
+ html: 'html',
44
+ css: 'css',
45
+ go: 'go',
46
+ rs: 'rust',
47
+ java: 'java',
48
+ cs: 'csharp',
49
+ };
50
+ return mapping[extension] ?? '';
51
+ };
52
+ const normalizeClipboardPath = (clipboardText, searchRoot) => {
53
+ const trimmed = clipboardText.trim();
54
+ if (!trimmed) {
55
+ return null;
56
+ }
57
+ const lines = trimmed
58
+ .split(/\r?\n/)
59
+ .map((line) => line.trim())
60
+ .filter(Boolean);
61
+ let candidate = lines[0] ?? '';
62
+ if (!candidate) {
63
+ return null;
64
+ }
65
+ if ((candidate.startsWith('"') && candidate.endsWith('"')) ||
66
+ (candidate.startsWith("'") && candidate.endsWith("'"))) {
67
+ candidate = candidate.slice(1, -1);
68
+ }
69
+ if (candidate.startsWith('file://')) {
70
+ candidate = decodeURIComponent(candidate.replace(/^file:\/\//, ''));
71
+ }
72
+ if (path.isAbsolute(candidate)) {
73
+ return candidate;
74
+ }
75
+ if (!searchRoot) {
76
+ return null;
77
+ }
78
+ return path.resolve(searchRoot, candidate);
79
+ };
80
+ const isLikelyTextBuffer = (buffer) => {
81
+ if (buffer.includes(0)) {
82
+ return false;
83
+ }
84
+ const sample = buffer.subarray(0, Math.min(buffer.length, 2048));
85
+ let suspiciousBytes = 0;
86
+ for (const byte of sample) {
87
+ const isPrintableAscii = byte >= 32 && byte <= 126;
88
+ const isWhitespace = byte === 9 || byte === 10 || byte === 13;
89
+ const isExtendedUtf8Byte = byte >= 128;
90
+ if (!isPrintableAscii && !isWhitespace && !isExtendedUtf8Byte) {
91
+ suspiciousBytes += 1;
92
+ }
93
+ }
94
+ return suspiciousBytes < 8;
95
+ };
96
+ const shouldCollapsePastedText = (text) => text.length >= COLLAPSE_TEXT_PASTE_CHARS ||
97
+ text.split(/\r?\n/).length >= COLLAPSE_TEXT_PASTE_LINES;
98
+ const buildAttachmentFromPath = async (absolutePath) => {
99
+ const fileStats = await fs.stat(absolutePath);
100
+ if (!fileStats.isFile()) {
101
+ throw new Error('Clipboard path is not a file');
102
+ }
103
+ const fileName = path.basename(absolutePath);
104
+ const extension = path.extname(fileName).toLowerCase();
105
+ const imageMimeType = IMAGE_MIME_BY_EXTENSION[extension];
106
+ if (imageMimeType) {
107
+ if (fileStats.size > IMAGE_EMBED_MAX_BYTES) {
108
+ return {
109
+ id: crypto.randomUUID(),
110
+ label: `Image: ${fileName} (${Math.round(fileStats.size / 1024)}KB, too large to embed)`,
111
+ payload: `[Image attachment: ${fileName}] (${imageMimeType}, ${fileStats.size} bytes, too large to embed)`,
112
+ };
113
+ }
114
+ const imageBuffer = await fs.readFile(absolutePath);
115
+ return {
116
+ id: crypto.randomUUID(),
117
+ label: `Image: ${fileName}`,
118
+ payload: `![${fileName}](data:${imageMimeType};base64,${imageBuffer.toString('base64')})`,
119
+ };
120
+ }
121
+ const fileBuffer = await fs.readFile(absolutePath);
122
+ if (fileStats.size <= TEXT_EMBED_MAX_BYTES &&
123
+ isLikelyTextBuffer(fileBuffer)) {
124
+ const fileText = fileBuffer.toString('utf8');
125
+ const truncatedText = fileText.length > TEXT_EMBED_MAX_CHARS
126
+ ? `${fileText.slice(0, TEXT_EMBED_MAX_CHARS)}\n\n...[truncated ${fileText.length - TEXT_EMBED_MAX_CHARS} chars]`
127
+ : fileText;
128
+ const language = inferCodeFenceLanguage(fileName);
129
+ return {
130
+ id: crypto.randomUUID(),
131
+ label: `File: ${fileName} (${truncatedText.length} chars)`,
132
+ payload: `Attached file: ${fileName}\n\`\`\`${language}\n${truncatedText}\n\`\`\``,
133
+ };
134
+ }
135
+ return {
136
+ id: crypto.randomUUID(),
137
+ label: `File: ${fileName} (${Math.round(fileStats.size / 1024)}KB binary)`,
138
+ payload: `[File attachment: ${fileName}] (${fileStats.size} bytes, binary content omitted)`,
139
+ };
140
+ };
14
141
  export function InteractiveInput({ question, questionId, predefinedOptions = [], onSubmit, onInputActivity, searchRoot, }) {
15
142
  const [mode, setMode] = useState(predefinedOptions.length > 0 ? 'option' : 'input');
16
143
  const [selectedIndex, setSelectedIndex] = useState(0);
@@ -23,6 +150,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
23
150
  const [textareaRenderVersion, setTextareaRenderVersion] = useState(0);
24
151
  const [focusRequestToken, setFocusRequestToken] = useState(0);
25
152
  const [clipboardStatus, setClipboardStatus] = useState(null);
153
+ const [queuedAttachments, setQueuedAttachments] = useState([]);
26
154
  const textareaRef = useRef(null);
27
155
  const latestInputValueRef = useRef(inputValue);
28
156
  const latestCaretPositionRef = useRef(caretPosition);
@@ -176,6 +304,7 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
176
304
  setSelectedIndex(0);
177
305
  setInputValue('');
178
306
  setCaretPosition(0);
307
+ setQueuedAttachments([]);
179
308
  latestInputValueRef.current = '';
180
309
  latestCaretPositionRef.current = 0;
181
310
  setFileSuggestions([]);
@@ -301,18 +430,123 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
301
430
  });
302
431
  onInputActivity?.();
303
432
  }, [inputValue, mode, onInputActivity]);
304
- const submitCurrentSelection = useCallback(() => {
305
- if (mode === 'option' && predefinedOptions.length > 0) {
306
- onSubmit(questionId, predefinedOptions[selectedIndex]);
433
+ const insertTextAtCaret = useCallback((text) => {
434
+ if (!text) {
435
+ return;
436
+ }
437
+ const textareaState = safeReadTextarea();
438
+ const currentValue = textareaState?.value ?? inputValue;
439
+ const currentCaret = Math.max(0, Math.min(textareaState?.caret ?? caretPosition, currentValue.length));
440
+ const nextValue = currentValue.slice(0, currentCaret) +
441
+ text +
442
+ currentValue.slice(currentCaret);
443
+ setTextareaValue(nextValue, currentCaret + text.length);
444
+ }, [caretPosition, inputValue, safeReadTextarea, setTextareaValue]);
445
+ const queueAttachment = useCallback((attachment) => {
446
+ setQueuedAttachments((previous) => [...previous, attachment]);
447
+ }, []);
448
+ const handlePastedText = useCallback((pastedText) => {
449
+ if (!pastedText) {
450
+ return;
451
+ }
452
+ const pasteAsPlainText = () => {
453
+ insertTextAtCaret(pastedText);
454
+ setClipboardStatus('Pasted text');
455
+ onInputActivity?.();
456
+ };
457
+ const normalizedPath = normalizeClipboardPath(pastedText, searchRoot);
458
+ if (normalizedPath) {
459
+ void buildAttachmentFromPath(normalizedPath)
460
+ .then((attachment) => {
461
+ queueAttachment(attachment);
462
+ setClipboardStatus(`Queued ${attachment.label}`);
463
+ onInputActivity?.();
464
+ })
465
+ .catch(() => {
466
+ pasteAsPlainText();
467
+ });
468
+ return;
469
+ }
470
+ if (shouldCollapsePastedText(pastedText)) {
471
+ queueAttachment({
472
+ id: crypto.randomUUID(),
473
+ label: `Pasted text block (${pastedText.length} chars)`,
474
+ payload: pastedText,
475
+ });
476
+ setClipboardStatus('Queued pasted text block');
477
+ onInputActivity?.();
307
478
  return;
308
479
  }
309
- const textareaValue = safeReadTextarea()?.value ?? inputValue;
310
- onSubmit(questionId, textareaValue);
480
+ pasteAsPlainText();
481
+ }, [insertTextAtCaret, onInputActivity, queueAttachment, searchRoot]);
482
+ const pasteClipboardIntoInput = useCallback(() => {
483
+ requestInputFocus();
484
+ void readTextFromClipboard()
485
+ .then(async (clipboardText) => {
486
+ if (clipboardText.trim()) {
487
+ handlePastedText(clipboardText);
488
+ return;
489
+ }
490
+ const clipboardPaths = await readFilePathsFromClipboard();
491
+ if (clipboardPaths.length > 0) {
492
+ const attachments = await Promise.all(clipboardPaths.map(async (clipboardPath) => {
493
+ try {
494
+ return await buildAttachmentFromPath(clipboardPath);
495
+ }
496
+ catch {
497
+ return null;
498
+ }
499
+ }));
500
+ const validAttachments = attachments.filter((value) => value !== null);
501
+ if (validAttachments.length > 0) {
502
+ validAttachments.forEach((attachment) => {
503
+ queueAttachment(attachment);
504
+ });
505
+ setClipboardStatus(validAttachments.length === 1
506
+ ? `Queued ${validAttachments[0].label}`
507
+ : `Queued ${validAttachments.length} clipboard files`);
508
+ onInputActivity?.();
509
+ return;
510
+ }
511
+ }
512
+ const imageDataUrl = await readImageDataUrlFromClipboard();
513
+ if (imageDataUrl) {
514
+ queueAttachment({
515
+ id: crypto.randomUUID(),
516
+ label: 'Image: pasted-image.png',
517
+ payload: `![pasted-image.png](${imageDataUrl})`,
518
+ });
519
+ setClipboardStatus('Queued clipboard image');
520
+ onInputActivity?.();
521
+ return;
522
+ }
523
+ setClipboardStatus('Paste failed: clipboard is empty');
524
+ })
525
+ .catch((error) => {
526
+ const errorMessage = error instanceof Error ? error.message : 'unknown error';
527
+ setClipboardStatus(`Paste failed: ${errorMessage}`);
528
+ });
529
+ }, [handlePastedText, onInputActivity, queueAttachment, requestInputFocus]);
530
+ const submitCurrentSelection = useCallback(() => {
531
+ const baseValue = mode === 'option' && predefinedOptions.length > 0
532
+ ? predefinedOptions[selectedIndex]
533
+ : (safeReadTextarea()?.value ?? inputValue);
534
+ const attachmentPayload = queuedAttachments
535
+ .map((attachment) => attachment.payload)
536
+ .join('\n\n');
537
+ const finalValue = attachmentPayload
538
+ ? baseValue.trim().length > 0
539
+ ? `${baseValue}\n\n${attachmentPayload}`
540
+ : attachmentPayload
541
+ : baseValue;
542
+ onSubmit(questionId, finalValue);
543
+ setQueuedAttachments([]);
311
544
  }, [
312
545
  inputValue,
313
546
  mode,
314
547
  onSubmit,
315
548
  predefinedOptions,
549
+ queuedAttachments,
316
550
  questionId,
317
551
  safeReadTextarea,
318
552
  selectedIndex,
@@ -383,6 +617,18 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
383
617
  submitCurrentSelection();
384
618
  return;
385
619
  }
620
+ const pastedText = extractPastedText(key);
621
+ if (pastedText !== null) {
622
+ if (mode === 'option' && hasOptions) {
623
+ setModeToInput();
624
+ }
625
+ handlePastedText(pastedText);
626
+ return;
627
+ }
628
+ if (isPasteShortcut(key)) {
629
+ pasteClipboardIntoInput();
630
+ return;
631
+ }
386
632
  if (isCopyShortcut(key)) {
387
633
  copyInputToClipboard();
388
634
  return;
@@ -462,7 +708,9 @@ export function InteractiveInput({ question, questionId, predefinedOptions = [],
462
708
  ? `#search root: ${searchRoot}`
463
709
  : '#search root: no search root' }), _jsx("text", { fg: "gray", children: isIndexingFiles
464
710
  ? '#search index: indexing...'
465
- : `#search index: ${repositoryFiles.length} files indexed` })] })), _jsxs("box", { flexDirection: isNarrow ? 'column' : 'row', justifyContent: "space-between", marginBottom: 0, gap: isNarrow ? 0 : undefined, children: [_jsx("text", { fg: "gray", children: mode === 'input' ? 'Custom input' : 'Option selection' }), _jsxs("text", { fg: "gray", children: [inputValue.length, " chars"] })] }), mode === 'input' && clipboardStatus && (_jsx("text", { fg: clipboardStatus.startsWith('Copy failed:') ? 'red' : 'green', children: clipboardStatus })), mode === 'input' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 0, onClick: submitCurrentSelection, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: hasOptions
466
- ? 'Enter/Ctrl+J newline (or #search apply) • #search nav: ↑/↓ or Ctrl+N/P Tab mode switch • #path for repo file autocomplete • Cmd/Ctrl+C copy'
467
- : 'Enter/Ctrl+J newline #search nav: ↑/↓ or Ctrl+N/P Enter/Tab #search apply #path for repo file autocomplete Cmd/Ctrl+C copy' }))] }));
711
+ : `#search index: ${repositoryFiles.length} files indexed` })] })), _jsxs("box", { flexDirection: isNarrow ? 'column' : 'row', justifyContent: "space-between", marginBottom: 0, gap: isNarrow ? 0 : undefined, children: [_jsx("text", { fg: "gray", children: mode === 'input' ? 'Custom input' : 'Option selection' }), _jsx("text", { fg: "gray", children: mode === 'input' && queuedAttachments.length > 0
712
+ ? `${inputValue.length} chars + ${queuedAttachments.length} queued`
713
+ : `${inputValue.length} chars` })] }), mode === 'input' && clipboardStatus && (_jsx("text", { fg: clipboardStatus.startsWith('Copy failed:') ? 'red' : 'green', children: clipboardStatus })), mode === 'input' && queuedAttachments.length > 0 && (_jsxs("box", { flexDirection: "column", width: "100%", children: [_jsx("text", { fg: "yellow", children: _jsx("strong", { children: "QUEUED ATTACHMENTS" }) }), queuedAttachments.map((attachment) => (_jsxs("text", { fg: "gray", wrapMode: "word", children: ["- ", attachment.label] }, attachment.id)))] })), mode === 'input' && (_jsx("box", { backgroundColor: "cyan", paddingLeft: 1, paddingRight: 1, alignSelf: "flex-start", marginBottom: 0, onClick: submitCurrentSelection, children: _jsxs("text", { fg: "black", children: [_jsx("strong", { children: "Send" }), " \u2303S"] }) })), mode === 'input' && (_jsx("text", { fg: "gray", wrapMode: "word", children: hasOptions
714
+ ? 'Enter/Ctrl+J newline (or #search apply) • #search nav: ↑/↓ or Ctrl+N/P • Tab mode switch • #path for repo file autocomplete • Cmd/Ctrl+C copy • Cmd/Ctrl+V paste/attach'
715
+ : 'Enter/Ctrl+J newline • #search nav: ↑/↓ or Ctrl+N/P • Enter/Tab #search apply • #path for repo file autocomplete • Cmd/Ctrl+C copy • Cmd/Ctrl+V paste/attach' }))] }));
468
716
  }
@@ -24,6 +24,8 @@ export const isControlKeyShortcut = (key, letter) => {
24
24
  export const isSubmitShortcut = (key) => isControlKeyShortcut(key, 's');
25
25
  export const isCopyShortcut = (key) => isControlKeyShortcut(key, 'c') ||
26
26
  (key.ctrl && key.shift && key.name.toLowerCase() === 'c');
27
+ export const isPasteShortcut = (key) => isControlKeyShortcut(key, 'v') ||
28
+ (key.ctrl && key.shift && key.name.toLowerCase() === 'v');
27
29
  export const isReverseTabShortcut = (key) => key.name === 'backtab' ||
28
30
  (key.name === 'tab' && key.shift) ||
29
31
  key.sequence === '\u001b[Z';
@@ -42,6 +44,19 @@ export const isPrintableCharacter = (key) => {
42
44
  }
43
45
  return null;
44
46
  };
47
+ export const extractPastedText = (key) => {
48
+ if (key.ctrl || key.meta || key.option || key.sequence.length <= 1) {
49
+ return null;
50
+ }
51
+ for (const character of key.sequence) {
52
+ const code = character.charCodeAt(0);
53
+ const isAllowedControl = code === 9 || code === 10 || code === 13;
54
+ if (!isAllowedControl && code < 32) {
55
+ return null;
56
+ }
57
+ }
58
+ return key.sequence;
59
+ };
45
60
  export const textareaKeyBindings = [
46
61
  { name: 's', ctrl: true, action: 'submit' },
47
62
  { name: 's', meta: true, action: 'submit' },
package/dist/index.js CHANGED
@@ -4,8 +4,8 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
4
  import notifier from 'node-notifier';
5
5
  import yargs from 'yargs';
6
6
  import { hideBin } from 'yargs/helpers';
7
+ import { askQuestionInSession, startIntensiveChatSession, stopIntensiveChatSession, } from './commands/intensive-chat/index.js';
7
8
  import { getCmdWindowInput } from './commands/input/index.js';
8
- import { startIntensiveChatSession, askQuestionInSession, stopIntensiveChatSession, } from './commands/intensive-chat/index.js';
9
9
  import { USER_INPUT_TIMEOUT_SECONDS, USER_INPUT_TIMEOUT_SENTINEL, } from './constants.js';
10
10
  import logger from './utils/logger.js';
11
11
  import { validateRepositoryBaseDirectory } from './utils/base-directory.js';
@@ -158,10 +158,11 @@ if (isToolEnabled('start_intensive_chat')) {
158
158
  const { sessionTitle, baseDirectory } = args;
159
159
  try {
160
160
  const validatedBaseDirectory = await validateRepositoryBaseDirectory(baseDirectory);
161
- // Start a new intensive chat session, passing global timeout
162
161
  const sessionId = await startIntensiveChatSession(sessionTitle, validatedBaseDirectory, globalTimeoutSeconds);
163
162
  // Track this session for the client
164
- activeChatSessions.set(sessionId, sessionTitle);
163
+ activeChatSessions.set(sessionId, {
164
+ title: sessionTitle,
165
+ });
165
166
  return {
166
167
  content: [
167
168
  {
@@ -200,8 +201,8 @@ if (isToolEnabled('ask_intensive_chat')) {
200
201
  async (args) => {
201
202
  // Use inferred args type
202
203
  const { sessionId, question, predefinedOptions, baseDirectory } = args;
203
- // Check if session exists
204
- if (!activeChatSessions.has(sessionId)) {
204
+ const activeSession = activeChatSessions.get(sessionId);
205
+ if (!activeSession) {
205
206
  return {
206
207
  content: [
207
208
  { type: 'text', text: 'Error: Invalid or expired session ID.' },
@@ -210,7 +211,6 @@ if (isToolEnabled('ask_intensive_chat')) {
210
211
  }
211
212
  try {
212
213
  const validatedBaseDirectory = await validateRepositoryBaseDirectory(baseDirectory);
213
- // Ask the question in the session
214
214
  const answer = await askQuestionInSession(sessionId, question, validatedBaseDirectory, predefinedOptions);
215
215
  // Check for the specific timeout indicator
216
216
  if (answer === USER_INPUT_TIMEOUT_SENTINEL) {
@@ -279,8 +279,8 @@ if (isToolEnabled('stop_intensive_chat')) {
279
279
  async (args) => {
280
280
  // Use inferred args type
281
281
  const { sessionId } = args;
282
- // Check if session exists
283
- if (!activeChatSessions.has(sessionId)) {
282
+ const activeSession = activeChatSessions.get(sessionId);
283
+ if (!activeSession) {
284
284
  return {
285
285
  content: [
286
286
  { type: 'text', text: 'Error: Invalid or expired session ID.' },
@@ -288,7 +288,6 @@ if (isToolEnabled('stop_intensive_chat')) {
288
288
  };
289
289
  }
290
290
  try {
291
- // Stop the session
292
291
  const success = await stopIntensiveChatSession(sessionId);
293
292
  // Remove session from map if successful
294
293
  if (success) {
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  // === Start Intensive Chat Definition ===
3
3
  const startCapability = {
4
- description: 'Start a persistent OpenTUI intensive chat session for gathering multiple answers quickly with markdown-friendly prompts.',
4
+ description: 'Start a persistent intensive chat session for gathering multiple answers quickly with markdown-friendly prompts.',
5
5
  parameters: {
6
6
  type: 'object',
7
7
  properties: {
@@ -18,17 +18,18 @@ const startCapability = {
18
18
  },
19
19
  };
20
20
  const startDescription = (globalTimeoutSeconds) => `<description>
21
- Start an intensive chat session (OpenTUI terminal UI) for gathering multiple answers quickly from the user.
21
+ Start an intensive chat session for gathering multiple answers quickly from the user.
22
22
  **Highly recommended** for scenarios requiring a sequence of related inputs or confirmations.
23
23
  Very useful for gathering multiple answers from the user in a short period of time.
24
24
  Especially useful for brainstorming ideas or discussing complex topics with the user.
25
25
  </description>
26
26
 
27
27
  <importantNotes>
28
- - (!important!) Opens a persistent console window that stays open for multiple questions.
28
+ - (!important!) Opens a persistent interaction session that stays active for multiple questions.
29
29
  - (!important!) Returns a session ID that **must** be used for subsequent questions via 'ask_intensive_chat'.
30
30
  - (!important!) **Must** be closed with 'stop_intensive_chat' when finished gathering all inputs.
31
31
  - (!important!) After starting a session, **immediately** continue asking all necessary questions using 'ask_intensive_chat' within the **same response message**. Do not end the response until the chat is closed with 'stop_intensive_chat'. This creates a seamless conversational flow for the user.
32
+ - (!important!) Continue the prompt loop until the user explicitly says one of: "Stop prompting", "End session", or "Don't ask anymore".
32
33
  </importantNotes>
33
34
 
34
35
  <whenToUseThisTool>
@@ -41,7 +42,7 @@ Especially useful for brainstorming ideas or discussing complex topics with the
41
42
  </whenToUseThisTool>
42
43
 
43
44
  <features>
44
- - Opens a persistent OpenTUI window for continuous interaction
45
+ - Opens a persistent interactive prompt surface for continuous interaction
45
46
  - Renders markdown prompts, including code/diff snippets, for richer question context
46
47
  - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") in prompt content
47
48
  - Supports option mode + free-text mode while asking follow-up questions
@@ -49,6 +50,7 @@ Especially useful for brainstorming ideas or discussing complex topics with the
49
50
  - Returns a session ID for subsequent interactions
50
51
  - Keeps full chat history visible to the user
51
52
  - Maintains state between questions
53
+ - Backend-agnostic contract: start/ask/stop behavior is consistent across available UI backends
52
54
  - Requires baseDirectory and pins autocomplete/search scope to the repository root
53
55
  </features>
54
56
 
@@ -85,7 +87,7 @@ const startToolDefinition = {
85
87
  };
86
88
  // === Ask Intensive Chat Definition ===
87
89
  const askCapability = {
88
- description: 'Ask a markdown-friendly question in an active OpenTUI intensive chat session.',
90
+ description: 'Ask a markdown-friendly question in an active intensive chat session.',
89
91
  parameters: {
90
92
  type: 'object',
91
93
  properties: {
@@ -120,6 +122,8 @@ Ask a new question in an active intensive chat session previously started with '
120
122
  - (!important!) Supports predefined options for quick selection.
121
123
  - (!important!) Returns the user's answer or indicates if they didn't respond.
122
124
  - (!important!) **Use this repeatedly within the same response message** after 'start_intensive_chat' until all questions are asked.
125
+ - (!important!) If response is empty or times out for required input, re-prompt and do not proceed with assumptions.
126
+ - (!important!) Keep the loop active until the user explicitly says one of: "Stop prompting", "End session", or "Don't ask anymore".
123
127
  </importantNotes>
124
128
 
125
129
  <whenToUseThisTool>
@@ -193,9 +197,10 @@ const stopDescription = `<description>
193
197
  </description>
194
198
 
195
199
  <importantNotes>
196
- - (!important!) Closes the console window for the intensive chat.
200
+ - (!important!) Closes the active intensive chat session.
197
201
  - (!important!) Frees up system resources.
198
202
  - (!important!) **Should always be called** as the final step when finished with an intensive chat session, typically at the end of the response message where 'start_intensive_chat' was called.
203
+ - (!important!) Only stop the session when the user explicitly wants to end prompting, such as with "Stop prompting", "End session", or "Don't ask anymore".
199
204
  </importantNotes>
200
205
 
201
206
  <whenToUseThisTool>
@@ -207,7 +212,7 @@ const stopDescription = `<description>
207
212
  </whenToUseThisTool>
208
213
 
209
214
  <features>
210
- - Gracefully closes the console window
215
+ - Gracefully closes the active session in the current backend
211
216
  - Cleans up system resources
212
217
  - Marks the session as complete
213
218
  </features>
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  // Define capability conforming to ToolCapabilityInfo
3
3
  const capabilityInfo = {
4
- description: 'Ask the user a question in an OpenTUI terminal prompt with markdown-friendly rendering and await their reply.',
4
+ description: 'Ask the user a question in an interactive prompt surface and await their reply.',
5
5
  parameters: {
6
6
  type: 'object',
7
7
  properties: {
@@ -29,7 +29,7 @@ const capabilityInfo = {
29
29
  };
30
30
  // Define description conforming to ToolRegistrationDescription
31
31
  const registrationDescription = (globalTimeoutSeconds) => `<description>
32
- Send a question to the user via the OpenTUI terminal prompt. **Crucial for clarifying requirements, confirming plans, or resolving ambiguity.**
32
+ Send a question to the user via an interactive prompt surface. **Crucial for clarifying requirements, confirming plans, or resolving ambiguity.**
33
33
  You should call this tool whenever it has **any** uncertainty or needs clarification or confirmation, even for trivial or silly questions.
34
34
  Feel free to ask anything! **Proactive questioning is preferred over making assumptions.**
35
35
  </description>
@@ -39,9 +39,15 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
39
39
  - (!important!) Continue to generate existing messages after user answers.
40
40
  - (!important!) Provide predefined options for quick selection if applicable.
41
41
  - (!important!) **Essential for validating assumptions before proceeding with significant actions (e.g., code edits, running commands).**
42
+ - (!important!) **Do not exit the prompt loop** until the user explicitly says one of: "Stop prompting", "End session", or "Don't ask anymore".
43
+ - (!important!) Immediately before final/closing handoff, ask exactly: "Are you satisfied with this result, or would you like any changes?"
44
+ - (!important!) If a required prompt times out or response is empty, re-prompt indefinitely and do not proceed with assumptions.
45
+ - (!important!) If the user skips a requested command/script, ask why it was skipped and whether to continue with alternatives or stop.
42
46
  </importantNotes>
43
47
 
44
48
  <whenToUseThisTool>
49
+ - Before starting any task, even if requirements appear clear
50
+ - After completing any task, to run the mandatory satisfaction check
45
51
  - When you need clarification on user requirements or preferences
46
52
  - When multiple implementation approaches are possible and user input is needed
47
53
  - **Before making potentially impactful changes (code edits, file operations, complex commands)**
@@ -51,14 +57,19 @@ Feel free to ask anything! **Proactive questioning is preferred over making assu
51
57
  - When facing ambiguous instructions that require clarification
52
58
  - When seeking feedback on generated code or solutions
53
59
  - When needing permission to modify critical files or functionality
60
+ - When user instructions are conflicting or unclear
61
+ - When the user asks to be prompted, asks a direct question, or asks a reply question
62
+ - When the user skips a command you requested
63
+ - Immediately before any final/closing handoff
54
64
  - **Whenever you feel even slightly unsure about the user's intent or the correct next step.**
55
65
  </whenToUseThisTool>
56
66
 
57
67
  <features>
58
- - OpenTUI prompt with markdown rendering (including code/diff blocks)
68
+ - Interactive prompt UI with markdown rendering (including code/diff blocks)
59
69
  - Preserves markdown links, including VS Code file links (for example: "vscode://file/<abs-path>:<line>:<column>") when provided in the prompt text
60
70
  - Supports option mode + free-text input mode when predefinedOptions are provided
61
71
  - Returns user response or timeout notification (timeout defaults to ${globalTimeoutSeconds} seconds)
72
+ - Backend-agnostic contract: same request/response behavior regardless of the active UI backend
62
73
  - Maintains context across user interactions
63
74
  - Handles empty responses gracefully
64
75
  - Shows project context in the prompt header/title
@@ -1,5 +1,40 @@
1
1
  import os from 'os';
2
2
  import { spawn } from 'child_process';
3
+ function decodeFileUri(value) {
4
+ const trimmed = value.trim();
5
+ if (!trimmed.startsWith('file://')) {
6
+ return trimmed;
7
+ }
8
+ return decodeURIComponent(trimmed.replace(/^file:\/\//, ''));
9
+ }
10
+ function normalizePathCandidate(value) {
11
+ const trimmed = value.trim();
12
+ if (!trimmed) {
13
+ return null;
14
+ }
15
+ const decoded = decodeFileUri(trimmed);
16
+ if (decoded.startsWith('/')) {
17
+ return decoded;
18
+ }
19
+ if (/^[a-zA-Z]:[\\/]/.test(decoded)) {
20
+ return decoded;
21
+ }
22
+ return null;
23
+ }
24
+ function parseNonEmptyLines(value) {
25
+ return value
26
+ .split(/\r?\n/)
27
+ .map((line) => line.trim())
28
+ .filter((line) => Boolean(line) && !line.startsWith('#'));
29
+ }
30
+ function parseFilePaths(value) {
31
+ return parseNonEmptyLines(value)
32
+ .map((line) => normalizePathCandidate(line))
33
+ .filter((line) => typeof line === 'string');
34
+ }
35
+ function uniquePaths(values) {
36
+ return Array.from(new Set(values));
37
+ }
3
38
  function runCommand(command, args, input) {
4
39
  return new Promise((resolve, reject) => {
5
40
  const child = spawn(command, args, {
@@ -65,3 +100,152 @@ export async function readTextFromClipboard() {
65
100
  }
66
101
  throw new Error(`Clipboard paste is not supported on platform: ${platform}`);
67
102
  }
103
+ export async function readFilePathsFromClipboard() {
104
+ const platform = os.platform();
105
+ if (platform === 'darwin') {
106
+ const aliasResult = await runCommand('osascript', [
107
+ '-e',
108
+ 'try',
109
+ '-e',
110
+ 'POSIX path of (the clipboard as alias)',
111
+ '-e',
112
+ 'on error',
113
+ '-e',
114
+ 'return ""',
115
+ '-e',
116
+ 'end try',
117
+ ]);
118
+ const aliasPaths = parseFilePaths(aliasResult.stdout);
119
+ if (aliasPaths.length > 0) {
120
+ return aliasPaths;
121
+ }
122
+ const listResult = await runCommand('osascript', [
123
+ '-e',
124
+ 'try',
125
+ '-e',
126
+ 'set L to the clipboard as list',
127
+ '-e',
128
+ 'set out to {}',
129
+ '-e',
130
+ 'repeat with i in L',
131
+ '-e',
132
+ 'set end of out to POSIX path of i',
133
+ '-e',
134
+ 'end repeat',
135
+ '-e',
136
+ 'set text item delimiters of AppleScript to linefeed',
137
+ '-e',
138
+ 'return out as text',
139
+ '-e',
140
+ 'on error',
141
+ '-e',
142
+ 'return ""',
143
+ '-e',
144
+ 'end try',
145
+ ]);
146
+ const listPaths = parseFilePaths(listResult.stdout);
147
+ if (listPaths.length > 0) {
148
+ return listPaths;
149
+ }
150
+ const jxaResult = await runCommand('osascript', [
151
+ '-l',
152
+ 'JavaScript',
153
+ '-e',
154
+ 'ObjC.import("AppKit")',
155
+ '-e',
156
+ 'ObjC.import("Foundation")',
157
+ '-e',
158
+ 'const pb = $.NSPasteboard.generalPasteboard',
159
+ '-e',
160
+ 'const classes = $.NSArray.arrayWithObject($.NSURL)',
161
+ '-e',
162
+ 'const urls = pb.readObjectsForClassesOptions(classes, $())',
163
+ '-e',
164
+ 'if (!urls || urls.count === 0) { "" } else { const out = []; for (let i = 0; i < urls.count; i += 1) { out.push(ObjC.unwrap(urls.objectAtIndex(i).path)); } out.join("\\n"); }',
165
+ ]);
166
+ const jxaPaths = parseFilePaths(jxaResult.stdout);
167
+ if (jxaPaths.length > 0) {
168
+ return uniquePaths(jxaPaths);
169
+ }
170
+ const jxaTypeProbeResult = await runCommand('osascript', [
171
+ '-l',
172
+ 'JavaScript',
173
+ '-e',
174
+ 'ObjC.import("AppKit")',
175
+ '-e',
176
+ 'ObjC.import("Foundation")',
177
+ '-e',
178
+ 'const pb = $.NSPasteboard.generalPasteboard',
179
+ '-e',
180
+ 'const out = []',
181
+ '-e',
182
+ 'const pushLine = (value) => { if (!value) { return; } const text = String(value).trim(); if (!text) { return; } out.push(text); }',
183
+ '-e',
184
+ 'const types = ObjC.deepUnwrap(pb.types) || []',
185
+ '-e',
186
+ 'for (const type of types) { const data = pb.dataForType(type); if (!data || data.isNil()) { continue; } let text = ""; const utf8 = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF8StringEncoding); if (utf8 && !utf8.isNil()) { text = ObjC.unwrap(utf8); } if (!text) { const utf16 = $.NSString.alloc.initWithDataEncoding(data, $.NSUTF16StringEncoding); if (utf16 && !utf16.isNil()) { text = ObjC.unwrap(utf16); } } if (!text) { continue; } const lines = String(text).split(/\\r?\\n/); for (const line of lines) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) { continue; } pushLine(trimmed); } }',
187
+ '-e',
188
+ 'out.join("\\n")',
189
+ ]);
190
+ return uniquePaths(parseFilePaths(jxaTypeProbeResult.stdout));
191
+ }
192
+ if (platform === 'linux') {
193
+ try {
194
+ const uriListResult = await runCommand('xclip', [
195
+ '-selection',
196
+ 'clipboard',
197
+ '-o',
198
+ '-t',
199
+ 'text/uri-list',
200
+ ]);
201
+ return parseFilePaths(uriListResult.stdout);
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ }
207
+ if (platform === 'win32') {
208
+ try {
209
+ const fileDropResult = await runCommand('powershell', [
210
+ '-NoProfile',
211
+ '-Command',
212
+ '$paths = Get-Clipboard -Format FileDropList; if ($paths) { $paths | ForEach-Object { $_.FullName } }',
213
+ ]);
214
+ return parseFilePaths(fileDropResult.stdout);
215
+ }
216
+ catch {
217
+ return [];
218
+ }
219
+ }
220
+ return [];
221
+ }
222
+ export async function readImageDataUrlFromClipboard() {
223
+ const platform = os.platform();
224
+ if (platform !== 'darwin') {
225
+ return null;
226
+ }
227
+ try {
228
+ const imageResult = await runCommand('osascript', [
229
+ '-l',
230
+ 'JavaScript',
231
+ '-e',
232
+ 'ObjC.import("AppKit")',
233
+ '-e',
234
+ 'ObjC.import("Foundation")',
235
+ '-e',
236
+ 'const pb = $.NSPasteboard.generalPasteboard',
237
+ '-e',
238
+ 'const image = $.NSImage.alloc.initWithPasteboard(pb)',
239
+ '-e',
240
+ 'if (!image || image.isNil()) { "" } else { const tiff = image.TIFFRepresentation; if (!tiff || tiff.isNil()) { "" } else { const rep = $.NSBitmapImageRep.imageRepWithData(tiff); if (!rep || rep.isNil()) { "" } else { const pngData = rep.representationUsingTypeProperties($.NSBitmapImageFileTypePNG, $()); if (!pngData || pngData.isNil()) { "" } else { ObjC.unwrap(pngData.base64EncodedStringWithOptions(0)); } } } }',
241
+ ]);
242
+ const base64Payload = imageResult.stdout.trim();
243
+ if (!base64Payload) {
244
+ return null;
245
+ }
246
+ return `data:image/png;base64,${base64Payload}`;
247
+ }
248
+ catch {
249
+ return null;
250
+ }
251
+ }
package/package.json CHANGED
@@ -1,13 +1,19 @@
1
1
  {
2
2
  "name": "@rawwee/interactive-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.3.0",
4
4
  "main": "dist/index.js",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "interactive-mcp": "dist/index.js"
8
8
  },
9
9
  "files": [
10
- "dist",
10
+ "dist/constants.js",
11
+ "dist/index.js",
12
+ "dist/commands/input",
13
+ "dist/commands/intensive-chat",
14
+ "dist/components",
15
+ "dist/tool-definitions",
16
+ "dist/utils",
11
17
  "README.md",
12
18
  "LICENSE",
13
19
  "package.json"